| 1 | import { ErrorMessage } from "./ErrorMessage"; |
| 2 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { |
| 4 | AlertCircle, |
| 5 | ChevronDown, |
| 6 | ChevronRight, |
| 7 | Clock, |
| 8 | List, |
| 9 | Loader2, |
| 10 | RotateCw, |
| 11 | X, |
| 12 | XCircle, |
| 13 | } from "lucide-react"; |
| 14 | import { app } from "../lib/bridge"; |
| 15 | import { desktopHost } from "../lib/desktopHost"; |
| 16 | import { useT } from "../lib/i18n"; |
| 17 | import type { TaskEvent, TaskSnapshot } from "../lib/types"; |
| 18 | |
| 19 | type CatalogTask = TaskSnapshot & { __projectKey: string; __projectLabel: string; __catalogKey: string }; |
| 20 | |
| 21 | function hasTaskCatalogBinding(): boolean { |
| 22 | return typeof desktopHost().app?.ListTaskPage === "function"; |
| 23 | } |
| 24 | |
| 25 | // --- helpers --- |
| 26 | |
| 27 | type TaskTimerSnapshot = TaskSnapshot & { runtime_lease_until?: string }; |
| 28 | |
| 29 | const STATE_CONFIG: Record< |
| 30 | string, |
| 31 | { key: "queued" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "stale"; color: string; dot: string } |
| 32 | > = { |
| 33 | queued: { key: "queued", color: "#6b7280", dot: "⚪" }, |
| 34 | running: { key: "running", color: "#3b82f6", dot: "🔵" }, |
| 35 | waiting: { key: "waiting", color: "#f59e0b", dot: "🟡" }, |
| 36 | succeeded: { key: "succeeded", color: "#22c55e", dot: "🟢" }, |
| 37 | failed: { key: "failed", color: "#ef4444", dot: "🔴" }, |
| 38 | cancelled: { key: "cancelled", color: "#9ca3af", dot: "⏹️" }, |
| 39 | stale: { key: "stale", color: "#d4d4d8", dot: "⬜" }, |
| 40 | }; |
| 41 | |
| 42 | function stateConfig(state: string, t: ReturnType<typeof useT>) { |
| 43 | const config = STATE_CONFIG[state]; |
| 44 | return config |
| 45 | ? { ...config, label: t(`task.state.${config.key}` as never) } |
| 46 | : { label: state, color: "#6b7280", dot: "❓" }; |
| 47 | } |
| 48 | |
| 49 | function runtimeConfig(state: string | undefined, t: ReturnType<typeof useT>) { |
| 50 | switch (state) { |
| 51 | case "alive": |
| 52 | return { label: t("task.runtime.live"), color: "#22c55e" }; |
| 53 | case "exited": |
| 54 | return { label: t("task.runtime.exited"), color: "#9ca3af" }; |
| 55 | default: |
| 56 | return { label: t("task.runtime.unknown"), color: "#6b7280" }; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | function safeStateClass(state: string): string { |
| 61 | // Sanitize state for use in CSS class names — only allow word chars. |
| 62 | return state.replace(/[^a-zA-Z0-9_-]/g, "_"); |
| 63 | } |
| 64 | |
| 65 | function isTerminalState(state: string): boolean { |
| 66 | return state === "succeeded" || state === "failed" || state === "cancelled" || state === "stale"; |
| 67 | } |
| 68 | |
| 69 | function isStoppableState(state: string): boolean { |
| 70 | return state === "queued" || state === "running" || state === "waiting"; |
| 71 | } |
| 72 | |
| 73 | function elapsed(task: TaskTimerSnapshot, nowMs: number): string { |
| 74 | if (!task.created_at) return "—"; |
| 75 | const startMs = new Date(task.created_at).getTime(); |
| 76 | if (task.state === "queued") return "—"; |
| 77 | const live = task.runtime_state === "alive" && !isTerminalState(task.state); |
| 78 | let endMs = live ? nowMs : new Date(task.updated_at).getTime(); |
| 79 | if (task.state === "stale" && task.runtime_lease_until) { |
| 80 | const leaseEndMs = new Date(task.runtime_lease_until).getTime(); |
| 81 | // Stale is inferred when an alive runtime lease expires. The observer does |
| 82 | // not rewrite updated_at, so the expired lease is the best bounded end time. |
| 83 | if (!isNaN(leaseEndMs) && leaseEndMs >= startMs && leaseEndMs <= nowMs) { |
| 84 | endMs = leaseEndMs; |
| 85 | } |
| 86 | } |
| 87 | const ms = endMs - startMs; |
| 88 | if (isNaN(ms) || ms < 0) return "—"; |
| 89 | const s = Math.floor(ms / 1000); |
| 90 | if (s < 60) return `${s}s`; |
| 91 | const m = Math.floor(s / 60); |
| 92 | if (m < 60) return `${m}m`; |
| 93 | const h = Math.floor(m / 60); |
| 94 | return `${h}h`; |
| 95 | } |
| 96 | |
| 97 | function shortID(id: string): string { |
| 98 | return id.length > 8 ? id.slice(0, 8) : id; |
| 99 | } |
| 100 | |
| 101 | function eventSummary(ev: TaskEvent, t: ReturnType<typeof useT>): string { |
| 102 | if (ev.error_code) return t("task.event.error", { code: ev.error_code }); |
| 103 | switch (ev.event_type) { |
| 104 | case "state_change": |
| 105 | return t("task.event.stateChange", { state: stateConfig(ev.state, t).label, runtime: runtimeConfig(ev.runtime_state, t).label }); |
| 106 | case "error": |
| 107 | return ev.error_summary || t("task.error"); |
| 108 | default: |
| 109 | return ev.event_type; |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // --- component --- |
| 114 | |
| 115 | const POLL_INTERVAL_MS = 5000; |
| 116 | |
| 117 | export function TaskMonitorPanel({ |
| 118 | tabID, |
| 119 | onClose, |
| 120 | onOpenSession, |
| 121 | initialOpen = false, |
| 122 | initialScope = "session", |
| 123 | popover = false, |
| 124 | summaryMode = false, |
| 125 | }: { |
| 126 | tabID: string; |
| 127 | onClose?: () => void; |
| 128 | onOpenSession?: (tabID: string, taskID: string) => Promise<boolean> | boolean; |
| 129 | initialOpen?: boolean; |
| 130 | initialScope?: "session" | "project" | "all"; |
| 131 | popover?: boolean; |
| 132 | summaryMode?: boolean; |
| 133 | }) { |
| 134 | const t = useT(); |
| 135 | const [tasks, setTasks] = useState<CatalogTask[]>([]); |
| 136 | const [scope, setScope] = useState<"session" | "project" | "all">(initialScope); |
| 137 | const [query, setQuery] = useState(""); |
| 138 | const [nextCursor, setNextCursor] = useState(""); |
| 139 | const [indexProgress, setIndexProgress] = useState<{ indexed: number; total: number; partial: boolean }>({ indexed: 0, total: 0, partial: true }); |
| 140 | const requestSeq = useRef(0); |
| 141 | const [loading, setLoading] = useState(true); |
| 142 | const [error, setError] = useState<string | null>(null); |
| 143 | const [expanded, setExpanded] = useState<Set<string>>(new Set()); |
| 144 | const [open, setOpen] = useState(initialOpen); |
| 145 | const [actionTask, setActionTask] = useState<string | null>(null); |
| 146 | const [actionError, setActionError] = useState<string | null>(null); |
| 147 | const [actionMessage, setActionMessage] = useState<string | null>(null); |
| 148 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| 149 | const [pendingStop, setPendingStop] = useState<CatalogTask | null>(null); |
| 150 | const stopButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map()); |
| 151 | const confirmStopRef = useRef<HTMLButtonElement | null>(null); |
| 152 | |
| 153 | // Per-task event state |
| 154 | const [taskEvents, setTaskEvents] = useState<Map<string, TaskEvent[]>>( |
| 155 | () => new Map(), |
| 156 | ); |
| 157 | const [eventsLoading, setEventsLoading] = useState<Set<string>>(new Set()); |
| 158 | const [eventsError, setEventsError] = useState<Map<string, string>>( |
| 159 | () => new Map(), |
| 160 | ); |
| 161 | const eventCursors = useRef<Map<string, number>>(new Map()); |
| 162 | |
| 163 | const fetchTasks = useCallback(async (cursor = "") => { |
| 164 | const seq = ++requestSeq.current; |
| 165 | try { |
| 166 | setError(null); |
| 167 | if (!hasTaskCatalogBinding()) { |
| 168 | const legacy = await app.ListTasksForTab(tabID); |
| 169 | if (seq !== requestSeq.current) return; |
| 170 | const filtered = legacy.filter((task) => !query.trim() || [task.task_id, task.session_id, task.error_code, task.error_summary].some((value) => (value || "").toLowerCase().includes(query.trim().toLowerCase()))); |
| 171 | setTasks(filtered.map((task) => ({ ...task, __projectKey: "", __projectLabel: "", __catalogKey: task.task_id }))); |
| 172 | setNextCursor(""); |
| 173 | setIndexProgress({ indexed: filtered.length, total: filtered.length, partial: false }); |
| 174 | return; |
| 175 | } |
| 176 | const page = await app.ListTaskPage({ scope, tabId: tabID, projectKey: "", states: [], query, cursor, limit: 50 }); |
| 177 | if (seq !== requestSeq.current) return; |
| 178 | const decorated = (page.items ?? []).map((item) => ({ ...item.task, __projectKey: item.projectKey, __projectLabel: item.projectLabel, __catalogKey: `${item.projectKey}:${item.task.task_id}` })); |
| 179 | setTasks((current) => cursor ? [...current, ...decorated.filter((item) => !current.some((existing) => existing.__catalogKey === item.__catalogKey))] : decorated); |
| 180 | setNextCursor(page.nextCursor || ""); |
| 181 | setIndexProgress({ indexed: page.status.indexed, total: page.status.total, partial: page.partial }); |
| 182 | } catch (e) { |
| 183 | if (seq !== requestSeq.current) return; |
| 184 | setError(String(e)); |
| 185 | } finally { |
| 186 | setLoading(false); |
| 187 | } |
| 188 | }, [query, scope, tabID]); |
| 189 | |
| 190 | // Fetch events for a single task, using afterSequence for incremental load. |
| 191 | const fetchEvents = useCallback(async (task: CatalogTask) => { |
| 192 | const taskID = task.__catalogKey; |
| 193 | setEventsLoading((prev) => new Set(prev).add(taskID)); |
| 194 | setEventsError((prev) => { |
| 195 | const next = new Map(prev); |
| 196 | next.delete(taskID); |
| 197 | return next; |
| 198 | }); |
| 199 | try { |
| 200 | const cursor = eventCursors.current.get(taskID) ?? 0; |
| 201 | const events = hasTaskCatalogBinding() |
| 202 | ? (await app.ListTaskEventPage({ projectKey: task.__projectKey, taskId: task.task_id, after: cursor, limit: 50 })).items ?? [] |
| 203 | : await app.ListTaskEventsForTab(tabID, task.task_id, cursor); |
| 204 | if (events.length > 0) { |
| 205 | setTaskEvents((prev) => { |
| 206 | const next = new Map(prev); |
| 207 | const existing = next.get(taskID) ?? []; |
| 208 | // Merge, deduplicate by sequence |
| 209 | const seen = new Set(existing.map((e) => e.sequence)); |
| 210 | const merged = [...existing, ...events.filter((e) => !seen.has(e.sequence))]; |
| 211 | merged.sort((a, b) => a.sequence - b.sequence); |
| 212 | next.set(taskID, merged); |
| 213 | return next; |
| 214 | }); |
| 215 | // Update cursor to the max sequence |
| 216 | const maxSeq = events.reduce( |
| 217 | (max, e) => Math.max(max, e.sequence), |
| 218 | cursor, |
| 219 | ); |
| 220 | eventCursors.current.set(taskID, maxSeq); |
| 221 | } |
| 222 | } catch (e) { |
| 223 | setEventsError((prev) => { |
| 224 | const next = new Map(prev); |
| 225 | next.set(taskID, String(e)); |
| 226 | return next; |
| 227 | }); |
| 228 | } finally { |
| 229 | setEventsLoading((prev) => { |
| 230 | const next = new Set(prev); |
| 231 | next.delete(taskID); |
| 232 | return next; |
| 233 | }); |
| 234 | } |
| 235 | }, [tabID]); |
| 236 | |
| 237 | // Initial fetch + periodic polling |
| 238 | useEffect(() => { |
| 239 | void fetchTasks(""); |
| 240 | const interval = setInterval(() => { |
| 241 | void fetchTasks(""); |
| 242 | }, POLL_INTERVAL_MS); |
| 243 | return () => clearInterval(interval); |
| 244 | }, [fetchTasks]); |
| 245 | |
| 246 | // Live tasks need a ticking clock; terminal and queued tasks stay frozen at |
| 247 | // their persisted end/update time. |
| 248 | useEffect(() => { |
| 249 | if (!tasks.some((task) => task.runtime_state === "alive" && !isTerminalState(task.state))) return; |
| 250 | const interval = setInterval(() => setNowMs(Date.now()), 1000); |
| 251 | return () => clearInterval(interval); |
| 252 | }, [tasks]); |
| 253 | |
| 254 | useEffect(() => { |
| 255 | if (pendingStop) confirmStopRef.current?.focus(); |
| 256 | }, [pendingStop]); |
| 257 | |
| 258 | useEffect(() => { |
| 259 | if (!pendingStop) return; |
| 260 | const current = tasks.find((task) => task.__catalogKey === pendingStop.__catalogKey); |
| 261 | if (!current || !isStoppableState(current.state)) setPendingStop(null); |
| 262 | }, [pendingStop, tasks]); |
| 263 | |
| 264 | const dismissStopConfirmation = () => { |
| 265 | const taskKey = pendingStop?.__catalogKey; |
| 266 | setPendingStop(null); |
| 267 | if (taskKey) { |
| 268 | requestAnimationFrame(() => stopButtonRefs.current.get(taskKey)?.focus()); |
| 269 | } |
| 270 | }; |
| 271 | |
| 272 | const toggleTask = (task: CatalogTask) => { |
| 273 | const id = task.__catalogKey; |
| 274 | setExpanded((prev) => { |
| 275 | const next = new Set(prev); |
| 276 | if (next.has(id)) { |
| 277 | next.delete(id); |
| 278 | } else { |
| 279 | next.add(id); |
| 280 | // Load events on first expand |
| 281 | if (!taskEvents.has(id)) { |
| 282 | void fetchEvents(task); |
| 283 | } |
| 284 | } |
| 285 | return next; |
| 286 | }); |
| 287 | }; |
| 288 | |
| 289 | const controlTask = async (task: CatalogTask, action: "stop" | "requeue" | "open") => { |
| 290 | setPendingStop(null); |
| 291 | setActionTask(task.__catalogKey); |
| 292 | setActionError(null); |
| 293 | setActionMessage(null); |
| 294 | try { |
| 295 | if (action === "open" && onOpenSession && scope === "session") { |
| 296 | const opened = await onOpenSession(tabID, task.task_id); |
| 297 | if (opened) onClose?.(); |
| 298 | return; |
| 299 | } |
| 300 | const request = { projectKey: task.__projectKey, taskId: task.task_id, expectedVersion: task.version, reason: "desktop request", idempotencyKey: `desktop-${action}-${task.task_id}-${task.version}` }; |
| 301 | const result = hasTaskCatalogBinding() |
| 302 | ? action === "stop" |
| 303 | ? await app.StopTaskByKey(request) |
| 304 | : action === "requeue" |
| 305 | ? await app.RequeueTaskByKey(request) |
| 306 | : await app.OpenTaskSessionByKey({ projectKey: task.__projectKey, taskId: task.task_id }) |
| 307 | : action === "stop" |
| 308 | ? await app.StopTaskForTab(tabID, task.task_id, task.version, request.reason, request.idempotencyKey) |
| 309 | : action === "requeue" |
| 310 | ? await app.RequeueTaskForTab(tabID, task.task_id, task.version, request.idempotencyKey) |
| 311 | : await app.OpenTaskSessionForTab(tabID, task.task_id); |
| 312 | if (result.error) { |
| 313 | setActionError(`${result.error.code}: ${result.error.message}`); |
| 314 | } else if (action === "open") { |
| 315 | const sessionID = result.session_id?.trim(); |
| 316 | if (!sessionID) throw new Error("Task session is unavailable"); |
| 317 | setActionMessage(`Session: ${sessionID}`); |
| 318 | } else { |
| 319 | setActionMessage(result.idempotent ? "Already applied" : "Task updated"); |
| 320 | await fetchTasks(""); |
| 321 | } |
| 322 | } catch (e) { |
| 323 | setActionError(String(e)); |
| 324 | } finally { |
| 325 | setActionTask(null); |
| 326 | } |
| 327 | }; |
| 328 | |
| 329 | const sorted = [...tasks].sort( |
| 330 | (a, b) => |
| 331 | new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), |
| 332 | ); |
| 333 | |
| 334 | return ( |
| 335 | <div className={`taskmonitor${popover ? " taskmonitor--popover" : ""}`}> |
| 336 | <div className="taskmonitor__head"> |
| 337 | <button |
| 338 | className="taskmonitor__toggle" |
| 339 | onClick={() => setOpen((v) => !v)} |
| 340 | aria-expanded={open} |
| 341 | aria-label={open ? "Collapse tasks" : "Expand tasks"} |
| 342 | > |
| 343 | {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />} |
| 344 | </button> |
| 345 | <span className="taskmonitor__title">{summaryMode ? t("summary.session") : t("summary.tasks")}</span> |
| 346 | <span className="taskmonitor__count">{tasks.length}</span> |
| 347 | <button |
| 348 | className="taskmonitor__refresh" |
| 349 | onClick={() => { |
| 350 | setLoading(true); |
| 351 | void fetchTasks(""); |
| 352 | }} |
| 353 | title={t("summary.refresh")} |
| 354 | aria-label={t("summary.refresh")} |
| 355 | > |
| 356 | <RotateCw size={12} /> |
| 357 | </button> |
| 358 | {onClose && ( |
| 359 | <button |
| 360 | className="taskmonitor__close" |
| 361 | onClick={onClose} |
| 362 | title={t("common.close")} |
| 363 | aria-label={t("summary.close")} |
| 364 | > |
| 365 | <X size={14} /> |
| 366 | </button> |
| 367 | )} |
| 368 | </div> |
| 369 | |
| 370 | {open && ( |
| 371 | <div className="taskmonitor__body"> |
| 372 | {!summaryMode && ( |
| 373 | <div className="taskmonitor__filters"> |
| 374 | <select value={scope} onChange={(event) => setScope(event.target.value as "session" | "project" | "all")} aria-label="Task scope"> |
| 375 | <option value="session">Current session</option> |
| 376 | <option value="project">Current project</option> |
| 377 | <option value="all">All projects</option> |
| 378 | </select> |
| 379 | <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter tasks" aria-label="Filter tasks" /> |
| 380 | </div> |
| 381 | )} |
| 382 | {indexProgress.partial && <div className="taskmonitor__indexing">Indexing tasks ({indexProgress.indexed}/{indexProgress.total})</div>} |
| 383 | {summaryMode && <div className="taskmonitor__category-title">{t("summary.tasks")}</div>} |
| 384 | {actionError && <div className="taskmonitor__state taskmonitor__state--error"><ErrorMessage error={actionError} /></div>} |
| 385 | {actionMessage && <div className="taskmonitor__state">{actionMessage}</div>} |
| 386 | {loading && ( |
| 387 | <div className="taskmonitor__state"> |
| 388 | <Loader2 size={16} className="taskmonitor__spinner" /> |
| 389 | <span>{t("common.loading")}</span> |
| 390 | </div> |
| 391 | )} |
| 392 | |
| 393 | {error && ( |
| 394 | <div className="taskmonitor__state taskmonitor__state--error"> |
| 395 | <AlertCircle size={16} /> |
| 396 | <span><ErrorMessage error={error} /></span> |
| 397 | </div> |
| 398 | )} |
| 399 | |
| 400 | {!loading && !error && sorted.length === 0 && ( |
| 401 | <div className="taskmonitor__state taskmonitor__state--empty"> |
| 402 | <Clock size={16} /> |
| 403 | <span>{t("summary.noTasks")}</span> |
| 404 | </div> |
| 405 | )} |
| 406 | |
| 407 | {!loading && |
| 408 | sorted.map((task) => { |
| 409 | const cfg = stateConfig(task.state, t); |
| 410 | const runtime = runtimeConfig(task.runtime_state, t); |
| 411 | const taskKey = task.__catalogKey; |
| 412 | const isOpen = expanded.has(taskKey); |
| 413 | const terminal = isTerminalState(task.state); |
| 414 | const evs = taskEvents.get(taskKey) ?? []; |
| 415 | const evLoading = eventsLoading.has(taskKey); |
| 416 | const evError = eventsError.get(taskKey); |
| 417 | |
| 418 | return ( |
| 419 | <div |
| 420 | key={taskKey} |
| 421 | className={`taskmonitor__task taskmonitor__task--${safeStateClass(task.state)}`} |
| 422 | > |
| 423 | <div className="taskmonitor__task-head"> |
| 424 | <button |
| 425 | className="taskmonitor__expand" |
| 426 | onClick={() => toggleTask(task)} |
| 427 | aria-expanded={isOpen} |
| 428 | aria-label={t("summary.taskLabel", { id: shortID(task.task_id), state: cfg.label })} |
| 429 | > |
| 430 | <span |
| 431 | className="taskmonitor__dot" |
| 432 | style={{ color: cfg.color }} |
| 433 | > |
| 434 | {cfg.dot} |
| 435 | </span> |
| 436 | <span className="taskmonitor__id"> |
| 437 | {shortID(task.task_id)} |
| 438 | </span> |
| 439 | {scope === "all" && <span className="taskmonitor__project">{task.__projectLabel}</span>} |
| 440 | <span |
| 441 | className="taskmonitor__badge" |
| 442 | style={{ |
| 443 | backgroundColor: cfg.color + "18", |
| 444 | color: cfg.color, |
| 445 | }} |
| 446 | > |
| 447 | {cfg.label} |
| 448 | </span> |
| 449 | <span |
| 450 | className="taskmonitor__runtime" |
| 451 | style={{ color: runtime.color }} |
| 452 | title="Runtime process state" |
| 453 | > |
| 454 | <span aria-hidden="true">{task.runtime_state === "alive" ? "●" : "○"}</span> |
| 455 | {runtime.label} |
| 456 | </span> |
| 457 | {terminal && ( |
| 458 | <XCircle size={12} className="taskmonitor__terminal" /> |
| 459 | )} |
| 460 | <span className="taskmonitor__time"> |
| 461 | {elapsed(task, nowMs)} |
| 462 | </span> |
| 463 | {isOpen ? ( |
| 464 | <ChevronDown size={12} /> |
| 465 | ) : ( |
| 466 | <ChevronRight size={12} /> |
| 467 | )} |
| 468 | </button> |
| 469 | </div> |
| 470 | |
| 471 | {isOpen && ( |
| 472 | <div className="taskmonitor__detail"> |
| 473 | <dl> |
| 474 | <dt>{t("summary.taskId")}</dt> |
| 475 | <dd>{task.task_id}</dd> |
| 476 | <dt>{t("summary.sessionId")}</dt> |
| 477 | <dd>{task.session_id || "—"}</dd> |
| 478 | <dt>{t("summary.state")}</dt> |
| 479 | <dd>{cfg.label}</dd> |
| 480 | <dt>{t("summary.runtime")}</dt> |
| 481 | <dd>{runtime.label}</dd> |
| 482 | <dt>{t("summary.updated")}</dt> |
| 483 | <dd>{new Date(task.updated_at).toLocaleString()}</dd> |
| 484 | {task.error_code && ( |
| 485 | <> |
| 486 | <dt>{t("summary.errorCode")}</dt> |
| 487 | <dd className="taskmonitor__err">{task.error_code}</dd> |
| 488 | </> |
| 489 | )} |
| 490 | {task.error_summary && ( |
| 491 | <> |
| 492 | <dt>{t("summary.detail")}</dt> |
| 493 | <dd className="taskmonitor__err-summary"> |
| 494 | {task.error_summary} |
| 495 | </dd> |
| 496 | </> |
| 497 | )} |
| 498 | </dl> |
| 499 | |
| 500 | {/* Events section */} |
| 501 | <div className="taskmonitor__events"> |
| 502 | <div className="taskmonitor__events-head"> |
| 503 | <List size={12} /> |
| 504 | <span>{t("summary.recentEvents")}</span> |
| 505 | {evs.length > 0 && ( |
| 506 | <span className="taskmonitor__events-count"> |
| 507 | {evs.length} |
| 508 | </span> |
| 509 | )} |
| 510 | </div> |
| 511 | |
| 512 | {evLoading && evs.length === 0 && ( |
| 513 | <div className="taskmonitor__state"> |
| 514 | <Loader2 |
| 515 | size={12} |
| 516 | className="taskmonitor__spinner" |
| 517 | /> |
| 518 | <span>{t("summary.loadingEvents")}</span> |
| 519 | </div> |
| 520 | )} |
| 521 | |
| 522 | {evError && ( |
| 523 | <div className="taskmonitor__state taskmonitor__state--error"> |
| 524 | <AlertCircle size={12} /> |
| 525 | <span><ErrorMessage error={evError} /></span> |
| 526 | </div> |
| 527 | )} |
| 528 | |
| 529 | {!evLoading && !evError && evs.length === 0 && ( |
| 530 | <div className="taskmonitor__state taskmonitor__state--empty"> |
| 531 | <span>{t("summary.noEvents")}</span> |
| 532 | </div> |
| 533 | )} |
| 534 | |
| 535 | {evs.length > 0 && ( |
| 536 | <ul className="taskmonitor__event-list"> |
| 537 | {evs.map((ev) => ( |
| 538 | <li |
| 539 | key={ev.sequence} |
| 540 | className="taskmonitor__event" |
| 541 | > |
| 542 | <span className="taskmonitor__event-seq"> |
| 543 | #{ev.sequence} |
| 544 | </span> |
| 545 | <span className="taskmonitor__event-type"> |
| 546 | {eventSummary(ev, t)} |
| 547 | </span> |
| 548 | <span className="taskmonitor__event-time"> |
| 549 | {new Date(ev.timestamp).toLocaleTimeString()} |
| 550 | </span> |
| 551 | </li> |
| 552 | ))} |
| 553 | </ul> |
| 554 | )} |
| 555 | </div> |
| 556 | {pendingStop?.__catalogKey === taskKey ? ( |
| 557 | <div |
| 558 | className="taskmonitor__confirm" |
| 559 | role="group" |
| 560 | aria-label={t("summary.confirmStop")} |
| 561 | onKeyDown={(event) => { |
| 562 | if (event.key === "Escape") { |
| 563 | event.preventDefault(); |
| 564 | dismissStopConfirmation(); |
| 565 | } |
| 566 | }} |
| 567 | > |
| 568 | <span className="taskmonitor__confirm-copy">{t("summary.confirmStop")}</span> |
| 569 | <div className="taskmonitor__confirm-actions"> |
| 570 | <button |
| 571 | ref={confirmStopRef} |
| 572 | type="button" |
| 573 | className="taskmonitor__confirm-stop" |
| 574 | disabled={actionTask === taskKey} |
| 575 | onClick={() => void controlTask(task, "stop")} |
| 576 | > |
| 577 | {t("summary.stop")} |
| 578 | </button> |
| 579 | <button type="button" onClick={dismissStopConfirmation}>{t("summary.keep")}</button> |
| 580 | </div> |
| 581 | </div> |
| 582 | ) : ( |
| 583 | <div className="taskmonitor__actions"> |
| 584 | {isStoppableState(task.state) && ( |
| 585 | <button |
| 586 | ref={(node) => { |
| 587 | if (node) stopButtonRefs.current.set(taskKey, node); |
| 588 | else stopButtonRefs.current.delete(taskKey); |
| 589 | }} |
| 590 | className="taskmonitor__stop" |
| 591 | disabled={actionTask === taskKey} |
| 592 | onClick={() => setPendingStop(task)} |
| 593 | > |
| 594 | {t("summary.stop")} |
| 595 | </button> |
| 596 | )} |
| 597 | {(task.state === "failed" || task.state === "stale") && ( |
| 598 | <button disabled={actionTask === taskKey || task.runtime_state === "alive"} onClick={() => void controlTask(task, "requeue")}>{t("summary.requeue")}</button> |
| 599 | )} |
| 600 | <button disabled={actionTask === taskKey} onClick={() => void controlTask(task, "open")}>{t("summary.openSession")}</button> |
| 601 | </div> |
| 602 | )} |
| 603 | </div> |
| 604 | )} |
| 605 | </div> |
| 606 | ); |
| 607 | })} |
| 608 | {nextCursor && !loading && !error && ( |
| 609 | <button className="taskmonitor__load-more" onClick={() => void fetchTasks(nextCursor)}> |
| 610 | Load more |
| 611 | </button> |
| 612 | )} |
| 613 | </div> |
| 614 | )} |
| 615 | </div> |
| 616 | ); |
| 617 | } |
| 618 |