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