app-ui

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

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

Install react-use-websocket
Eric Abruzzese 2025-01-15T14:28:15Z
Update the diagnostics create form to navigate to the details page with query parameters instead of POSTing to the external service
Eric Abruzzese 2025-01-15T14:29:34Z
Collect events on the diagnostic details page and construct a dashboard state
Eric Abruzzese 2025-01-15T14:30:03Z
Formatting and cleanup
Eric Abruzzese 2025-01-15T14:34:02Z
Show dashboard messages, resources, operations, and plots
Michael Peterson 2025-01-28T20:23:45Z
Support async plot annotations
Eric Abruzzese 2025-01-31T17:53:30Z
Fix an issue that would cause a page crash if datasets weren't perfectly aligned
Eric Abruzzese 2025-02-03T20:19:05Z
Add VITE_APTIBLE_AI_URL to .env.example
Eric Abruzzese 2025-02-04T16:30:13Z
Update VITE_APTIBLE_AI_URL references to point to Hotshot
Eric Abruzzese 2025-02-05T17:50:02Z
Update the analysis using PlotAnnotated events
Eric Abruzzese 2025-02-05T17:51:11Z
Install react-use-websocket
Eric Abruzzese 2025-01-15T14:28:15Z
Update the diagnostics create form to navigate to the details page with query parameters instead of POSTing to the external service
Eric Abruzzese 2025-01-15T14:29:34Z
Collect events on the diagnostic details page and construct a dashboard state
Eric Abruzzese 2025-01-15T14:30:03Z
Formatting and cleanup
Eric Abruzzese 2025-01-15T14:34:02Z
Show dashboard messages, resources, operations, and plots
Michael Peterson 2025-01-28T20:23:45Z
Support async plot annotations
Eric Abruzzese 2025-01-31T17:53:30Z
Fix an issue that would cause a page crash if datasets weren't perfectly aligned
Eric Abruzzese 2025-02-03T20:19:05Z
Add VITE_APTIBLE_AI_URL to .env.example
Eric Abruzzese 2025-02-04T16:30:13Z
Update VITE_APTIBLE_AI_URL references to point to Hotshot
Eric Abruzzese 2025-02-05T17:50:02Z
Update the analysis using PlotAnnotated events
Eric Abruzzese 2025-02-05T17:51:11Z
Refactor charts
Michael Peterson 2025-02-05T18:25:59Z
Refactor OperationsTimeline, chart hover state
Michael Peterson 2025-02-05T18:42:20Z
Extract types into aptible-ai/index, useDashboard hook
Michael Peterson 2025-02-05T19:21:56Z
Fix a linter error
Eric Abruzzese 2025-02-05T19:40:48Z
Fix linter errors
Eric Abruzzese 2025-02-05T19:50:07Z
+5 -161 src/ui/pages/diagnostics-detail.tsx #
......@@ -1,9 +1,9 @@
11 import { selectAptibleAiUrl } from "@app/config";
22 import { useSelector } from "@app/react";
3-import React, { useRef, useContext } from "react";
3+import React from "react";
44 import { diagnosticsCreateUrl } from "@app/routes";
55 import { selectAccessToken } from "@app/token";
6-import { useEffect, useState, createContext } from "react";
6+import { useEffect, useState } from "react";
77 import { useSearchParams } from "react-router-dom";
88 import useWebSocket, { ReadyState } from "react-use-websocket";
99 import { AppSidebarLayout } from "../layouts";
......@@ -19,6 +19,9 @@ import {
1919 } from "../shared/icons";
2020 import { StreamingText } from "../shared/llm";
2121 import { DiagnosticsLineChart } from "../shared/diagnostics/line-chart";
22+import { OperationsTimeline } from "../shared/diagnostics/operations-timeline";
23+import { Annotation } from "@app/chart/chartjs-plugin-annoations";
24+import { HoverContext, type HoverState } from "../shared/diagnostics/hover";
2225
2326 type Message = {
2427 id: string;
......@@ -39,15 +42,6 @@ type Point = {
3942 value: number;
4043 };
4144
42-type Annotation = {
43- label: string;
44- description: string;
45- x_min: number;
46- x_max: number;
47- y_min: number;
48- y_max: number;
49-};
50-
5145 type Series = {
5246 label: string;
5347 description: string;
......@@ -84,156 +78,6 @@ type Dashboard = {
8478 messages: Message[];
8579 };
8680
87-type HoverState = {
88- timestamp: string | null;
89- setTimestamp: (timestamp: string | null) => void;
90-};
91-
92-const HoverContext = createContext<HoverState>({
93- timestamp: null,
94- setTimestamp: () => { },
95-});
96-
97-const OperationsTimeline = ({
98- operations,
99- startTime,
100- endTime,
101- synchronizedHoverContext
102-}: {
103- operations: Operation[],
104- startTime: string,
105- endTime: string,
106- synchronizedHoverContext: React.Context<HoverState>
107-}) => {
108- const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
109- const start = new Date(startTime);
110- const end = new Date(endTime);
111- const minutesDiff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
112- const timelineRef = useRef<HTMLDivElement>(null);
113-
114- // Create array of all minutes between start and end
115- const minutes = Array.from({ length: minutesDiff + 1 }, (_, i) => i);
116-
117- // Map operations to their minute positions
118- const operationsByMinute = operations.reduce((acc, op) => {
119- const opTime = new Date(op.created_at);
120- const minute = Math.floor((opTime.getTime() - start.getTime()) / (1000 * 60));
121- acc[minute] = op;
122- return acc;
123- }, {} as { [key: number]: Operation });
124-
125- // Handle mouse move over timeline
126- const handleMouseMove = (e: React.MouseEvent) => {
127- if (!timelineRef.current) return;
128-
129- const rect = timelineRef.current.getBoundingClientRect();
130- const x = e.clientX - rect.left;
131- const percentage = x / rect.width;
132- const totalMilliseconds = end.getTime() - start.getTime();
133- const hoverTime = new Date(start.getTime() + (percentage * totalMilliseconds));
134-
135- // Round to nearest minute
136- hoverTime.setSeconds(0);
137- hoverTime.setMilliseconds(0);
138-
139- // Format timestamp correctly
140- const formattedTimestamp = hoverTime.toISOString().slice(0, -5) + 'Z';
141- setTimestamp(formattedTimestamp);
142- };
143-
144- // Handle mouse leave
145- const handleMouseLeave = () => {
146- setTimestamp(null);
147- };
148-
149- // Calculate vertical line position when timestamp changes
150- const getVerticalLinePosition = () => {
151- if (!timestamp) return null;
152-
153- try {
154- const hoverTime = new Date(timestamp);
155- const timeElapsed = hoverTime.getTime() - start.getTime();
156- const totalDuration = end.getTime() - start.getTime();
157- const position = (timeElapsed / totalDuration) * 100;
158-
159- // Ensure position is between 0 and 100
160- return Math.max(0, Math.min(100, position));
161- } catch (error) {
162- console.error('Error calculating vertical line position:', error);
163- return null;
164- }
165- };
166-
167- const verticalLinePosition = getVerticalLinePosition();
168-
169- // Helper function to extract operation type from description
170- const getOperationType = (description: string) => {
171- const match = description.match(/^\((succeeded|failed)\) (\w+)/);
172- return match ? match[2] : 'unknown';
173- };
174-
175- return (
176- <div className="mt-4">
177- <div
178- ref={timelineRef}
179- className="relative h-16"
180- onMouseMove={handleMouseMove}
181- onMouseLeave={handleMouseLeave}
182- >
183- <div className="absolute w-full h-0.5 bg-gray-200 top-1/2 transform -translate-y-1/2" />
184-
185- {/* Vertical hover line */}
186- {verticalLinePosition !== null && (
187- <div
188- className="absolute h-full w-px bg-transparent top-0"
189- style={{
190- left: `${verticalLinePosition}%`,
191- borderLeft: '1px dashed #94a3b8'
192- }}
193- />
194- )}
195-
196- {minutes.map((minute) => {
197- const leftPercentage = (minute / minutesDiff) * 100;
198- const operation = operationsByMinute[minute];
199-
200- return (
201- <div
202- key={minute}
203- className="absolute top-1/2 transform -translate-y-1/2"
204- style={{ left: `${leftPercentage}%` }}
205- >
206- <div className="group relative">
207- {operation ? (
208- <>
209- <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'}`} />
210-
211- {/* Operation type label */}
212- <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-gray-100 px-1 rounded">
213- <span className="font-mono text-[10px] whitespace-nowrap uppercase">
214- {getOperationType(operation.description)}
215- </span>
216- </div>
217-
218- {/* Tooltip */}
219- <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">
220- <p className="text-sm">{operation.description}</p>
221- <p className="text-xs text-gray-300">({new Date(operation.created_at).toLocaleTimeString()} local)</p>
222- </div>
223- </>
224- ) : (
225- // Empty marker for minutes without operations
226- <div className="hidden" />
227- )}
228- </div>
229- </div>
230- );
231- })}
232- </div>
233- </div>
234- );
235-};
236-
23781 const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: {
23882 messages: Message[];
23983 showAllMessages: boolean;
+11 -0 src/ui/shared/diagnostics/hover.tsx #
......@@ -0,0 +1,11 @@
1+import { createContext } from "react";
2+
3+export type HoverState = {
4+ timestamp: string | null;
5+ setTimestamp: (timestamp: string | null) => void;
6+};
7+
8+export const HoverContext = createContext<HoverState>({
9+ timestamp: null,
10+ setTimestamp: () => { },
11+});
+2 -1 src/ui/shared/diagnostics/line-chart.tsx #
......@@ -16,6 +16,7 @@ import "chartjs-adapter-luxon";
1616 import { Line } from "react-chartjs-2";
1717 import { verticalLinePlugin } from "../../../chart/chartjs-plugin-vertical-line";
1818 import { annotationsPlugin, type Annotation } from "../../../chart/chartjs-plugin-annoations";
19+import { type HoverState } from "./hover";
1920
2021 ChartJS.register(
2122 CategoryScale,
......@@ -55,7 +56,7 @@ export const DiagnosticsLineChart = ({
5556 yAxisLabel?: string;
5657 yAxisUnit?: string;
5758 annotations?: Annotation[];
58- synchronizedHoverContext: React.Context<{ timestamp: string | null; setTimestamp: (timestamp: string | null) => void; }>;
59+ synchronizedHoverContext: React.Context<HoverState>;
5960 }) => {
6061 const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
6162 const chartRef = React.useRef<ChartJS<"line">>();
+150 -0 src/ui/shared/diagnostics/operations-timeline.tsx #
......@@ -0,0 +1,150 @@
1+import React, { useRef, useContext } from "react";
2+import { type HoverState } from "./hover";
3+
4+type Operation = {
5+ id: number;
6+ status: string;
7+ created_at: string;
8+ description: string;
9+ log_lines: string[];
10+};
11+
12+export const OperationsTimeline = ({
13+ operations,
14+ startTime,
15+ endTime,
16+ synchronizedHoverContext
17+}: {
18+ operations: Operation[],
19+ startTime: string,
20+ endTime: string,
21+ synchronizedHoverContext: React.Context<HoverState>
22+}) => {
23+ const { timestamp, setTimestamp } = useContext(synchronizedHoverContext);
24+ const start = new Date(startTime);
25+ const end = new Date(endTime);
26+ const minutesDiff = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
27+ const timelineRef = useRef<HTMLDivElement>(null);
28+
29+ // Create array of all minutes between start and end
30+ const minutes = Array.from({ length: minutesDiff + 1 }, (_, i) => i);
31+
32+ // Map operations to their minute positions
33+ const operationsByMinute = operations.reduce((acc, op) => {
34+ const opTime = new Date(op.created_at);
35+ const minute = Math.floor((opTime.getTime() - start.getTime()) / (1000 * 60));
36+ acc[minute] = op;
37+ return acc;
38+ }, {} as { [key: number]: Operation });
39+
40+ // Handle mouse move over timeline
41+ const handleMouseMove = (e: React.MouseEvent) => {
42+ if (!timelineRef.current) return;
43+
44+ const rect = timelineRef.current.getBoundingClientRect();
45+ const x = e.clientX - rect.left;
46+ const percentage = x / rect.width;
47+ const totalMilliseconds = end.getTime() - start.getTime();
48+ const hoverTime = new Date(start.getTime() + (percentage * totalMilliseconds));
49+
50+ // Round to nearest minute
51+ hoverTime.setSeconds(0);
52+ hoverTime.setMilliseconds(0);
53+
54+ // Format timestamp correctly
55+ const formattedTimestamp = hoverTime.toISOString().slice(0, -5) + 'Z';
56+ setTimestamp(formattedTimestamp);
57+ };
58+
59+ // Handle mouse leave
60+ const handleMouseLeave = () => {
61+ setTimestamp(null);
62+ };
63+
64+ // Calculate vertical line position when timestamp changes
65+ const getVerticalLinePosition = () => {
66+ if (!timestamp) return null;
67+
68+ try {
69+ const hoverTime = new Date(timestamp);
70+ const timeElapsed = hoverTime.getTime() - start.getTime();
71+ const totalDuration = end.getTime() - start.getTime();
72+ const position = (timeElapsed / totalDuration) * 100;
73+
74+ // Ensure position is between 0 and 100
75+ return Math.max(0, Math.min(100, position));
76+ } catch (error) {
77+ console.error('Error calculating vertical line position:', error);
78+ return null;
79+ }
80+ };
81+
82+ const verticalLinePosition = getVerticalLinePosition();
83+
84+ // Helper function to extract operation type from description
85+ const getOperationType = (description: string) => {
86+ const match = description.match(/^\((succeeded|failed)\) (\w+)/);
87+ return match ? match[2] : 'unknown';
88+ };
89+
90+ return (
91+ <div className="mt-4">
92+ <div
93+ ref={timelineRef}
94+ className="relative h-16"
95+ onMouseMove={handleMouseMove}
96+ onMouseLeave={handleMouseLeave}
97+ >
98+ <div className="absolute w-full h-0.5 bg-gray-200 top-1/2 transform -translate-y-1/2" />
99+
100+ {/* Vertical hover line */}
101+ {verticalLinePosition !== null && (
102+ <div
103+ className="absolute h-full w-px bg-transparent top-0"
104+ style={{
105+ left: `${verticalLinePosition}%`,
106+ borderLeft: '1px dashed #94a3b8'
107+ }}
108+ />
109+ )}
110+
111+ {minutes.map((minute) => {
112+ const leftPercentage = (minute / minutesDiff) * 100;
113+ const operation = operationsByMinute[minute];
114+
115+ return (
116+ <div
117+ key={minute}
118+ className="absolute top-1/2 transform -translate-y-1/2"
119+ style={{ left: `${leftPercentage}%` }}
120+ >
121+ <div className="group relative">
122+ {operation ? (
123+ <>
124+ <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'}`} />
125+
126+ {/* Operation type label */}
127+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-gray-100 px-1 rounded">
128+ <span className="font-mono text-[10px] whitespace-nowrap uppercase">
129+ {getOperationType(operation.description)}
130+ </span>
131+ </div>
132+
133+ {/* Tooltip */}
134+ <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">
135+ <p className="text-sm">{operation.description}</p>
136+ <p className="text-xs text-gray-300">({new Date(operation.created_at).toLocaleTimeString()} local)</p>
137+ </div>
138+ </>
139+ ) : (
140+ // Empty marker for minutes without operations
141+ <div className="hidden" />
142+ )}
143+ </div>
144+ </div>
145+ );
146+ })}
147+ </div>
148+ </div>
149+ );
150+};
+5 -2 src/ui/shared/llm.tsx #
......@@ -1,5 +1,8 @@
11 import React, { useEffect, useState } from "react";
22
3+const TEXT_ANIMATION_INTERVAL = 150;
4+const ELLIPSIS_INTERVAL = 500;
5+
36 export const StreamingText = ({ text, showEllipsis = false, animate = true }: { text: string, showEllipsis?: boolean, animate?: boolean }) => {
47 const words = text.split(' ');
58 const [visibleWords, setVisibleWords] = React.useState<number>(animate ? 0 : words.length);
......@@ -17,7 +20,7 @@ export const StreamingText = ({ text, showEllipsis = false, animate = true }: {
1720 setIsComplete(true);
1821 return prev;
1922 });
20- }, 150);
23+ }, TEXT_ANIMATION_INTERVAL);
2124
2225 return () => clearInterval(timer);
2326 }, [words.length, animate]);
......@@ -46,7 +49,7 @@ export const AnimatedEllipsis = () => {
4649 if (prev === '...') return '';
4750 return prev + '.';
4851 });
49- }, 500);
52+ }, ELLIPSIS_INTERVAL);
5053
5154 return () => clearInterval(interval);
5255 }, []);
Back to top