app-ui

created pr with 46.1 on 2025-02-06T15:20:57Z · by c8ef7d19
cmds
checkout latest patchset:
ssh pr.pico.sh print 46 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print 46.[rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 46

Patchset 46.1 on 2025-02-06T15:20:57Z · commit c08ba0a

Install react-use-websocket
Eric Abruzzese 2025-01-15T14:28:15Z
Update the diagnostics create form to navigate to the details page with query parameters instead of POSTing to the external service
Eric Abruzzese 2025-01-15T14:29:34Z
Collect events on the diagnostic details page and construct a dashboard state
Eric Abruzzese 2025-01-15T14:30:03Z
Formatting and cleanup
Eric Abruzzese 2025-01-15T14:34:02Z
Show dashboard messages, resources, operations, and plots
Michael Peterson 2025-01-28T20:23:45Z
Support async plot annotations
Eric Abruzzese 2025-01-31T17:53:30Z
Fix an issue that would cause a page crash if datasets weren't perfectly aligned
Eric Abruzzese 2025-02-03T20:19:05Z
Add VITE_APTIBLE_AI_URL to .env.example
Eric Abruzzese 2025-02-04T16:30:13Z
Update VITE_APTIBLE_AI_URL references to point to Hotshot
Eric Abruzzese 2025-02-05T17:50:02Z
Update the analysis using PlotAnnotated events
Eric Abruzzese 2025-02-05T17:51:11Z
Install react-use-websocket
Eric Abruzzese 2025-01-15T14:28:15Z
Update the diagnostics create form to navigate to the details page with query parameters instead of POSTing to the external service
Eric Abruzzese 2025-01-15T14:29:34Z
Collect events on the diagnostic details page and construct a dashboard state
Eric Abruzzese 2025-01-15T14:30:03Z
Formatting and cleanup
Eric Abruzzese 2025-01-15T14:34:02Z
Show dashboard messages, resources, operations, and plots
Michael Peterson 2025-01-28T20:23:45Z
Support async plot annotations
Eric Abruzzese 2025-01-31T17:53:30Z
Fix an issue that would cause a page crash if datasets weren't perfectly aligned
Eric Abruzzese 2025-02-03T20:19:05Z
Add VITE_APTIBLE_AI_URL to .env.example
Eric Abruzzese 2025-02-04T16:30:13Z
Update VITE_APTIBLE_AI_URL references to point to Hotshot
Eric Abruzzese 2025-02-05T17:50:02Z
Update the analysis using PlotAnnotated events
Eric Abruzzese 2025-02-05T17:51:11Z
Refactor charts
Michael Peterson 2025-02-05T18:25:59Z
Refactor OperationsTimeline, chart hover state
Michael Peterson 2025-02-05T18:42:20Z
Extract types into aptible-ai/index, useDashboard hook
Michael Peterson 2025-02-05T19:21:56Z
Fix a linter error
Eric Abruzzese 2025-02-05T19:40:48Z
Fix linter errors
Eric Abruzzese 2025-02-05T19:50:07Z
+95 -0 src/chart/chartjs-plugin-annoations.ts #
......@@ -0,0 +1,95 @@
1+import { Chart as ChartJS } from "chart.js";
2+
3+
4+// Update ChartJS interface to include annotations
5+declare module 'chart.js' {
6+ interface Chart {
7+ annotationAreas?: Array<{
8+ x1: number;
9+ x2: number;
10+ y1: number;
11+ y2: number;
12+ description: string;
13+ }>;
14+ }
15+
16+ interface PluginOptionsByType<TType> {
17+ annotations?: Annotation[];
18+ }
19+}
20+
21+export type Annotation = {
22+ label: string;
23+ description: string;
24+ x_min: number;
25+ x_max: number;
26+ y_min: number;
27+ y_max: number;
28+};
29+
30+// ChartJS plugin to draw annotations on a line chart
31+export const annotationsPlugin = {
32+ id: 'annotations',
33+ afterDraw: (chart: ChartJS, args: any, options: any) => {
34+ const ctx = chart.ctx;
35+ const annotations = chart.options?.plugins?.annotations || [];
36+
37+ annotations.forEach((annotation: any) => {
38+ // Convert timestamps to numbers for the time scale
39+ const xScale = chart.scales.x;
40+ const yScale = chart.scales.y;
41+
42+ // Parse the timestamps into Date objects and get their timestamps
43+ const x1 = new Date(annotation.x_min).getTime();
44+ const x2 = new Date(annotation.x_max).getTime();
45+
46+ // Convert to pixel coordinates
47+ const pixelX1 = xScale.getPixelForValue(x1);
48+ const pixelX2 = xScale.getPixelForValue(x2);
49+ const pixelY1 = yScale.getPixelForValue(annotation.y_max);
50+ const pixelY2 = yScale.getPixelForValue(annotation.y_min);
51+
52+ // Draw annotation rectangle
53+ ctx.save();
54+ ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
55+ ctx.fillRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
56+
57+ // Rectangle border
58+ ctx.strokeStyle = 'rgb(200, 0, 0)';
59+ ctx.strokeRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
60+
61+ // Annotation label
62+ ctx.save();
63+ const padding = 4;
64+ ctx.font = '10px monospace';
65+ const textMetrics = ctx.measureText(annotation.label);
66+ const textHeight = 12;
67+ const radius = 4;
68+
69+ const boxX = pixelX1 + padding;
70+ const boxY = pixelY1 - textHeight - padding * 2;
71+ const boxWidth = textMetrics.width + padding * 2;
72+ const boxHeight = textHeight + padding * 2;
73+
74+ ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
75+ ctx.shadowBlur = 4;
76+ ctx.shadowOffsetX = 2;
77+ ctx.shadowOffsetY = 2;
78+
79+ ctx.fillStyle = 'rgba(200, 0, 0, 0.75)';
80+ ctx.beginPath();
81+ ctx.roundRect(boxX, boxY, boxWidth, boxHeight, radius);
82+ ctx.fill();
83+
84+ ctx.shadowColor = 'transparent';
85+ ctx.strokeStyle = 'rgb(200, 0, 0)';
86+ ctx.lineWidth = 1;
87+ ctx.stroke();
88+
89+ ctx.fillStyle = 'white';
90+ ctx.textBaseline = 'bottom';
91+ ctx.fillText(annotation.label, pixelX1 + padding * 2, pixelY1 - padding);
92+ ctx.restore();
93+ });
94+ }
95+};
+25 -0 src/chart/chartjs-plugin-vertical-line.ts #
......@@ -0,0 +1,25 @@
1+import { Chart as ChartJS } from "chart.js";
2+
3+// ChartJS plugin to draw a vertical line on hover
4+export const verticalLinePlugin = {
5+ id: 'verticalLine',
6+ beforeDraw: (chart: ChartJS) => {
7+ if (chart.tooltip?.getActiveElements()?.length) {
8+ const activePoint = chart.tooltip.getActiveElements()[0];
9+ const ctx = chart.ctx;
10+ const x = activePoint.element.x;
11+ const topY = chart.scales.y.top;
12+ const bottomY = chart.scales.y.bottom;
13+
14+ ctx.save();
15+ ctx.beginPath();
16+ ctx.moveTo(x, topY);
17+ ctx.lineTo(x, bottomY);
18+ ctx.lineWidth = 1;
19+ ctx.strokeStyle = '#94a3b8';
20+ ctx.setLineDash([5, 5]);
21+ ctx.stroke();
22+ ctx.restore();
23+ }
24+ }
25+};
+3 -338 src/ui/pages/diagnostics-detail.tsx #
......@@ -17,148 +17,8 @@ import {
1717 IconSource,
1818 IconInfo,
1919 } from "../shared/icons";
20-import {
21- CategoryScale,
22- Chart as ChartJS,
23- Colors,
24- Legend,
25- LineElement,
26- LinearScale,
27- PointElement,
28- TimeScale,
29- type TimeUnit,
30- Title,
31- Tooltip,
32-} from "chart.js";
33-import "chartjs-adapter-luxon";
34-import { Line } from "react-chartjs-2";
3520 import { StreamingText } from "../shared/llm";
36-
37-// Chart.js plugin to draw a vertical line on hover
38-const verticalLinePlugin = {
39- id: 'verticalLine',
40- beforeDraw: (chart: ChartJS) => {
41- if (chart.tooltip?.getActiveElements()?.length) {
42- const activePoint = chart.tooltip.getActiveElements()[0];
43- const ctx = chart.ctx;
44- const x = activePoint.element.x;
45- const topY = chart.scales.y.top;
46- const bottomY = chart.scales.y.bottom;
47-
48- ctx.save();
49- ctx.beginPath();
50- ctx.moveTo(x, topY);
51- ctx.lineTo(x, bottomY);
52- ctx.lineWidth = 1;
53- ctx.strokeStyle = '#94a3b8';
54- ctx.setLineDash([5, 5]);
55- ctx.stroke();
56- ctx.restore();
57- }
58- }
59-};
60-
61-// ChartJS plugin to draw annotations
62-declare module 'chart.js' {
63- interface Chart {
64- annotationAreas?: Array<{
65- x1: number;
66- x2: number;
67- y1: number;
68- y2: number;
69- description: string;
70- }>;
71- }
72-
73- interface PluginOptionsByType<TType> {
74- annotations?: Annotation[];
75- }
76-}
77-
78-const annotationsPlugin = {
79- id: 'annotations',
80- afterDraw: (chart: ChartJS, args: any, options: any) => {
81- const ctx = chart.ctx;
82- const annotations = chart.options?.plugins?.annotations || [];
83-
84- annotations.forEach((annotation: any) => {
85- // Convert timestamps to numbers for the time scale
86- const xScale = chart.scales.x;
87- const yScale = chart.scales.y;
88-
89- // Parse the timestamps into Date objects and get their timestamps
90- const x1 = new Date(annotation.x_min).getTime();
91- const x2 = new Date(annotation.x_max).getTime();
92-
93- // Convert to pixel coordinates
94- const pixelX1 = xScale.getPixelForValue(x1);
95- const pixelX2 = xScale.getPixelForValue(x2);
96- const pixelY1 = yScale.getPixelForValue(annotation.y_max);
97- const pixelY2 = yScale.getPixelForValue(annotation.y_min);
98-
99- // Draw annotation rectangle
100- ctx.save();
101- ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // Solid red with 50% opacity
102- ctx.fillRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
103-
104- // Rectangle border
105- ctx.strokeStyle = 'rgb(200, 0, 0)'; // Solid darker red
106- ctx.strokeRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
107-
108- // Annotation label
109- ctx.save();
110- const padding = 4;
111- ctx.font = '10px monospace'; // Reduced font size
112- const textMetrics = ctx.measureText(annotation.label);
113- const textHeight = 12; // Reduced height to match smaller font
114- const radius = 4; // Border radius
115-
116- // Calculate label box dimensions
117- const boxX = pixelX1 + padding;
118- const boxY = pixelY1 - textHeight - padding * 2;
119- const boxWidth = textMetrics.width + padding * 2;
120- const boxHeight = textHeight + padding * 2;
121-
122- // Label box shadow
123- ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
124- ctx.shadowBlur = 4;
125- ctx.shadowOffsetX = 2;
126- ctx.shadowOffsetY = 2;
127-
128- // Label box background
129- ctx.fillStyle = 'rgba(200, 0, 0, 0.75)';
130- ctx.beginPath();
131- ctx.roundRect(boxX, boxY, boxWidth, boxHeight, radius);
132- ctx.fill();
133-
134- // Border
135- ctx.shadowColor = 'transparent';
136- ctx.strokeStyle = 'rgb(200, 0, 0)';
137- ctx.lineWidth = 1;
138- ctx.stroke();
139-
140- // Label text
141- ctx.fillStyle = 'white';
142- ctx.textBaseline = 'bottom';
143- ctx.fillText(annotation.label, pixelX1 + padding * 2, pixelY1 - padding);
144- ctx.restore();
145- });
146- }
147-};
148-
149-ChartJS.register(
150- CategoryScale,
151- Colors,
152- LinearScale,
153- PointElement,
154- LineElement,
155- TimeScale,
156- Title,
157- Tooltip,
158- Legend,
159- verticalLinePlugin,
160- annotationsPlugin
161-);
21+import { DiagnosticsLineChart } from "../shared/diagnostics/line-chart";
16222
16323 type Message = {
16424 id: string;
......@@ -490,7 +350,7 @@ const DiagnosticsResource = ({
490350 </div>
491351 )}
492352 <div className="mt-2 min-h-[200px]">
493- <SynchronizedHoverLineChartWrapper
353+ <DiagnosticsLineChart
494354 showLegend={true}
495355 keyId={plot.id}
496356 chart={{
......@@ -526,204 +386,9 @@ const DiagnosticsResource = ({
526386 );
527387 };
528388
529-const SynchronizedHoverLineChartWrapper = ({
530- showLegend = true,
531- keyId,
532- chart: { labels, datasets: originalDatasets, title },
533- xAxisUnit,
534- yAxisLabel,
535- yAxisUnit,
536- annotations = [],
537- synchronizedHoverContext,
538-}: {
539- showLegend?: boolean;
540- keyId: string;
541- chart: {
542- title: string;
543- labels: string[];
544- datasets: Array<{
545- label: string;
546- data: number[];
547- }>;
548- };
549- xAxisUnit: TimeUnit;
550- yAxisLabel?: string;
551- yAxisUnit?: string;
552- annotations?: Annotation[];
553- synchronizedHoverContext: React.Context<HoverState>;
554-}) => {
555- const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
556- const chartRef = React.useRef<ChartJS<"line">>();
557-
558- // Truncate sha256 resource names to 8 chars
559- const datasets = originalDatasets.map(dataset => ({
560- ...dataset,
561- label: dataset.label.length === 64 ? dataset.label.slice(0, 8) : dataset.label
562- }));
563-
564- if (!datasets || !title) return null;
565-
566- React.useEffect(() => {
567- const chart = chartRef.current;
568- if (!chart) return;
569-
570- if (!timestamp) {
571- chart.setActiveElements([]);
572- chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
573- chart.update();
574- return;
575- }
576-
577- const timestampIndex = labels.indexOf(timestamp);
578- if (timestampIndex === -1) return;
579-
580- const activeElements = datasets.reduce<{
581- datasetIndex: number;
582- index: number;
583- }[]>((acc, dataset, datasetIndex) => {
584- if (!dataset.data[timestampIndex]) return acc;
585-
586- return [
587- ...acc,
588- {
589- datasetIndex,
590- index: timestampIndex,
591- },
592- ];
593- }, []);
594-
595- chart.setActiveElements(activeElements);
596- chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });
597- chart.update();
598- }, [timestamp, labels, datasets]);
599-
600- const formatYAxisTick = (value: number, unit?: string) => {
601- if (!unit) return value;
602-
603- unit = unit.trim();
604- if (unit === '%') return `${value}%`;
605- if (unit.endsWith('B')) return `${value}${unit}`;
606-
607- return value;
608- };
609-
610- return (
611- <Line
612- ref={chartRef}
613- datasetIdKey={keyId}
614- data={{
615- labels,
616- datasets,
617- }}
618- options={{
619- responsive: true,
620- maintainAspectRatio: false,
621- animation: false,
622- plugins: {
623- tooltip: {
624- enabled: true,
625- mode: 'index',
626- intersect: false,
627- },
628- colors: {
629- forceOverride: true,
630- },
631- legend: {
632- display: showLegend,
633- labels: {
634- usePointStyle: true,
635- boxHeight: 5,
636- boxWidth: 3,
637- padding: 20,
638- },
639- },
640- title: {
641- font: {
642- size: 16,
643- weight: "normal",
644- },
645- color: "#595E63",
646- align: "start",
647- display: false,
648- text: title,
649- padding: showLegend
650- ? undefined
651- : {
652- top: 10,
653- bottom: 30,
654- },
655- },
656- annotations: annotations,
657- },
658- interaction: {
659- mode: 'index',
660- intersect: false,
661- },
662- onHover: (event, elements, chart) => {
663- if (!event.native) return;
664-
665- if (elements && elements.length > 0) {
666- const timestamp = labels[elements[0].index];
667- setTimestamp(timestamp);
668- } else {
669- setTimestamp(null);
670- }
671- },
672- scales: {
673- x: {
674- border: {
675- color: "#111920",
676- },
677- grid: {
678- display: false,
679- },
680- ticks: {
681- color: "#111920",
682- maxRotation: 0,
683- minRotation: 0,
684- autoSkip: true,
685- maxTicksLimit: 5,
686- },
687- adapters: {
688- date: {
689- zone: "UTC",
690- },
691- },
692- time: {
693- tooltipFormat: "yyyy-MM-dd HH:mm:ss 'UTC'",
694- unit: xAxisUnit,
695- displayFormats: {
696- minute: "HH:mm 'UTC'",
697- day: "MMM dd",
698- },
699- },
700- type: "time",
701- },
702- y: {
703- min: 0,
704- border: {
705- display: false,
706- },
707- title: yAxisLabel
708- ? {
709- display: true,
710- text: yAxisLabel,
711- }
712- : undefined,
713- ticks: {
714- callback: (value) => formatYAxisTick(value as number, yAxisUnit),
715- color: "#111920",
716- },
717- },
718- },
719- }}
720- />
721- );
722-};
723-
724389 export const DiagnosticsDetailPage = () => {
725390 // Parse the investigation parameters from the query string.
726- const [searchParams, setSearchParams] = useSearchParams();
391+ const [searchParams] = useSearchParams();
727392 const accessToken = useSelector(selectAccessToken);
728393 const appId = searchParams.get("appId");
729394 const symptomDescription = searchParams.get("symptomDescription");
+227 -0 src/ui/shared/diagnostics/line-chart.tsx #
......@@ -0,0 +1,227 @@
1+import React, { useContext } from "react";
2+import {
3+ CategoryScale,
4+ Chart as ChartJS,
5+ Colors,
6+ Legend,
7+ LineElement,
8+ LinearScale,
9+ PointElement,
10+ TimeScale,
11+ type TimeUnit,
12+ Title,
13+ Tooltip,
14+} from "chart.js";
15+import "chartjs-adapter-luxon";
16+import { Line } from "react-chartjs-2";
17+import { verticalLinePlugin } from "../../../chart/chartjs-plugin-vertical-line";
18+import { annotationsPlugin, type Annotation } from "../../../chart/chartjs-plugin-annoations";
19+
20+ChartJS.register(
21+ CategoryScale,
22+ Colors,
23+ LinearScale,
24+ PointElement,
25+ LineElement,
26+ TimeScale,
27+ Title,
28+ Tooltip,
29+ Legend,
30+ verticalLinePlugin,
31+ annotationsPlugin
32+);
33+
34+export const DiagnosticsLineChart = ({
35+ showLegend = true,
36+ keyId,
37+ chart: { labels, datasets: originalDatasets, title },
38+ xAxisUnit,
39+ yAxisLabel,
40+ yAxisUnit,
41+ annotations = [],
42+ synchronizedHoverContext,
43+}: {
44+ showLegend?: boolean;
45+ keyId: string;
46+ chart: {
47+ title: string;
48+ labels: string[];
49+ datasets: Array<{
50+ label: string;
51+ data: number[];
52+ }>;
53+ };
54+ xAxisUnit: TimeUnit;
55+ yAxisLabel?: string;
56+ yAxisUnit?: string;
57+ annotations?: Annotation[];
58+ synchronizedHoverContext: React.Context<{ timestamp: string | null; setTimestamp: (timestamp: string | null) => void; }>;
59+}) => {
60+ const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
61+ const chartRef = React.useRef<ChartJS<"line">>();
62+
63+ // Truncate sha256 resource names to 8 chars
64+ const datasets = originalDatasets.map(dataset => ({
65+ ...dataset,
66+ label: dataset.label.length === 64 ? dataset.label.slice(0, 8) : dataset.label
67+ }));
68+
69+ if (!datasets || !title) return null;
70+
71+ React.useEffect(() => {
72+ const chart = chartRef.current;
73+ if (!chart) return;
74+
75+ if (!timestamp) {
76+ chart.setActiveElements([]);
77+ chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
78+ chart.update();
79+ return;
80+ }
81+
82+ const timestampIndex = labels.indexOf(timestamp);
83+ if (timestampIndex === -1) return;
84+
85+ const activeElements = datasets.reduce<{
86+ datasetIndex: number;
87+ index: number;
88+ }[]>((acc, dataset, datasetIndex) => {
89+ if (!dataset.data[timestampIndex]) return acc;
90+
91+ return [
92+ ...acc,
93+ {
94+ datasetIndex,
95+ index: timestampIndex,
96+ },
97+ ];
98+ }, []);
99+
100+ chart.setActiveElements(activeElements);
101+ chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });
102+ chart.update();
103+ }, [timestamp, labels, datasets]);
104+
105+ const formatYAxisTick = (value: number, unit?: string) => {
106+ if (!unit) return value;
107+
108+ unit = unit.trim();
109+ if (unit === '%') return `${value}%`;
110+ if (unit.endsWith('B')) return `${value}${unit}`;
111+
112+ return value;
113+ };
114+
115+ return (
116+ <Line
117+ ref={chartRef}
118+ datasetIdKey={keyId}
119+ data={{
120+ labels,
121+ datasets,
122+ }}
123+ options={{
124+ responsive: true,
125+ maintainAspectRatio: false,
126+ animation: false,
127+ plugins: {
128+ tooltip: {
129+ enabled: true,
130+ mode: 'index',
131+ intersect: false,
132+ },
133+ colors: {
134+ forceOverride: true,
135+ },
136+ legend: {
137+ display: showLegend,
138+ labels: {
139+ usePointStyle: true,
140+ boxHeight: 5,
141+ boxWidth: 3,
142+ padding: 20,
143+ },
144+ },
145+ title: {
146+ font: {
147+ size: 16,
148+ weight: "normal",
149+ },
150+ color: "#595E63",
151+ align: "start",
152+ display: false,
153+ text: title,
154+ padding: showLegend
155+ ? undefined
156+ : {
157+ top: 10,
158+ bottom: 30,
159+ },
160+ },
161+ annotations: annotations,
162+ },
163+ interaction: {
164+ mode: 'index',
165+ intersect: false,
166+ },
167+ onHover: (event, elements, chart) => {
168+ if (!event.native) return;
169+
170+ if (elements && elements.length > 0) {
171+ const timestamp = labels[elements[0].index];
172+ setTimestamp(timestamp);
173+ } else {
174+ setTimestamp(null);
175+ }
176+ },
177+ scales: {
178+ x: {
179+ border: {
180+ color: "#111920",
181+ },
182+ grid: {
183+ display: false,
184+ },
185+ ticks: {
186+ color: "#111920",
187+ maxRotation: 0,
188+ minRotation: 0,
189+ autoSkip: true,
190+ maxTicksLimit: 5,
191+ },
192+ adapters: {
193+ date: {
194+ zone: "UTC",
195+ },
196+ },
197+ time: {
198+ tooltipFormat: "yyyy-MM-dd HH:mm:ss 'UTC'",
199+ unit: xAxisUnit,
200+ displayFormats: {
201+ minute: "HH:mm 'UTC'",
202+ day: "MMM dd",
203+ },
204+ },
205+ type: "time",
206+ },
207+ y: {
208+ min: 0,
209+ border: {
210+ display: false,
211+ },
212+ title: yAxisLabel
213+ ? {
214+ display: true,
215+ text: yAxisLabel,
216+ }
217+ : undefined,
218+ ticks: {
219+ callback: (value) => formatYAxisTick(value as number, yAxisUnit),
220+ color: "#111920",
221+ },
222+ },
223+ },
224+ }}
225+ />
226+ );
227+};
Back to top