dashboard / erock/app-ui / Install react-use-websocket #46 rss

closed · opened on 2025-02-06T15:20:57Z by erock
Help
checkout latest patchset:
ssh pr.pico.sh print pr-46 | git am -3
checkout any patchset in a patch request:
ssh pr.pico.sh print ps-X | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 46
add review to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add --review 46
accept PR:
ssh pr.pico.sh pr accept 46
close PR:
ssh pr.pico.sh pr close 46

Logs

erock created pr with ps-99 on 2025-02-06T15:20:57Z
erock changed status on 2025-02-06T15:27:00Z {"status":"closed"}

Patchsets

ps-99 by erock on 2025-02-06T15:20:57Z

Patchset ps-99

Install react-use-websocket

Eric Abruzzese
2025-01-15T14:28:15Z
package.json
+1 -0
yarn.lock
+8 -0

Support async plot annotations

Eric Abruzzese
2025-01-31T17:53:30Z

Add VITE_APTIBLE_AI_URL to .env.example

Eric Abruzzese
2025-02-04T16:30:13Z
.env.example
+1 -0

Install react-use-websocket

Eric Abruzzese
2025-01-15T14:28:15Z
package.json
+1 -0
yarn.lock
+8 -0

Support async plot annotations

Eric Abruzzese
2025-01-31T17:53:30Z

Add VITE_APTIBLE_AI_URL to .env.example

Eric Abruzzese
2025-02-04T16:30:13Z
.env.example
+1 -0

Fix a linter error

Eric Abruzzese
2025-02-05T19:40:48Z
Back to top

Install react-use-websocket

package.json link
+1 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
diff --git a/package.json b/package.json
index c965d487f..f660837e3 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
     "react-dom": "^18.3.1",
     "react-router": "^7.0.2",
     "react-router-dom": "^7.0.2",
+    "react-use-websocket": "^4.11.1",
     "starfx": "^0.13.4",
     "tailwindcss": "^3.4.16",
     "typescript": "^5.7.2",
yarn.lock link
+8 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
diff --git a/yarn.lock b/yarn.lock
index acf63f08e..852c6b9a0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2067,6 +2067,7 @@ __metadata:
     react-dom: ^18.3.1
     react-router: ^7.0.2
     react-router-dom: ^7.0.2
+    react-use-websocket: ^4.11.1
     starfx: ^0.13.4
     tailwindcss: ^3.4.16
     typescript: ^5.7.2
@@ -4946,6 +4947,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-use-websocket@npm:^4.11.1":
+  version: 4.11.1
+  resolution: "react-use-websocket@npm:4.11.1"
+  checksum: 8104c2a5c1e5cd32a152a1f03cfa7786a629e72e997fd5ee5b9be4f8fdeef75913e2af2c8689b0aaae37087ba5cf2be3fb69d2bb3282e3a92c8f3f2f88fc7709
+  languageName: node
+  linkType: hard
+
 "react@npm:^18.2.0, react@npm:^18.3.1":
   version: 18.3.1
   resolution: "react@npm:18.3.1"

Update the diagnostics create form to navigate to the details page with query parameters instead of POSTing to the external service

src/routes/index.ts link
+2 -2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 17a4deb9c..5ca8f9649 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -408,8 +408,8 @@ export const DIAGNOSTICS_URL = "/diagnostics";
 export const diagnosticsUrl = () => DIAGNOSTICS_URL;
 export const DIAGNOSTICS_CREATE_URL = "/diagnostics/create";
 export const diagnosticsCreateUrl = () => DIAGNOSTICS_CREATE_URL;
-export const DIAGNOSTICS_DETAIL_URL = "/diagnostics/:id";
-export const diagnosticsDetailUrl = (id: string) => `/diagnostics/${id}`;
+export const DIAGNOSTICS_DETAIL_URL = "/diagnostics/detail";
+export const diagnosticsDetailUrl = (appId: string, symptoms: string, start: Date, end: Date) => `/diagnostics/detail?app_id=${appId}&symptom_description=${symptoms}&start_time=${start.toISOString()}&end_time=${end.toISOString()}`;
 
 export const SOURCES_PATH = "/sources";
 export const sourcesUrl = () => SOURCES_PATH;
src/ui/pages/diagnostics-create.tsx link
+15 -27
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
diff --git a/src/ui/pages/diagnostics-create.tsx b/src/ui/pages/diagnostics-create.tsx
index 0efaefbdf..949543fe4 100644
--- a/src/ui/pages/diagnostics-create.tsx
+++ b/src/ui/pages/diagnostics-create.tsx
@@ -6,7 +6,6 @@ import {
 import { DateTime } from "luxon";
 import { useEffect, useMemo, useState } from "react";
 import DatePicker from "react-datepicker";
-import { useDispatch, useLoader, useSelector } from "starfx/react";
 import { AppSidebarLayout } from "../layouts";
 import {
   Banner,
@@ -21,13 +20,9 @@ import {
 import { AppSelect } from "../shared/select-apps";
 
 import "react-datepicker/dist/react-datepicker.css";
-import { createDashboard } from "@app/aptible-ai";
-import { type WebState, schema } from "@app/schema";
 import { useNavigate } from "react-router-dom";
 
 export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
-  const dispatch = useDispatch();
-
   const symptomOptions = [
     { label: "App is slow", value: "App is slow" },
     { label: "App is unavailable", value: "App is unavailable" },
@@ -43,7 +38,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
   // invalid.
   const now = useMemo(
     () => DateTime.now().minus({ minutes: DateTime.local().offset }),
-    [],
+    []
   );
 
   const timePresets = [
@@ -60,7 +55,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
   const [timePreset, setTimePreset] = useState(timePresets[2].value);
 
   const [startDate, setStartDate] = useState<DateTime>(
-    DateTime.fromISO(timePreset),
+    DateTime.fromISO(timePreset)
   );
   const onSelectStartDate = (date: Date) => {
     const dateTime = DateTime.fromJSDate(date);
@@ -100,33 +95,26 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
       startDate !== null &&
       endDate !== null &&
       startDate < endDate,
-    [symptoms, appId, startDate, endDate],
+    [symptoms, appId, startDate, endDate]
   );
 
   // Submit the form.
-  const submitAction = createDashboard({
-    symptoms: symptoms,
-    appId,
-    start: startDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
-    end: endDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
-  });
-  const dashboardData = useSelector((s: WebState) =>
-    schema.cache.selectById(s, { id: submitAction.payload.key }),
-  );
-  const { isLoading } = useLoader(submitAction);
+  const [isLoading, setIsLoading] = useState(false);
+  const navigate = useNavigate();
   const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault();
-    dispatch(submitAction);
+    setIsLoading(true);
+    navigate(
+      diagnosticsDetailUrl(
+        appId,
+        symptoms,
+        startDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
+        endDate.toUTC(0, { keepLocalTime: true }).toJSDate()
+      )
+    );
+    setIsLoading(false);
   };
 
-  // Navigate to the dashboard when it is created.
-  const navigate = useNavigate();
-  useEffect(() => {
-    if (dashboardData?.id) {
-      navigate(diagnosticsDetailUrl(dashboardData.id));
-    }
-  }, [dashboardData]);
-
   return (
     <>
       <form onSubmit={handleSubmit}>

Collect events on the diagnostic details page and construct a dashboard state

src/ui/pages/diagnostics-detail.tsx link
+179 -74
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index f7ab81b99..2bbdc9be0 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -1,76 +1,192 @@
 import { selectAptibleAiUrl } from "@app/config";
 import { useSelector } from "@app/react";
-import { diagnosticsCreateUrl, diagnosticsDetailUrl } from "@app/routes";
+import { diagnosticsCreateUrl } from "@app/routes";
 import { selectAccessToken } from "@app/token";
 import { useEffect, useState } from "react";
-import { useParams } from "react-router-dom";
+import { Link, useSearchParams } from "react-router-dom";
 import { AppSidebarLayout } from "../layouts";
-import { Breadcrumbs, Loading, LoadingSpinner } from "../shared";
-import { Button } from "../shared/button";
+import { Breadcrumbs, PreText } from "../shared";
+import useWebSocket, { ReadyState } from "react-use-websocket";
+
+type Message = {
+  id: string;
+  severity: string;
+  message: string;
+};
+
+type Operation = {
+  id: number;
+  status: string;
+  created_at: Date;
+  description: string;
+  log_lines: string[];
+};
+
+type Point = {
+  timestamp: Date;
+  value: number;
+};
+
+type Annotation = {
+  label: string;
+  description: string;
+  x_min: number;
+  x_max: number;
+  y_min: number;
+  y_max: number;
+};
+
+type Series = {
+  label: string;
+  description: string;
+  interpretation: string;
+  annotations: Annotation[];
+  points: Point[];
+};
+
+type Plot = {
+  id: string;
+  title: string;
+  description: string;
+  interpretation: string;
+  analysis: string;
+  unit: string;
+  series: Series[];
+  annotations: Annotation[];
+};
+
+type Resource = {
+  id: string;
+  type: string;
+  notes: string;
+  plots: {
+    [key: string]: Plot;
+  };
+  operations: Operation[];
+};
+
+type Dashboard = {
+  resources: {
+    [key: string]: Resource;
+  };
+  messages: Message[];
+};
 
-const loadingMessages = [
-  "Consulting the tech support crystal ball...",
-  "Teaching hamsters to debug code...",
-  "Bribing the servers with virtual cookies...",
-  "Performing diagnostic interpretive dance...",
-  "Teaching the AI to be less artificial and more intelligent...",
-];
 
 export const DiagnosticsDetailPage = () => {
-  const { id } = useParams();
-  const aptibleAiUrl = useSelector(selectAptibleAiUrl);
-  const dashboardUrl = `${aptibleAiUrl}/app/dashboards/${id}/`;
-  const [messageIndex, setMessageIndex] = useState(0);
-  const [isDashboardReady, setIsDashboardReady] = useState(false);
+  // Parse the investigation parameters from the query string.
+  const [searchParams, setSearchParams] = useSearchParams();
   const accessToken = useSelector(selectAccessToken);
+  const appId = searchParams.get("app_id");
+  const symptomDescription = searchParams.get("symptom_description");
+  const startTime = searchParams.get("start_time");
+  const endTime = searchParams.get("end_time");
 
-  useEffect(() => {
-    const checkDashboard = async () => {
-      try {
-        // TODO: Figure out how to get a status code from an action, so that we
-        // can swap out this fetch implementation.
-        const response = await fetch(dashboardUrl, {
-          // Credentials are included to allow aptible-ai to set the session
-          // cookie, effectively logging us in.
-          credentials: "include",
-          headers: {
-            Authorization: `Bearer ${accessToken}`,
-          },
-        });
-        if (response.ok) {
-          setIsDashboardReady(true);
-          return true;
-        }
-      } catch (error) {
-        // Ignore errors - we'll try again
-      }
-      return false;
-    };
-
-    let interval: NodeJS.Timeout;
-
-    const initialize = async () => {
-      // Check immediately, in case the dashboard has already been created.
-      const isReady = await checkDashboard();
-
-      // Only set up the interval if the dashboard isn't ready yet.
-      if (!isReady) {
-        interval = setInterval(async () => {
-          setMessageIndex((current) => (current + 1) % loadingMessages.length);
-          const ready = await checkDashboard();
-          if (ready) {
-            clearInterval(interval);
-          }
-        }, 3000);
+  // If any of the parameters are missing, display an error message with a link
+  // to the diagnostics create page.
+  if (!appId || !symptomDescription || !startTime || !endTime) {
+    return (
+      <div>
+        <p>Error: Missing parameters</p>
+        <Link to={diagnosticsCreateUrl()}>Go back to diagnostics page</Link>
+      </div>
+    );
+  }
+
+  // Connect to the Aptible AI WebSocket.
+  const aptibleAiUrl = useSelector(selectAptibleAiUrl);
+  const [socketConnected, setSocketConnected] = useState(true);
+  const { lastJsonMessage: event, readyState } = useWebSocket<Record<string, any>>(
+    `${aptibleAiUrl}/troubleshoot`,
+    {
+      queryParams: {
+        token: accessToken,
+        resource_id: appId,
+        symptom_description: symptomDescription,
+        start_time: startTime,
+        end_time: endTime,
       }
-    };
+    },
+    socketConnected
+  );
 
-    initialize();
+  // If the socket is closed, set the socketConnected state to false (this is
+  // mostly helpful for hot reloading, since the socket will typically close on
+  // its own under normal circumstances).
+  useEffect(() => {
+    if (readyState === ReadyState.CLOSED) {
+      setSocketConnected(false);
+    }
+  }, [readyState]);
+
+  const [dashboard, setDashboard] = useState<Dashboard>({
+    resources: {},
+    messages: [],
+  });
 
-    return () => {
-      if (interval) clearInterval(interval);
-    };
-  }, [id, dashboardUrl, accessToken]);
+  // Process each event from the websocket, and update the dashboard state.
+  useEffect(() => {
+    if (event?.type === "ResourceDiscovered") {
+      setDashboard((prev) => ({
+        ...prev,
+        resources: {
+          ...prev.resources,
+          [event.resource_id]: {
+            id: event.resource_id,
+            type: event.resource_type,
+            notes: event.notes,
+            metrics: [],
+            operations: [],
+          },
+        },
+      }));
+    } else if (event?.type === "ResourceMetricsRetrieved") {
+      setDashboard((prev) => ({
+        ...prev,
+        resources: {
+          ...prev.resources,
+          [event.resource_id]: {
+            ...prev.resources[event.resource_id],
+            plots: {
+              ...prev.resources[event.resource_id].plots,
+              [event.metric_name]: {
+                name: event.metric_name,
+                plot: event.plot,
+              },
+            },
+          },
+        },
+      }));
+    } else if (event?.type === "ResourceOperationsRetrieved") {
+      setDashboard((prev) => ({
+        ...prev,
+        resources: {
+          ...prev.resources,
+          [event.resource_id]: {
+            ...prev.resources[event.resource_id],
+            operations: [
+              ...prev.resources[event.resource_id].operations,
+              ...event.operations,
+            ],
+          },
+        },
+      }));
+    } else if (event?.type === "Message") {
+      setDashboard((prev) => ({
+        ...prev,
+        messages: [
+          ...prev.messages,
+          {
+            id: event.id,
+            severity: event.severity,
+            message: event.message,
+          },
+        ],
+      }));
+    } else {
+      console.log(`Unhandled event type ${event?.type}`, event);
+    }
+  }, [JSON.stringify(event)]);
 
   return (
     <AppSidebarLayout>
@@ -81,25 +197,14 @@ export const DiagnosticsDetailPage = () => {
             to: diagnosticsCreateUrl(),
           },
           {
-            name: `${id}`,
-            to: diagnosticsDetailUrl(`${id}`),
+            name: `${appId} (${symptomDescription})`,
+            to: window.location.href,
           },
         ]}
       />
 
       <div className="flex flex-row items-center justify-center flex-1 min-h-[500px]">
-        <div className="scale-150 flex flex-row items-center gap-3">
-          {!isDashboardReady ? (
-            <>
-              <LoadingSpinner />
-              <Loading text={loadingMessages[messageIndex]} />
-            </>
-          ) : (
-            <form action={dashboardUrl} target="_blank" method="post">
-              <Button type="submit">View Dashboard</Button>
-            </form>
-          )}
-        </div>
+        <PreText className="max-w-7xl overflow-x-auto overflow-y-auto" text={JSON.stringify(dashboard, null, 2)} allowCopy />
       </div>
     </AppSidebarLayout>
   );

Formatting and cleanup

src/routes/index.ts link
+7 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 5ca8f9649..6503030b7 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -409,7 +409,13 @@ export const diagnosticsUrl = () => DIAGNOSTICS_URL;
 export const DIAGNOSTICS_CREATE_URL = "/diagnostics/create";
 export const diagnosticsCreateUrl = () => DIAGNOSTICS_CREATE_URL;
 export const DIAGNOSTICS_DETAIL_URL = "/diagnostics/detail";
-export const diagnosticsDetailUrl = (appId: string, symptoms: string, start: Date, end: Date) => `/diagnostics/detail?app_id=${appId}&symptom_description=${symptoms}&start_time=${start.toISOString()}&end_time=${end.toISOString()}`;
+export const diagnosticsDetailUrl = (
+  appId: string,
+  symptoms: string,
+  start: Date,
+  end: Date,
+) =>
+  `/diagnostics/detail?appId=${appId}&symptomDescription=${symptoms}&startTime=${start.toISOString()}&endTime=${end.toISOString()}`;
 
 export const SOURCES_PATH = "/sources";
 export const sourcesUrl = () => SOURCES_PATH;
src/ui/pages/diagnostics-create.tsx link
+5 -5
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
diff --git a/src/ui/pages/diagnostics-create.tsx b/src/ui/pages/diagnostics-create.tsx
index 949543fe4..bc8fa182d 100644
--- a/src/ui/pages/diagnostics-create.tsx
+++ b/src/ui/pages/diagnostics-create.tsx
@@ -38,7 +38,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
   // invalid.
   const now = useMemo(
     () => DateTime.now().minus({ minutes: DateTime.local().offset }),
-    []
+    [],
   );
 
   const timePresets = [
@@ -55,7 +55,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
   const [timePreset, setTimePreset] = useState(timePresets[2].value);
 
   const [startDate, setStartDate] = useState<DateTime>(
-    DateTime.fromISO(timePreset)
+    DateTime.fromISO(timePreset),
   );
   const onSelectStartDate = (date: Date) => {
     const dateTime = DateTime.fromJSDate(date);
@@ -95,7 +95,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
       startDate !== null &&
       endDate !== null &&
       startDate < endDate,
-    [symptoms, appId, startDate, endDate]
+    [symptoms, appId, startDate, endDate],
   );
 
   // Submit the form.
@@ -109,8 +109,8 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
         appId,
         symptoms,
         startDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
-        endDate.toUTC(0, { keepLocalTime: true }).toJSDate()
-      )
+        endDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
+      ),
     );
     setIsLoading(false);
   };
src/ui/pages/diagnostics-detail.tsx link
+19 -19
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index 2bbdc9be0..2bd4710ef 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -3,10 +3,10 @@ import { useSelector } from "@app/react";
 import { diagnosticsCreateUrl } from "@app/routes";
 import { selectAccessToken } from "@app/token";
 import { useEffect, useState } from "react";
-import { Link, useSearchParams } from "react-router-dom";
+import { Link, useParams, useSearchParams } from "react-router-dom";
+import useWebSocket, { ReadyState } from "react-use-websocket";
 import { AppSidebarLayout } from "../layouts";
 import { Breadcrumbs, PreText } from "../shared";
-import useWebSocket, { ReadyState } from "react-use-websocket";
 
 type Message = {
   id: string;
@@ -17,13 +17,13 @@ type Message = {
 type Operation = {
   id: number;
   status: string;
-  created_at: Date;
+  created_at: string;
   description: string;
   log_lines: string[];
 };
 
 type Point = {
-  timestamp: Date;
+  timestamp: string;
   value: number;
 };
 
@@ -72,31 +72,27 @@ type Dashboard = {
   messages: Message[];
 };
 
-
 export const DiagnosticsDetailPage = () => {
   // Parse the investigation parameters from the query string.
   const [searchParams, setSearchParams] = useSearchParams();
   const accessToken = useSelector(selectAccessToken);
-  const appId = searchParams.get("app_id");
-  const symptomDescription = searchParams.get("symptom_description");
-  const startTime = searchParams.get("start_time");
-  const endTime = searchParams.get("end_time");
+  const appId = searchParams.get("appId");
+  const symptomDescription = searchParams.get("symptomDescription");
+  const startTime = searchParams.get("startTime");
+  const endTime = searchParams.get("endTime");
 
   // If any of the parameters are missing, display an error message with a link
   // to the diagnostics create page.
   if (!appId || !symptomDescription || !startTime || !endTime) {
-    return (
-      <div>
-        <p>Error: Missing parameters</p>
-        <Link to={diagnosticsCreateUrl()}>Go back to diagnostics page</Link>
-      </div>
-    );
+    throw new Error("Missing parameters");
   }
 
   // Connect to the Aptible AI WebSocket.
   const aptibleAiUrl = useSelector(selectAptibleAiUrl);
   const [socketConnected, setSocketConnected] = useState(true);
-  const { lastJsonMessage: event, readyState } = useWebSocket<Record<string, any>>(
+  const { lastJsonMessage: event, readyState } = useWebSocket<
+    Record<string, any>
+  >(
     `${aptibleAiUrl}/troubleshoot`,
     {
       queryParams: {
@@ -105,9 +101,9 @@ export const DiagnosticsDetailPage = () => {
         symptom_description: symptomDescription,
         start_time: startTime,
         end_time: endTime,
-      }
+      },
     },
-    socketConnected
+    socketConnected,
   );
 
   // If the socket is closed, set the socketConnected state to false (this is
@@ -204,7 +200,11 @@ export const DiagnosticsDetailPage = () => {
       />
 
       <div className="flex flex-row items-center justify-center flex-1 min-h-[500px]">
-        <PreText className="max-w-7xl overflow-x-auto overflow-y-auto" text={JSON.stringify(dashboard, null, 2)} allowCopy />
+        <PreText
+          className="max-w-7xl overflow-x-auto overflow-y-auto"
+          text={JSON.stringify(dashboard, null, 2)}
+          allowCopy
+        />
       </div>
     </AppSidebarLayout>
   );

Show dashboard messages, resources, operations, and plots

public/aptible-mark.png link
+0 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
diff --git a/public/aptible-mark.png b/public/aptible-mark.png
new file mode 100644
index 0000000000000000000000000000000000000000..8eea6065176326b7777bdfced61fa01f028330f4
GIT binary patch
literal 733
zc$@&;0wVp1P)<h;3K|Lk000e1NJLTq002M$002M;1^@s6s%dfF00009a7bBm000XU
z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0&+=2K~#7F?U}(%
z#4r#=C#DcYC(r>UzzHD^&;d&YoB(kGI-&!X4rl;^z3`*3#P--3J9ait;zZ)id+QaE
zAzD~i*ku|L;{AcH+m==#_vq_yHbXRY9dci@M<@Gd^g;rN1c;SCxcIr}?T%JcMHn4m
ztUQ=1!l(dJ@?eSx(E+68LGB1q0aD3>oDqTpq?QM{A_N7<B@c2$U<b%84^l^91(-@6
zq>P|^7fda0Fl*thkv+pFJ)7Bm*Ex4U3G#iSc0xm|{d0Rf>kdfvcm9D6t-pJuV{#C+
z6SRYCCkzHiC2!W%Th2O6^F^1wZ7Ur?4Goz}p0lp+6EjFgP%%SpEi5MATQge(6+7hb
zg5vVMwX;M}u|u{Eou-oSp23ZvXQ9Y-RkZv$J-e<ME8jhf6G6`s8$p#U-#s&71n-;R
zpBpBS@1C6~0z1Hz^4$yxBCrCKAm7a*9Kjo)BzYhi!5N@Lc_0<R8lYr(AQ53O089R!
zZl{z7n+SRq*EOyP{{k>S`KOlXiVDHZVrFt(V~g-709(GL6DkZdQ%r;|K+W<XCc-Ab
z9OOZ01T`ml5E4PnO&)L~IA4Tnl?R*%u2bPq`StHlYF<O0<BMQB6%Lly-=`z}UxqcM
zrlF6`vxFiHoeD?E>+jQ=GGU%26=C~SxOjOW6`_ABT#Y=Ch=5b!>g2)L2&!gzFeZYk
zT^__n2ssrlRvyGe;07pO9)w2V1gJ(HghcQKs7@YmBe()oD-SpkYyqm52fheH0cIl)
zToJYd%uXKIBJ=~yRvrvRK!DlHgY5_=z|P2negw5^@}L)CVPRomVQ2XU&gIJ_dtx#&
U00000NkvXXu0mjf0RRC1|0JIy&j0`b

literal 0
Kc$@(M0RR6000031

public/thinking.gif link
+0 -0
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
diff --git a/public/thinking.gif b/public/thinking.gif
new file mode 100644
index 0000000000000000000000000000000000000000..72f7b9a4a095821ce70fb28464a0cc8736095752
GIT binary patch
literal 50525
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

literal 0
Kc$@(M0RR6000031

src/ui/pages/diagnostics-detail.tsx link
+696 -12
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index 2bd4710ef..bb3912e9c 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -1,12 +1,164 @@
 import { selectAptibleAiUrl } from "@app/config";
 import { useSelector } from "@app/react";
+import React, { useRef, useContext } from "react";
 import { diagnosticsCreateUrl } from "@app/routes";
 import { selectAccessToken } from "@app/token";
-import { useEffect, useState } from "react";
-import { Link, useParams, useSearchParams } from "react-router-dom";
+import { useEffect, useState, createContext } from "react";
+import { useSearchParams } from "react-router-dom";
 import useWebSocket, { ReadyState } from "react-use-websocket";
 import { AppSidebarLayout } from "../layouts";
-import { Breadcrumbs, PreText } from "../shared";
+import { Breadcrumbs } from "../shared";
+import {
+  IconBox,
+  IconCloud,
+  IconCylinder,
+  IconEndpoint,
+  IconService,
+  IconSource,
+  IconInfo,
+} from "../shared/icons";
+import {
+  CategoryScale,
+  Chart as ChartJS,
+  Colors,
+  Legend,
+  LineElement,
+  LinearScale,
+  PointElement,
+  TimeScale,
+  type TimeUnit,
+  Title,
+  Tooltip,
+} from "chart.js";
+import "chartjs-adapter-luxon";
+import { Line } from "react-chartjs-2";
+import { StreamingText } from "../shared/llm";
+
+// Chart.js plugin to draw a vertical line on hover
+const verticalLinePlugin = {
+  id: 'verticalLine',
+  beforeDraw: (chart: ChartJS) => {
+    if (chart.tooltip?.getActiveElements()?.length) {
+      const activePoint = chart.tooltip.getActiveElements()[0];
+      const ctx = chart.ctx;
+      const x = activePoint.element.x;
+      const topY = chart.scales.y.top;
+      const bottomY = chart.scales.y.bottom;
+
+      ctx.save();
+      ctx.beginPath();
+      ctx.moveTo(x, topY);
+      ctx.lineTo(x, bottomY);
+      ctx.lineWidth = 1;
+      ctx.strokeStyle = '#94a3b8';
+      ctx.setLineDash([5, 5]);
+      ctx.stroke();
+      ctx.restore();
+    }
+  }
+};
+
+// ChartJS plugin to draw annotations
+declare module 'chart.js' {
+  interface Chart {
+    annotationAreas?: Array<{
+      x1: number;
+      x2: number;
+      y1: number;
+      y2: number;
+      description: string;
+    }>;
+  }
+
+  interface PluginOptionsByType<TType> {
+    annotations?: Annotation[];
+  }
+}
+
+const annotationsPlugin = {
+  id: 'annotations',
+  afterDraw: (chart: ChartJS, args: any, options: any) => {
+    const ctx = chart.ctx;
+    const annotations = chart.options?.plugins?.annotations || [];
+
+    annotations.forEach((annotation: any) => {
+      // Convert timestamps to numbers for the time scale
+      const xScale = chart.scales.x;
+      const yScale = chart.scales.y;
+
+      // Parse the timestamps into Date objects and get their timestamps
+      const x1 = new Date(annotation.x_min).getTime();
+      const x2 = new Date(annotation.x_max).getTime();
+
+      // Convert to pixel coordinates
+      const pixelX1 = xScale.getPixelForValue(x1);
+      const pixelX2 = xScale.getPixelForValue(x2);
+      const pixelY1 = yScale.getPixelForValue(annotation.y_max);
+      const pixelY2 = yScale.getPixelForValue(annotation.y_min);
+
+      // Draw annotation rectangle
+      ctx.save();
+      ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // Solid red with 50% opacity
+      ctx.fillRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
+
+      // Rectangle border
+      ctx.strokeStyle = 'rgb(200, 0, 0)'; // Solid darker red
+      ctx.strokeRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
+
+      // Annotation label
+      ctx.save();
+      const padding = 4;
+      ctx.font = '10px monospace'; // Reduced font size
+      const textMetrics = ctx.measureText(annotation.label);
+      const textHeight = 12; // Reduced height to match smaller font
+      const radius = 4; // Border radius
+
+      // Calculate label box dimensions
+      const boxX = pixelX1 + padding;
+      const boxY = pixelY1 - textHeight - padding * 2;
+      const boxWidth = textMetrics.width + padding * 2;
+      const boxHeight = textHeight + padding * 2;
+
+      // Label box shadow
+      ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
+      ctx.shadowBlur = 4;
+      ctx.shadowOffsetX = 2;
+      ctx.shadowOffsetY = 2;
+
+      // Label box background
+      ctx.fillStyle = 'rgba(200, 0, 0, 0.75)';
+      ctx.beginPath();
+      ctx.roundRect(boxX, boxY, boxWidth, boxHeight, radius);
+      ctx.fill();
+
+      // Border
+      ctx.shadowColor = 'transparent';
+      ctx.strokeStyle = 'rgb(200, 0, 0)';
+      ctx.lineWidth = 1;
+      ctx.stroke();
+
+      // Label text
+      ctx.fillStyle = 'white';
+      ctx.textBaseline = 'bottom';
+      ctx.fillText(annotation.label, pixelX1 + padding * 2, pixelY1 - padding);
+      ctx.restore();
+    });
+  }
+};
+
+ChartJS.register(
+  CategoryScale,
+  Colors,
+  LinearScale,
+  PointElement,
+  LineElement,
+  TimeScale,
+  Title,
+  Tooltip,
+  Legend,
+  verticalLinePlugin,
+  annotationsPlugin
+);
 
 type Message = {
   id: string;
@@ -72,6 +224,493 @@ type Dashboard = {
   messages: Message[];
 };
 
+type HoverState = {
+  timestamp: string | null;
+  setTimestamp: (timestamp: string | null) => void;
+};
+
+const HoverContext = createContext<HoverState>({
+  timestamp: null,
+  setTimestamp: () => { },
+});
+
+const OperationsTimeline = ({
+  operations,
+  startTime,
+  endTime,
+  synchronizedHoverContext
+}: {
+  operations: Operation[],
+  startTime: string,
+  endTime: string,
+  synchronizedHoverContext: React.Context<HoverState>
+}) => {
+  const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
+  const start = new Date(startTime);
+  const end = new Date(endTime);
+  const minutesDiff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
+  const timelineRef = useRef<HTMLDivElement>(null);
+
+  // Create array of all minutes between start and end
+  const minutes = Array.from({ length: minutesDiff + 1 }, (_, i) => i);
+
+  // Map operations to their minute positions
+  const operationsByMinute = operations.reduce((acc, op) => {
+    const opTime = new Date(op.created_at);
+    const minute = Math.floor((opTime.getTime() - start.getTime()) / (1000 * 60));
+    acc[minute] = op;
+    return acc;
+  }, {} as { [key: number]: Operation });
+
+  // Handle mouse move over timeline
+  const handleMouseMove = (e: React.MouseEvent) => {
+    if (!timelineRef.current) return;
+
+    const rect = timelineRef.current.getBoundingClientRect();
+    const x = e.clientX - rect.left;
+    const percentage = x / rect.width;
+    const totalMilliseconds = end.getTime() - start.getTime();
+    const hoverTime = new Date(start.getTime() + (percentage * totalMilliseconds));
+
+    // Round to nearest minute
+    hoverTime.setSeconds(0);
+    hoverTime.setMilliseconds(0);
+
+    // Format timestamp correctly
+    const formattedTimestamp = hoverTime.toISOString().slice(0, -5) + 'Z';
+    setTimestamp(formattedTimestamp);
+  };
+
+  // Handle mouse leave
+  const handleMouseLeave = () => {
+    setTimestamp(null);
+  };
+
+  // Calculate vertical line position when timestamp changes
+  const getVerticalLinePosition = () => {
+    if (!timestamp) return null;
+
+    try {
+      const hoverTime = new Date(timestamp);
+      const timeElapsed = hoverTime.getTime() - start.getTime();
+      const totalDuration = end.getTime() - start.getTime();
+      const position = (timeElapsed / totalDuration) * 100;
+
+      // Ensure position is between 0 and 100
+      return Math.max(0, Math.min(100, position));
+    } catch (error) {
+      console.error('Error calculating vertical line position:', error);
+      return null;
+    }
+  };
+
+  const verticalLinePosition = getVerticalLinePosition();
+
+  // Helper function to extract operation type from description
+  const getOperationType = (description: string) => {
+    const match = description.match(/^\((succeeded|failed)\) (\w+)/);
+    return match ? match[2] : 'unknown';
+  };
+
+  return (
+    <div className="mt-4">
+      <div
+        ref={timelineRef}
+        className="relative h-16"
+        onMouseMove={handleMouseMove}
+        onMouseLeave={handleMouseLeave}
+      >
+        <div className="absolute w-full h-0.5 bg-gray-200 top-1/2 transform -translate-y-1/2" />
+
+        {/* Vertical hover line */}
+        {verticalLinePosition !== null && (
+          <div
+            className="absolute h-full w-px bg-transparent top-0"
+            style={{
+              left: `${verticalLinePosition}%`,
+              borderLeft: '1px dashed #94a3b8'
+            }}
+          />
+        )}
+
+        {minutes.map((minute) => {
+          const leftPercentage = (minute / minutesDiff) * 100;
+          const operation = operationsByMinute[minute];
+
+          return (
+            <div
+              key={minute}
+              className="absolute top-1/2 transform -translate-y-1/2"
+              style={{ left: `${leftPercentage}%` }}
+            >
+              <div className="group relative">
+                {operation ? (
+                  <>
+                    <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'}`} />
+
+                    {/* Operation type label */}
+                    <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-gray-100 px-1 rounded">
+                      <span className="font-mono text-[10px] whitespace-nowrap uppercase">
+                        {getOperationType(operation.description)}
+                      </span>
+                    </div>
+
+                    {/* Tooltip */}
+                    <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">
+                      <p className="text-sm">{operation.description}</p>
+                      <p className="text-xs text-gray-300">({new Date(operation.created_at).toLocaleTimeString()} local)</p>
+                    </div>
+                  </>
+                ) : (
+                  // Empty marker for minutes without operations
+                  <div className="hidden" />
+                )}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+};
+
+const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: {
+  messages: Message[];
+  showAllMessages: boolean;
+  setShowAllMessages: (show: boolean) => void;
+}) => {
+  return (
+    <div className="border rounded-lg p-4 bg-gray-50">
+      <div className="flex justify-between items-center mb-4">
+        <h2 className="text-lg font-semibold">Messages</h2>
+        {messages.length > 1 && (
+          <button
+            onClick={() => setShowAllMessages(!showAllMessages)}
+            className="text-blue-600 hover:text-blue-800 text-sm"
+          >
+            {showAllMessages ? 'Show Latest' : `Show All (${messages.length})`}
+          </button>
+        )}
+      </div>
+      <div className="space-y-6">
+        {(showAllMessages ? messages : messages.slice(-1)).map((message, index) => (
+          <div
+            key={message.id}
+            className="flex items-start"
+          >
+            <img
+              src={message.id === 'completion-message' ? '/aptible-mark.png' : '/thinking.gif'}
+              className="w-[28px] h-[28px] mr-3"
+              aria-label="App"
+            />
+            <div className="flex-1 bg-white rounded-lg px-4 py-2 shadow-sm">
+              <StreamingText
+                text={message.message}
+                showEllipsis={(showAllMessages ? index === messages.length - 1 : true) && message.id !== 'completion-message'}
+                animate={showAllMessages ? index === messages.length - 1 : true}
+              />
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+const DiagnosticsResource = ({
+  resourceId,
+  resource,
+  startTime,
+  endTime,
+  synchronizedHoverContext
+}: {
+  resourceId: string;
+  resource: Resource;
+  startTime: string;
+  endTime: string;
+  synchronizedHoverContext: React.Context<HoverState>;
+}) => {
+  return (
+    <div className="border rounded-lg p-4">
+      <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">
+        {resource.type === "app" ? <IconBox /> :
+          resource.type === "database" ? <IconCylinder /> :
+            resource.type === "endpoint" ? <IconEndpoint /> :
+              resource.type === "service" ? <IconService /> :
+                resource.type === "source" ? <IconSource /> :
+                  <IconCloud />}
+        <span className="font-mono text-lg font-bold">{resourceId}</span>
+      </h3>
+
+      {/* Operations */}
+      {resource.operations && resource.operations.length > 0 && (
+        <div className="mt-2">
+          <div className="border rounded-lg bg-white shadow-sm animate-fade-in">
+            <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">Operations</h4>
+            <div className="p-6">
+              <OperationsTimeline
+                operations={resource.operations}
+                startTime={startTime}
+                endTime={endTime}
+                synchronizedHoverContext={synchronizedHoverContext}
+              />
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Plots */}
+      {resource.plots && Object.entries(resource.plots).length > 0 && (
+        <div className="mt-2">
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
+            {Object.entries(resource.plots)
+              .filter(([_, plot]) =>
+                plot.series.some(series => series.points && series.points.length > 0)
+              )
+              .map(([plotId, plot]) => (
+                <div
+                  key={plotId}
+                  className="border rounded-lg bg-white shadow-sm animate-fade-in"
+                >
+                  <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">
+                    {plot.title}
+                  </h4>
+                  <div className="p-6">
+                    {plot.interpretation && (
+                      <div className="mt-4 bg-orange-100 p-3 rounded-md">
+                        <div className="flex items-start gap-2">
+                          <IconInfo className="w-4 h-4 mt-1 text-yellow-600 flex-shrink-0" />
+                          <div>
+                            <p className="text-gray-600">
+                              <strong className="mr-1">Interpretation:</strong>
+                              {plot.interpretation}
+                            </p>
+                          </div>
+                        </div>
+                      </div>
+                    )}
+                    <div className="mt-2 min-h-[200px]">
+                      <SynchronizedHoverLineChartWrapper
+                        showLegend={true}
+                        keyId={plot.id}
+                        chart={{
+                          title: " ",
+                          labels: plot.series[0]?.points.map(point => point.timestamp) || [],
+                          datasets: plot.series.map(series => ({
+                            label: series.label,
+                            data: series.points.map(point => point.value)
+                          }))
+                        }}
+                        xAxisUnit="minute"
+                        yAxisLabel={plot.title}
+                        yAxisUnit={plot.unit}
+                        annotations={plot.annotations}
+                        synchronizedHoverContext={synchronizedHoverContext}
+                      />
+                    </div>
+                    {plot.analysis && (
+                      <div className="mt-4">
+                        <p className="mt-1 text-gray-500 text-xs">
+                          <strong>Analysis:</strong>
+                          {plot.analysis}
+                        </p>
+                      </div>
+                    )}
+                  </div>
+                </div>
+              ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};
+
+const SynchronizedHoverLineChartWrapper = ({
+  showLegend = true,
+  keyId,
+  chart: { labels, datasets: originalDatasets, title },
+  xAxisUnit,
+  yAxisLabel,
+  yAxisUnit,
+  annotations = [],
+  synchronizedHoverContext,
+}: {
+  showLegend?: boolean;
+  keyId: string;
+  chart: {
+    title: string;
+    labels: string[];
+    datasets: Array<{
+      label: string;
+      data: number[];
+    }>;
+  };
+  xAxisUnit: TimeUnit;
+  yAxisLabel?: string;
+  yAxisUnit?: string;
+  annotations?: Annotation[];
+  synchronizedHoverContext: React.Context<HoverState>;
+}) => {
+  const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
+  const chartRef = React.useRef<ChartJS<"line">>();
+
+  // Truncate sha256 resource names to 8 chars
+  const datasets = originalDatasets.map(dataset => ({
+    ...dataset,
+    label: dataset.label.length === 64 ? dataset.label.slice(0, 8) : dataset.label
+  }));
+
+  if (!datasets || !title) return null;
+
+  React.useEffect(() => {
+    const chart = chartRef.current;
+    if (!chart) return;
+
+    if (!timestamp) {
+      chart.setActiveElements([]);
+      chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
+      chart.update();
+      return;
+    }
+
+    const timestampIndex = labels.indexOf(timestamp);
+    if (timestampIndex === -1) return;
+
+    const activeElements = datasets.map((dataset, datasetIndex) => ({
+      datasetIndex,
+      index: timestampIndex,
+    }));
+
+    chart.setActiveElements(activeElements);
+    chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });
+    chart.update();
+  }, [timestamp, labels, datasets]);
+
+  const formatYAxisTick = (value: number, unit?: string) => {
+    if (!unit) return value;
+
+    unit = unit.trim();
+    if (unit === '%') return `${value}%`;
+    if (unit.endsWith('B')) return `${value}${unit}`;
+
+    return value;
+  };
+
+  return (
+    <Line
+      ref={chartRef}
+      datasetIdKey={keyId}
+      data={{
+        labels,
+        datasets,
+      }}
+      options={{
+        responsive: true,
+        maintainAspectRatio: false,
+        animation: false,
+        plugins: {
+          tooltip: {
+            enabled: true,
+            mode: 'index',
+            intersect: false,
+          },
+          colors: {
+            forceOverride: true,
+          },
+          legend: {
+            display: showLegend,
+            labels: {
+              usePointStyle: true,
+              boxHeight: 5,
+              boxWidth: 3,
+              padding: 20,
+            },
+          },
+          title: {
+            font: {
+              size: 16,
+              weight: "normal",
+            },
+            color: "#595E63",
+            align: "start",
+            display: false,
+            text: title,
+            padding: showLegend
+              ? undefined
+              : {
+                top: 10,
+                bottom: 30,
+              },
+          },
+          annotations: annotations,
+        },
+        interaction: {
+          mode: 'index',
+          intersect: false,
+        },
+        onHover: (event, elements, chart) => {
+          if (!event.native) return;
+
+          if (elements && elements.length > 0) {
+            const timestamp = labels[elements[0].index];
+            setTimestamp(timestamp);
+          } else {
+            setTimestamp(null);
+          }
+        },
+        scales: {
+          x: {
+            border: {
+              color: "#111920",
+            },
+            grid: {
+              display: false,
+            },
+            ticks: {
+              color: "#111920",
+              maxRotation: 0,
+              minRotation: 0,
+              autoSkip: true,
+              maxTicksLimit: 5,
+            },
+            adapters: {
+              date: {
+                zone: "UTC",
+              },
+            },
+            time: {
+              tooltipFormat: "yyyy-MM-dd HH:mm:ss 'UTC'",
+              unit: xAxisUnit,
+              displayFormats: {
+                minute: "HH:mm 'UTC'",
+                day: "MMM dd",
+              },
+            },
+            type: "time",
+          },
+          y: {
+            min: 0,
+            border: {
+              display: false,
+            },
+            title: yAxisLabel
+              ? {
+                display: true,
+                text: yAxisLabel,
+              }
+              : undefined,
+            ticks: {
+              callback: (value) => formatYAxisTick(value as number, yAxisUnit),
+              color: "#111920",
+            },
+          },
+        },
+      }}
+    />
+  );
+};
+
 export const DiagnosticsDetailPage = () => {
   // Parse the investigation parameters from the query string.
   const [searchParams, setSearchParams] = useSearchParams();
@@ -120,6 +759,10 @@ export const DiagnosticsDetailPage = () => {
     messages: [],
   });
 
+  const [showAllMessages, setShowAllMessages] = useState(false);
+  const [hoverTimestamp, setHoverTimestamp] = useState<string | null>(null);
+  const [hasShownCompletion, setHasShownCompletion] = useState(false);
+
   // Process each event from the websocket, and update the dashboard state.
   useEffect(() => {
     if (event?.type === "ResourceDiscovered") {
@@ -131,7 +774,7 @@ export const DiagnosticsDetailPage = () => {
             id: event.resource_id,
             type: event.resource_type,
             notes: event.notes,
-            metrics: [],
+            plots: {},
             operations: [],
           },
         },
@@ -146,8 +789,14 @@ export const DiagnosticsDetailPage = () => {
             plots: {
               ...prev.resources[event.resource_id].plots,
               [event.metric_name]: {
-                name: event.metric_name,
-                plot: event.plot,
+                id: event.plot.id,
+                title: event.plot.title,
+                description: event.plot.description,
+                interpretation: event.plot.interpretation,
+                analysis: event.plot.analysis,
+                unit: event.plot.unit,
+                series: event.plot.series,
+                annotations: event.plot.annotations,
               },
             },
           },
@@ -184,6 +833,24 @@ export const DiagnosticsDetailPage = () => {
     }
   }, [JSON.stringify(event)]);
 
+  // Insert an "analysis complete" message if the socket is closed
+  useEffect(() => {
+    if (readyState === ReadyState.CLOSED && !hasShownCompletion) {
+      setHasShownCompletion(true);
+      setDashboard((prev) => ({
+        ...prev,
+        messages: [
+          ...prev.messages,
+          {
+            id: 'completion-message',
+            severity: 'info',
+            message: 'Analysis complete.',
+          },
+        ],
+      }));
+    }
+  }, [readyState, hasShownCompletion]);
+
   return (
     <AppSidebarLayout>
       <Breadcrumbs
@@ -199,12 +866,29 @@ export const DiagnosticsDetailPage = () => {
         ]}
       />
 
-      <div className="flex flex-row items-center justify-center flex-1 min-h-[500px]">
-        <PreText
-          className="max-w-7xl overflow-x-auto overflow-y-auto"
-          text={JSON.stringify(dashboard, null, 2)}
-          allowCopy
-        />
+      <div className="flex flex-col gap-4 p-4">
+        <HoverContext.Provider value={{ timestamp: hoverTimestamp, setTimestamp: setHoverTimestamp }}>
+          <DiagnosticsMessages
+            messages={dashboard.messages}
+            showAllMessages={showAllMessages}
+            setShowAllMessages={setShowAllMessages}
+          />
+
+          {/* Resources Section */}
+          <h2 className="text-lg font-semibold mb-2">Resources</h2>
+          <div className="space-y-4">
+            {Object.entries(dashboard.resources).map(([resourceId, resource]) => (
+              <DiagnosticsResource
+                key={resourceId}
+                resourceId={resourceId}
+                resource={resource}
+                startTime={startTime!}
+                endTime={endTime!}
+                synchronizedHoverContext={HoverContext}
+              />
+            ))}
+          </div>
+        </HoverContext.Provider>
       </div>
     </AppSidebarLayout>
   );
src/ui/shared/llm.tsx link
+59 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
diff --git a/src/ui/shared/llm.tsx b/src/ui/shared/llm.tsx
new file mode 100644
index 000000000..2d59c548f
--- /dev/null
+++ b/src/ui/shared/llm.tsx
@@ -0,0 +1,59 @@
+import React, { useEffect, useState } from "react";
+
+export const StreamingText = ({ text, showEllipsis = false, animate = true }: { text: string, showEllipsis?: boolean, animate?: boolean }) => {
+  const words = text.split(' ');
+  const [visibleWords, setVisibleWords] = React.useState<number>(animate ? 0 : words.length);
+  const [isComplete, setIsComplete] = React.useState<boolean>(!animate);
+
+  React.useEffect(() => {
+    if (!animate) return;
+
+    const timer = setInterval(() => {
+      setVisibleWords(prev => {
+        if (prev < words.length) {
+          return prev + 1;
+        }
+        clearInterval(timer);
+        setIsComplete(true);
+        return prev;
+      });
+    }, 150);
+
+    return () => clearInterval(timer);
+  }, [words.length, animate]);
+
+  return (
+    <div className="inline-block">
+      {words.map((word, idx) => (
+        <span
+          key={idx}
+          className={`inline-block ${idx < words.length - 1 ? 'mr-1' : ''} ${idx < visibleWords ? '' : 'hidden'}`}
+        >
+          {word}
+        </span>
+      ))}
+      {showEllipsis && isComplete && <AnimatedEllipsis />}
+    </div>
+  );
+};
+
+export const AnimatedEllipsis = () => {
+  const [dots, setDots] = useState('');
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      setDots(prev => {
+        if (prev === '...') return '';
+        return prev + '.';
+      });
+    }, 500);
+
+    return () => clearInterval(interval);
+  }, []);
+
+  return (
+    <span className="inline-block w-6">
+      {dots}
+    </span>
+  );
+};
tailwind.config.cjs link
+7 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index af8f1ca1f..aab01ed89 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -9,6 +9,13 @@ module.exports = {
     extend: {
       animation: {
         "spin-slow": "spin 3s linear infinite",
+        "fade-in": "fade-in 0.5s ease-out",
+      },
+      keyframes: {
+        'fade-in': {
+          '0%': { opacity: '0' },
+          '100%': { opacity: '1' },
+        }
       },
       borderWidth: {
         DEFAULT: "1px",

Support async plot annotations

src/ui/pages/diagnostics-detail.tsx link
+18 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index bb3912e9c..e39580452 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -788,7 +788,7 @@ export const DiagnosticsDetailPage = () => {
             ...prev.resources[event.resource_id],
             plots: {
               ...prev.resources[event.resource_id].plots,
-              [event.metric_name]: {
+              [event.plot.id]: {
                 id: event.plot.id,
                 title: event.plot.title,
                 description: event.plot.description,
@@ -802,6 +802,23 @@ export const DiagnosticsDetailPage = () => {
           },
         },
       }));
+    } else if (event?.type === "PlotAnnotated") {
+      setDashboard((prev) => ({
+        ...prev,
+        resources: {
+          ...prev.resources,
+          [event.resource_id]: {
+            ...prev.resources[event.resource_id],
+            plots: {
+              ...prev.resources[event.resource_id].plots,
+              [event.plot_id]: {
+                ...prev.resources[event.resource_id].plots[event.plot_id],
+                annotations: event.annotations,
+              },
+            },
+          },
+        },
+      }));
     } else if (event?.type === "ResourceOperationsRetrieved") {
       setDashboard((prev) => ({
         ...prev,

Fix an issue that would cause a page crash if datasets weren't perfectly aligned

src/ui/pages/diagnostics-detail.tsx link
+14 -4
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index e39580452..f4421afc4 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -577,10 +577,20 @@ const SynchronizedHoverLineChartWrapper = ({
     const timestampIndex = labels.indexOf(timestamp);
     if (timestampIndex === -1) return;
 
-    const activeElements = datasets.map((dataset, datasetIndex) => ({
-      datasetIndex,
-      index: timestampIndex,
-    }));
+    const activeElements = datasets.reduce<{
+      datasetIndex: number;
+      index: number;
+    }[]>((acc, dataset, datasetIndex) => {
+      if (!dataset.data[timestampIndex]) return acc;
+
+      return [
+        ...acc,
+        {
+          datasetIndex,
+          index: timestampIndex,
+        },
+      ];
+    }, []);
 
     chart.setActiveElements(activeElements);
     chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });

Add VITE_APTIBLE_AI_URL to .env.example

.env.example link
+1 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
diff --git a/.env.example b/.env.example
index e095010d1..f65b8d69f 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@ VITE_BILLING_URL="https://goldenboy.aptible.com"
 VITE_LEGACY_DASHBOARD_URL="https://dashboard.aptible.com"
 VITE_METRIC_TUNNEL_URL="https://metrictunnel-nextgen.aptible.com"
 VITE_PORTAL_URL="https://portal.aptible.com"
+VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
 
 # Leave the auth token unset unless you're testing Sentry integration
 SENTRY_AUTH_TOKEN=

Update VITE_APTIBLE_AI_URL references to point to Hotshot

.github/workflows/deploy.yml link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 95a2b9f4c..da5a42bef 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -36,7 +36,7 @@ jobs:
             VITE_SENTRY_DSN=${{ secrets.PROD_SENTRY_DSN }}
             VITE_ORIGIN=app
             VITE_METRIC_TUNNEL_URL=https://metrictunnel-nextgen.aptible.com
-            VITE_APTIBLE_AI_URL=https://app.aptible.ai
+            VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
             VITE_TUNA_ENABLED=true
             VITE_MINTLIFY_CHAT_KEY=${{ secrets.MINTLIFY_CHAT_KEY }}
             VITE_STRIPE_PUBLISHABLE_KEY=${{ secrets.STRIPE_PUBLISHABLE_KEY }}
.github/workflows/staging.yml link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml
index 1bb8a54ce..3e4012d0a 100644
--- a/.github/workflows/staging.yml
+++ b/.github/workflows/staging.yml
@@ -32,7 +32,7 @@ jobs:
             VITE_LEGACY_DASHBOARD_URL=https://dashboard-sbx-main.aptible-sandbox.com
             VITE_METRIC_TUNNEL_URL=https://metrictunnel-sbx-main.aptible-sandbox.com
             VITE_PORTAL_URL=https://portal-sbx-main.aptible-sandbox.com
-            VITE_APTIBLE_AI_URL=https://aptiblebot.aptible-staging.com
+            VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com/"
             SENTRY_AUTH_TOKEN=${{ secrets.STAGING_SENTRY_AUTH_TOKEN }}
             SENTRY_ORG=aptible
             SENTRY_PROJECT=app-ui-sbx-main
Dockerfile link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/Dockerfile b/Dockerfile
index 82b267d76..6a4a833cd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,7 +7,7 @@ ARG VITE_API_URL=https://api.aptible.com
 ARG VITE_LEGACY_DASHBOARD_URL=https://dashboard.aptible.com
 ARG VITE_METRIC_TUNNEL_URL=https://metrictunnel.aptible.com
 ARG VITE_PORTAL_URL=https://portal.aptible.com
-ARG VITE_APTIBLE_AI_URL=https://app.aptible.ai
+ARG VITE_APTIBLE_AI_URL=wss://app-86559.on-aptible.com
 ARG VITE_ORIGIN=app
 ARG VITE_TUNA_ENABLED=false
 ARG NODE_ENV=production
README.md link
+2 -2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
diff --git a/README.md b/README.md
index 5cf6ab497..628e1e727 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ VITE_BILLING_URL="https://goldenboy.aptible.com"
 VITE_LEGACY_DASHBOARD_URL="https://dashboard.aptible.com"
 VITE_METRIC_TUNNEL_URL="https://metrictunnel-nextgen.aptible.com"
 VITE_PORTAL_URL="https://portal.aptible.com"
-VITE_APTIBLE_AI_URL="https://app.aptible.ai"
+VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
 ```
 
 Staging APIs:
@@ -54,7 +54,7 @@ VITE_BILLING_URL="https://goldenboy-sbx-main.aptible-sandbox.com"
 VITE_LEGACY_DASHBOARD_URL="https://dashboard-sbx-main.aptible-sandbox.com"
 VITE_METRIC_TUNNEL_URL="https://metrictunnel-sbx-main.aptible-sandbox.com"
 VITE_PORTAL_URL="https://portal-sbx-main.aptible-sandbox.com"
-VITE_APTIBLE_AI_URL="https://aptiblebot.aptible-staging.com"
+VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
 ```
 
 **4. Run Start Commands**
src/mocks/data.ts link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/src/mocks/data.ts b/src/mocks/data.ts
index ee3bce58c..45c0d3193 100644
--- a/src/mocks/data.ts
+++ b/src/mocks/data.ts
@@ -53,7 +53,7 @@ export const testEnv = defaultConfig({
   legacyDashboardUrl: "https://dashboard.aptible-test.com",
   metricTunnelUrl: "https://metrictunnel.aptible-test.com",
   portalUrl: "https://portal.aptible-test.com",
-  aptibleAiUrl: "https://aptiblebot.aptible-test.com",
+  aptibleAiUrl: "wss://ai.aptible-test.com",
 });
 
 export const testUserId = createId();

Update the analysis using PlotAnnotated events

src/ui/pages/diagnostics-detail.tsx link
+2 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index f4421afc4..925ac093e 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -511,7 +511,7 @@ const DiagnosticsResource = ({
                     {plot.analysis && (
                       <div className="mt-4">
                         <p className="mt-1 text-gray-500 text-xs">
-                          <strong>Analysis:</strong>
+                          <strong>Analysis: </strong>
                           {plot.analysis}
                         </p>
                       </div>
@@ -823,6 +823,7 @@ export const DiagnosticsDetailPage = () => {
               ...prev.resources[event.resource_id].plots,
               [event.plot_id]: {
                 ...prev.resources[event.resource_id].plots[event.plot_id],
+                analysis: event.analysis,
                 annotations: event.annotations,
               },
             },

Install react-use-websocket

package.json link
+1 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
diff --git a/package.json b/package.json
index c965d487f..f660837e3 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
     "react-dom": "^18.3.1",
     "react-router": "^7.0.2",
     "react-router-dom": "^7.0.2",
+    "react-use-websocket": "^4.11.1",
     "starfx": "^0.13.4",
     "tailwindcss": "^3.4.16",
     "typescript": "^5.7.2",
yarn.lock link
+8 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
diff --git a/yarn.lock b/yarn.lock
index acf63f08e..852c6b9a0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2067,6 +2067,7 @@ __metadata:
     react-dom: ^18.3.1
     react-router: ^7.0.2
     react-router-dom: ^7.0.2
+    react-use-websocket: ^4.11.1
     starfx: ^0.13.4
     tailwindcss: ^3.4.16
     typescript: ^5.7.2
@@ -4946,6 +4947,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-use-websocket@npm:^4.11.1":
+  version: 4.11.1
+  resolution: "react-use-websocket@npm:4.11.1"
+  checksum: 8104c2a5c1e5cd32a152a1f03cfa7786a629e72e997fd5ee5b9be4f8fdeef75913e2af2c8689b0aaae37087ba5cf2be3fb69d2bb3282e3a92c8f3f2f88fc7709
+  languageName: node
+  linkType: hard
+
 "react@npm:^18.2.0, react@npm:^18.3.1":
   version: 18.3.1
   resolution: "react@npm:18.3.1"

Update the diagnostics create form to navigate to the details page with query parameters instead of POSTing to the external service

src/routes/index.ts link
+2 -2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 17a4deb9c..5ca8f9649 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -408,8 +408,8 @@ export const DIAGNOSTICS_URL = "/diagnostics";
 export const diagnosticsUrl = () => DIAGNOSTICS_URL;
 export const DIAGNOSTICS_CREATE_URL = "/diagnostics/create";
 export const diagnosticsCreateUrl = () => DIAGNOSTICS_CREATE_URL;
-export const DIAGNOSTICS_DETAIL_URL = "/diagnostics/:id";
-export const diagnosticsDetailUrl = (id: string) => `/diagnostics/${id}`;
+export const DIAGNOSTICS_DETAIL_URL = "/diagnostics/detail";
+export const diagnosticsDetailUrl = (appId: string, symptoms: string, start: Date, end: Date) => `/diagnostics/detail?app_id=${appId}&symptom_description=${symptoms}&start_time=${start.toISOString()}&end_time=${end.toISOString()}`;
 
 export const SOURCES_PATH = "/sources";
 export const sourcesUrl = () => SOURCES_PATH;
src/ui/pages/diagnostics-create.tsx link
+15 -27
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
diff --git a/src/ui/pages/diagnostics-create.tsx b/src/ui/pages/diagnostics-create.tsx
index 0efaefbdf..949543fe4 100644
--- a/src/ui/pages/diagnostics-create.tsx
+++ b/src/ui/pages/diagnostics-create.tsx
@@ -6,7 +6,6 @@ import {
 import { DateTime } from "luxon";
 import { useEffect, useMemo, useState } from "react";
 import DatePicker from "react-datepicker";
-import { useDispatch, useLoader, useSelector } from "starfx/react";
 import { AppSidebarLayout } from "../layouts";
 import {
   Banner,
@@ -21,13 +20,9 @@ import {
 import { AppSelect } from "../shared/select-apps";
 
 import "react-datepicker/dist/react-datepicker.css";
-import { createDashboard } from "@app/aptible-ai";
-import { type WebState, schema } from "@app/schema";
 import { useNavigate } from "react-router-dom";
 
 export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
-  const dispatch = useDispatch();
-
   const symptomOptions = [
     { label: "App is slow", value: "App is slow" },
     { label: "App is unavailable", value: "App is unavailable" },
@@ -43,7 +38,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
   // invalid.
   const now = useMemo(
     () => DateTime.now().minus({ minutes: DateTime.local().offset }),
-    [],
+    []
   );
 
   const timePresets = [
@@ -60,7 +55,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
   const [timePreset, setTimePreset] = useState(timePresets[2].value);
 
   const [startDate, setStartDate] = useState<DateTime>(
-    DateTime.fromISO(timePreset),
+    DateTime.fromISO(timePreset)
   );
   const onSelectStartDate = (date: Date) => {
     const dateTime = DateTime.fromJSDate(date);
@@ -100,33 +95,26 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
       startDate !== null &&
       endDate !== null &&
       startDate < endDate,
-    [symptoms, appId, startDate, endDate],
+    [symptoms, appId, startDate, endDate]
   );
 
   // Submit the form.
-  const submitAction = createDashboard({
-    symptoms: symptoms,
-    appId,
-    start: startDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
-    end: endDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
-  });
-  const dashboardData = useSelector((s: WebState) =>
-    schema.cache.selectById(s, { id: submitAction.payload.key }),
-  );
-  const { isLoading } = useLoader(submitAction);
+  const [isLoading, setIsLoading] = useState(false);
+  const navigate = useNavigate();
   const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
     e.preventDefault();
-    dispatch(submitAction);
+    setIsLoading(true);
+    navigate(
+      diagnosticsDetailUrl(
+        appId,
+        symptoms,
+        startDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
+        endDate.toUTC(0, { keepLocalTime: true }).toJSDate()
+      )
+    );
+    setIsLoading(false);
   };
 
-  // Navigate to the dashboard when it is created.
-  const navigate = useNavigate();
-  useEffect(() => {
-    if (dashboardData?.id) {
-      navigate(diagnosticsDetailUrl(dashboardData.id));
-    }
-  }, [dashboardData]);
-
   return (
     <>
       <form onSubmit={handleSubmit}>

Collect events on the diagnostic details page and construct a dashboard state

src/ui/pages/diagnostics-detail.tsx link
+179 -74
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index f7ab81b99..2bbdc9be0 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -1,76 +1,192 @@
 import { selectAptibleAiUrl } from "@app/config";
 import { useSelector } from "@app/react";
-import { diagnosticsCreateUrl, diagnosticsDetailUrl } from "@app/routes";
+import { diagnosticsCreateUrl } from "@app/routes";
 import { selectAccessToken } from "@app/token";
 import { useEffect, useState } from "react";
-import { useParams } from "react-router-dom";
+import { Link, useSearchParams } from "react-router-dom";
 import { AppSidebarLayout } from "../layouts";
-import { Breadcrumbs, Loading, LoadingSpinner } from "../shared";
-import { Button } from "../shared/button";
+import { Breadcrumbs, PreText } from "../shared";
+import useWebSocket, { ReadyState } from "react-use-websocket";
+
+type Message = {
+  id: string;
+  severity: string;
+  message: string;
+};
+
+type Operation = {
+  id: number;
+  status: string;
+  created_at: Date;
+  description: string;
+  log_lines: string[];
+};
+
+type Point = {
+  timestamp: Date;
+  value: number;
+};
+
+type Annotation = {
+  label: string;
+  description: string;
+  x_min: number;
+  x_max: number;
+  y_min: number;
+  y_max: number;
+};
+
+type Series = {
+  label: string;
+  description: string;
+  interpretation: string;
+  annotations: Annotation[];
+  points: Point[];
+};
+
+type Plot = {
+  id: string;
+  title: string;
+  description: string;
+  interpretation: string;
+  analysis: string;
+  unit: string;
+  series: Series[];
+  annotations: Annotation[];
+};
+
+type Resource = {
+  id: string;
+  type: string;
+  notes: string;
+  plots: {
+    [key: string]: Plot;
+  };
+  operations: Operation[];
+};
+
+type Dashboard = {
+  resources: {
+    [key: string]: Resource;
+  };
+  messages: Message[];
+};
 
-const loadingMessages = [
-  "Consulting the tech support crystal ball...",
-  "Teaching hamsters to debug code...",
-  "Bribing the servers with virtual cookies...",
-  "Performing diagnostic interpretive dance...",
-  "Teaching the AI to be less artificial and more intelligent...",
-];
 
 export const DiagnosticsDetailPage = () => {
-  const { id } = useParams();
-  const aptibleAiUrl = useSelector(selectAptibleAiUrl);
-  const dashboardUrl = `${aptibleAiUrl}/app/dashboards/${id}/`;
-  const [messageIndex, setMessageIndex] = useState(0);
-  const [isDashboardReady, setIsDashboardReady] = useState(false);
+  // Parse the investigation parameters from the query string.
+  const [searchParams, setSearchParams] = useSearchParams();
   const accessToken = useSelector(selectAccessToken);
+  const appId = searchParams.get("app_id");
+  const symptomDescription = searchParams.get("symptom_description");
+  const startTime = searchParams.get("start_time");
+  const endTime = searchParams.get("end_time");
 
-  useEffect(() => {
-    const checkDashboard = async () => {
-      try {
-        // TODO: Figure out how to get a status code from an action, so that we
-        // can swap out this fetch implementation.
-        const response = await fetch(dashboardUrl, {
-          // Credentials are included to allow aptible-ai to set the session
-          // cookie, effectively logging us in.
-          credentials: "include",
-          headers: {
-            Authorization: `Bearer ${accessToken}`,
-          },
-        });
-        if (response.ok) {
-          setIsDashboardReady(true);
-          return true;
-        }
-      } catch (error) {
-        // Ignore errors - we'll try again
-      }
-      return false;
-    };
-
-    let interval: NodeJS.Timeout;
-
-    const initialize = async () => {
-      // Check immediately, in case the dashboard has already been created.
-      const isReady = await checkDashboard();
-
-      // Only set up the interval if the dashboard isn't ready yet.
-      if (!isReady) {
-        interval = setInterval(async () => {
-          setMessageIndex((current) => (current + 1) % loadingMessages.length);
-          const ready = await checkDashboard();
-          if (ready) {
-            clearInterval(interval);
-          }
-        }, 3000);
+  // If any of the parameters are missing, display an error message with a link
+  // to the diagnostics create page.
+  if (!appId || !symptomDescription || !startTime || !endTime) {
+    return (
+      <div>
+        <p>Error: Missing parameters</p>
+        <Link to={diagnosticsCreateUrl()}>Go back to diagnostics page</Link>
+      </div>
+    );
+  }
+
+  // Connect to the Aptible AI WebSocket.
+  const aptibleAiUrl = useSelector(selectAptibleAiUrl);
+  const [socketConnected, setSocketConnected] = useState(true);
+  const { lastJsonMessage: event, readyState } = useWebSocket<Record<string, any>>(
+    `${aptibleAiUrl}/troubleshoot`,
+    {
+      queryParams: {
+        token: accessToken,
+        resource_id: appId,
+        symptom_description: symptomDescription,
+        start_time: startTime,
+        end_time: endTime,
       }
-    };
+    },
+    socketConnected
+  );
 
-    initialize();
+  // If the socket is closed, set the socketConnected state to false (this is
+  // mostly helpful for hot reloading, since the socket will typically close on
+  // its own under normal circumstances).
+  useEffect(() => {
+    if (readyState === ReadyState.CLOSED) {
+      setSocketConnected(false);
+    }
+  }, [readyState]);
+
+  const [dashboard, setDashboard] = useState<Dashboard>({
+    resources: {},
+    messages: [],
+  });
 
-    return () => {
-      if (interval) clearInterval(interval);
-    };
-  }, [id, dashboardUrl, accessToken]);
+  // Process each event from the websocket, and update the dashboard state.
+  useEffect(() => {
+    if (event?.type === "ResourceDiscovered") {
+      setDashboard((prev) => ({
+        ...prev,
+        resources: {
+          ...prev.resources,
+          [event.resource_id]: {
+            id: event.resource_id,
+            type: event.resource_type,
+            notes: event.notes,
+            metrics: [],
+            operations: [],
+          },
+        },
+      }));
+    } else if (event?.type === "ResourceMetricsRetrieved") {
+      setDashboard((prev) => ({
+        ...prev,
+        resources: {
+          ...prev.resources,
+          [event.resource_id]: {
+            ...prev.resources[event.resource_id],
+            plots: {
+              ...prev.resources[event.resource_id].plots,
+              [event.metric_name]: {
+                name: event.metric_name,
+                plot: event.plot,
+              },
+            },
+          },
+        },
+      }));
+    } else if (event?.type === "ResourceOperationsRetrieved") {
+      setDashboard((prev) => ({
+        ...prev,
+        resources: {
+          ...prev.resources,
+          [event.resource_id]: {
+            ...prev.resources[event.resource_id],
+            operations: [
+              ...prev.resources[event.resource_id].operations,
+              ...event.operations,
+            ],
+          },
+        },
+      }));
+    } else if (event?.type === "Message") {
+      setDashboard((prev) => ({
+        ...prev,
+        messages: [
+          ...prev.messages,
+          {
+            id: event.id,
+            severity: event.severity,
+            message: event.message,
+          },
+        ],
+      }));
+    } else {
+      console.log(`Unhandled event type ${event?.type}`, event);
+    }
+  }, [JSON.stringify(event)]);
 
   return (
     <AppSidebarLayout>
@@ -81,25 +197,14 @@ export const DiagnosticsDetailPage = () => {
             to: diagnosticsCreateUrl(),
           },
           {
-            name: `${id}`,
-            to: diagnosticsDetailUrl(`${id}`),
+            name: `${appId} (${symptomDescription})`,
+            to: window.location.href,
           },
         ]}
       />
 
       <div className="flex flex-row items-center justify-center flex-1 min-h-[500px]">
-        <div className="scale-150 flex flex-row items-center gap-3">
-          {!isDashboardReady ? (
-            <>
-              <LoadingSpinner />
-              <Loading text={loadingMessages[messageIndex]} />
-            </>
-          ) : (
-            <form action={dashboardUrl} target="_blank" method="post">
-              <Button type="submit">View Dashboard</Button>
-            </form>
-          )}
-        </div>
+        <PreText className="max-w-7xl overflow-x-auto overflow-y-auto" text={JSON.stringify(dashboard, null, 2)} allowCopy />
       </div>
     </AppSidebarLayout>
   );

Formatting and cleanup

src/routes/index.ts link
+7 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
diff --git a/src/routes/index.ts b/src/routes/index.ts
index 5ca8f9649..6503030b7 100644
--- a/src/routes/index.ts
+++ b/src/routes/index.ts
@@ -409,7 +409,13 @@ export const diagnosticsUrl = () => DIAGNOSTICS_URL;
 export const DIAGNOSTICS_CREATE_URL = "/diagnostics/create";
 export const diagnosticsCreateUrl = () => DIAGNOSTICS_CREATE_URL;
 export const DIAGNOSTICS_DETAIL_URL = "/diagnostics/detail";
-export const diagnosticsDetailUrl = (appId: string, symptoms: string, start: Date, end: Date) => `/diagnostics/detail?app_id=${appId}&symptom_description=${symptoms}&start_time=${start.toISOString()}&end_time=${end.toISOString()}`;
+export const diagnosticsDetailUrl = (
+  appId: string,
+  symptoms: string,
+  start: Date,
+  end: Date,
+) =>
+  `/diagnostics/detail?appId=${appId}&symptomDescription=${symptoms}&startTime=${start.toISOString()}&endTime=${end.toISOString()}`;
 
 export const SOURCES_PATH = "/sources";
 export const sourcesUrl = () => SOURCES_PATH;
src/ui/pages/diagnostics-create.tsx link
+5 -5
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
diff --git a/src/ui/pages/diagnostics-create.tsx b/src/ui/pages/diagnostics-create.tsx
index 949543fe4..bc8fa182d 100644
--- a/src/ui/pages/diagnostics-create.tsx
+++ b/src/ui/pages/diagnostics-create.tsx
@@ -38,7 +38,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
   // invalid.
   const now = useMemo(
     () => DateTime.now().minus({ minutes: DateTime.local().offset }),
-    []
+    [],
   );
 
   const timePresets = [
@@ -55,7 +55,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
   const [timePreset, setTimePreset] = useState(timePresets[2].value);
 
   const [startDate, setStartDate] = useState<DateTime>(
-    DateTime.fromISO(timePreset)
+    DateTime.fromISO(timePreset),
   );
   const onSelectStartDate = (date: Date) => {
     const dateTime = DateTime.fromJSDate(date);
@@ -95,7 +95,7 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
       startDate !== null &&
       endDate !== null &&
       startDate < endDate,
-    [symptoms, appId, startDate, endDate]
+    [symptoms, appId, startDate, endDate],
   );
 
   // Submit the form.
@@ -109,8 +109,8 @@ export const DiagnosticsCreateForm = ({ appId }: { appId: string }) => {
         appId,
         symptoms,
         startDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
-        endDate.toUTC(0, { keepLocalTime: true }).toJSDate()
-      )
+        endDate.toUTC(0, { keepLocalTime: true }).toJSDate(),
+      ),
     );
     setIsLoading(false);
   };
src/ui/pages/diagnostics-detail.tsx link
+19 -19
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index 2bbdc9be0..2bd4710ef 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -3,10 +3,10 @@ import { useSelector } from "@app/react";
 import { diagnosticsCreateUrl } from "@app/routes";
 import { selectAccessToken } from "@app/token";
 import { useEffect, useState } from "react";
-import { Link, useSearchParams } from "react-router-dom";
+import { Link, useParams, useSearchParams } from "react-router-dom";
+import useWebSocket, { ReadyState } from "react-use-websocket";
 import { AppSidebarLayout } from "../layouts";
 import { Breadcrumbs, PreText } from "../shared";
-import useWebSocket, { ReadyState } from "react-use-websocket";
 
 type Message = {
   id: string;
@@ -17,13 +17,13 @@ type Message = {
 type Operation = {
   id: number;
   status: string;
-  created_at: Date;
+  created_at: string;
   description: string;
   log_lines: string[];
 };
 
 type Point = {
-  timestamp: Date;
+  timestamp: string;
   value: number;
 };
 
@@ -72,31 +72,27 @@ type Dashboard = {
   messages: Message[];
 };
 
-
 export const DiagnosticsDetailPage = () => {
   // Parse the investigation parameters from the query string.
   const [searchParams, setSearchParams] = useSearchParams();
   const accessToken = useSelector(selectAccessToken);
-  const appId = searchParams.get("app_id");
-  const symptomDescription = searchParams.get("symptom_description");
-  const startTime = searchParams.get("start_time");
-  const endTime = searchParams.get("end_time");
+  const appId = searchParams.get("appId");
+  const symptomDescription = searchParams.get("symptomDescription");
+  const startTime = searchParams.get("startTime");
+  const endTime = searchParams.get("endTime");
 
   // If any of the parameters are missing, display an error message with a link
   // to the diagnostics create page.
   if (!appId || !symptomDescription || !startTime || !endTime) {
-    return (
-      <div>
-        <p>Error: Missing parameters</p>
-        <Link to={diagnosticsCreateUrl()}>Go back to diagnostics page</Link>
-      </div>
-    );
+    throw new Error("Missing parameters");
   }
 
   // Connect to the Aptible AI WebSocket.
   const aptibleAiUrl = useSelector(selectAptibleAiUrl);
   const [socketConnected, setSocketConnected] = useState(true);
-  const { lastJsonMessage: event, readyState } = useWebSocket<Record<string, any>>(
+  const { lastJsonMessage: event, readyState } = useWebSocket<
+    Record<string, any>
+  >(
     `${aptibleAiUrl}/troubleshoot`,
     {
       queryParams: {
@@ -105,9 +101,9 @@ export const DiagnosticsDetailPage = () => {
         symptom_description: symptomDescription,
         start_time: startTime,
         end_time: endTime,
-      }
+      },
     },
-    socketConnected
+    socketConnected,
   );
 
   // If the socket is closed, set the socketConnected state to false (this is
@@ -204,7 +200,11 @@ export const DiagnosticsDetailPage = () => {
       />
 
       <div className="flex flex-row items-center justify-center flex-1 min-h-[500px]">
-        <PreText className="max-w-7xl overflow-x-auto overflow-y-auto" text={JSON.stringify(dashboard, null, 2)} allowCopy />
+        <PreText
+          className="max-w-7xl overflow-x-auto overflow-y-auto"
+          text={JSON.stringify(dashboard, null, 2)}
+          allowCopy
+        />
       </div>
     </AppSidebarLayout>
   );

Show dashboard messages, resources, operations, and plots

public/aptible-mark.png link
+0 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
diff --git a/public/aptible-mark.png b/public/aptible-mark.png
new file mode 100644
index 0000000000000000000000000000000000000000..8eea6065176326b7777bdfced61fa01f028330f4
GIT binary patch
literal 733
zc$@&;0wVp1P)<h;3K|Lk000e1NJLTq002M$002M;1^@s6s%dfF00009a7bBm000XU
z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yP<VFdsH0&+=2K~#7F?U}(%
z#4r#=C#DcYC(r>UzzHD^&;d&YoB(kGI-&!X4rl;^z3`*3#P--3J9ait;zZ)id+QaE
zAzD~i*ku|L;{AcH+m==#_vq_yHbXRY9dci@M<@Gd^g;rN1c;SCxcIr}?T%JcMHn4m
ztUQ=1!l(dJ@?eSx(E+68LGB1q0aD3>oDqTpq?QM{A_N7<B@c2$U<b%84^l^91(-@6
zq>P|^7fda0Fl*thkv+pFJ)7Bm*Ex4U3G#iSc0xm|{d0Rf>kdfvcm9D6t-pJuV{#C+
z6SRYCCkzHiC2!W%Th2O6^F^1wZ7Ur?4Goz}p0lp+6EjFgP%%SpEi5MATQge(6+7hb
zg5vVMwX;M}u|u{Eou-oSp23ZvXQ9Y-RkZv$J-e<ME8jhf6G6`s8$p#U-#s&71n-;R
zpBpBS@1C6~0z1Hz^4$yxBCrCKAm7a*9Kjo)BzYhi!5N@Lc_0<R8lYr(AQ53O089R!
zZl{z7n+SRq*EOyP{{k>S`KOlXiVDHZVrFt(V~g-709(GL6DkZdQ%r;|K+W<XCc-Ab
z9OOZ01T`ml5E4PnO&)L~IA4Tnl?R*%u2bPq`StHlYF<O0<BMQB6%Lly-=`z}UxqcM
zrlF6`vxFiHoeD?E>+jQ=GGU%26=C~SxOjOW6`_ABT#Y=Ch=5b!>g2)L2&!gzFeZYk
zT^__n2ssrlRvyGe;07pO9)w2V1gJ(HghcQKs7@YmBe()oD-SpkYyqm52fheH0cIl)
zToJYd%uXKIBJ=~yRvrvRK!DlHgY5_=z|P2negw5^@}L)CVPRomVQ2XU&gIJ_dtx#&
U00000NkvXXu0mjf0RRC1|0JIy&j0`b

literal 0
Kc$@(M0RR6000031

public/thinking.gif link
+0 -0
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
diff --git a/public/thinking.gif b/public/thinking.gif
new file mode 100644
index 0000000000000000000000000000000000000000..72f7b9a4a095821ce70fb28464a0cc8736095752
GIT binary patch
literal 50525
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

literal 0
Kc$@(M0RR6000031

src/ui/pages/diagnostics-detail.tsx link
+696 -12
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index 2bd4710ef..bb3912e9c 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -1,12 +1,164 @@
 import { selectAptibleAiUrl } from "@app/config";
 import { useSelector } from "@app/react";
+import React, { useRef, useContext } from "react";
 import { diagnosticsCreateUrl } from "@app/routes";
 import { selectAccessToken } from "@app/token";
-import { useEffect, useState } from "react";
-import { Link, useParams, useSearchParams } from "react-router-dom";
+import { useEffect, useState, createContext } from "react";
+import { useSearchParams } from "react-router-dom";
 import useWebSocket, { ReadyState } from "react-use-websocket";
 import { AppSidebarLayout } from "../layouts";
-import { Breadcrumbs, PreText } from "../shared";
+import { Breadcrumbs } from "../shared";
+import {
+  IconBox,
+  IconCloud,
+  IconCylinder,
+  IconEndpoint,
+  IconService,
+  IconSource,
+  IconInfo,
+} from "../shared/icons";
+import {
+  CategoryScale,
+  Chart as ChartJS,
+  Colors,
+  Legend,
+  LineElement,
+  LinearScale,
+  PointElement,
+  TimeScale,
+  type TimeUnit,
+  Title,
+  Tooltip,
+} from "chart.js";
+import "chartjs-adapter-luxon";
+import { Line } from "react-chartjs-2";
+import { StreamingText } from "../shared/llm";
+
+// Chart.js plugin to draw a vertical line on hover
+const verticalLinePlugin = {
+  id: 'verticalLine',
+  beforeDraw: (chart: ChartJS) => {
+    if (chart.tooltip?.getActiveElements()?.length) {
+      const activePoint = chart.tooltip.getActiveElements()[0];
+      const ctx = chart.ctx;
+      const x = activePoint.element.x;
+      const topY = chart.scales.y.top;
+      const bottomY = chart.scales.y.bottom;
+
+      ctx.save();
+      ctx.beginPath();
+      ctx.moveTo(x, topY);
+      ctx.lineTo(x, bottomY);
+      ctx.lineWidth = 1;
+      ctx.strokeStyle = '#94a3b8';
+      ctx.setLineDash([5, 5]);
+      ctx.stroke();
+      ctx.restore();
+    }
+  }
+};
+
+// ChartJS plugin to draw annotations
+declare module 'chart.js' {
+  interface Chart {
+    annotationAreas?: Array<{
+      x1: number;
+      x2: number;
+      y1: number;
+      y2: number;
+      description: string;
+    }>;
+  }
+
+  interface PluginOptionsByType<TType> {
+    annotations?: Annotation[];
+  }
+}
+
+const annotationsPlugin = {
+  id: 'annotations',
+  afterDraw: (chart: ChartJS, args: any, options: any) => {
+    const ctx = chart.ctx;
+    const annotations = chart.options?.plugins?.annotations || [];
+
+    annotations.forEach((annotation: any) => {
+      // Convert timestamps to numbers for the time scale
+      const xScale = chart.scales.x;
+      const yScale = chart.scales.y;
+
+      // Parse the timestamps into Date objects and get their timestamps
+      const x1 = new Date(annotation.x_min).getTime();
+      const x2 = new Date(annotation.x_max).getTime();
+
+      // Convert to pixel coordinates
+      const pixelX1 = xScale.getPixelForValue(x1);
+      const pixelX2 = xScale.getPixelForValue(x2);
+      const pixelY1 = yScale.getPixelForValue(annotation.y_max);
+      const pixelY2 = yScale.getPixelForValue(annotation.y_min);
+
+      // Draw annotation rectangle
+      ctx.save();
+      ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // Solid red with 50% opacity
+      ctx.fillRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
+
+      // Rectangle border
+      ctx.strokeStyle = 'rgb(200, 0, 0)'; // Solid darker red
+      ctx.strokeRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
+
+      // Annotation label
+      ctx.save();
+      const padding = 4;
+      ctx.font = '10px monospace'; // Reduced font size
+      const textMetrics = ctx.measureText(annotation.label);
+      const textHeight = 12; // Reduced height to match smaller font
+      const radius = 4; // Border radius
+
+      // Calculate label box dimensions
+      const boxX = pixelX1 + padding;
+      const boxY = pixelY1 - textHeight - padding * 2;
+      const boxWidth = textMetrics.width + padding * 2;
+      const boxHeight = textHeight + padding * 2;
+
+      // Label box shadow
+      ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
+      ctx.shadowBlur = 4;
+      ctx.shadowOffsetX = 2;
+      ctx.shadowOffsetY = 2;
+
+      // Label box background
+      ctx.fillStyle = 'rgba(200, 0, 0, 0.75)';
+      ctx.beginPath();
+      ctx.roundRect(boxX, boxY, boxWidth, boxHeight, radius);
+      ctx.fill();
+
+      // Border
+      ctx.shadowColor = 'transparent';
+      ctx.strokeStyle = 'rgb(200, 0, 0)';
+      ctx.lineWidth = 1;
+      ctx.stroke();
+
+      // Label text
+      ctx.fillStyle = 'white';
+      ctx.textBaseline = 'bottom';
+      ctx.fillText(annotation.label, pixelX1 + padding * 2, pixelY1 - padding);
+      ctx.restore();
+    });
+  }
+};
+
+ChartJS.register(
+  CategoryScale,
+  Colors,
+  LinearScale,
+  PointElement,
+  LineElement,
+  TimeScale,
+  Title,
+  Tooltip,
+  Legend,
+  verticalLinePlugin,
+  annotationsPlugin
+);
 
 type Message = {
   id: string;
@@ -72,6 +224,493 @@ type Dashboard = {
   messages: Message[];
 };
 
+type HoverState = {
+  timestamp: string | null;
+  setTimestamp: (timestamp: string | null) => void;
+};
+
+const HoverContext = createContext<HoverState>({
+  timestamp: null,
+  setTimestamp: () => { },
+});
+
+const OperationsTimeline = ({
+  operations,
+  startTime,
+  endTime,
+  synchronizedHoverContext
+}: {
+  operations: Operation[],
+  startTime: string,
+  endTime: string,
+  synchronizedHoverContext: React.Context<HoverState>
+}) => {
+  const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
+  const start = new Date(startTime);
+  const end = new Date(endTime);
+  const minutesDiff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
+  const timelineRef = useRef<HTMLDivElement>(null);
+
+  // Create array of all minutes between start and end
+  const minutes = Array.from({ length: minutesDiff + 1 }, (_, i) => i);
+
+  // Map operations to their minute positions
+  const operationsByMinute = operations.reduce((acc, op) => {
+    const opTime = new Date(op.created_at);
+    const minute = Math.floor((opTime.getTime() - start.getTime()) / (1000 * 60));
+    acc[minute] = op;
+    return acc;
+  }, {} as { [key: number]: Operation });
+
+  // Handle mouse move over timeline
+  const handleMouseMove = (e: React.MouseEvent) => {
+    if (!timelineRef.current) return;
+
+    const rect = timelineRef.current.getBoundingClientRect();
+    const x = e.clientX - rect.left;
+    const percentage = x / rect.width;
+    const totalMilliseconds = end.getTime() - start.getTime();
+    const hoverTime = new Date(start.getTime() + (percentage * totalMilliseconds));
+
+    // Round to nearest minute
+    hoverTime.setSeconds(0);
+    hoverTime.setMilliseconds(0);
+
+    // Format timestamp correctly
+    const formattedTimestamp = hoverTime.toISOString().slice(0, -5) + 'Z';
+    setTimestamp(formattedTimestamp);
+  };
+
+  // Handle mouse leave
+  const handleMouseLeave = () => {
+    setTimestamp(null);
+  };
+
+  // Calculate vertical line position when timestamp changes
+  const getVerticalLinePosition = () => {
+    if (!timestamp) return null;
+
+    try {
+      const hoverTime = new Date(timestamp);
+      const timeElapsed = hoverTime.getTime() - start.getTime();
+      const totalDuration = end.getTime() - start.getTime();
+      const position = (timeElapsed / totalDuration) * 100;
+
+      // Ensure position is between 0 and 100
+      return Math.max(0, Math.min(100, position));
+    } catch (error) {
+      console.error('Error calculating vertical line position:', error);
+      return null;
+    }
+  };
+
+  const verticalLinePosition = getVerticalLinePosition();
+
+  // Helper function to extract operation type from description
+  const getOperationType = (description: string) => {
+    const match = description.match(/^\((succeeded|failed)\) (\w+)/);
+    return match ? match[2] : 'unknown';
+  };
+
+  return (
+    <div className="mt-4">
+      <div
+        ref={timelineRef}
+        className="relative h-16"
+        onMouseMove={handleMouseMove}
+        onMouseLeave={handleMouseLeave}
+      >
+        <div className="absolute w-full h-0.5 bg-gray-200 top-1/2 transform -translate-y-1/2" />
+
+        {/* Vertical hover line */}
+        {verticalLinePosition !== null && (
+          <div
+            className="absolute h-full w-px bg-transparent top-0"
+            style={{
+              left: `${verticalLinePosition}%`,
+              borderLeft: '1px dashed #94a3b8'
+            }}
+          />
+        )}
+
+        {minutes.map((minute) => {
+          const leftPercentage = (minute / minutesDiff) * 100;
+          const operation = operationsByMinute[minute];
+
+          return (
+            <div
+              key={minute}
+              className="absolute top-1/2 transform -translate-y-1/2"
+              style={{ left: `${leftPercentage}%` }}
+            >
+              <div className="group relative">
+                {operation ? (
+                  <>
+                    <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'}`} />
+
+                    {/* Operation type label */}
+                    <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-gray-100 px-1 rounded">
+                      <span className="font-mono text-[10px] whitespace-nowrap uppercase">
+                        {getOperationType(operation.description)}
+                      </span>
+                    </div>
+
+                    {/* Tooltip */}
+                    <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">
+                      <p className="text-sm">{operation.description}</p>
+                      <p className="text-xs text-gray-300">({new Date(operation.created_at).toLocaleTimeString()} local)</p>
+                    </div>
+                  </>
+                ) : (
+                  // Empty marker for minutes without operations
+                  <div className="hidden" />
+                )}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+};
+
+const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: {
+  messages: Message[];
+  showAllMessages: boolean;
+  setShowAllMessages: (show: boolean) => void;
+}) => {
+  return (
+    <div className="border rounded-lg p-4 bg-gray-50">
+      <div className="flex justify-between items-center mb-4">
+        <h2 className="text-lg font-semibold">Messages</h2>
+        {messages.length > 1 && (
+          <button
+            onClick={() => setShowAllMessages(!showAllMessages)}
+            className="text-blue-600 hover:text-blue-800 text-sm"
+          >
+            {showAllMessages ? 'Show Latest' : `Show All (${messages.length})`}
+          </button>
+        )}
+      </div>
+      <div className="space-y-6">
+        {(showAllMessages ? messages : messages.slice(-1)).map((message, index) => (
+          <div
+            key={message.id}
+            className="flex items-start"
+          >
+            <img
+              src={message.id === 'completion-message' ? '/aptible-mark.png' : '/thinking.gif'}
+              className="w-[28px] h-[28px] mr-3"
+              aria-label="App"
+            />
+            <div className="flex-1 bg-white rounded-lg px-4 py-2 shadow-sm">
+              <StreamingText
+                text={message.message}
+                showEllipsis={(showAllMessages ? index === messages.length - 1 : true) && message.id !== 'completion-message'}
+                animate={showAllMessages ? index === messages.length - 1 : true}
+              />
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+const DiagnosticsResource = ({
+  resourceId,
+  resource,
+  startTime,
+  endTime,
+  synchronizedHoverContext
+}: {
+  resourceId: string;
+  resource: Resource;
+  startTime: string;
+  endTime: string;
+  synchronizedHoverContext: React.Context<HoverState>;
+}) => {
+  return (
+    <div className="border rounded-lg p-4">
+      <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">
+        {resource.type === "app" ? <IconBox /> :
+          resource.type === "database" ? <IconCylinder /> :
+            resource.type === "endpoint" ? <IconEndpoint /> :
+              resource.type === "service" ? <IconService /> :
+                resource.type === "source" ? <IconSource /> :
+                  <IconCloud />}
+        <span className="font-mono text-lg font-bold">{resourceId}</span>
+      </h3>
+
+      {/* Operations */}
+      {resource.operations && resource.operations.length > 0 && (
+        <div className="mt-2">
+          <div className="border rounded-lg bg-white shadow-sm animate-fade-in">
+            <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">Operations</h4>
+            <div className="p-6">
+              <OperationsTimeline
+                operations={resource.operations}
+                startTime={startTime}
+                endTime={endTime}
+                synchronizedHoverContext={synchronizedHoverContext}
+              />
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Plots */}
+      {resource.plots && Object.entries(resource.plots).length > 0 && (
+        <div className="mt-2">
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
+            {Object.entries(resource.plots)
+              .filter(([_, plot]) =>
+                plot.series.some(series => series.points && series.points.length > 0)
+              )
+              .map(([plotId, plot]) => (
+                <div
+                  key={plotId}
+                  className="border rounded-lg bg-white shadow-sm animate-fade-in"
+                >
+                  <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">
+                    {plot.title}
+                  </h4>
+                  <div className="p-6">
+                    {plot.interpretation && (
+                      <div className="mt-4 bg-orange-100 p-3 rounded-md">
+                        <div className="flex items-start gap-2">
+                          <IconInfo className="w-4 h-4 mt-1 text-yellow-600 flex-shrink-0" />
+                          <div>
+                            <p className="text-gray-600">
+                              <strong className="mr-1">Interpretation:</strong>
+                              {plot.interpretation}
+                            </p>
+                          </div>
+                        </div>
+                      </div>
+                    )}
+                    <div className="mt-2 min-h-[200px]">
+                      <SynchronizedHoverLineChartWrapper
+                        showLegend={true}
+                        keyId={plot.id}
+                        chart={{
+                          title: " ",
+                          labels: plot.series[0]?.points.map(point => point.timestamp) || [],
+                          datasets: plot.series.map(series => ({
+                            label: series.label,
+                            data: series.points.map(point => point.value)
+                          }))
+                        }}
+                        xAxisUnit="minute"
+                        yAxisLabel={plot.title}
+                        yAxisUnit={plot.unit}
+                        annotations={plot.annotations}
+                        synchronizedHoverContext={synchronizedHoverContext}
+                      />
+                    </div>
+                    {plot.analysis && (
+                      <div className="mt-4">
+                        <p className="mt-1 text-gray-500 text-xs">
+                          <strong>Analysis:</strong>
+                          {plot.analysis}
+                        </p>
+                      </div>
+                    )}
+                  </div>
+                </div>
+              ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};
+
+const SynchronizedHoverLineChartWrapper = ({
+  showLegend = true,
+  keyId,
+  chart: { labels, datasets: originalDatasets, title },
+  xAxisUnit,
+  yAxisLabel,
+  yAxisUnit,
+  annotations = [],
+  synchronizedHoverContext,
+}: {
+  showLegend?: boolean;
+  keyId: string;
+  chart: {
+    title: string;
+    labels: string[];
+    datasets: Array<{
+      label: string;
+      data: number[];
+    }>;
+  };
+  xAxisUnit: TimeUnit;
+  yAxisLabel?: string;
+  yAxisUnit?: string;
+  annotations?: Annotation[];
+  synchronizedHoverContext: React.Context<HoverState>;
+}) => {
+  const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
+  const chartRef = React.useRef<ChartJS<"line">>();
+
+  // Truncate sha256 resource names to 8 chars
+  const datasets = originalDatasets.map(dataset => ({
+    ...dataset,
+    label: dataset.label.length === 64 ? dataset.label.slice(0, 8) : dataset.label
+  }));
+
+  if (!datasets || !title) return null;
+
+  React.useEffect(() => {
+    const chart = chartRef.current;
+    if (!chart) return;
+
+    if (!timestamp) {
+      chart.setActiveElements([]);
+      chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
+      chart.update();
+      return;
+    }
+
+    const timestampIndex = labels.indexOf(timestamp);
+    if (timestampIndex === -1) return;
+
+    const activeElements = datasets.map((dataset, datasetIndex) => ({
+      datasetIndex,
+      index: timestampIndex,
+    }));
+
+    chart.setActiveElements(activeElements);
+    chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });
+    chart.update();
+  }, [timestamp, labels, datasets]);
+
+  const formatYAxisTick = (value: number, unit?: string) => {
+    if (!unit) return value;
+
+    unit = unit.trim();
+    if (unit === '%') return `${value}%`;
+    if (unit.endsWith('B')) return `${value}${unit}`;
+
+    return value;
+  };
+
+  return (
+    <Line
+      ref={chartRef}
+      datasetIdKey={keyId}
+      data={{
+        labels,
+        datasets,
+      }}
+      options={{
+        responsive: true,
+        maintainAspectRatio: false,
+        animation: false,
+        plugins: {
+          tooltip: {
+            enabled: true,
+            mode: 'index',
+            intersect: false,
+          },
+          colors: {
+            forceOverride: true,
+          },
+          legend: {
+            display: showLegend,
+            labels: {
+              usePointStyle: true,
+              boxHeight: 5,
+              boxWidth: 3,
+              padding: 20,
+            },
+          },
+          title: {
+            font: {
+              size: 16,
+              weight: "normal",
+            },
+            color: "#595E63",
+            align: "start",
+            display: false,
+            text: title,
+            padding: showLegend
+              ? undefined
+              : {
+                top: 10,
+                bottom: 30,
+              },
+          },
+          annotations: annotations,
+        },
+        interaction: {
+          mode: 'index',
+          intersect: false,
+        },
+        onHover: (event, elements, chart) => {
+          if (!event.native) return;
+
+          if (elements && elements.length > 0) {
+            const timestamp = labels[elements[0].index];
+            setTimestamp(timestamp);
+          } else {
+            setTimestamp(null);
+          }
+        },
+        scales: {
+          x: {
+            border: {
+              color: "#111920",
+            },
+            grid: {
+              display: false,
+            },
+            ticks: {
+              color: "#111920",
+              maxRotation: 0,
+              minRotation: 0,
+              autoSkip: true,
+              maxTicksLimit: 5,
+            },
+            adapters: {
+              date: {
+                zone: "UTC",
+              },
+            },
+            time: {
+              tooltipFormat: "yyyy-MM-dd HH:mm:ss 'UTC'",
+              unit: xAxisUnit,
+              displayFormats: {
+                minute: "HH:mm 'UTC'",
+                day: "MMM dd",
+              },
+            },
+            type: "time",
+          },
+          y: {
+            min: 0,
+            border: {
+              display: false,
+            },
+            title: yAxisLabel
+              ? {
+                display: true,
+                text: yAxisLabel,
+              }
+              : undefined,
+            ticks: {
+              callback: (value) => formatYAxisTick(value as number, yAxisUnit),
+              color: "#111920",
+            },
+          },
+        },
+      }}
+    />
+  );
+};
+
 export const DiagnosticsDetailPage = () => {
   // Parse the investigation parameters from the query string.
   const [searchParams, setSearchParams] = useSearchParams();
@@ -120,6 +759,10 @@ export const DiagnosticsDetailPage = () => {
     messages: [],
   });
 
+  const [showAllMessages, setShowAllMessages] = useState(false);
+  const [hoverTimestamp, setHoverTimestamp] = useState<string | null>(null);
+  const [hasShownCompletion, setHasShownCompletion] = useState(false);
+
   // Process each event from the websocket, and update the dashboard state.
   useEffect(() => {
     if (event?.type === "ResourceDiscovered") {
@@ -131,7 +774,7 @@ export const DiagnosticsDetailPage = () => {
             id: event.resource_id,
             type: event.resource_type,
             notes: event.notes,
-            metrics: [],
+            plots: {},
             operations: [],
           },
         },
@@ -146,8 +789,14 @@ export const DiagnosticsDetailPage = () => {
             plots: {
               ...prev.resources[event.resource_id].plots,
               [event.metric_name]: {
-                name: event.metric_name,
-                plot: event.plot,
+                id: event.plot.id,
+                title: event.plot.title,
+                description: event.plot.description,
+                interpretation: event.plot.interpretation,
+                analysis: event.plot.analysis,
+                unit: event.plot.unit,
+                series: event.plot.series,
+                annotations: event.plot.annotations,
               },
             },
           },
@@ -184,6 +833,24 @@ export const DiagnosticsDetailPage = () => {
     }
   }, [JSON.stringify(event)]);
 
+  // Insert an "analysis complete" message if the socket is closed
+  useEffect(() => {
+    if (readyState === ReadyState.CLOSED && !hasShownCompletion) {
+      setHasShownCompletion(true);
+      setDashboard((prev) => ({
+        ...prev,
+        messages: [
+          ...prev.messages,
+          {
+            id: 'completion-message',
+            severity: 'info',
+            message: 'Analysis complete.',
+          },
+        ],
+      }));
+    }
+  }, [readyState, hasShownCompletion]);
+
   return (
     <AppSidebarLayout>
       <Breadcrumbs
@@ -199,12 +866,29 @@ export const DiagnosticsDetailPage = () => {
         ]}
       />
 
-      <div className="flex flex-row items-center justify-center flex-1 min-h-[500px]">
-        <PreText
-          className="max-w-7xl overflow-x-auto overflow-y-auto"
-          text={JSON.stringify(dashboard, null, 2)}
-          allowCopy
-        />
+      <div className="flex flex-col gap-4 p-4">
+        <HoverContext.Provider value={{ timestamp: hoverTimestamp, setTimestamp: setHoverTimestamp }}>
+          <DiagnosticsMessages
+            messages={dashboard.messages}
+            showAllMessages={showAllMessages}
+            setShowAllMessages={setShowAllMessages}
+          />
+
+          {/* Resources Section */}
+          <h2 className="text-lg font-semibold mb-2">Resources</h2>
+          <div className="space-y-4">
+            {Object.entries(dashboard.resources).map(([resourceId, resource]) => (
+              <DiagnosticsResource
+                key={resourceId}
+                resourceId={resourceId}
+                resource={resource}
+                startTime={startTime!}
+                endTime={endTime!}
+                synchronizedHoverContext={HoverContext}
+              />
+            ))}
+          </div>
+        </HoverContext.Provider>
       </div>
     </AppSidebarLayout>
   );
src/ui/shared/llm.tsx link
+59 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
diff --git a/src/ui/shared/llm.tsx b/src/ui/shared/llm.tsx
new file mode 100644
index 000000000..2d59c548f
--- /dev/null
+++ b/src/ui/shared/llm.tsx
@@ -0,0 +1,59 @@
+import React, { useEffect, useState } from "react";
+
+export const StreamingText = ({ text, showEllipsis = false, animate = true }: { text: string, showEllipsis?: boolean, animate?: boolean }) => {
+  const words = text.split(' ');
+  const [visibleWords, setVisibleWords] = React.useState<number>(animate ? 0 : words.length);
+  const [isComplete, setIsComplete] = React.useState<boolean>(!animate);
+
+  React.useEffect(() => {
+    if (!animate) return;
+
+    const timer = setInterval(() => {
+      setVisibleWords(prev => {
+        if (prev < words.length) {
+          return prev + 1;
+        }
+        clearInterval(timer);
+        setIsComplete(true);
+        return prev;
+      });
+    }, 150);
+
+    return () => clearInterval(timer);
+  }, [words.length, animate]);
+
+  return (
+    <div className="inline-block">
+      {words.map((word, idx) => (
+        <span
+          key={idx}
+          className={`inline-block ${idx < words.length - 1 ? 'mr-1' : ''} ${idx < visibleWords ? '' : 'hidden'}`}
+        >
+          {word}
+        </span>
+      ))}
+      {showEllipsis && isComplete && <AnimatedEllipsis />}
+    </div>
+  );
+};
+
+export const AnimatedEllipsis = () => {
+  const [dots, setDots] = useState('');
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      setDots(prev => {
+        if (prev === '...') return '';
+        return prev + '.';
+      });
+    }, 500);
+
+    return () => clearInterval(interval);
+  }, []);
+
+  return (
+    <span className="inline-block w-6">
+      {dots}
+    </span>
+  );
+};
tailwind.config.cjs link
+7 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
diff --git a/tailwind.config.cjs b/tailwind.config.cjs
index af8f1ca1f..aab01ed89 100644
--- a/tailwind.config.cjs
+++ b/tailwind.config.cjs
@@ -9,6 +9,13 @@ module.exports = {
     extend: {
       animation: {
         "spin-slow": "spin 3s linear infinite",
+        "fade-in": "fade-in 0.5s ease-out",
+      },
+      keyframes: {
+        'fade-in': {
+          '0%': { opacity: '0' },
+          '100%': { opacity: '1' },
+        }
       },
       borderWidth: {
         DEFAULT: "1px",

Support async plot annotations

src/ui/pages/diagnostics-detail.tsx link
+18 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index bb3912e9c..e39580452 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -788,7 +788,7 @@ export const DiagnosticsDetailPage = () => {
             ...prev.resources[event.resource_id],
             plots: {
               ...prev.resources[event.resource_id].plots,
-              [event.metric_name]: {
+              [event.plot.id]: {
                 id: event.plot.id,
                 title: event.plot.title,
                 description: event.plot.description,
@@ -802,6 +802,23 @@ export const DiagnosticsDetailPage = () => {
           },
         },
       }));
+    } else if (event?.type === "PlotAnnotated") {
+      setDashboard((prev) => ({
+        ...prev,
+        resources: {
+          ...prev.resources,
+          [event.resource_id]: {
+            ...prev.resources[event.resource_id],
+            plots: {
+              ...prev.resources[event.resource_id].plots,
+              [event.plot_id]: {
+                ...prev.resources[event.resource_id].plots[event.plot_id],
+                annotations: event.annotations,
+              },
+            },
+          },
+        },
+      }));
     } else if (event?.type === "ResourceOperationsRetrieved") {
       setDashboard((prev) => ({
         ...prev,

Fix an issue that would cause a page crash if datasets weren't perfectly aligned

src/ui/pages/diagnostics-detail.tsx link
+14 -4
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index e39580452..f4421afc4 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -577,10 +577,20 @@ const SynchronizedHoverLineChartWrapper = ({
     const timestampIndex = labels.indexOf(timestamp);
     if (timestampIndex === -1) return;
 
-    const activeElements = datasets.map((dataset, datasetIndex) => ({
-      datasetIndex,
-      index: timestampIndex,
-    }));
+    const activeElements = datasets.reduce<{
+      datasetIndex: number;
+      index: number;
+    }[]>((acc, dataset, datasetIndex) => {
+      if (!dataset.data[timestampIndex]) return acc;
+
+      return [
+        ...acc,
+        {
+          datasetIndex,
+          index: timestampIndex,
+        },
+      ];
+    }, []);
 
     chart.setActiveElements(activeElements);
     chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });

Add VITE_APTIBLE_AI_URL to .env.example

.env.example link
+1 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
diff --git a/.env.example b/.env.example
index e095010d1..f65b8d69f 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@ VITE_BILLING_URL="https://goldenboy.aptible.com"
 VITE_LEGACY_DASHBOARD_URL="https://dashboard.aptible.com"
 VITE_METRIC_TUNNEL_URL="https://metrictunnel-nextgen.aptible.com"
 VITE_PORTAL_URL="https://portal.aptible.com"
+VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
 
 # Leave the auth token unset unless you're testing Sentry integration
 SENTRY_AUTH_TOKEN=

Update VITE_APTIBLE_AI_URL references to point to Hotshot

.github/workflows/deploy.yml link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 95a2b9f4c..da5a42bef 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -36,7 +36,7 @@ jobs:
             VITE_SENTRY_DSN=${{ secrets.PROD_SENTRY_DSN }}
             VITE_ORIGIN=app
             VITE_METRIC_TUNNEL_URL=https://metrictunnel-nextgen.aptible.com
-            VITE_APTIBLE_AI_URL=https://app.aptible.ai
+            VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
             VITE_TUNA_ENABLED=true
             VITE_MINTLIFY_CHAT_KEY=${{ secrets.MINTLIFY_CHAT_KEY }}
             VITE_STRIPE_PUBLISHABLE_KEY=${{ secrets.STRIPE_PUBLISHABLE_KEY }}
.github/workflows/staging.yml link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml
index 1bb8a54ce..b3097a8b7 100644
--- a/.github/workflows/staging.yml
+++ b/.github/workflows/staging.yml
@@ -32,7 +32,7 @@ jobs:
             VITE_LEGACY_DASHBOARD_URL=https://dashboard-sbx-main.aptible-sandbox.com
             VITE_METRIC_TUNNEL_URL=https://metrictunnel-sbx-main.aptible-sandbox.com
             VITE_PORTAL_URL=https://portal-sbx-main.aptible-sandbox.com
-            VITE_APTIBLE_AI_URL=https://aptiblebot.aptible-staging.com
+            VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
             SENTRY_AUTH_TOKEN=${{ secrets.STAGING_SENTRY_AUTH_TOKEN }}
             SENTRY_ORG=aptible
             SENTRY_PROJECT=app-ui-sbx-main
Dockerfile link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/Dockerfile b/Dockerfile
index 82b267d76..6a4a833cd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,7 +7,7 @@ ARG VITE_API_URL=https://api.aptible.com
 ARG VITE_LEGACY_DASHBOARD_URL=https://dashboard.aptible.com
 ARG VITE_METRIC_TUNNEL_URL=https://metrictunnel.aptible.com
 ARG VITE_PORTAL_URL=https://portal.aptible.com
-ARG VITE_APTIBLE_AI_URL=https://app.aptible.ai
+ARG VITE_APTIBLE_AI_URL=wss://app-86559.on-aptible.com
 ARG VITE_ORIGIN=app
 ARG VITE_TUNA_ENABLED=false
 ARG NODE_ENV=production
README.md link
+2 -2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
diff --git a/README.md b/README.md
index 5cf6ab497..628e1e727 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@ VITE_BILLING_URL="https://goldenboy.aptible.com"
 VITE_LEGACY_DASHBOARD_URL="https://dashboard.aptible.com"
 VITE_METRIC_TUNNEL_URL="https://metrictunnel-nextgen.aptible.com"
 VITE_PORTAL_URL="https://portal.aptible.com"
-VITE_APTIBLE_AI_URL="https://app.aptible.ai"
+VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
 ```
 
 Staging APIs:
@@ -54,7 +54,7 @@ VITE_BILLING_URL="https://goldenboy-sbx-main.aptible-sandbox.com"
 VITE_LEGACY_DASHBOARD_URL="https://dashboard-sbx-main.aptible-sandbox.com"
 VITE_METRIC_TUNNEL_URL="https://metrictunnel-sbx-main.aptible-sandbox.com"
 VITE_PORTAL_URL="https://portal-sbx-main.aptible-sandbox.com"
-VITE_APTIBLE_AI_URL="https://aptiblebot.aptible-staging.com"
+VITE_APTIBLE_AI_URL="wss://app-86559.on-aptible.com"
 ```
 
 **4. Run Start Commands**
src/mocks/data.ts link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/src/mocks/data.ts b/src/mocks/data.ts
index ee3bce58c..45c0d3193 100644
--- a/src/mocks/data.ts
+++ b/src/mocks/data.ts
@@ -53,7 +53,7 @@ export const testEnv = defaultConfig({
   legacyDashboardUrl: "https://dashboard.aptible-test.com",
   metricTunnelUrl: "https://metrictunnel.aptible-test.com",
   portalUrl: "https://portal.aptible-test.com",
-  aptibleAiUrl: "https://aptiblebot.aptible-test.com",
+  aptibleAiUrl: "wss://ai.aptible-test.com",
 });
 
 export const testUserId = createId();

Update the analysis using PlotAnnotated events

src/ui/pages/diagnostics-detail.tsx link
+2 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index f4421afc4..925ac093e 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -511,7 +511,7 @@ const DiagnosticsResource = ({
                     {plot.analysis && (
                       <div className="mt-4">
                         <p className="mt-1 text-gray-500 text-xs">
-                          <strong>Analysis:</strong>
+                          <strong>Analysis: </strong>
                           {plot.analysis}
                         </p>
                       </div>
@@ -823,6 +823,7 @@ export const DiagnosticsDetailPage = () => {
               ...prev.resources[event.resource_id].plots,
               [event.plot_id]: {
                 ...prev.resources[event.resource_id].plots[event.plot_id],
+                analysis: event.analysis,
                 annotations: event.annotations,
               },
             },

Refactor charts

src/chart/chartjs-plugin-annoations.ts link
+95 -0
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
diff --git a/src/chart/chartjs-plugin-annoations.ts b/src/chart/chartjs-plugin-annoations.ts
new file mode 100644
index 000000000..9379cb88e
--- /dev/null
+++ b/src/chart/chartjs-plugin-annoations.ts
@@ -0,0 +1,95 @@
+import { Chart as ChartJS } from "chart.js";
+
+
+// Update ChartJS interface to include annotations
+declare module 'chart.js' {
+  interface Chart {
+    annotationAreas?: Array<{
+      x1: number;
+      x2: number;
+      y1: number;
+      y2: number;
+      description: string;
+    }>;
+  }
+
+  interface PluginOptionsByType<TType> {
+    annotations?: Annotation[];
+  }
+}
+
+export type Annotation = {
+  label: string;
+  description: string;
+  x_min: number;
+  x_max: number;
+  y_min: number;
+  y_max: number;
+};
+
+// ChartJS plugin to draw annotations on a line chart
+export const annotationsPlugin = {
+  id: 'annotations',
+  afterDraw: (chart: ChartJS, args: any, options: any) => {
+    const ctx = chart.ctx;
+    const annotations = chart.options?.plugins?.annotations || [];
+
+    annotations.forEach((annotation: any) => {
+      // Convert timestamps to numbers for the time scale
+      const xScale = chart.scales.x;
+      const yScale = chart.scales.y;
+
+      // Parse the timestamps into Date objects and get their timestamps
+      const x1 = new Date(annotation.x_min).getTime();
+      const x2 = new Date(annotation.x_max).getTime();
+
+      // Convert to pixel coordinates
+      const pixelX1 = xScale.getPixelForValue(x1);
+      const pixelX2 = xScale.getPixelForValue(x2);
+      const pixelY1 = yScale.getPixelForValue(annotation.y_max);
+      const pixelY2 = yScale.getPixelForValue(annotation.y_min);
+
+      // Draw annotation rectangle
+      ctx.save();
+      ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
+      ctx.fillRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
+
+      // Rectangle border
+      ctx.strokeStyle = 'rgb(200, 0, 0)';
+      ctx.strokeRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
+
+      // Annotation label
+      ctx.save();
+      const padding = 4;
+      ctx.font = '10px monospace';
+      const textMetrics = ctx.measureText(annotation.label);
+      const textHeight = 12;
+      const radius = 4;
+
+      const boxX = pixelX1 + padding;
+      const boxY = pixelY1 - textHeight - padding * 2;
+      const boxWidth = textMetrics.width + padding * 2;
+      const boxHeight = textHeight + padding * 2;
+
+      ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
+      ctx.shadowBlur = 4;
+      ctx.shadowOffsetX = 2;
+      ctx.shadowOffsetY = 2;
+
+      ctx.fillStyle = 'rgba(200, 0, 0, 0.75)';
+      ctx.beginPath();
+      ctx.roundRect(boxX, boxY, boxWidth, boxHeight, radius);
+      ctx.fill();
+
+      ctx.shadowColor = 'transparent';
+      ctx.strokeStyle = 'rgb(200, 0, 0)';
+      ctx.lineWidth = 1;
+      ctx.stroke();
+
+      ctx.fillStyle = 'white';
+      ctx.textBaseline = 'bottom';
+      ctx.fillText(annotation.label, pixelX1 + padding * 2, pixelY1 - padding);
+      ctx.restore();
+    });
+  }
+};
src/chart/chartjs-plugin-vertical-line.ts link
+25 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
diff --git a/src/chart/chartjs-plugin-vertical-line.ts b/src/chart/chartjs-plugin-vertical-line.ts
new file mode 100644
index 000000000..cc3bf7fd1
--- /dev/null
+++ b/src/chart/chartjs-plugin-vertical-line.ts
@@ -0,0 +1,25 @@
+import { Chart as ChartJS } from "chart.js";
+
+// ChartJS plugin to draw a vertical line on hover
+export const verticalLinePlugin = {
+  id: 'verticalLine',
+  beforeDraw: (chart: ChartJS) => {
+    if (chart.tooltip?.getActiveElements()?.length) {
+      const activePoint = chart.tooltip.getActiveElements()[0];
+      const ctx = chart.ctx;
+      const x = activePoint.element.x;
+      const topY = chart.scales.y.top;
+      const bottomY = chart.scales.y.bottom;
+
+      ctx.save();
+      ctx.beginPath();
+      ctx.moveTo(x, topY);
+      ctx.lineTo(x, bottomY);
+      ctx.lineWidth = 1;
+      ctx.strokeStyle = '#94a3b8';
+      ctx.setLineDash([5, 5]);
+      ctx.stroke();
+      ctx.restore();
+    }
+  }
+};
src/ui/pages/diagnostics-detail.tsx link
+3 -338
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index 925ac093e..98dbc5977 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -17,148 +17,8 @@ import {
   IconSource,
   IconInfo,
 } from "../shared/icons";
-import {
-  CategoryScale,
-  Chart as ChartJS,
-  Colors,
-  Legend,
-  LineElement,
-  LinearScale,
-  PointElement,
-  TimeScale,
-  type TimeUnit,
-  Title,
-  Tooltip,
-} from "chart.js";
-import "chartjs-adapter-luxon";
-import { Line } from "react-chartjs-2";
 import { StreamingText } from "../shared/llm";
-
-// Chart.js plugin to draw a vertical line on hover
-const verticalLinePlugin = {
-  id: 'verticalLine',
-  beforeDraw: (chart: ChartJS) => {
-    if (chart.tooltip?.getActiveElements()?.length) {
-      const activePoint = chart.tooltip.getActiveElements()[0];
-      const ctx = chart.ctx;
-      const x = activePoint.element.x;
-      const topY = chart.scales.y.top;
-      const bottomY = chart.scales.y.bottom;
-
-      ctx.save();
-      ctx.beginPath();
-      ctx.moveTo(x, topY);
-      ctx.lineTo(x, bottomY);
-      ctx.lineWidth = 1;
-      ctx.strokeStyle = '#94a3b8';
-      ctx.setLineDash([5, 5]);
-      ctx.stroke();
-      ctx.restore();
-    }
-  }
-};
-
-// ChartJS plugin to draw annotations
-declare module 'chart.js' {
-  interface Chart {
-    annotationAreas?: Array<{
-      x1: number;
-      x2: number;
-      y1: number;
-      y2: number;
-      description: string;
-    }>;
-  }
-
-  interface PluginOptionsByType<TType> {
-    annotations?: Annotation[];
-  }
-}
-
-const annotationsPlugin = {
-  id: 'annotations',
-  afterDraw: (chart: ChartJS, args: any, options: any) => {
-    const ctx = chart.ctx;
-    const annotations = chart.options?.plugins?.annotations || [];
-
-    annotations.forEach((annotation: any) => {
-      // Convert timestamps to numbers for the time scale
-      const xScale = chart.scales.x;
-      const yScale = chart.scales.y;
-
-      // Parse the timestamps into Date objects and get their timestamps
-      const x1 = new Date(annotation.x_min).getTime();
-      const x2 = new Date(annotation.x_max).getTime();
-
-      // Convert to pixel coordinates
-      const pixelX1 = xScale.getPixelForValue(x1);
-      const pixelX2 = xScale.getPixelForValue(x2);
-      const pixelY1 = yScale.getPixelForValue(annotation.y_max);
-      const pixelY2 = yScale.getPixelForValue(annotation.y_min);
-
-      // Draw annotation rectangle
-      ctx.save();
-      ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // Solid red with 50% opacity
-      ctx.fillRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
-
-      // Rectangle border
-      ctx.strokeStyle = 'rgb(200, 0, 0)'; // Solid darker red
-      ctx.strokeRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
-
-      // Annotation label
-      ctx.save();
-      const padding = 4;
-      ctx.font = '10px monospace'; // Reduced font size
-      const textMetrics = ctx.measureText(annotation.label);
-      const textHeight = 12; // Reduced height to match smaller font
-      const radius = 4; // Border radius
-
-      // Calculate label box dimensions
-      const boxX = pixelX1 + padding;
-      const boxY = pixelY1 - textHeight - padding * 2;
-      const boxWidth = textMetrics.width + padding * 2;
-      const boxHeight = textHeight + padding * 2;
-
-      // Label box shadow
-      ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
-      ctx.shadowBlur = 4;
-      ctx.shadowOffsetX = 2;
-      ctx.shadowOffsetY = 2;
-
-      // Label box background
-      ctx.fillStyle = 'rgba(200, 0, 0, 0.75)';
-      ctx.beginPath();
-      ctx.roundRect(boxX, boxY, boxWidth, boxHeight, radius);
-      ctx.fill();
-
-      // Border
-      ctx.shadowColor = 'transparent';
-      ctx.strokeStyle = 'rgb(200, 0, 0)';
-      ctx.lineWidth = 1;
-      ctx.stroke();
-
-      // Label text
-      ctx.fillStyle = 'white';
-      ctx.textBaseline = 'bottom';
-      ctx.fillText(annotation.label, pixelX1 + padding * 2, pixelY1 - padding);
-      ctx.restore();
-    });
-  }
-};
-
-ChartJS.register(
-  CategoryScale,
-  Colors,
-  LinearScale,
-  PointElement,
-  LineElement,
-  TimeScale,
-  Title,
-  Tooltip,
-  Legend,
-  verticalLinePlugin,
-  annotationsPlugin
-);
+import { DiagnosticsLineChart } from "../shared/diagnostics/line-chart";
 
 type Message = {
   id: string;
@@ -490,7 +350,7 @@ const DiagnosticsResource = ({
                       </div>
                     )}
                     <div className="mt-2 min-h-[200px]">
-                      <SynchronizedHoverLineChartWrapper
+                      <DiagnosticsLineChart
                         showLegend={true}
                         keyId={plot.id}
                         chart={{
@@ -526,204 +386,9 @@ const DiagnosticsResource = ({
   );
 };
 
-const SynchronizedHoverLineChartWrapper = ({
-  showLegend = true,
-  keyId,
-  chart: { labels, datasets: originalDatasets, title },
-  xAxisUnit,
-  yAxisLabel,
-  yAxisUnit,
-  annotations = [],
-  synchronizedHoverContext,
-}: {
-  showLegend?: boolean;
-  keyId: string;
-  chart: {
-    title: string;
-    labels: string[];
-    datasets: Array<{
-      label: string;
-      data: number[];
-    }>;
-  };
-  xAxisUnit: TimeUnit;
-  yAxisLabel?: string;
-  yAxisUnit?: string;
-  annotations?: Annotation[];
-  synchronizedHoverContext: React.Context<HoverState>;
-}) => {
-  const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
-  const chartRef = React.useRef<ChartJS<"line">>();
-
-  // Truncate sha256 resource names to 8 chars
-  const datasets = originalDatasets.map(dataset => ({
-    ...dataset,
-    label: dataset.label.length === 64 ? dataset.label.slice(0, 8) : dataset.label
-  }));
-
-  if (!datasets || !title) return null;
-
-  React.useEffect(() => {
-    const chart = chartRef.current;
-    if (!chart) return;
-
-    if (!timestamp) {
-      chart.setActiveElements([]);
-      chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
-      chart.update();
-      return;
-    }
-
-    const timestampIndex = labels.indexOf(timestamp);
-    if (timestampIndex === -1) return;
-
-    const activeElements = datasets.reduce<{
-      datasetIndex: number;
-      index: number;
-    }[]>((acc, dataset, datasetIndex) => {
-      if (!dataset.data[timestampIndex]) return acc;
-
-      return [
-        ...acc,
-        {
-          datasetIndex,
-          index: timestampIndex,
-        },
-      ];
-    }, []);
-
-    chart.setActiveElements(activeElements);
-    chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });
-    chart.update();
-  }, [timestamp, labels, datasets]);
-
-  const formatYAxisTick = (value: number, unit?: string) => {
-    if (!unit) return value;
-
-    unit = unit.trim();
-    if (unit === '%') return `${value}%`;
-    if (unit.endsWith('B')) return `${value}${unit}`;
-
-    return value;
-  };
-
-  return (
-    <Line
-      ref={chartRef}
-      datasetIdKey={keyId}
-      data={{
-        labels,
-        datasets,
-      }}
-      options={{
-        responsive: true,
-        maintainAspectRatio: false,
-        animation: false,
-        plugins: {
-          tooltip: {
-            enabled: true,
-            mode: 'index',
-            intersect: false,
-          },
-          colors: {
-            forceOverride: true,
-          },
-          legend: {
-            display: showLegend,
-            labels: {
-              usePointStyle: true,
-              boxHeight: 5,
-              boxWidth: 3,
-              padding: 20,
-            },
-          },
-          title: {
-            font: {
-              size: 16,
-              weight: "normal",
-            },
-            color: "#595E63",
-            align: "start",
-            display: false,
-            text: title,
-            padding: showLegend
-              ? undefined
-              : {
-                top: 10,
-                bottom: 30,
-              },
-          },
-          annotations: annotations,
-        },
-        interaction: {
-          mode: 'index',
-          intersect: false,
-        },
-        onHover: (event, elements, chart) => {
-          if (!event.native) return;
-
-          if (elements && elements.length > 0) {
-            const timestamp = labels[elements[0].index];
-            setTimestamp(timestamp);
-          } else {
-            setTimestamp(null);
-          }
-        },
-        scales: {
-          x: {
-            border: {
-              color: "#111920",
-            },
-            grid: {
-              display: false,
-            },
-            ticks: {
-              color: "#111920",
-              maxRotation: 0,
-              minRotation: 0,
-              autoSkip: true,
-              maxTicksLimit: 5,
-            },
-            adapters: {
-              date: {
-                zone: "UTC",
-              },
-            },
-            time: {
-              tooltipFormat: "yyyy-MM-dd HH:mm:ss 'UTC'",
-              unit: xAxisUnit,
-              displayFormats: {
-                minute: "HH:mm 'UTC'",
-                day: "MMM dd",
-              },
-            },
-            type: "time",
-          },
-          y: {
-            min: 0,
-            border: {
-              display: false,
-            },
-            title: yAxisLabel
-              ? {
-                display: true,
-                text: yAxisLabel,
-              }
-              : undefined,
-            ticks: {
-              callback: (value) => formatYAxisTick(value as number, yAxisUnit),
-              color: "#111920",
-            },
-          },
-        },
-      }}
-    />
-  );
-};
-
 export const DiagnosticsDetailPage = () => {
   // Parse the investigation parameters from the query string.
-  const [searchParams, setSearchParams] = useSearchParams();
+  const [searchParams] = useSearchParams();
   const accessToken = useSelector(selectAccessToken);
   const appId = searchParams.get("appId");
   const symptomDescription = searchParams.get("symptomDescription");
src/ui/shared/diagnostics/line-chart.tsx link
+227 -0
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
diff --git a/src/ui/shared/diagnostics/line-chart.tsx b/src/ui/shared/diagnostics/line-chart.tsx
new file mode 100644
index 000000000..4162d559b
--- /dev/null
+++ b/src/ui/shared/diagnostics/line-chart.tsx
@@ -0,0 +1,227 @@
+import React, { useContext } from "react";
+import {
+  CategoryScale,
+  Chart as ChartJS,
+  Colors,
+  Legend,
+  LineElement,
+  LinearScale,
+  PointElement,
+  TimeScale,
+  type TimeUnit,
+  Title,
+  Tooltip,
+} from "chart.js";
+import "chartjs-adapter-luxon";
+import { Line } from "react-chartjs-2";
+import { verticalLinePlugin } from "../../../chart/chartjs-plugin-vertical-line";
+import { annotationsPlugin, type Annotation } from "../../../chart/chartjs-plugin-annoations";
+
+ChartJS.register(
+  CategoryScale,
+  Colors,
+  LinearScale,
+  PointElement,
+  LineElement,
+  TimeScale,
+  Title,
+  Tooltip,
+  Legend,
+  verticalLinePlugin,
+  annotationsPlugin
+);
+
+export const DiagnosticsLineChart = ({
+  showLegend = true,
+  keyId,
+  chart: { labels, datasets: originalDatasets, title },
+  xAxisUnit,
+  yAxisLabel,
+  yAxisUnit,
+  annotations = [],
+  synchronizedHoverContext,
+}: {
+  showLegend?: boolean;
+  keyId: string;
+  chart: {
+    title: string;
+    labels: string[];
+    datasets: Array<{
+      label: string;
+      data: number[];
+    }>;
+  };
+  xAxisUnit: TimeUnit;
+  yAxisLabel?: string;
+  yAxisUnit?: string;
+  annotations?: Annotation[];
+  synchronizedHoverContext: React.Context<{ timestamp: string | null; setTimestamp: (timestamp: string | null) => void; }>;
+}) => {
+  const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
+  const chartRef = React.useRef<ChartJS<"line">>();
+
+  // Truncate sha256 resource names to 8 chars
+  const datasets = originalDatasets.map(dataset => ({
+    ...dataset,
+    label: dataset.label.length === 64 ? dataset.label.slice(0, 8) : dataset.label
+  }));
+
+  if (!datasets || !title) return null;
+
+  React.useEffect(() => {
+    const chart = chartRef.current;
+    if (!chart) return;
+
+    if (!timestamp) {
+      chart.setActiveElements([]);
+      chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
+      chart.update();
+      return;
+    }
+
+    const timestampIndex = labels.indexOf(timestamp);
+    if (timestampIndex === -1) return;
+
+    const activeElements = datasets.reduce<{
+      datasetIndex: number;
+      index: number;
+    }[]>((acc, dataset, datasetIndex) => {
+      if (!dataset.data[timestampIndex]) return acc;
+
+      return [
+        ...acc,
+        {
+          datasetIndex,
+          index: timestampIndex,
+        },
+      ];
+    }, []);
+
+    chart.setActiveElements(activeElements);
+    chart.tooltip?.setActiveElements(activeElements, { x: 0, y: 0 });
+    chart.update();
+  }, [timestamp, labels, datasets]);
+
+  const formatYAxisTick = (value: number, unit?: string) => {
+    if (!unit) return value;
+
+    unit = unit.trim();
+    if (unit === '%') return `${value}%`;
+    if (unit.endsWith('B')) return `${value}${unit}`;
+
+    return value;
+  };
+
+  return (
+    <Line
+      ref={chartRef}
+      datasetIdKey={keyId}
+      data={{
+        labels,
+        datasets,
+      }}
+      options={{
+        responsive: true,
+        maintainAspectRatio: false,
+        animation: false,
+        plugins: {
+          tooltip: {
+            enabled: true,
+            mode: 'index',
+            intersect: false,
+          },
+          colors: {
+            forceOverride: true,
+          },
+          legend: {
+            display: showLegend,
+            labels: {
+              usePointStyle: true,
+              boxHeight: 5,
+              boxWidth: 3,
+              padding: 20,
+            },
+          },
+          title: {
+            font: {
+              size: 16,
+              weight: "normal",
+            },
+            color: "#595E63",
+            align: "start",
+            display: false,
+            text: title,
+            padding: showLegend
+              ? undefined
+              : {
+                top: 10,
+                bottom: 30,
+              },
+          },
+          annotations: annotations,
+        },
+        interaction: {
+          mode: 'index',
+          intersect: false,
+        },
+        onHover: (event, elements, chart) => {
+          if (!event.native) return;
+
+          if (elements && elements.length > 0) {
+            const timestamp = labels[elements[0].index];
+            setTimestamp(timestamp);
+          } else {
+            setTimestamp(null);
+          }
+        },
+        scales: {
+          x: {
+            border: {
+              color: "#111920",
+            },
+            grid: {
+              display: false,
+            },
+            ticks: {
+              color: "#111920",
+              maxRotation: 0,
+              minRotation: 0,
+              autoSkip: true,
+              maxTicksLimit: 5,
+            },
+            adapters: {
+              date: {
+                zone: "UTC",
+              },
+            },
+            time: {
+              tooltipFormat: "yyyy-MM-dd HH:mm:ss 'UTC'",
+              unit: xAxisUnit,
+              displayFormats: {
+                minute: "HH:mm 'UTC'",
+                day: "MMM dd",
+              },
+            },
+            type: "time",
+          },
+          y: {
+            min: 0,
+            border: {
+              display: false,
+            },
+            title: yAxisLabel
+              ? {
+                display: true,
+                text: yAxisLabel,
+              }
+              : undefined,
+            ticks: {
+              callback: (value) => formatYAxisTick(value as number, yAxisUnit),
+              color: "#111920",
+            },
+          },
+        },
+      }}
+    />
+  );
+};

Refactor OperationsTimeline, chart hover state

src/ui/pages/diagnostics-detail.tsx link
+5 -161
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index 98dbc5977..0413c83d7 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -1,9 +1,9 @@
 import { selectAptibleAiUrl } from "@app/config";
 import { useSelector } from "@app/react";
-import React, { useRef, useContext } from "react";
+import React from "react";
 import { diagnosticsCreateUrl } from "@app/routes";
 import { selectAccessToken } from "@app/token";
-import { useEffect, useState, createContext } from "react";
+import { useEffect, useState } from "react";
 import { useSearchParams } from "react-router-dom";
 import useWebSocket, { ReadyState } from "react-use-websocket";
 import { AppSidebarLayout } from "../layouts";
@@ -19,6 +19,9 @@ import {
 } from "../shared/icons";
 import { StreamingText } from "../shared/llm";
 import { DiagnosticsLineChart } from "../shared/diagnostics/line-chart";
+import { OperationsTimeline } from "../shared/diagnostics/operations-timeline";
+import { Annotation } from "@app/chart/chartjs-plugin-annoations";
+import { HoverContext, type HoverState } from "../shared/diagnostics/hover";
 
 type Message = {
   id: string;
@@ -39,15 +42,6 @@ type Point = {
   value: number;
 };
 
-type Annotation = {
-  label: string;
-  description: string;
-  x_min: number;
-  x_max: number;
-  y_min: number;
-  y_max: number;
-};
-
 type Series = {
   label: string;
   description: string;
@@ -84,156 +78,6 @@ type Dashboard = {
   messages: Message[];
 };
 
-type HoverState = {
-  timestamp: string | null;
-  setTimestamp: (timestamp: string | null) => void;
-};
-
-const HoverContext = createContext<HoverState>({
-  timestamp: null,
-  setTimestamp: () => { },
-});
-
-const OperationsTimeline = ({
-  operations,
-  startTime,
-  endTime,
-  synchronizedHoverContext
-}: {
-  operations: Operation[],
-  startTime: string,
-  endTime: string,
-  synchronizedHoverContext: React.Context<HoverState>
-}) => {
-  const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
-  const start = new Date(startTime);
-  const end = new Date(endTime);
-  const minutesDiff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
-  const timelineRef = useRef<HTMLDivElement>(null);
-
-  // Create array of all minutes between start and end
-  const minutes = Array.from({ length: minutesDiff + 1 }, (_, i) => i);
-
-  // Map operations to their minute positions
-  const operationsByMinute = operations.reduce((acc, op) => {
-    const opTime = new Date(op.created_at);
-    const minute = Math.floor((opTime.getTime() - start.getTime()) / (1000 * 60));
-    acc[minute] = op;
-    return acc;
-  }, {} as { [key: number]: Operation });
-
-  // Handle mouse move over timeline
-  const handleMouseMove = (e: React.MouseEvent) => {
-    if (!timelineRef.current) return;
-
-    const rect = timelineRef.current.getBoundingClientRect();
-    const x = e.clientX - rect.left;
-    const percentage = x / rect.width;
-    const totalMilliseconds = end.getTime() - start.getTime();
-    const hoverTime = new Date(start.getTime() + (percentage * totalMilliseconds));
-
-    // Round to nearest minute
-    hoverTime.setSeconds(0);
-    hoverTime.setMilliseconds(0);
-
-    // Format timestamp correctly
-    const formattedTimestamp = hoverTime.toISOString().slice(0, -5) + 'Z';
-    setTimestamp(formattedTimestamp);
-  };
-
-  // Handle mouse leave
-  const handleMouseLeave = () => {
-    setTimestamp(null);
-  };
-
-  // Calculate vertical line position when timestamp changes
-  const getVerticalLinePosition = () => {
-    if (!timestamp) return null;
-
-    try {
-      const hoverTime = new Date(timestamp);
-      const timeElapsed = hoverTime.getTime() - start.getTime();
-      const totalDuration = end.getTime() - start.getTime();
-      const position = (timeElapsed / totalDuration) * 100;
-
-      // Ensure position is between 0 and 100
-      return Math.max(0, Math.min(100, position));
-    } catch (error) {
-      console.error('Error calculating vertical line position:', error);
-      return null;
-    }
-  };
-
-  const verticalLinePosition = getVerticalLinePosition();
-
-  // Helper function to extract operation type from description
-  const getOperationType = (description: string) => {
-    const match = description.match(/^\((succeeded|failed)\) (\w+)/);
-    return match ? match[2] : 'unknown';
-  };
-
-  return (
-    <div className="mt-4">
-      <div
-        ref={timelineRef}
-        className="relative h-16"
-        onMouseMove={handleMouseMove}
-        onMouseLeave={handleMouseLeave}
-      >
-        <div className="absolute w-full h-0.5 bg-gray-200 top-1/2 transform -translate-y-1/2" />
-
-        {/* Vertical hover line */}
-        {verticalLinePosition !== null && (
-          <div
-            className="absolute h-full w-px bg-transparent top-0"
-            style={{
-              left: `${verticalLinePosition}%`,
-              borderLeft: '1px dashed #94a3b8'
-            }}
-          />
-        )}
-
-        {minutes.map((minute) => {
-          const leftPercentage = (minute / minutesDiff) * 100;
-          const operation = operationsByMinute[minute];
-
-          return (
-            <div
-              key={minute}
-              className="absolute top-1/2 transform -translate-y-1/2"
-              style={{ left: `${leftPercentage}%` }}
-            >
-              <div className="group relative">
-                {operation ? (
-                  <>
-                    <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'}`} />
-
-                    {/* Operation type label */}
-                    <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-gray-100 px-1 rounded">
-                      <span className="font-mono text-[10px] whitespace-nowrap uppercase">
-                        {getOperationType(operation.description)}
-                      </span>
-                    </div>
-
-                    {/* Tooltip */}
-                    <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">
-                      <p className="text-sm">{operation.description}</p>
-                      <p className="text-xs text-gray-300">({new Date(operation.created_at).toLocaleTimeString()} local)</p>
-                    </div>
-                  </>
-                ) : (
-                  // Empty marker for minutes without operations
-                  <div className="hidden" />
-                )}
-              </div>
-            </div>
-          );
-        })}
-      </div>
-    </div>
-  );
-};
-
 const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: {
   messages: Message[];
   showAllMessages: boolean;
src/ui/shared/diagnostics/hover.tsx link
+11 -0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
diff --git a/src/ui/shared/diagnostics/hover.tsx b/src/ui/shared/diagnostics/hover.tsx
new file mode 100644
index 000000000..fa987e96a
--- /dev/null
+++ b/src/ui/shared/diagnostics/hover.tsx
@@ -0,0 +1,11 @@
+import { createContext } from "react";
+
+export type HoverState = {
+  timestamp: string | null;
+  setTimestamp: (timestamp: string | null) => void;
+};
+
+export const HoverContext = createContext<HoverState>({
+  timestamp: null,
+  setTimestamp: () => { },
+});
src/ui/shared/diagnostics/line-chart.tsx link
+2 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
diff --git a/src/ui/shared/diagnostics/line-chart.tsx b/src/ui/shared/diagnostics/line-chart.tsx
index 4162d559b..b9e09a5f4 100644
--- a/src/ui/shared/diagnostics/line-chart.tsx
+++ b/src/ui/shared/diagnostics/line-chart.tsx
@@ -16,6 +16,7 @@ import "chartjs-adapter-luxon";
 import { Line } from "react-chartjs-2";
 import { verticalLinePlugin } from "../../../chart/chartjs-plugin-vertical-line";
 import { annotationsPlugin, type Annotation } from "../../../chart/chartjs-plugin-annoations";
+import { type HoverState } from "./hover";
 
 ChartJS.register(
   CategoryScale,
@@ -55,7 +56,7 @@ export const DiagnosticsLineChart = ({
   yAxisLabel?: string;
   yAxisUnit?: string;
   annotations?: Annotation[];
-  synchronizedHoverContext: React.Context<{ timestamp: string | null; setTimestamp: (timestamp: string | null) => void; }>;
+  synchronizedHoverContext: React.Context<HoverState>;
 }) => {
   const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
   const chartRef = React.useRef<ChartJS<"line">>();
src/ui/shared/diagnostics/operations-timeline.tsx link
+150 -0
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
diff --git a/src/ui/shared/diagnostics/operations-timeline.tsx b/src/ui/shared/diagnostics/operations-timeline.tsx
new file mode 100644
index 000000000..3823802cc
--- /dev/null
+++ b/src/ui/shared/diagnostics/operations-timeline.tsx
@@ -0,0 +1,150 @@
+import React, { useRef, useContext } from "react";
+import { type HoverState } from "./hover";
+
+type Operation = {
+  id: number;
+  status: string;
+  created_at: string;
+  description: string;
+  log_lines: string[];
+};
+
+export const OperationsTimeline = ({
+  operations,
+  startTime,
+  endTime,
+  synchronizedHoverContext
+}: {
+  operations: Operation[],
+  startTime: string,
+  endTime: string,
+  synchronizedHoverContext: React.Context<HoverState>
+}) => {
+  const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
+  const start = new Date(startTime);
+  const end = new Date(endTime);
+  const minutesDiff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
+  const timelineRef = useRef<HTMLDivElement>(null);
+
+  // Create array of all minutes between start and end
+  const minutes = Array.from({ length: minutesDiff + 1 }, (_, i) => i);
+
+  // Map operations to their minute positions
+  const operationsByMinute = operations.reduce((acc, op) => {
+    const opTime = new Date(op.created_at);
+    const minute = Math.floor((opTime.getTime() - start.getTime()) / (1000 * 60));
+    acc[minute] = op;
+    return acc;
+  }, {} as { [key: number]: Operation });
+
+  // Handle mouse move over timeline
+  const handleMouseMove = (e: React.MouseEvent) => {
+    if (!timelineRef.current) return;
+
+    const rect = timelineRef.current.getBoundingClientRect();
+    const x = e.clientX - rect.left;
+    const percentage = x / rect.width;
+    const totalMilliseconds = end.getTime() - start.getTime();
+    const hoverTime = new Date(start.getTime() + (percentage * totalMilliseconds));
+
+    // Round to nearest minute
+    hoverTime.setSeconds(0);
+    hoverTime.setMilliseconds(0);
+
+    // Format timestamp correctly
+    const formattedTimestamp = hoverTime.toISOString().slice(0, -5) + 'Z';
+    setTimestamp(formattedTimestamp);
+  };
+
+  // Handle mouse leave
+  const handleMouseLeave = () => {
+    setTimestamp(null);
+  };
+
+  // Calculate vertical line position when timestamp changes
+  const getVerticalLinePosition = () => {
+    if (!timestamp) return null;
+
+    try {
+      const hoverTime = new Date(timestamp);
+      const timeElapsed = hoverTime.getTime() - start.getTime();
+      const totalDuration = end.getTime() - start.getTime();
+      const position = (timeElapsed / totalDuration) * 100;
+
+      // Ensure position is between 0 and 100
+      return Math.max(0, Math.min(100, position));
+    } catch (error) {
+      console.error('Error calculating vertical line position:', error);
+      return null;
+    }
+  };
+
+  const verticalLinePosition = getVerticalLinePosition();
+
+  // Helper function to extract operation type from description
+  const getOperationType = (description: string) => {
+    const match = description.match(/^\((succeeded|failed)\) (\w+)/);
+    return match ? match[2] : 'unknown';
+  };
+
+  return (
+    <div className="mt-4">
+      <div
+        ref={timelineRef}
+        className="relative h-16"
+        onMouseMove={handleMouseMove}
+        onMouseLeave={handleMouseLeave}
+      >
+        <div className="absolute w-full h-0.5 bg-gray-200 top-1/2 transform -translate-y-1/2" />
+
+        {/* Vertical hover line */}
+        {verticalLinePosition !== null && (
+          <div
+            className="absolute h-full w-px bg-transparent top-0"
+            style={{
+              left: `${verticalLinePosition}%`,
+              borderLeft: '1px dashed #94a3b8'
+            }}
+          />
+        )}
+
+        {minutes.map((minute) => {
+          const leftPercentage = (minute / minutesDiff) * 100;
+          const operation = operationsByMinute[minute];
+
+          return (
+            <div
+              key={minute}
+              className="absolute top-1/2 transform -translate-y-1/2"
+              style={{ left: `${leftPercentage}%` }}
+            >
+              <div className="group relative">
+                {operation ? (
+                  <>
+                    <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'}`} />
+
+                    {/* Operation type label */}
+                    <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-gray-100 px-1 rounded">
+                      <span className="font-mono text-[10px] whitespace-nowrap uppercase">
+                        {getOperationType(operation.description)}
+                      </span>
+                    </div>
+
+                    {/* Tooltip */}
+                    <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">
+                      <p className="text-sm">{operation.description}</p>
+                      <p className="text-xs text-gray-300">({new Date(operation.created_at).toLocaleTimeString()} local)</p>
+                    </div>
+                  </>
+                ) : (
+                  // Empty marker for minutes without operations
+                  <div className="hidden" />
+                )}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+};
src/ui/shared/llm.tsx link
+5 -2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
diff --git a/src/ui/shared/llm.tsx b/src/ui/shared/llm.tsx
index 2d59c548f..1eaac4916 100644
--- a/src/ui/shared/llm.tsx
+++ b/src/ui/shared/llm.tsx
@@ -1,5 +1,8 @@
 import React, { useEffect, useState } from "react";
 
+const TEXT_ANIMATION_INTERVAL = 150;
+const ELLIPSIS_INTERVAL = 500;
+
 export const StreamingText = ({ text, showEllipsis = false, animate = true }: { text: string, showEllipsis?: boolean, animate?: boolean }) => {
   const words = text.split(' ');
   const [visibleWords, setVisibleWords] = React.useState<number>(animate ? 0 : words.length);
@@ -17,7 +20,7 @@ export const StreamingText = ({ text, showEllipsis = false, animate = true }: {
         setIsComplete(true);
         return prev;
       });
-    }, 150);
+    }, TEXT_ANIMATION_INTERVAL);
 
     return () => clearInterval(timer);
   }, [words.length, animate]);
@@ -46,7 +49,7 @@ export const AnimatedEllipsis = () => {
         if (prev === '...') return '';
         return prev + '.';
       });
-    }, 500);
+    }, ELLIPSIS_INTERVAL);
 
     return () => clearInterval(interval);
   }, []);

Extract types into aptible-ai/index, useDashboard hook

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

Fix a linter error

src/ui/pages/diagnostics.tsx link
+2 -2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
diff --git a/src/ui/pages/diagnostics.tsx b/src/ui/pages/diagnostics.tsx
index b9654735f..964b79288 100644
--- a/src/ui/pages/diagnostics.tsx
+++ b/src/ui/pages/diagnostics.tsx
@@ -1,4 +1,4 @@
-import { diagnosticsCreateUrl, diagnosticsDetailUrl } from "@app/routes";
+import { diagnosticsCreateUrl } from "@app/routes";
 import { Link } from "react-router-dom";
 import { AppSidebarLayout } from "../layouts";
 import {
@@ -64,7 +64,7 @@ export const DiagnosticsPage = () => {
         <TBody>
           <Tr>
             <Td className="flex-1">
-              <Link to={diagnosticsDetailUrl("")} className="flex">
+              <Link to="#" className="flex">
                 <img
                   src="/resource-types/logo-diagnostics.png"
                   className="w-[32px] h-[32px] mr-2 align-middle"

Fix linter errors

src/chart/chartjs-plugin-annotations.ts link
+14 -15
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
diff --git a/src/chart/chartjs-plugin-annoations.ts b/src/chart/chartjs-plugin-annotations.ts
similarity index 80%
rename from src/chart/chartjs-plugin-annoations.ts
rename to src/chart/chartjs-plugin-annotations.ts
index 715c47f73..276fa1208 100644
--- a/src/chart/chartjs-plugin-annoations.ts
+++ b/src/chart/chartjs-plugin-annotations.ts
@@ -1,9 +1,8 @@
-import { Chart as ChartJS } from "chart.js";
-import { type Annotation } from "@app/aptible-ai";
-
+import type { Annotation } from "@app/aptible-ai";
+import type { Chart as ChartJS } from "chart.js";
 
 // Update ChartJS interface to include annotations
-declare module 'chart.js' {
+declare module "chart.js" {
   interface Chart {
     annotationAreas?: Array<{
       x1: number;
@@ -21,7 +20,7 @@ declare module 'chart.js' {
 
 // ChartJS plugin to draw annotations on a line chart
 export const annotationsPlugin = {
-  id: 'annotations',
+  id: "annotations",
   afterDraw: (chart: ChartJS, args: any, options: any) => {
     const ctx = chart.ctx;
     const annotations = chart.options?.plugins?.annotations || [];
@@ -43,17 +42,17 @@ export const annotationsPlugin = {
 
       // Draw annotation rectangle
       ctx.save();
-      ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
+      ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
       ctx.fillRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
 
       // Rectangle border
-      ctx.strokeStyle = 'rgb(200, 0, 0)';
+      ctx.strokeStyle = "rgb(200, 0, 0)";
       ctx.strokeRect(pixelX1, pixelY1, pixelX2 - pixelX1, pixelY2 - pixelY1);
 
       // Annotation label
       ctx.save();
       const padding = 4;
-      ctx.font = '10px monospace';
+      ctx.font = "10px monospace";
       const textMetrics = ctx.measureText(annotation.label);
       const textHeight = 12;
       const radius = 4;
@@ -63,25 +62,25 @@ export const annotationsPlugin = {
       const boxWidth = textMetrics.width + padding * 2;
       const boxHeight = textHeight + padding * 2;
 
-      ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
+      ctx.shadowColor = "rgba(0, 0, 0, 0.3)";
       ctx.shadowBlur = 4;
       ctx.shadowOffsetX = 2;
       ctx.shadowOffsetY = 2;
 
-      ctx.fillStyle = 'rgba(200, 0, 0, 0.75)';
+      ctx.fillStyle = "rgba(200, 0, 0, 0.75)";
       ctx.beginPath();
       ctx.roundRect(boxX, boxY, boxWidth, boxHeight, radius);
       ctx.fill();
 
-      ctx.shadowColor = 'transparent';
-      ctx.strokeStyle = 'rgb(200, 0, 0)';
+      ctx.shadowColor = "transparent";
+      ctx.strokeStyle = "rgb(200, 0, 0)";
       ctx.lineWidth = 1;
       ctx.stroke();
 
-      ctx.fillStyle = 'white';
-      ctx.textBaseline = 'bottom';
+      ctx.fillStyle = "white";
+      ctx.textBaseline = "bottom";
       ctx.fillText(annotation.label, pixelX1 + padding * 2, pixelY1 - padding);
       ctx.restore();
     });
-  }
+  },
 };
src/chart/chartjs-plugin-vertical-line.ts link
+4 -4
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
diff --git a/src/chart/chartjs-plugin-vertical-line.ts b/src/chart/chartjs-plugin-vertical-line.ts
index cc3bf7fd1..2fb2036df 100644
--- a/src/chart/chartjs-plugin-vertical-line.ts
+++ b/src/chart/chartjs-plugin-vertical-line.ts
@@ -1,8 +1,8 @@
-import { Chart as ChartJS } from "chart.js";
+import type { Chart as ChartJS } from "chart.js";
 
 // ChartJS plugin to draw a vertical line on hover
 export const verticalLinePlugin = {
-  id: 'verticalLine',
+  id: "verticalLine",
   beforeDraw: (chart: ChartJS) => {
     if (chart.tooltip?.getActiveElements()?.length) {
       const activePoint = chart.tooltip.getActiveElements()[0];
@@ -16,10 +16,10 @@ export const verticalLinePlugin = {
       ctx.moveTo(x, topY);
       ctx.lineTo(x, bottomY);
       ctx.lineWidth = 1;
-      ctx.strokeStyle = '#94a3b8';
+      ctx.strokeStyle = "#94a3b8";
       ctx.setLineDash([5, 5]);
       ctx.stroke();
       ctx.restore();
     }
-  }
+  },
 };
src/ui/hooks/use-dashboard.ts link
+22 -10
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
diff --git a/src/ui/hooks/use-dashboard.ts b/src/ui/hooks/use-dashboard.ts
index 8ac0ae311..43063ed94 100644
--- a/src/ui/hooks/use-dashboard.ts
+++ b/src/ui/hooks/use-dashboard.ts
@@ -1,9 +1,9 @@
-import { useEffect, useState } from "react";
-import { useSelector } from "@app/react";
+import type { Message, Resource } from "@app/aptible-ai";
 import { selectAptibleAiUrl } from "@app/config";
+import { useSelector } from "@app/react";
 import { selectAccessToken } from "@app/token";
+import { useEffect, useState } from "react";
 import useWebSocket, { ReadyState } from "react-use-websocket";
-import { type Message, type Resource } from "@app/aptible-ai";
 
 type Dashboard = {
   resources: {
@@ -19,7 +19,10 @@ type UseDashboardParams = {
   endTime: string;
 };
 
-const handleDashboardEvent = (dashboard: Dashboard, event: Record<string, any>): Dashboard => {
+const handleDashboardEvent = (
+  dashboard: Dashboard,
+  event: Record<string, any>,
+): Dashboard => {
   switch (event?.type) {
     case "ResourceDiscovered":
       return {
@@ -108,7 +111,12 @@ const handleDashboardEvent = (dashboard: Dashboard, event: Record<string, any>):
   }
 };
 
-export const useDashboard = ({ appId, symptomDescription, startTime, endTime }: UseDashboardParams) => {
+export const useDashboard = ({
+  appId,
+  symptomDescription,
+  startTime,
+  endTime,
+}: UseDashboardParams) => {
   const aptibleAiUrl = useSelector(selectAptibleAiUrl);
   const accessToken = useSelector(selectAccessToken);
   const [socketConnected, setSocketConnected] = useState(true);
@@ -118,7 +126,9 @@ export const useDashboard = ({ appId, symptomDescription, startTime, endTime }:
   });
   const [hasShownCompletion, setHasShownCompletion] = useState(false);
 
-  const { lastJsonMessage: event, readyState } = useWebSocket<Record<string, any>>(
+  const { lastJsonMessage: event, readyState } = useWebSocket<
+    Record<string, any>
+  >(
     `${aptibleAiUrl}/troubleshoot`,
     {
       queryParams: {
@@ -140,7 +150,9 @@ export const useDashboard = ({ appId, symptomDescription, startTime, endTime }:
 
   useEffect(() => {
     if (event) {
-      setDashboard(prevDashboard => handleDashboardEvent(prevDashboard, event));
+      setDashboard((prevDashboard) =>
+        handleDashboardEvent(prevDashboard, event),
+      );
     }
   }, [JSON.stringify(event)]);
 
@@ -153,9 +165,9 @@ export const useDashboard = ({ appId, symptomDescription, startTime, endTime }:
         messages: [
           ...prev.messages,
           {
-            id: 'completion-message',
-            severity: 'info',
-            message: 'Analysis complete.',
+            id: "completion-message",
+            severity: "info",
+            message: "Analysis complete.",
           },
         ],
       }));
src/ui/pages/diagnostics-detail.tsx link
+16 -12
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
diff --git a/src/ui/pages/diagnostics-detail.tsx b/src/ui/pages/diagnostics-detail.tsx
index e76e8d7cd..8b37e2c2d 100644
--- a/src/ui/pages/diagnostics-detail.tsx
+++ b/src/ui/pages/diagnostics-detail.tsx
@@ -1,12 +1,12 @@
 import { diagnosticsCreateUrl } from "@app/routes";
 import { useState } from "react";
 import { useSearchParams } from "react-router-dom";
+import { useDashboard } from "../hooks/use-dashboard";
 import { AppSidebarLayout } from "../layouts";
 import { Breadcrumbs } from "../shared";
 import { HoverContext } from "../shared/diagnostics/hover";
 import { DiagnosticsMessages } from "../shared/diagnostics/messages";
 import { DiagnosticsResource } from "../shared/diagnostics/resource";
-import { useDashboard } from "../hooks/use-dashboard";
 
 export const DiagnosticsDetailPage = () => {
   const [searchParams] = useSearchParams();
@@ -45,7 +45,9 @@ export const DiagnosticsDetailPage = () => {
       />
 
       <div className="flex flex-col gap-4 p-4">
-        <HoverContext.Provider value={{ timestamp: hoverTimestamp, setTimestamp: setHoverTimestamp }}>
+        <HoverContext.Provider
+          value={{ timestamp: hoverTimestamp, setTimestamp: setHoverTimestamp }}
+        >
           <DiagnosticsMessages
             messages={dashboard.messages}
             showAllMessages={showAllMessages}
@@ -54,16 +56,18 @@ export const DiagnosticsDetailPage = () => {
 
           <h2 className="text-lg font-semibold mb-2">Resources</h2>
           <div className="space-y-4">
-            {Object.entries(dashboard.resources).map(([resourceId, resource]) => (
-              <DiagnosticsResource
-                key={resourceId}
-                resourceId={resourceId}
-                resource={resource}
-                startTime={startTime!}
-                endTime={endTime!}
-                synchronizedHoverContext={HoverContext}
-              />
-            ))}
+            {Object.entries(dashboard.resources).map(
+              ([resourceId, resource]) => (
+                <DiagnosticsResource
+                  key={resourceId}
+                  resourceId={resourceId}
+                  resource={resource}
+                  startTime={startTime}
+                  endTime={endTime}
+                  synchronizedHoverContext={HoverContext}
+                />
+              ),
+            )}
           </div>
         </HoverContext.Provider>
       </div>
src/ui/shared/diagnostics/hover.tsx link
+1 -1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
diff --git a/src/ui/shared/diagnostics/hover.tsx b/src/ui/shared/diagnostics/hover.tsx
index fa987e96a..70eedc4ec 100644
--- a/src/ui/shared/diagnostics/hover.tsx
+++ b/src/ui/shared/diagnostics/hover.tsx
@@ -7,5 +7,5 @@ export type HoverState = {
 
 export const HoverContext = createContext<HoverState>({
   timestamp: null,
-  setTimestamp: () => { },
+  setTimestamp: () => {},
 });
src/ui/shared/diagnostics/line-chart.tsx link
+30 -26
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
diff --git a/src/ui/shared/diagnostics/line-chart.tsx b/src/ui/shared/diagnostics/line-chart.tsx
index 65c5f5d84..56f900135 100644
--- a/src/ui/shared/diagnostics/line-chart.tsx
+++ b/src/ui/shared/diagnostics/line-chart.tsx
@@ -1,4 +1,3 @@
-import React, { useContext } from "react";
 import {
   CategoryScale,
   Chart as ChartJS,
@@ -12,12 +11,13 @@ import {
   Title,
   Tooltip,
 } from "chart.js";
+import React, { useContext, useEffect } from "react";
 import "chartjs-adapter-luxon";
+import type { Annotation } from "@app/aptible-ai";
 import { Line } from "react-chartjs-2";
+import { annotationsPlugin } from "../../../chart/chartjs-plugin-annotations";
 import { verticalLinePlugin } from "../../../chart/chartjs-plugin-vertical-line";
-import { annotationsPlugin } from "../../../chart/chartjs-plugin-annoations";
-import { type Annotation } from "@app/aptible-ai";
-import { type HoverState } from "./hover";
+import type { HoverState } from "./hover";
 
 ChartJS.register(
   CategoryScale,
@@ -30,7 +30,7 @@ ChartJS.register(
   Tooltip,
   Legend,
   verticalLinePlugin,
-  annotationsPlugin
+  annotationsPlugin,
 );
 
 export const DiagnosticsLineChart = ({
@@ -63,14 +63,13 @@ export const DiagnosticsLineChart = ({
   const chartRef = React.useRef<ChartJS<"line">>();
 
   // Truncate sha256 resource names to 8 chars
-  const datasets = originalDatasets.map(dataset => ({
+  const datasets = originalDatasets.map((dataset) => ({
     ...dataset,
-    label: dataset.label.length === 64 ? dataset.label.slice(0, 8) : dataset.label
+    label:
+      dataset.label.length === 64 ? dataset.label.slice(0, 8) : dataset.label,
   }));
 
-  if (!datasets || !title) return null;
-
-  React.useEffect(() => {
+  useEffect(() => {
     const chart = chartRef.current;
     if (!chart) return;
 
@@ -84,10 +83,12 @@ export const DiagnosticsLineChart = ({
     const timestampIndex = labels.indexOf(timestamp);
     if (timestampIndex === -1) return;
 
-    const activeElements = datasets.reduce<{
-      datasetIndex: number;
-      index: number;
-    }[]>((acc, dataset, datasetIndex) => {
+    const activeElements = datasets.reduce<
+      {
+        datasetIndex: number;
+        index: number;
+      }[]
+    >((acc, dataset, datasetIndex) => {
       if (!dataset.data[timestampIndex]) return acc;
 
       return [
@@ -105,15 +106,18 @@ export const DiagnosticsLineChart = ({
   }, [timestamp, labels, datasets]);
 
   const formatYAxisTick = (value: number, unit?: string) => {
-    if (!unit) return value;
+    const unitStr = unit?.trim() ?? "";
+
+    if (!unitStr) return value;
 
-    unit = unit.trim();
-    if (unit === '%') return `${value}%`;
-    if (unit.endsWith('B')) return `${value}${unit}`;
+    if (unitStr === "%") return `${value}%`;
+    if (unitStr.endsWith("B")) return `${value}${unitStr}`;
 
     return value;
   };
 
+  if (!datasets || !title) return null;
+
   return (
     <Line
       ref={chartRef}
@@ -129,7 +133,7 @@ export const DiagnosticsLineChart = ({
         plugins: {
           tooltip: {
             enabled: true,
-            mode: 'index',
+            mode: "index",
             intersect: false,
           },
           colors: {
@@ -156,14 +160,14 @@ export const DiagnosticsLineChart = ({
             padding: showLegend
               ? undefined
               : {
-                top: 10,
-                bottom: 30,
-              },
+                  top: 10,
+                  bottom: 30,
+                },
           },
           annotations: annotations,
         },
         interaction: {
-          mode: 'index',
+          mode: "index",
           intersect: false,
         },
         onHover: (event, elements, chart) => {
@@ -213,9 +217,9 @@ export const DiagnosticsLineChart = ({
             },
             title: yAxisLabel
               ? {
-                display: true,
-                text: yAxisLabel,
-              }
+                  display: true,
+                  text: yAxisLabel,
+                }
               : undefined,
             ticks: {
               callback: (value) => formatYAxisTick(value as number, yAxisUnit),
src/ui/shared/diagnostics/messages.tsx link
+33 -20
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
diff --git a/src/ui/shared/diagnostics/messages.tsx b/src/ui/shared/diagnostics/messages.tsx
index 392860336..7c324329f 100644
--- a/src/ui/shared/diagnostics/messages.tsx
+++ b/src/ui/shared/diagnostics/messages.tsx
@@ -1,7 +1,11 @@
+import type { Message } from "@app/aptible-ai";
 import { StreamingText } from "../llm";
-import { type Message } from "@app/aptible-ai";
 
-export const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: {
+export const DiagnosticsMessages = ({
+  messages,
+  showAllMessages,
+  setShowAllMessages,
+}: {
   messages: Message[];
   showAllMessages: boolean;
   setShowAllMessages: (show: boolean) => void;
@@ -12,33 +16,42 @@ export const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessa
         <h2 className="text-lg font-semibold">Messages</h2>
         {messages.length > 1 && (
           <button
+            type="button"
             onClick={() => setShowAllMessages(!showAllMessages)}
             className="text-blue-600 hover:text-blue-800 text-sm"
           >
-            {showAllMessages ? 'Show Latest' : `Show All (${messages.length})`}
+            {showAllMessages ? "Show Latest" : `Show All (${messages.length})`}
           </button>
         )}
       </div>
       <div className="space-y-6">
-        {(showAllMessages ? messages : messages.slice(-1)).map((message, index) => (
-          <div
-            key={message.id}
-            className="flex items-start"
-          >
-            <img
-              src={message.id === 'completion-message' ? '/aptible-mark.png' : '/thinking.gif'}
-              className="w-[28px] h-[28px] mr-3"
-              aria-label="App"
-            />
-            <div className="flex-1 bg-white rounded-lg px-4 py-2 shadow-sm">
-              <StreamingText
-                text={message.message}
-                showEllipsis={(showAllMessages ? index === messages.length - 1 : true) && message.id !== 'completion-message'}
-                animate={showAllMessages ? index === messages.length - 1 : true}
+        {(showAllMessages ? messages : messages.slice(-1)).map(
+          (message, index) => (
+            <div key={message.id} className="flex items-start">
+              <img
+                src={
+                  message.id === "completion-message"
+                    ? "/aptible-mark.png"
+                    : "/thinking.gif"
+                }
+                className="w-[28px] h-[28px] mr-3"
+                aria-label="App"
               />
+              <div className="flex-1 bg-white rounded-lg px-4 py-2 shadow-sm">
+                <StreamingText
+                  text={message.message}
+                  showEllipsis={
+                    (showAllMessages ? index === messages.length - 1 : true) &&
+                    message.id !== "completion-message"
+                  }
+                  animate={
+                    showAllMessages ? index === messages.length - 1 : true
+                  }
+                />
+              </div>
             </div>
-          </div>
-        ))}
+          ),
+        )}
       </div>
     </div>
   );
src/ui/shared/diagnostics/operations-timeline.tsx link
+37 -22
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
diff --git a/src/ui/shared/diagnostics/operations-timeline.tsx b/src/ui/shared/diagnostics/operations-timeline.tsx
index 16f90ce00..03bf5f046 100644
--- a/src/ui/shared/diagnostics/operations-timeline.tsx
+++ b/src/ui/shared/diagnostics/operations-timeline.tsx
@@ -1,34 +1,42 @@
-import React, { useRef, useContext } from "react";
-import { type HoverState } from "./hover";
-import { type Operation } from "@app/aptible-ai";
+import type { Operation } from "@app/aptible-ai";
+import type React from "react";
+import { useContext, useRef } from "react";
+import type { HoverState } from "./hover";
 
 export const OperationsTimeline = ({
   operations,
   startTime,
   endTime,
-  synchronizedHoverContext
+  synchronizedHoverContext,
 }: {
-  operations: Operation[],
-  startTime: string,
-  endTime: string,
-  synchronizedHoverContext: React.Context<HoverState>
+  operations: Operation[];
+  startTime: string;
+  endTime: string;
+  synchronizedHoverContext: React.Context<HoverState>;
 }) => {
   const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
   const start = new Date(startTime);
   const end = new Date(endTime);
-  const minutesDiff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
+  const minutesDiff = Math.floor(
+    (end.getTime() - start.getTime()) / (1000 * 60),
+  );
   const timelineRef = useRef<HTMLDivElement>(null);
 
   // Create array of all minutes between start and end
   const minutes = Array.from({ length: minutesDiff + 1 }, (_, i) => i);
 
   // Map operations to their minute positions
-  const operationsByMinute = operations.reduce((acc, op) => {
-    const opTime = new Date(op.created_at);
-    const minute = Math.floor((opTime.getTime() - start.getTime()) / (1000 * 60));
-    acc[minute] = op;
-    return acc;
-  }, {} as { [key: number]: Operation });
+  const operationsByMinute = operations.reduce(
+    (acc, op) => {
+      const opTime = new Date(op.created_at);
+      const minute = Math.floor(
+        (opTime.getTime() - start.getTime()) / (1000 * 60),
+      );
+      acc[minute] = op;
+      return acc;
+    },
+    {} as { [key: number]: Operation },
+  );
 
   // Handle mouse move over timeline
   const handleMouseMove = (e: React.MouseEvent) => {
@@ -38,14 +46,16 @@ export const OperationsTimeline = ({
     const x = e.clientX - rect.left;
     const percentage = x / rect.width;
     const totalMilliseconds = end.getTime() - start.getTime();
-    const hoverTime = new Date(start.getTime() + (percentage * totalMilliseconds));
+    const hoverTime = new Date(
+      start.getTime() + percentage * totalMilliseconds,
+    );
 
     // Round to nearest minute
     hoverTime.setSeconds(0);
     hoverTime.setMilliseconds(0);
 
     // Format timestamp correctly
-    const formattedTimestamp = hoverTime.toISOString().slice(0, -5) + 'Z';
+    const formattedTimestamp = `${hoverTime.toISOString().slice(0, -5)}Z`;
     setTimestamp(formattedTimestamp);
   };
 
@@ -67,7 +77,7 @@ export const OperationsTimeline = ({
       // Ensure position is between 0 and 100
       return Math.max(0, Math.min(100, position));
     } catch (error) {
-      console.error('Error calculating vertical line position:', error);
+      console.error("Error calculating vertical line position:", error);
       return null;
     }
   };
@@ -77,7 +87,7 @@ export const OperationsTimeline = ({
   // Helper function to extract operation type from description
   const getOperationType = (description: string) => {
     const match = description.match(/^\((succeeded|failed)\) (\w+)/);
-    return match ? match[2] : 'unknown';
+    return match ? match[2] : "unknown";
   };
 
   return (
@@ -96,7 +106,7 @@ export const OperationsTimeline = ({
             className="absolute h-full w-px bg-transparent top-0"
             style={{
               left: `${verticalLinePosition}%`,
-              borderLeft: '1px dashed #94a3b8'
+              borderLeft: "1px dashed #94a3b8",
             }}
           />
         )}
@@ -114,7 +124,9 @@ export const OperationsTimeline = ({
               <div className="group relative">
                 {operation ? (
                   <>
-                    <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'}`} />
+                    <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"}`}
+                    />
 
                     {/* Operation type label */}
                     <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-gray-100 px-1 rounded">
@@ -126,7 +138,10 @@ export const OperationsTimeline = ({
                     {/* Tooltip */}
                     <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">
                       <p className="text-sm">{operation.description}</p>
-                      <p className="text-xs text-gray-300">({new Date(operation.created_at).toLocaleTimeString()} local)</p>
+                      <p className="text-xs text-gray-300">
+                        ({new Date(operation.created_at).toLocaleTimeString()}{" "}
+                        local)
+                      </p>
                     </div>
                   </>
                 ) : (
src/ui/shared/diagnostics/resource.tsx link
+26 -11
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
diff --git a/src/ui/shared/diagnostics/resource.tsx b/src/ui/shared/diagnostics/resource.tsx
index 46ded9509..a0fa9c34d 100644
--- a/src/ui/shared/diagnostics/resource.tsx
+++ b/src/ui/shared/diagnostics/resource.tsx
@@ -1,9 +1,17 @@
-import React from "react";
-import { IconBox, IconCloud, IconCylinder, IconEndpoint, IconService, IconSource, IconInfo } from "../../shared/icons";
+import type { Resource } from "@app/aptible-ai";
+import type React from "react";
+import {
+  IconBox,
+  IconCloud,
+  IconCylinder,
+  IconEndpoint,
+  IconInfo,
+  IconService,
+  IconSource,
+} from "../../shared/icons";
+import type { HoverState } from "./hover";
 import { DiagnosticsLineChart } from "./line-chart";
 import { OperationsTimeline } from "./operations-timeline";
-import { type HoverState } from "./hover";
-import { type Resource } from "@app/aptible-ai";
 
 const ResourceIcon = ({ type }: { type: Resource["type"] }) => {
   switch (type) {
@@ -27,7 +35,7 @@ export const DiagnosticsResource = ({
   resource,
   startTime,
   endTime,
-  synchronizedHoverContext
+  synchronizedHoverContext,
 }: {
   resourceId: string;
   resource: Resource;
@@ -46,7 +54,9 @@ export const DiagnosticsResource = ({
       {resource.operations && resource.operations.length > 0 && (
         <div className="mt-2">
           <div className="border rounded-lg bg-white shadow-sm animate-fade-in">
-            <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">Operations</h4>
+            <h4 className="font-medium text-gray-900 p-3 rounded-t-lg border-b">
+              Operations
+            </h4>
             <div className="p-6">
               <OperationsTimeline
                 operations={resource.operations}
@@ -66,7 +76,9 @@ export const DiagnosticsResource = ({
             {Object.entries(resource.plots)
               .filter(([_, plot]) =>
                 // Only show plots that have data
-                plot.series.some(series => series.points && series.points.length > 0)
+                plot.series.some(
+                  (series) => series.points && series.points.length > 0,
+                ),
               )
               .map(([plotId, plot]) => (
                 <div
@@ -96,11 +108,14 @@ export const DiagnosticsResource = ({
                         keyId={plot.id}
                         chart={{
                           title: " ",
-                          labels: plot.series[0]?.points.map(point => point.timestamp) || [],
-                          datasets: plot.series.map(series => ({
+                          labels:
+                            plot.series[0]?.points.map(
+                              (point) => point.timestamp,
+                            ) || [],
+                          datasets: plot.series.map((series) => ({
                             label: series.label,
-                            data: series.points.map(point => point.value)
-                          }))
+                            data: series.points.map((point) => point.value),
+                          })),
                         }}
                         xAxisUnit="minute"
                         yAxisLabel={plot.title}
src/ui/shared/llm.tsx link
+16 -14
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
diff --git a/src/ui/shared/llm.tsx b/src/ui/shared/llm.tsx
index 1eaac4916..f2b9a9a5a 100644
--- a/src/ui/shared/llm.tsx
+++ b/src/ui/shared/llm.tsx
@@ -3,16 +3,22 @@ import React, { useEffect, useState } from "react";
 const TEXT_ANIMATION_INTERVAL = 150;
 const ELLIPSIS_INTERVAL = 500;
 
-export const StreamingText = ({ text, showEllipsis = false, animate = true }: { text: string, showEllipsis?: boolean, animate?: boolean }) => {
-  const words = text.split(' ');
-  const [visibleWords, setVisibleWords] = React.useState<number>(animate ? 0 : words.length);
+export const StreamingText = ({
+  text,
+  showEllipsis = false,
+  animate = true,
+}: { text: string; showEllipsis?: boolean; animate?: boolean }) => {
+  const words = text.split(" ");
+  const [visibleWords, setVisibleWords] = React.useState<number>(
+    animate ? 0 : words.length,
+  );
   const [isComplete, setIsComplete] = React.useState<boolean>(!animate);
 
   React.useEffect(() => {
     if (!animate) return;
 
     const timer = setInterval(() => {
-      setVisibleWords(prev => {
+      setVisibleWords((prev) => {
         if (prev < words.length) {
           return prev + 1;
         }
@@ -30,7 +36,7 @@ export const StreamingText = ({ text, showEllipsis = false, animate = true }: {
       {words.map((word, idx) => (
         <span
           key={idx}
-          className={`inline-block ${idx < words.length - 1 ? 'mr-1' : ''} ${idx < visibleWords ? '' : 'hidden'}`}
+          className={`inline-block ${idx < words.length - 1 ? "mr-1" : ""} ${idx < visibleWords ? "" : "hidden"}`}
         >
           {word}
         </span>
@@ -41,22 +47,18 @@ export const StreamingText = ({ text, showEllipsis = false, animate = true }: {
 };
 
 export const AnimatedEllipsis = () => {
-  const [dots, setDots] = useState('');
+  const [dots, setDots] = useState("");
 
   useEffect(() => {
     const interval = setInterval(() => {
-      setDots(prev => {
-        if (prev === '...') return '';
-        return prev + '.';
+      setDots((prev) => {
+        if (prev === "...") return "";
+        return `${prev}.`;
       });
     }, ELLIPSIS_INTERVAL);
 
     return () => clearInterval(interval);
   }, []);
 
-  return (
-    <span className="inline-block w-6">
-      {dots}
-    </span>
-  );
+  return <span className="inline-block w-6">{dots}</span>;
 };