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 0deffd7

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
+0 -0 public/aptible-mark.png #
Binaries are not rendered as diffs.
+0 -0 public/thinking.gif #
Binaries are not rendered as diffs.
+696 -12 src/ui/pages/diagnostics-detail.tsx #
......@@ -1,12 +1,164 @@
11 import { selectAptibleAiUrl } from "@app/config";
22 import { useSelector } from "@app/react";
3+import React, { useRef, useContext } from "react";
34 import { diagnosticsCreateUrl } from "@app/routes";
45 import { selectAccessToken } from "@app/token";
5-import { useEffect, useState } from "react";
6-import { Link, useParams, useSearchParams } from "react-router-dom";
6+import { useEffect, useState, createContext } from "react";
7+import { useSearchParams } from "react-router-dom";
78 import useWebSocket, { ReadyState } from "react-use-websocket";
89 import { AppSidebarLayout } from "../layouts";
9-import { Breadcrumbs, PreText } from "../shared";
10+import { Breadcrumbs } from "../shared";
11+import {
12+ IconBox,
13+ IconCloud,
14+ IconCylinder,
15+ IconEndpoint,
16+ IconService,
17+ IconSource,
18+ IconInfo,
19+} 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";
35+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+);
10162
11163 type Message = {
12164 id: string;
......@@ -72,6 +224,493 @@ type Dashboard = {
72224 messages: Message[];
73225 };
74226
227+type HoverState = {
228+ timestamp: string | null;
229+ setTimestamp: (timestamp: string | null) => void;
230+};
231+
232+const HoverContext = createContext<HoverState>({
233+ timestamp: null,
234+ setTimestamp: () => { },
235+});
236+
237+const OperationsTimeline = ({
238+ operations,
239+ startTime,
240+ endTime,
241+ synchronizedHoverContext
242+}: {
243+ operations: Operation[],
244+ startTime: string,
245+ endTime: string,
246+ synchronizedHoverContext: React.Context<HoverState>
247+}) => {
248+ const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
249+ const start = new Date(startTime);
250+ const end = new Date(endTime);
251+ const minutesDiff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
252+ const timelineRef = useRef<HTMLDivElement>(null);
253+
254+ // Create array of all minutes between start and end
255+ const minutes = Array.from({ length: minutesDiff + 1 }, (_, i) => i);
256+
257+ // Map operations to their minute positions
258+ const operationsByMinute = operations.reduce((acc, op) => {
259+ const opTime = new Date(op.created_at);
260+ const minute = Math.floor((opTime.getTime() - start.getTime()) / (1000 * 60));
261+ acc[minute] = op;
262+ return acc;
263+ }, {} as { [key: number]: Operation });
264+
265+ // Handle mouse move over timeline
266+ const handleMouseMove = (e: React.MouseEvent) => {
267+ if (!timelineRef.current) return;
268+
269+ const rect = timelineRef.current.getBoundingClientRect();
270+ const x = e.clientX - rect.left;
271+ const percentage = x / rect.width;
272+ const totalMilliseconds = end.getTime() - start.getTime();
273+ const hoverTime = new Date(start.getTime() + (percentage * totalMilliseconds));
274+
275+ // Round to nearest minute
276+ hoverTime.setSeconds(0);
277+ hoverTime.setMilliseconds(0);
278+
279+ // Format timestamp correctly
280+ const formattedTimestamp = hoverTime.toISOString().slice(0, -5) + 'Z';
281+ setTimestamp(formattedTimestamp);
282+ };
283+
284+ // Handle mouse leave
285+ const handleMouseLeave = () => {
286+ setTimestamp(null);
287+ };
288+
289+ // Calculate vertical line position when timestamp changes
290+ const getVerticalLinePosition = () => {
291+ if (!timestamp) return null;
292+
293+ try {
294+ const hoverTime = new Date(timestamp);
295+ const timeElapsed = hoverTime.getTime() - start.getTime();
296+ const totalDuration = end.getTime() - start.getTime();
297+ const position = (timeElapsed / totalDuration) * 100;
298+
299+ // Ensure position is between 0 and 100
300+ return Math.max(0, Math.min(100, position));
301+ } catch (error) {
302+ console.error('Error calculating vertical line position:', error);
303+ return null;
304+ }
305+ };
306+
307+ const verticalLinePosition = getVerticalLinePosition();
308+
309+ // Helper function to extract operation type from description
310+ const getOperationType = (description: string) => {
311+ const match = description.match(/^\((succeeded|failed)\) (\w+)/);
312+ return match ? match[2] : 'unknown';
313+ };
314+
315+ return (
316+ <div className="mt-4">
317+ <div
318+ ref={timelineRef}
319+ className="relative h-16"
320+ onMouseMove={handleMouseMove}
321+ onMouseLeave={handleMouseLeave}
322+ >
323+ <div className="absolute w-full h-0.5 bg-gray-200 top-1/2 transform -translate-y-1/2" />
324+
325+ {/* Vertical hover line */}
326+ {verticalLinePosition !== null && (
327+ <div
328+ className="absolute h-full w-px bg-transparent top-0"
329+ style={{
330+ left: `${verticalLinePosition}%`,
331+ borderLeft: '1px dashed #94a3b8'
332+ }}
333+ />
334+ )}
335+
336+ {minutes.map((minute) => {
337+ const leftPercentage = (minute / minutesDiff) * 100;
338+ const operation = operationsByMinute[minute];
339+
340+ return (
341+ <div
342+ key={minute}
343+ className="absolute top-1/2 transform -translate-y-1/2"
344+ style={{ left: `${leftPercentage}%` }}
345+ >
346+ <div className="group relative">
347+ {operation ? (
348+ <>
349+ <div className={`relative w-3 h-3 ${operation.status === 'succeeded' ? 'bg-lime-400' : 'bg-red-400'} rounded-full cursor-pointer before:absolute before:inset-0 before:rounded-full before:animate-ping before:opacity-75 ${operation.status === 'succeeded' ? 'before:bg-lime-400' : 'before:bg-red-400'}`} />
350+
351+ {/* Operation type label */}
352+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-gray-100 px-1 rounded">
353+ <span className="font-mono text-[10px] whitespace-nowrap uppercase">
354+ {getOperationType(operation.description)}
355+ </span>
356+ </div>
357+
358+ {/* Tooltip */}
359+ <div className="invisible group-hover:visible absolute bottom-full mb-2 -left-1/2 w-48 bg-gray-800 text-white text-sm rounded p-2 z-10">
360+ <p className="text-sm">{operation.description}</p>
361+ <p className="text-xs text-gray-300">({new Date(operation.created_at).toLocaleTimeString()} local)</p>
362+ </div>
363+ </>
364+ ) : (
365+ // Empty marker for minutes without operations
366+ <div className="hidden" />
367+ )}
368+ </div>
369+ </div>
370+ );
371+ })}
372+ </div>
373+ </div>
374+ );
375+};
376+
377+const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: {
378+ messages: Message[];
379+ showAllMessages: boolean;
380+ setShowAllMessages: (show: boolean) => void;
381+}) => {
382+ return (
383+ <div className="border rounded-lg p-4 bg-gray-50">
384+ <div className="flex justify-between items-center mb-4">
385+ <h2 className="text-lg font-semibold">Messages</h2>
386+ {messages.length > 1 && (
387+ <button
388+ onClick={() => setShowAllMessages(!showAllMessages)}
389+ className="text-blue-600 hover:text-blue-800 text-sm"
390+ >
391+ {showAllMessages ? 'Show Latest' : `Show All (${messages.length})`}
392+ </button>
393+ )}
394+ </div>
395+ <div className="space-y-6">
396+ {(showAllMessages ? messages : messages.slice(-1)).map((message, index) => (
397+ <div
398+ key={message.id}
399+ className="flex items-start"
400+ >
401+ <img
402+ src={message.id === 'completion-message' ? '/aptible-mark.png' : '/thinking.gif'}
403+ className="w-[28px] h-[28px] mr-3"
404+ aria-label="App"
405+ />
406+ <div className="flex-1 bg-white rounded-lg px-4 py-2 shadow-sm">
407+ <StreamingText
408+ text={message.message}
409+ showEllipsis={(showAllMessages ? index === messages.length - 1 : true) && message.id !== 'completion-message'}
410+ animate={showAllMessages ? index === messages.length - 1 : true}
411+ />
412+ </div>
413+ </div>
414+ ))}
415+ </div>
416+ </div>
417+ );
418+};
419+
420+const DiagnosticsResource = ({
421+ resourceId,
422+ resource,
423+ startTime,
424+ endTime,
425+ synchronizedHoverContext
426+}: {
427+ resourceId: string;
428+ resource: Resource;
429+ startTime: string;
430+ endTime: string;
431+ synchronizedHoverContext: React.Context<HoverState>;
432+}) => {
433+ return (
434+ <div className="border rounded-lg p-4">
435+ <h3 className="font-medium text-xl flex gap-2 items-center bg-gray-50 p-4 -m-4 mb-4 border-b rounded-t-lg">
436+ {resource.type === "app" ? <IconBox /> :
437+ resource.type === "database" ? <IconCylinder /> :
438+ resource.type === "endpoint" ? <IconEndpoint /> :
439+ resource.type === "service" ? <IconService /> :
440+ resource.type === "source" ? <IconSource /> :
441+ <IconCloud />}
442+ <span className="font-mono text-lg font-bold">{resourceId}</span>
443+ </h3>
444+
445+ {/* Operations */}
446+ {resource.operations && resource.operations.length > 0 && (
447+ <div className="mt-2">
448+ <div className="border rounded-lg bg-white shadow-sm animate-fade-in">
449+ <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">Operations</h4>
450+ <div className="p-6">
451+ <OperationsTimeline
452+ operations={resource.operations}
453+ startTime={startTime}
454+ endTime={endTime}
455+ synchronizedHoverContext={synchronizedHoverContext}
456+ />
457+ </div>
458+ </div>
459+ </div>
460+ )}
461+
462+ {/* Plots */}
463+ {resource.plots && Object.entries(resource.plots).length > 0 && (
464+ <div className="mt-2">
465+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
466+ {Object.entries(resource.plots)
467+ .filter(([_, plot]) =>
468+ plot.series.some(series => series.points && series.points.length > 0)
469+ )
470+ .map(([plotId, plot]) => (
471+ <div
472+ key={plotId}
473+ className="border rounded-lg bg-white shadow-sm animate-fade-in"
474+ >
475+ <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">
476+ {plot.title}
477+ </h4>
478+ <div className="p-6">
479+ {plot.interpretation && (
480+ <div className="mt-4 bg-orange-100 p-3 rounded-md">
481+ <div className="flex items-start gap-2">
482+ <IconInfo className="w-4 h-4 mt-1 text-yellow-600 flex-shrink-0" />
483+ <div>
484+ <p className="text-gray-600">
485+ <strong className="mr-1">Interpretation:</strong>
486+ {plot.interpretation}
487+ </p>
488+ </div>
489+ </div>
490+ </div>
491+ )}
492+ <div className="mt-2 min-h-[200px]">
493+ <SynchronizedHoverLineChartWrapper
494+ showLegend={true}
495+ keyId={plot.id}
496+ chart={{
497+ title: " ",
498+ labels: plot.series[0]?.points.map(point => point.timestamp) || [],
499+ datasets: plot.series.map(series => ({
500+ label: series.label,
501+ data: series.points.map(point => point.value)
502+ }))
503+ }}
504+ xAxisUnit="minute"
505+ yAxisLabel={plot.title}
506+ yAxisUnit={plot.unit}
507+ annotations={plot.annotations}
508+ synchronizedHoverContext={synchronizedHoverContext}
509+ />
510+ </div>
511+ {plot.analysis && (
512+ <div className="mt-4">
513+ <p className="mt-1 text-gray-500 text-xs">
514+ <strong>Analysis:</strong>
515+ {plot.analysis}
516+ </p>
517+ </div>
518+ )}
519+ </div>
520+ </div>
521+ ))}
522+ </div>
523+ </div>
524+ )}
525+ </div>
526+ );
527+};
528+
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.map((dataset, datasetIndex) => ({
581+ datasetIndex,
582+ index: timestampIndex,
583+ }));
584+
585+ chart.setActiveElements(activeElements);
586+ chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });
587+ chart.update();
588+ }, [timestamp, labels, datasets]);
589+
590+ const formatYAxisTick = (value: number, unit?: string) => {
591+ if (!unit) return value;
592+
593+ unit = unit.trim();
594+ if (unit === '%') return `${value}%`;
595+ if (unit.endsWith('B')) return `${value}${unit}`;
596+
597+ return value;
598+ };
599+
600+ return (
601+ <Line
602+ ref={chartRef}
603+ datasetIdKey={keyId}
604+ data={{
605+ labels,
606+ datasets,
607+ }}
608+ options={{
609+ responsive: true,
610+ maintainAspectRatio: false,
611+ animation: false,
612+ plugins: {
613+ tooltip: {
614+ enabled: true,
615+ mode: 'index',
616+ intersect: false,
617+ },
618+ colors: {
619+ forceOverride: true,
620+ },
621+ legend: {
622+ display: showLegend,
623+ labels: {
624+ usePointStyle: true,
625+ boxHeight: 5,
626+ boxWidth: 3,
627+ padding: 20,
628+ },
629+ },
630+ title: {
631+ font: {
632+ size: 16,
633+ weight: "normal",
634+ },
635+ color: "#595E63",
636+ align: "start",
637+ display: false,
638+ text: title,
639+ padding: showLegend
640+ ? undefined
641+ : {
642+ top: 10,
643+ bottom: 30,
644+ },
645+ },
646+ annotations: annotations,
647+ },
648+ interaction: {
649+ mode: 'index',
650+ intersect: false,
651+ },
652+ onHover: (event, elements, chart) => {
653+ if (!event.native) return;
654+
655+ if (elements && elements.length > 0) {
656+ const timestamp = labels[elements[0].index];
657+ setTimestamp(timestamp);
658+ } else {
659+ setTimestamp(null);
660+ }
661+ },
662+ scales: {
663+ x: {
664+ border: {
665+ color: "#111920",
666+ },
667+ grid: {
668+ display: false,
669+ },
670+ ticks: {
671+ color: "#111920",
672+ maxRotation: 0,
673+ minRotation: 0,
674+ autoSkip: true,
675+ maxTicksLimit: 5,
676+ },
677+ adapters: {
678+ date: {
679+ zone: "UTC",
680+ },
681+ },
682+ time: {
683+ tooltipFormat: "yyyy-MM-dd HH:mm:ss 'UTC'",
684+ unit: xAxisUnit,
685+ displayFormats: {
686+ minute: "HH:mm 'UTC'",
687+ day: "MMM dd",
688+ },
689+ },
690+ type: "time",
691+ },
692+ y: {
693+ min: 0,
694+ border: {
695+ display: false,
696+ },
697+ title: yAxisLabel
698+ ? {
699+ display: true,
700+ text: yAxisLabel,
701+ }
702+ : undefined,
703+ ticks: {
704+ callback: (value) => formatYAxisTick(value as number, yAxisUnit),
705+ color: "#111920",
706+ },
707+ },
708+ },
709+ }}
710+ />
711+ );
712+};
713+
75714 export const DiagnosticsDetailPage = () => {
76715 // Parse the investigation parameters from the query string.
77716 const [searchParams, setSearchParams] = useSearchParams();
......@@ -120,6 +759,10 @@ export const DiagnosticsDetailPage = () => {
120759 messages: [],
121760 });
122761
762+ const [showAllMessages, setShowAllMessages] = useState(false);
763+ const [hoverTimestamp, setHoverTimestamp] = useState<string | null>(null);
764+ const [hasShownCompletion, setHasShownCompletion] = useState(false);
765+
123766 // Process each event from the websocket, and update the dashboard state.
124767 useEffect(() => {
125768 if (event?.type === "ResourceDiscovered") {
......@@ -131,7 +774,7 @@ export const DiagnosticsDetailPage = () => {
131774 id: event.resource_id,
132775 type: event.resource_type,
133776 notes: event.notes,
134- metrics: [],
777+ plots: {},
135778 operations: [],
136779 },
137780 },
......@@ -146,8 +789,14 @@ export const DiagnosticsDetailPage = () => {
146789 plots: {
147790 ...prev.resources[event.resource_id].plots,
148791 [event.metric_name]: {
149- name: event.metric_name,
150- plot: event.plot,
792+ id: event.plot.id,
793+ title: event.plot.title,
794+ description: event.plot.description,
795+ interpretation: event.plot.interpretation,
796+ analysis: event.plot.analysis,
797+ unit: event.plot.unit,
798+ series: event.plot.series,
799+ annotations: event.plot.annotations,
151800 },
152801 },
153802 },
......@@ -184,6 +833,24 @@ export const DiagnosticsDetailPage = () => {
184833 }
185834 }, [JSON.stringify(event)]);
186835
836+ // Insert an "analysis complete" message if the socket is closed
837+ useEffect(() => {
838+ if (readyState === ReadyState.CLOSED && !hasShownCompletion) {
839+ setHasShownCompletion(true);
840+ setDashboard((prev) => ({
841+ ...prev,
842+ messages: [
843+ ...prev.messages,
844+ {
845+ id: 'completion-message',
846+ severity: 'info',
847+ message: 'Analysis complete.',
848+ },
849+ ],
850+ }));
851+ }
852+ }, [readyState, hasShownCompletion]);
853+
187854 return (
188855 <AppSidebarLayout>
189856 <Breadcrumbs
......@@ -199,12 +866,29 @@ export const DiagnosticsDetailPage = () => {
199866 ]}
200867 />
201868
202- <div className="flex flex-row items-center justify-center flex-1 min-h-[500px]">
203- <PreText
204- className="max-w-7xl overflow-x-auto overflow-y-auto"
205- text={JSON.stringify(dashboard, null, 2)}
206- allowCopy
207- />
869+ <div className="flex flex-col gap-4 p-4">
870+ <HoverContext.Provider value={{ timestamp: hoverTimestamp, setTimestamp: setHoverTimestamp }}>
871+ <DiagnosticsMessages
872+ messages={dashboard.messages}
873+ showAllMessages={showAllMessages}
874+ setShowAllMessages={setShowAllMessages}
875+ />
876+
877+ {/* Resources Section */}
878+ <h2 className="text-lg font-semibold mb-2">Resources</h2>
879+ <div className="space-y-4">
880+ {Object.entries(dashboard.resources).map(([resourceId, resource]) => (
881+ <DiagnosticsResource
882+ key={resourceId}
883+ resourceId={resourceId}
884+ resource={resource}
885+ startTime={startTime!}
886+ endTime={endTime!}
887+ synchronizedHoverContext={HoverContext}
888+ />
889+ ))}
890+ </div>
891+ </HoverContext.Provider>
208892 </div>
209893 </AppSidebarLayout>
210894 );
+59 -0 src/ui/shared/llm.tsx #
......@@ -0,0 +1,59 @@
1+import React, { useEffect, useState } from "react";
2+
3+export const StreamingText = ({ text, showEllipsis = false, animate = true }: { text: string, showEllipsis?: boolean, animate?: boolean }) => {
4+ const words = text.split(' ');
5+ const [visibleWords, setVisibleWords] = React.useState<number>(animate ? 0 : words.length);
6+ const [isComplete, setIsComplete] = React.useState<boolean>(!animate);
7+
8+ React.useEffect(() => {
9+ if (!animate) return;
10+
11+ const timer = setInterval(() => {
12+ setVisibleWords(prev => {
13+ if (prev < words.length) {
14+ return prev + 1;
15+ }
16+ clearInterval(timer);
17+ setIsComplete(true);
18+ return prev;
19+ });
20+ }, 150);
21+
22+ return () => clearInterval(timer);
23+ }, [words.length, animate]);
24+
25+ return (
26+ <div className="inline-block">
27+ {words.map((word, idx) => (
28+ <span
29+ key={idx}
30+ className={`inline-block ${idx < words.length - 1 ? 'mr-1' : ''} ${idx < visibleWords ? '' : 'hidden'}`}
31+ >
32+ {word}
33+ </span>
34+ ))}
35+ {showEllipsis && isComplete && <AnimatedEllipsis />}
36+ </div>
37+ );
38+};
39+
40+export const AnimatedEllipsis = () => {
41+ const [dots, setDots] = useState('');
42+
43+ useEffect(() => {
44+ const interval = setInterval(() => {
45+ setDots(prev => {
46+ if (prev === '...') return '';
47+ return prev + '.';
48+ });
49+ }, 500);
50+
51+ return () => clearInterval(interval);
52+ }, []);
53+
54+ return (
55+ <span className="inline-block w-6">
56+ {dots}
57+ </span>
58+ );
59+};
+7 -0 tailwind.config.cjs #
......@@ -9,6 +9,13 @@ module.exports = {
99 extend: {
1010 animation: {
1111 "spin-slow": "spin 3s linear infinite",
12+ "fade-in": "fade-in 0.5s ease-out",
13+ },
14+ keyframes: {
15+ 'fade-in': {
16+ '0%': { opacity: '0' },
17+ '100%': { opacity: '1' },
18+ }
1219 },
1320 borderWidth: {
1421 DEFAULT: "1px",
Back to top