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 053ed5f

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
+57 -0 src/aptible-ai/index.ts #
......@@ -47,3 +47,60 @@ export const deserializeDashboard = (payload: DashboardResponse): Dashboard => {
4747 id: `${payload.id}`,
4848 };
4949 };
50+
51+export type Message = {
52+ id: string;
53+ severity: string;
54+ message: string;
55+};
56+
57+export type Operation = {
58+ id: number;
59+ status: string;
60+ created_at: string;
61+ description: string;
62+ log_lines: string[];
63+};
64+
65+export type Point = {
66+ timestamp: string;
67+ value: number;
68+};
69+
70+export type Series = {
71+ label: string;
72+ description: string;
73+ interpretation: string;
74+ annotations: Annotation[];
75+ points: Point[];
76+};
77+
78+export type Plot = {
79+ id: string;
80+ title: string;
81+ description: string;
82+ interpretation: string;
83+ analysis: string;
84+ unit: string;
85+ series: Series[];
86+ annotations: Annotation[];
87+};
88+
89+export type Resource = {
90+ id: string;
91+ type: string;
92+ notes: string;
93+ plots: {
94+ [key: string]: Plot;
95+ };
96+ operations: Operation[];
97+};
98+
99+export type Annotation = {
100+ label: string;
101+ description: string;
102+ x_min: number;
103+ x_max: number;
104+ y_min: number;
105+ y_max: number;
106+};
+1 -9 src/chart/chartjs-plugin-annoations.ts #
......@@ -1,4 +1,5 @@
11 import { Chart as ChartJS } from "chart.js";
2+import { type Annotation } from "@app/aptible-ai";
23
34
45 // Update ChartJS interface to include annotations
......@@ -18,15 +19,6 @@ declare module 'chart.js' {
1819 }
1920 }
2021
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-
3022 // ChartJS plugin to draw annotations on a line chart
3123 export const annotationsPlugin = {
3224 id: 'annotations',
+170 -0 src/ui/hooks/use-dashboard.ts #
......@@ -0,0 +1,170 @@
1+import { useEffect, useState } from "react";
2+import { useSelector } from "@app/react";
3+import { selectAptibleAiUrl } from "@app/config";
4+import { selectAccessToken } from "@app/token";
5+import useWebSocket, { ReadyState } from "react-use-websocket";
6+import { type Message, type Resource } from "@app/aptible-ai";
7+
8+type Dashboard = {
9+ resources: {
10+ [key: string]: Resource;
11+ };
12+ messages: Message[];
13+};
14+
15+type UseDashboardParams = {
16+ appId: string;
17+ symptomDescription: string;
18+ startTime: string;
19+ endTime: string;
20+};
21+
22+const handleDashboardEvent = (dashboard: Dashboard, event: Record<string, any>): Dashboard => {
23+ switch (event?.type) {
24+ case "ResourceDiscovered":
25+ return {
26+ ...dashboard,
27+ resources: {
28+ ...dashboard.resources,
29+ [event.resource_id]: {
30+ id: event.resource_id,
31+ type: event.resource_type,
32+ notes: event.notes,
33+ plots: {},
34+ operations: [],
35+ },
36+ },
37+ };
38+ case "ResourceMetricsRetrieved":
39+ return {
40+ ...dashboard,
41+ resources: {
42+ ...dashboard.resources,
43+ [event.resource_id]: {
44+ ...dashboard.resources[event.resource_id],
45+ plots: {
46+ ...dashboard.resources[event.resource_id].plots,
47+ [event.plot.id]: {
48+ id: event.plot.id,
49+ title: event.plot.title,
50+ description: event.plot.description,
51+ interpretation: event.plot.interpretation,
52+ analysis: event.plot.analysis,
53+ unit: event.plot.unit,
54+ series: event.plot.series,
55+ annotations: event.plot.annotations,
56+ },
57+ },
58+ },
59+ },
60+ };
61+ case "PlotAnnotated":
62+ return {
63+ ...dashboard,
64+ resources: {
65+ ...dashboard.resources,
66+ [event.resource_id]: {
67+ ...dashboard.resources[event.resource_id],
68+ plots: {
69+ ...dashboard.resources[event.resource_id].plots,
70+ [event.plot_id]: {
71+ ...dashboard.resources[event.resource_id].plots[event.plot_id],
72+ analysis: event.analysis,
73+ annotations: event.annotations,
74+ },
75+ },
76+ },
77+ },
78+ };
79+ case "ResourceOperationsRetrieved":
80+ return {
81+ ...dashboard,
82+ resources: {
83+ ...dashboard.resources,
84+ [event.resource_id]: {
85+ ...dashboard.resources[event.resource_id],
86+ operations: [
87+ ...dashboard.resources[event.resource_id].operations,
88+ ...event.operations,
89+ ],
90+ },
91+ },
92+ };
93+ case "Message":
94+ return {
95+ ...dashboard,
96+ messages: [
97+ ...dashboard.messages,
98+ {
99+ id: event.id,
100+ severity: event.severity,
101+ message: event.message,
102+ },
103+ ],
104+ };
105+ default:
106+ console.log(`Unhandled event type ${event?.type}`, event);
107+ return dashboard;
108+ }
109+};
110+
111+export const useDashboard = ({ appId, symptomDescription, startTime, endTime }: UseDashboardParams) => {
112+ const aptibleAiUrl = useSelector(selectAptibleAiUrl);
113+ const accessToken = useSelector(selectAccessToken);
114+ const [socketConnected, setSocketConnected] = useState(true);
115+ const [dashboard, setDashboard] = useState<Dashboard>({
116+ resources: {},
117+ messages: [],
118+ });
119+ const [hasShownCompletion, setHasShownCompletion] = useState(false);
120+
121+ const { lastJsonMessage: event, readyState } = useWebSocket<Record<string, any>>(
122+ `${aptibleAiUrl}/troubleshoot`,
123+ {
124+ queryParams: {
125+ token: accessToken,
126+ resource_id: appId,
127+ symptom_description: symptomDescription,
128+ start_time: startTime,
129+ end_time: endTime,
130+ },
131+ },
132+ socketConnected,
133+ );
134+
135+ useEffect(() => {
136+ if (readyState === ReadyState.CLOSED) {
137+ setSocketConnected(false);
138+ }
139+ }, [readyState]);
140+
141+ useEffect(() => {
142+ if (event) {
143+ setDashboard(prevDashboard => handleDashboardEvent(prevDashboard, event));
144+ }
145+ }, [JSON.stringify(event)]);
146+
147+ // Show a message when the socket closes and the analysis is complete.
148+ useEffect(() => {
149+ if (readyState === ReadyState.CLOSED && !hasShownCompletion) {
150+ setHasShownCompletion(true);
151+ setDashboard((prev) => ({
152+ ...prev,
153+ messages: [
154+ ...prev.messages,
155+ {
156+ id: 'completion-message',
157+ severity: 'info',
158+ message: 'Analysis complete.',
159+ },
160+ ],
161+ }));
162+ }
163+ }, [readyState, hasShownCompletion]);
164+
165+ return {
166+ dashboard,
167+ isConnected: readyState === ReadyState.OPEN,
168+ isClosed: readyState === ReadyState.CLOSED,
169+ };
170+};
+10 -370 src/ui/pages/diagnostics-detail.tsx #
......@@ -1,392 +1,33 @@
1-import { selectAptibleAiUrl } from "@app/config";
2-import { useSelector } from "@app/react";
3-import React from "react";
41 import { diagnosticsCreateUrl } from "@app/routes";
5-import { selectAccessToken } from "@app/token";
6-import { useEffect, useState } from "react";
2+import { useState } from "react";
73 import { useSearchParams } from "react-router-dom";
8-import useWebSocket, { ReadyState } from "react-use-websocket";
94 import { AppSidebarLayout } from "../layouts";
105 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 { StreamingText } from "../shared/llm";
21-import { DiagnosticsLineChart } from "../shared/diagnostics/line-chart";
22-import { OperationsTimeline } from "../shared/diagnostics/operations-timeline";
23-import { Annotation } from "@app/chart/chartjs-plugin-annoations";
24-import { HoverContext, type HoverState } from "../shared/diagnostics/hover";
25-
26-type Message = {
27- id: string;
28- severity: string;
29- message: string;
30-};
31-
32-type Operation = {
33- id: number;
34- status: string;
35- created_at: string;
36- description: string;
37- log_lines: string[];
38-};
39-
40-type Point = {
41- timestamp: string;
42- value: number;
43-};
44-
45-type Series = {
46- label: string;
47- description: string;
48- interpretation: string;
49- annotations: Annotation[];
50- points: Point[];
51-};
52-
53-type Plot = {
54- id: string;
55- title: string;
56- description: string;
57- interpretation: string;
58- analysis: string;
59- unit: string;
60- series: Series[];
61- annotations: Annotation[];
62-};
63-
64-type Resource = {
65- id: string;
66- type: string;
67- notes: string;
68- plots: {
69- [key: string]: Plot;
70- };
71- operations: Operation[];
72-};
73-
74-type Dashboard = {
75- resources: {
76- [key: string]: Resource;
77- };
78- messages: Message[];
79-};
80-
81-const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: {
82- messages: Message[];
83- showAllMessages: boolean;
84- setShowAllMessages: (show: boolean) => void;
85-}) => {
86- return (
87- <div className="border rounded-lg p-4 bg-gray-50">
88- <div className="flex justify-between items-center mb-4">
89- <h2 className="text-lg font-semibold">Messages</h2>
90- {messages.length > 1 && (
91- <button
92- onClick={() => setShowAllMessages(!showAllMessages)}
93- className="text-blue-600 hover:text-blue-800 text-sm"
94- >
95- {showAllMessages ? 'Show Latest' : `Show All (${messages.length})`}
96- </button>
97- )}
98- </div>
99- <div className="space-y-6">
100- {(showAllMessages ? messages : messages.slice(-1)).map((message, index) => (
101- <div
102- key={message.id}
103- className="flex items-start"
104- >
105- <img
106- src={message.id === 'completion-message' ? '/aptible-mark.png' : '/thinking.gif'}
107- className="w-[28px] h-[28px] mr-3"
108- aria-label="App"
109- />
110- <div className="flex-1 bg-white rounded-lg px-4 py-2 shadow-sm">
111- <StreamingText
112- text={message.message}
113- showEllipsis={(showAllMessages ? index === messages.length - 1 : true) && message.id !== 'completion-message'}
114- animate={showAllMessages ? index === messages.length - 1 : true}
115- />
116- </div>
117- </div>
118- ))}
119- </div>
120- </div>
121- );
122-};
123-
124-const DiagnosticsResource = ({
125- resourceId,
126- resource,
127- startTime,
128- endTime,
129- synchronizedHoverContext
130-}: {
131- resourceId: string;
132- resource: Resource;
133- startTime: string;
134- endTime: string;
135- synchronizedHoverContext: React.Context<HoverState>;
136-}) => {
137- return (
138- <div className="border rounded-lg p-4">
139- <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">
140- {resource.type === "app" ? <IconBox /> :
141- resource.type === "database" ? <IconCylinder /> :
142- resource.type === "endpoint" ? <IconEndpoint /> :
143- resource.type === "service" ? <IconService /> :
144- resource.type === "source" ? <IconSource /> :
145- <IconCloud />}
146- <span className="font-mono text-lg font-bold">{resourceId}</span>
147- </h3>
148-
149- {/* Operations */}
150- {resource.operations && resource.operations.length > 0 && (
151- <div className="mt-2">
152- <div className="border rounded-lg bg-white shadow-sm animate-fade-in">
153- <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">Operations</h4>
154- <div className="p-6">
155- <OperationsTimeline
156- operations={resource.operations}
157- startTime={startTime}
158- endTime={endTime}
159- synchronizedHoverContext={synchronizedHoverContext}
160- />
161- </div>
162- </div>
163- </div>
164- )}
165-
166- {/* Plots */}
167- {resource.plots && Object.entries(resource.plots).length > 0 && (
168- <div className="mt-2">
169- <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
170- {Object.entries(resource.plots)
171- .filter(([_, plot]) =>
172- plot.series.some(series => series.points && series.points.length > 0)
173- )
174- .map(([plotId, plot]) => (
175- <div
176- key={plotId}
177- className="border rounded-lg bg-white shadow-sm animate-fade-in"
178- >
179- <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">
180- {plot.title}
181- </h4>
182- <div className="p-6">
183- {plot.interpretation && (
184- <div className="mt-4 bg-orange-100 p-3 rounded-md">
185- <div className="flex items-start gap-2">
186- <IconInfo className="w-4 h-4 mt-1 text-yellow-600 flex-shrink-0" />
187- <div>
188- <p className="text-gray-600">
189- <strong className="mr-1">Interpretation:</strong>
190- {plot.interpretation}
191- </p>
192- </div>
193- </div>
194- </div>
195- )}
196- <div className="mt-2 min-h-[200px]">
197- <DiagnosticsLineChart
198- showLegend={true}
199- keyId={plot.id}
200- chart={{
201- title: " ",
202- labels: plot.series[0]?.points.map(point => point.timestamp) || [],
203- datasets: plot.series.map(series => ({
204- label: series.label,
205- data: series.points.map(point => point.value)
206- }))
207- }}
208- xAxisUnit="minute"
209- yAxisLabel={plot.title}
210- yAxisUnit={plot.unit}
211- annotations={plot.annotations}
212- synchronizedHoverContext={synchronizedHoverContext}
213- />
214- </div>
215- {plot.analysis && (
216- <div className="mt-4">
217- <p className="mt-1 text-gray-500 text-xs">
218- <strong>Analysis: </strong>
219- {plot.analysis}
220- </p>
221- </div>
222- )}
223- </div>
224- </div>
225- ))}
226- </div>
227- </div>
228- )}
229- </div>
230- );
231-};
6+import { HoverContext } from "../shared/diagnostics/hover";
7+import { DiagnosticsMessages } from "../shared/diagnostics/messages";
8+import { DiagnosticsResource } from "../shared/diagnostics/resource";
9+import { useDashboard } from "../hooks/use-dashboard";
23210
23311 export const DiagnosticsDetailPage = () => {
234- // Parse the investigation parameters from the query string.
23512 const [searchParams] = useSearchParams();
236- const accessToken = useSelector(selectAccessToken);
23713 const appId = searchParams.get("appId");
23814 const symptomDescription = searchParams.get("symptomDescription");
23915 const startTime = searchParams.get("startTime");
24016 const endTime = searchParams.get("endTime");
24117
242- // If any of the parameters are missing, display an error message with a link
243- // to the diagnostics create page.
24418 if (!appId || !symptomDescription || !startTime || !endTime) {
24519 throw new Error("Missing parameters");
24620 }
24721
248- // Connect to the Aptible AI WebSocket.
249- const aptibleAiUrl = useSelector(selectAptibleAiUrl);
250- const [socketConnected, setSocketConnected] = useState(true);
251- const { lastJsonMessage: event, readyState } = useWebSocket<
252- Record<string, any>
253- >(
254- `${aptibleAiUrl}/troubleshoot`,
255- {
256- queryParams: {
257- token: accessToken,
258- resource_id: appId,
259- symptom_description: symptomDescription,
260- start_time: startTime,
261- end_time: endTime,
262- },
263- },
264- socketConnected,
265- );
266-
267- // If the socket is closed, set the socketConnected state to false (this is
268- // mostly helpful for hot reloading, since the socket will typically close on
269- // its own under normal circumstances).
270- useEffect(() => {
271- if (readyState === ReadyState.CLOSED) {
272- setSocketConnected(false);
273- }
274- }, [readyState]);
275-
276- const [dashboard, setDashboard] = useState<Dashboard>({
277- resources: {},
278- messages: [],
22+ const { dashboard } = useDashboard({
23+ appId,
24+ symptomDescription,
25+ startTime,
26+ endTime,
27927 });
28028
28129 const [showAllMessages, setShowAllMessages] = useState(false);
28230 const [hoverTimestamp, setHoverTimestamp] = useState<string | null>(null);
283- const [hasShownCompletion, setHasShownCompletion] = useState(false);
284-
285- // Process each event from the websocket, and update the dashboard state.
286- useEffect(() => {
287- if (event?.type === "ResourceDiscovered") {
288- setDashboard((prev) => ({
289- ...prev,
290- resources: {
291- ...prev.resources,
292- [event.resource_id]: {
293- id: event.resource_id,
294- type: event.resource_type,
295- notes: event.notes,
296- plots: {},
297- operations: [],
298- },
299- },
300- }));
301- } else if (event?.type === "ResourceMetricsRetrieved") {
302- setDashboard((prev) => ({
303- ...prev,
304- resources: {
305- ...prev.resources,
306- [event.resource_id]: {
307- ...prev.resources[event.resource_id],
308- plots: {
309- ...prev.resources[event.resource_id].plots,
310- [event.plot.id]: {
311- id: event.plot.id,
312- title: event.plot.title,
313- description: event.plot.description,
314- interpretation: event.plot.interpretation,
315- analysis: event.plot.analysis,
316- unit: event.plot.unit,
317- series: event.plot.series,
318- annotations: event.plot.annotations,
319- },
320- },
321- },
322- },
323- }));
324- } else if (event?.type === "PlotAnnotated") {
325- setDashboard((prev) => ({
326- ...prev,
327- resources: {
328- ...prev.resources,
329- [event.resource_id]: {
330- ...prev.resources[event.resource_id],
331- plots: {
332- ...prev.resources[event.resource_id].plots,
333- [event.plot_id]: {
334- ...prev.resources[event.resource_id].plots[event.plot_id],
335- analysis: event.analysis,
336- annotations: event.annotations,
337- },
338- },
339- },
340- },
341- }));
342- } else if (event?.type === "ResourceOperationsRetrieved") {
343- setDashboard((prev) => ({
344- ...prev,
345- resources: {
346- ...prev.resources,
347- [event.resource_id]: {
348- ...prev.resources[event.resource_id],
349- operations: [
350- ...prev.resources[event.resource_id].operations,
351- ...event.operations,
352- ],
353- },
354- },
355- }));
356- } else if (event?.type === "Message") {
357- setDashboard((prev) => ({
358- ...prev,
359- messages: [
360- ...prev.messages,
361- {
362- id: event.id,
363- severity: event.severity,
364- message: event.message,
365- },
366- ],
367- }));
368- } else {
369- console.log(`Unhandled event type ${event?.type}`, event);
370- }
371- }, [JSON.stringify(event)]);
372-
373- // Insert an "analysis complete" message if the socket is closed
374- useEffect(() => {
375- if (readyState === ReadyState.CLOSED && !hasShownCompletion) {
376- setHasShownCompletion(true);
377- setDashboard((prev) => ({
378- ...prev,
379- messages: [
380- ...prev.messages,
381- {
382- id: 'completion-message',
383- severity: 'info',
384- message: 'Analysis complete.',
385- },
386- ],
387- }));
388- }
389- }, [readyState, hasShownCompletion]);
39031
39132 return (
39233 <AppSidebarLayout>
......@@ -411,7 +52,6 @@ export const DiagnosticsDetailPage = () => {
41152 setShowAllMessages={setShowAllMessages}
41253 />
41354
414- {/* Resources Section */}
41555 <h2 className="text-lg font-semibold mb-2">Resources</h2>
41656 <div className="space-y-4">
41757 {Object.entries(dashboard.resources).map(([resourceId, resource]) => (
+2 -1 src/ui/shared/diagnostics/line-chart.tsx #
......@@ -15,7 +15,8 @@ import {
1515 import "chartjs-adapter-luxon";
1616 import { Line } from "react-chartjs-2";
1717 import { verticalLinePlugin } from "../../../chart/chartjs-plugin-vertical-line";
18-import { annotationsPlugin, type Annotation } from "../../../chart/chartjs-plugin-annoations";
18+import { annotationsPlugin } from "../../../chart/chartjs-plugin-annoations";
19+import { type Annotation } from "@app/aptible-ai";
1920 import { type HoverState } from "./hover";
2021
2122 ChartJS.register(
+45 -0 src/ui/shared/diagnostics/messages.tsx #
......@@ -0,0 +1,45 @@
1+import { StreamingText } from "../llm";
2+import { type Message } from "@app/aptible-ai";
3+
4+export const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: {
5+ messages: Message[];
6+ showAllMessages: boolean;
7+ setShowAllMessages: (show: boolean) => void;
8+}) => {
9+ return (
10+ <div className="border rounded-lg p-4 bg-gray-50">
11+ <div className="flex justify-between items-center mb-4">
12+ <h2 className="text-lg font-semibold">Messages</h2>
13+ {messages.length > 1 && (
14+ <button
15+ onClick={() => setShowAllMessages(!showAllMessages)}
16+ className="text-blue-600 hover:text-blue-800 text-sm"
17+ >
18+ {showAllMessages ? 'Show Latest' : `Show All (${messages.length})`}
19+ </button>
20+ )}
21+ </div>
22+ <div className="space-y-6">
23+ {(showAllMessages ? messages : messages.slice(-1)).map((message, index) => (
24+ <div
25+ key={message.id}
26+ className="flex items-start"
27+ >
28+ <img
29+ src={message.id === 'completion-message' ? '/aptible-mark.png' : '/thinking.gif'}
30+ className="w-[28px] h-[28px] mr-3"
31+ aria-label="App"
32+ />
33+ <div className="flex-1 bg-white rounded-lg px-4 py-2 shadow-sm">
34+ <StreamingText
35+ text={message.message}
36+ showEllipsis={(showAllMessages ? index === messages.length - 1 : true) && message.id !== 'completion-message'}
37+ animate={showAllMessages ? index === messages.length - 1 : true}
38+ />
39+ </div>
40+ </div>
41+ ))}
42+ </div>
43+ </div>
44+ );
45+};
+1 -8 src/ui/shared/diagnostics/operations-timeline.tsx #
......@@ -1,13 +1,6 @@
11 import React, { useRef, useContext } from "react";
22 import { type HoverState } from "./hover";
3-
4-type Operation = {
5- id: number;
6- status: string;
7- created_at: string;
8- description: string;
9- log_lines: string[];
10-};
3+import { type Operation } from "@app/aptible-ai";
114
125 export const OperationsTimeline = ({
136 operations,
+128 -0 src/ui/shared/diagnostics/resource.tsx #
......@@ -0,0 +1,128 @@
1+import React from "react";
2+import { IconBox, IconCloud, IconCylinder, IconEndpoint, IconService, IconSource, IconInfo } from "../../shared/icons";
3+import { DiagnosticsLineChart } from "./line-chart";
4+import { OperationsTimeline } from "./operations-timeline";
5+import { type HoverState } from "./hover";
6+import { type Resource } from "@app/aptible-ai";
7+
8+const ResourceIcon = ({ type }: { type: Resource["type"] }) => {
9+ switch (type) {
10+ case "app":
11+ return <IconBox />;
12+ case "database":
13+ return <IconCylinder />;
14+ case "endpoint":
15+ return <IconEndpoint />;
16+ case "service":
17+ return <IconService />;
18+ case "source":
19+ return <IconSource />;
20+ default:
21+ return <IconCloud />;
22+ }
23+};
24+
25+export const DiagnosticsResource = ({
26+ resourceId,
27+ resource,
28+ startTime,
29+ endTime,
30+ synchronizedHoverContext
31+}: {
32+ resourceId: string;
33+ resource: Resource;
34+ startTime: string;
35+ endTime: string;
36+ synchronizedHoverContext: React.Context<HoverState>;
37+}) => {
38+ return (
39+ <div className="border rounded-lg p-4">
40+ <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">
41+ <ResourceIcon type={resource.type} />
42+ <span className="font-mono text-lg font-bold">{resourceId}</span>
43+ </h3>
44+
45+ {/* Operations */}
46+ {resource.operations && resource.operations.length > 0 && (
47+ <div className="mt-2">
48+ <div className="border rounded-lg bg-white shadow-sm animate-fade-in">
49+ <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">Operations</h4>
50+ <div className="p-6">
51+ <OperationsTimeline
52+ operations={resource.operations}
53+ startTime={startTime}
54+ endTime={endTime}
55+ synchronizedHoverContext={synchronizedHoverContext}
56+ />
57+ </div>
58+ </div>
59+ </div>
60+ )}
61+
62+ {/* Plots */}
63+ {resource.plots && Object.entries(resource.plots).length > 0 && (
64+ <div className="mt-2">
65+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
66+ {Object.entries(resource.plots)
67+ .filter(([_, plot]) =>
68+ // Only show plots that have data
69+ plot.series.some(series => series.points && series.points.length > 0)
70+ )
71+ .map(([plotId, plot]) => (
72+ <div
73+ key={plotId}
74+ className="border rounded-lg bg-white shadow-sm animate-fade-in"
75+ >
76+ <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">
77+ {plot.title}
78+ </h4>
79+ <div className="p-6">
80+ {plot.interpretation && (
81+ <div className="mt-4 bg-orange-100 p-3 rounded-md">
82+ <div className="flex items-start gap-2">
83+ <IconInfo className="w-4 h-4 mt-1 text-yellow-600 flex-shrink-0" />
84+ <div>
85+ <p className="text-gray-600">
86+ <strong className="mr-1">Interpretation:</strong>
87+ {plot.interpretation}
88+ </p>
89+ </div>
90+ </div>
91+ </div>
92+ )}
93+ <div className="mt-2 min-h-[200px]">
94+ <DiagnosticsLineChart
95+ showLegend={true}
96+ keyId={plot.id}
97+ chart={{
98+ title: " ",
99+ labels: plot.series[0]?.points.map(point => point.timestamp) || [],
100+ datasets: plot.series.map(series => ({
101+ label: series.label,
102+ data: series.points.map(point => point.value)
103+ }))
104+ }}
105+ xAxisUnit="minute"
106+ yAxisLabel={plot.title}
107+ yAxisUnit={plot.unit}
108+ annotations={plot.annotations}
109+ synchronizedHoverContext={synchronizedHoverContext}
110+ />
111+ </div>
112+ {plot.analysis && (
113+ <div className="mt-4">
114+ <p className="mt-1 text-gray-500 text-xs">
115+ <strong>Analysis: </strong>
116+ {plot.analysis}
117+ </p>
118+ </div>
119+ )}
120+ </div>
121+ </div>
122+ ))}
123+ </div>
124+ </div>
125+ )}
126+ </div>
127+ );
128+};
Back to top