app-ui
created pr with
46.1
cmds
checkout latest patchset:
ssh pr.pico.sh print 46 | git am -3checkout any patchset in a patch request:
ssh pr.pico.sh print 46.[rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh pr add 46
Patchset
46.1
Install react-use-websocket
Eric Abruzzese
Update the diagnostics create form to navigate to the details page with query parameters instead of POSTing to the external service
2025-01-15T14:28:15ZEric Abruzzese
Collect events on the diagnostic details page and construct a dashboard state
2025-01-15T14:29:34ZEric Abruzzese
Formatting and cleanup
2025-01-15T14:30:03ZEric Abruzzese
Show dashboard messages, resources, operations, and plots
2025-01-15T14:34:02ZMichael Peterson
Support async plot annotations
2025-01-28T20:23:45ZEric Abruzzese
Fix an issue that would cause a page crash if datasets weren't perfectly aligned
2025-01-31T17:53:30ZEric Abruzzese
Add VITE_APTIBLE_AI_URL to .env.example
2025-02-03T20:19:05ZEric Abruzzese
Update VITE_APTIBLE_AI_URL references to point to Hotshot
2025-02-04T16:30:13ZEric Abruzzese
Update the analysis using PlotAnnotated events
2025-02-05T17:50:02ZEric Abruzzese
Install react-use-websocket
2025-02-05T17:51:11ZEric Abruzzese
Update the diagnostics create form to navigate to the details page with query parameters instead of POSTing to the external service
2025-01-15T14:28:15ZEric Abruzzese
Collect events on the diagnostic details page and construct a dashboard state
2025-01-15T14:29:34ZEric Abruzzese
Formatting and cleanup
2025-01-15T14:30:03ZEric Abruzzese
Show dashboard messages, resources, operations, and plots
2025-01-15T14:34:02ZMichael Peterson
Support async plot annotations
2025-01-28T20:23:45ZEric Abruzzese
Fix an issue that would cause a page crash if datasets weren't perfectly aligned
2025-01-31T17:53:30ZEric Abruzzese
Add VITE_APTIBLE_AI_URL to .env.example
2025-02-03T20:19:05ZEric Abruzzese
Update VITE_APTIBLE_AI_URL references to point to Hotshot
2025-02-04T16:30:13ZEric Abruzzese
Update the analysis using PlotAnnotated events
2025-02-05T17:50:02ZEric Abruzzese
Refactor charts
2025-02-05T17:51:11ZMichael Peterson
→ Refactor OperationsTimeline, chart hover state
2025-02-05T18:25:59ZMichael Peterson
Extract types into aptible-ai/index, useDashboard hook
2025-02-05T18:42:20ZMichael Peterson
Fix a linter error
2025-02-05T19:21:56ZEric Abruzzese
Fix linter errors
2025-02-05T19:40:48ZEric Abruzzese
2025-02-05T19:50:07Z
Refactor OperationsTimeline, chart hover state
Michael Peterson
2025-02-05T18:42:20ZSemantic diff summary
2 added,
7 modified,
0 signature changed,
2 removed
across 5 analyzed files
+5
-161
src/ui/pages/diagnostics-detail.tsx
#
| ... | ... | @@ -1,9 +1,9 @@ | |
| 1 | 1 | import { selectAptibleAiUrl } from "@app/config"; | |
| 2 | 2 | import { useSelector } from "@app/react"; | |
| 3 | - | import React, { useRef, useContext } from "react"; | |
| 3 | + | import React from "react"; | |
| 4 | 4 | import { diagnosticsCreateUrl } from "@app/routes"; | |
| 5 | 5 | import { selectAccessToken } from "@app/token"; | |
| 6 | - | import { useEffect, useState, createContext } from "react"; | |
| 6 | + | import { useEffect, useState } from "react"; | |
| 7 | 7 | import { useSearchParams } from "react-router-dom"; | |
| 8 | 8 | import useWebSocket, { ReadyState } from "react-use-websocket"; | |
| 9 | 9 | import { AppSidebarLayout } from "../layouts"; |
| ... | ... | @@ -19,6 +19,9 @@ import { | |
| 19 | 19 | } from "../shared/icons"; | |
| 20 | 20 | import { StreamingText } from "../shared/llm"; | |
| 21 | 21 | import { DiagnosticsLineChart } from "../shared/diagnostics/line-chart"; | |
| 22 | + | import { OperationsTimeline } from "../shared/diagnostics/operations-timeline"; | |
| 23 | + | import { Annotation } from "@app/chart/chartjs-plugin-annoations"; | |
| 24 | + | import { HoverContext, type HoverState } from "../shared/diagnostics/hover"; | |
| 22 | 25 | ||
| 23 | 26 | type Message = { | |
| 24 | 27 | id: string; |
| ... | ... | @@ -84,156 +78,6 @@ type Dashboard = { | |
| 84 | 78 | messages: Message[]; | |
| 85 | 79 | }; | |
| 86 | 80 | ||
| 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 | - | ||
| 237 | 81 | const DiagnosticsMessages = ({ messages, showAllMessages, setShowAllMessages }: { | |
| 238 | 82 | messages: Message[]; | |
| 239 | 83 | showAllMessages: boolean; |