diff --git a/web/hwlab-cloud-web/src/components/common/VirtualList.vue b/web/hwlab-cloud-web/src/components/common/VirtualList.vue index 69e9c992..bc3aa955 100644 --- a/web/hwlab-cloud-web/src/components/common/VirtualList.vue +++ b/web/hwlab-cloud-web/src/components/common/VirtualList.vue @@ -17,6 +17,7 @@ const props = withDefaults(defineProps<{ itemHeight: number; height?: number | string; overscan?: number; + windowLimit?: number; ariaLabel?: string; }>(), { height: 420, @@ -29,7 +30,13 @@ const viewportHeight = ref(typeof props.height === "number" ? props.height : 0); const root = ref(null); let resizeObserver: ResizeObserver | null = null; const startIndex = computed(() => Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan)); -const endIndex = computed(() => Math.min(props.items.length, Math.ceil((scrollTop.value + viewportHeight.value) / props.itemHeight) + props.overscan)); +const endIndex = computed(() => { + const requestedEnd = Math.ceil((scrollTop.value + viewportHeight.value) / props.itemHeight) + props.overscan; + const limitedEnd = props.windowLimit && props.windowLimit > 0 + ? Math.min(requestedEnd, startIndex.value + props.windowLimit) + : requestedEnd; + return Math.min(props.items.length, limitedEnd); +}); const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value)); const totalHeight = computed(() => props.items.length * props.itemHeight); const windowOffset = computed(() => startIndex.value * props.itemHeight); diff --git a/web/hwlab-cloud-web/src/stores/agent-observer.ts b/web/hwlab-cloud-web/src/stores/agent-observer.ts index 7a61428e..d329bf4f 100644 --- a/web/hwlab-cloud-web/src/stores/agent-observer.ts +++ b/web/hwlab-cloud-web/src/stores/agent-observer.ts @@ -51,6 +51,34 @@ export interface AgentObserverRun { changedAtMs: number; } +export type AgentObserverRelationKind = "scope" | "session" | "task" | "run" | "attempt" | "command"; + +export interface AgentObserverRelationRow { + id: string; + kind: AgentObserverRelationKind; + label: string; + detail: string | null; + parentId: string | null; + runId: string | null; + status: string | null; + depth: number; + childCount: number; + expanded: boolean; + position: number; + setSize: number; +} + +interface AgentObserverRelationNode { + id: string; + kind: AgentObserverRelationKind; + label: string; + detail: string | null; + parentId: string | null; + runId: string | null; + status: string | null; + children: string[]; +} + export const useAgentObserverStore = defineStore("agentObserver", () => { const entities = ref>({}); const orderedIds = ref([]); @@ -70,6 +98,8 @@ export const useAgentObserverStore = defineStore("agentObserver", () => { const error = ref(null); const nowMs = ref(Date.now()); const config = ref(null); + const expandedRelationIds = ref([]); + const collapsedRelationIds = ref([]); const seenEventIds = new Set(); const queue: unknown[] = []; let source: EventSource | null = null; @@ -84,7 +114,7 @@ export const useAgentObserverStore = defineStore("agentObserver", () => { const needle = search.value.trim().toLowerCase(); const result = runs.value.filter((run) => { if (statusFilter.value !== "all" && statusGroup(run.runStatus) !== statusFilter.value) return false; - if (projectFilter.value !== "all" && run.projectId !== projectFilter.value) return false; + if (projectFilter.value !== "all" && !matchesScopeFilter(run, projectFilter.value)) return false; if (agentFilter.value !== "all" && (run.backend ?? run.providerId) !== agentFilter.value) return false; if (!insideTimeWindow(run.updatedAt ?? run.startedAt, timeWindow.value)) return false; if (!needle) return true; @@ -94,6 +124,10 @@ export const useAgentObserverStore = defineStore("agentObserver", () => { return result.sort((left, right) => compareRuns(left, right, sort.value)); }); const projects = computed(() => unique(runs.value.map((run) => run.projectId))); + const scopeOptions = computed(() => [ + ...unique(runs.value.map((run) => run.projectId)).map((value) => ({ value: `project:${value}`, label: `项目 · ${value}` })), + ...unique(runs.value.map((run) => run.workspace)).map((value) => ({ value: `workspace:${value}`, label: `Workspace · ${value}` })) + ]); const agents = computed(() => unique(runs.value.map((run) => run.backend ?? run.providerId))); const selectedEvents = computed(() => { const run = selectedRun.value; @@ -113,6 +147,12 @@ export const useAgentObserverStore = defineStore("agentObserver", () => { }; }); const dataAgeMs = computed(() => lastEventAt.value ? Math.max(0, nowMs.value - Date.parse(lastEventAt.value)) : null); + const relationRows = computed(() => buildRelationRows( + visibleRuns.value, + selectedRunId.value, + new Set(expandedRelationIds.value), + new Set(collapsedRelationIds.value) + )); function connect(): void { disconnect(false); @@ -230,16 +270,16 @@ export const useAgentObserverStore = defineStore("agentObserver", () => { }; events.value.push(event); while (events.value.length > config.value.reducerEventLimit) { - const removed = events.value.shift(); - if (removed) seenEventIds.delete(removed.id); + events.value.shift(); } const previous = entities.value[runId]; - const sequenceIsCurrent = !previous || sourceSeq === null || previous.lastSourceSeq === null || sourceSeq >= previous.lastSourceSeq; + const sequenceIsCurrent = !previous || previous.lastSourceSeq === null || (sourceSeq !== null && sourceSeq >= previous.lastSourceSeq); + lastEventAt.value = latestTimestamp(lastEventAt.value, createdAt) ?? new Date().toISOString(); + nowMs.value = Date.now(); + if (previous && !sequenceIsCurrent) return; const terminalStatus = nonEmptyString(body?.terminalStatus); - const runStatus = sequenceIsCurrent - ? normalizeRunStatus(nonEmptyString(body?.runStatus), terminalStatus, event.status, event.terminal, previous?.runStatus) - : previous.runStatus; + const runStatus = normalizeRunStatus(nonEmptyString(body?.runStatus), terminalStatus, event.status, event.terminal, previous?.runStatus); const next: AgentObserverRun = { id: runId, taskId: nonEmptyString(body?.taskId) ?? previous?.taskId ?? null, @@ -261,15 +301,13 @@ export const useAgentObserverStore = defineStore("agentObserver", () => { lastMessage: event.message || (previous?.lastMessage ?? null), startedAt: previous?.startedAt ?? createdAt, updatedAt: createdAt ?? previous?.updatedAt ?? null, - lastSourceSeq: sequenceIsCurrent ? sourceSeq ?? previous?.lastSourceSeq ?? null : previous.lastSourceSeq, + lastSourceSeq: sourceSeq ?? previous?.lastSourceSeq ?? null, eventIds: [...(previous?.eventIds ?? []), eventId].slice(-config.value.inspectorEventTailLimit), changedAtMs: Date.now() }; entities.value = { ...entities.value, [runId]: next }; orderedIds.value = [runId, ...orderedIds.value.filter((id) => id !== runId)]; trimEntities(); - lastEventAt.value = createdAt ?? new Date().toISOString(); - nowMs.value = Date.now(); } function trimEntities(): void { @@ -286,14 +324,177 @@ export const useAgentObserverStore = defineStore("agentObserver", () => { selectedRunId.value = runId; } + function toggleRelation(relationId: string, expanded: boolean): void { + if (expanded) { + collapsedRelationIds.value = collapsedRelationIds.value.filter((id) => id !== relationId); + if (!expandedRelationIds.value.includes(relationId)) expandedRelationIds.value = [...expandedRelationIds.value, relationId]; + return; + } + expandedRelationIds.value = expandedRelationIds.value.filter((id) => id !== relationId); + if (!collapsedRelationIds.value.includes(relationId)) collapsedRelationIds.value = [...collapsedRelationIds.value, relationId]; + } + return { entities, orderedIds, events, runs, visibleRuns, selectedRun, selectedEvents, selectedRunId, view, density, search, statusFilter, projectFilter, agentFilter, timeWindow, sort, phase, lastEventAt, - connectedAt, error, config, projects, agents, stats, dataAgeMs, - connect, disconnect, reconnect, selectRun + connectedAt, error, config, projects, scopeOptions, agents, stats, dataAgeMs, relationRows, + connect, disconnect, reconnect, selectRun, toggleRelation }; }); +function buildRelationRows( + runs: AgentObserverRun[], + selectedRunId: string | null, + expandedIds: Set, + collapsedIds: Set +): AgentObserverRelationRow[] { + const nodes = new Map(); + const rootIds: string[] = []; + const selectedPath = new Set(); + const activePath = new Set(); + const unassociatedId = "scope:unassociated"; + + const ensureNode = (node: Omit): AgentObserverRelationNode => { + const existing = nodes.get(node.id); + if (existing) return existing; + const created = { ...node, children: [] }; + nodes.set(node.id, created); + if (node.parentId) { + const parent = nodes.get(node.parentId); + if (parent && !parent.children.includes(node.id)) parent.children.push(node.id); + } else if (!rootIds.includes(node.id)) rootIds.push(node.id); + return created; + }; + + const ensureUnassociated = (): AgentObserverRelationNode => ensureNode({ + id: unassociatedId, + kind: "scope", + label: "未关联", + detail: "Kafka 事件未提供完整 Project / Workspace → Session → Task lineage", + parentId: null, + runId: null, + status: null + }); + + for (const run of runs) { + const hasCompleteContainment = Boolean((run.projectId || run.workspace) && run.sessionId && run.taskId); + let parent = hasCompleteContainment + ? ensureNode({ + id: relationId("scope", `${run.projectId ?? ""}\u0000${run.workspace ?? ""}`), + kind: "scope", + label: `${run.projectId ?? "项目未提供"} / ${run.workspace ?? "Workspace 未提供"}`, + detail: null, + parentId: null, + runId: null, + status: null + }) + : ensureUnassociated(); + const path = [parent.id]; + + if (hasCompleteContainment && run.sessionId && run.taskId) { + parent = ensureNode({ + id: relationId("session", `${parent.id}\u0000${run.sessionId}`), + kind: "session", + label: `Session ${run.sessionId}`, + detail: run.traceId ? `Trace ${run.traceId}` : null, + parentId: parent.id, + runId: null, + status: null + }); + path.push(parent.id); + parent = ensureNode({ + id: relationId("task", `${parent.id}\u0000${run.taskId}`), + kind: "task", + label: `Task ${run.taskId}`, + detail: null, + parentId: parent.id, + runId: null, + status: null + }); + path.push(parent.id); + } + + parent = ensureNode({ + id: relationId("run", `${parent.id}\u0000${run.id}`), + kind: "run", + label: `Run ${run.id}`, + detail: run.phase, + parentId: parent.id, + runId: run.id, + status: run.runStatus + }); + path.push(parent.id); + + if (run.attemptId) { + parent = ensureNode({ + id: relationId("attempt", `${parent.id}\u0000${run.attemptId}`), + kind: "attempt", + label: `Attempt ${run.attemptId}`, + detail: null, + parentId: parent.id, + runId: run.id, + status: run.runStatus + }); + path.push(parent.id); + } + + if (run.commandId) { + parent = ensureNode({ + id: relationId("command", `${parent.id}\u0000${run.commandId}`), + kind: "command", + label: `Command ${run.commandId}`, + detail: [run.commandType, run.commandState].filter(Boolean).join(" · ") || null, + parentId: parent.id, + runId: run.id, + status: run.commandState ?? run.runStatus + }); + path.push(parent.id); + } + + if (run.id === selectedRunId) for (const id of path) selectedPath.add(id); + if (["running", "waiting"].includes(statusGroup(run.runStatus))) for (const id of path) activePath.add(id); + } + + const rows: AgentObserverRelationRow[] = []; + const append = (id: string, depth: number, position: number, setSize: number): void => { + const node = nodes.get(id); + if (!node) return; + const defaultExpanded = depth === 0 || selectedPath.has(id) || activePath.has(id); + const expanded = node.children.length > 0 && !collapsedIds.has(id) && (expandedIds.has(id) || defaultExpanded); + rows.push({ + id: node.id, + kind: node.kind, + label: node.label, + detail: node.detail, + parentId: node.parentId, + runId: node.runId, + status: node.status, + depth, + childCount: node.children.length, + expanded, + position, + setSize + }); + if (expanded) node.children.forEach((childId, index) => append(childId, depth + 1, index + 1, node.children.length)); + }; + rootIds.forEach((id, index) => append(id, 0, index + 1, rootIds.length)); + return rows; +} + +function relationId(kind: AgentObserverRelationKind, value: string): string { + return `${kind}:${encodeURIComponent(value)}`; +} + +function latestTimestamp(previous: string | null, next: string | null): string | null { + if (!previous) return next; + if (!next) return previous; + const previousMs = Date.parse(previous); + const nextMs = Date.parse(next); + if (!Number.isFinite(previousMs)) return next; + if (!Number.isFinite(nextMs)) return previous; + return nextMs >= previousMs ? next : previous; +} + function normalizeRunStatus(runStatus: string | null, terminalStatus: string | null, eventStatus: string, terminal: boolean, previous = "unknown"): string { if (terminalStatus) return terminalStatus === "cancelled" ? "canceled" : terminalStatus; if (terminal) return eventStatus; @@ -321,6 +522,12 @@ function unique(values: Array): string[] { return [...new Set(values.filter((value): value is string => Boolean(value)))].sort(); } +function matchesScopeFilter(run: AgentObserverRun, filter: string): boolean { + if (filter.startsWith("project:")) return run.projectId === filter.slice("project:".length); + if (filter.startsWith("workspace:")) return run.workspace === filter.slice("workspace:".length); + return run.projectId === filter; +} + function insideTimeWindow(value: string | null, window: string): boolean { if (window === "all") return true; const duration = window === "15m" ? 15 * 60_000 : window === "1h" ? 60 * 60_000 : window === "6h" ? 6 * 60 * 60_000 : 0; diff --git a/web/hwlab-cloud-web/src/styles/agent-observer.css b/web/hwlab-cloud-web/src/styles/agent-observer.css index 45a27ca2..5260f955 100644 --- a/web/hwlab-cloud-web/src/styles/agent-observer.css +++ b/web/hwlab-cloud-web/src/styles/agent-observer.css @@ -16,6 +16,14 @@ .agent-observer-segment button:last-child { border-right: 0; } .agent-observer-segment button[aria-pressed="true"] { background: var(--console-accent-soft); color: var(--console-accent); } .agent-observer-density { border: 1px solid var(--console-border); border-radius: 6px; } +.agent-observer-secondary-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.agent-observer-filter-trigger { display: none; min-height: 34px; align-items: center; gap: 6px; padding: 0 10px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); color: var(--console-text); } +.agent-observer-filter-backdrop { position: fixed; z-index: 70; inset: 0; display: flex; justify-content: flex-end; background: rgb(0 0 0 / .38); } +.agent-observer-filter-drawer { width: min(360px, 92vw); height: 100%; padding: 16px; display: grid; align-content: start; gap: 14px; background: var(--console-surface); box-shadow: -16px 0 42px rgb(0 0 0 / .22); } +.agent-observer-filter-drawer header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.agent-observer-filter-drawer header button { display: inline-flex; border: 0; background: transparent; color: var(--console-muted); } +.agent-observer-filter-drawer label { display: grid; gap: 6px; color: var(--console-muted); font-size: 12px; } +.agent-observer-filter-drawer select, .agent-observer-filter-drawer .agent-observer-density { width: 100%; min-height: 40px; } .agent-observer-status-strip { min-height: 38px; display: flex; align-items: center; gap: 0; overflow-x: auto; border-block: 1px solid var(--console-border); background: var(--console-surface-muted); } .agent-observer-status-strip > span { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 5px; padding: 0 13px; border-right: 1px solid var(--console-border); font-size: 12px; color: var(--console-muted); } .agent-observer-status-strip strong { color: var(--console-text); font-size: 14px; } @@ -56,10 +64,14 @@ .agent-observer-card footer { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--console-border); padding-top: 6px; } .agent-observer-tree { height: 100%; min-height: 0; padding: 8px 0; } .agent-observer-tree .console-virtual-list { overflow: auto; } -.agent-observer-tree-row { position: relative; height: calc(100% - 2px); margin: 0 10px; padding: 0 10px 0 22px; display: grid; grid-template-columns: 100px minmax(150px, 1.2fr) repeat(4, minmax(110px, .8fr)) minmax(120px, 1fr); align-items: center; gap: 9px; border-bottom: 1px solid var(--console-border); font-size: 12px; min-width: 1000px; } +.agent-observer-tree-row { position: relative; height: calc(100% - 2px); margin: 0 10px; padding-inline-end: 10px; display: grid; grid-template-columns: 18px 100px minmax(220px, 1fr) minmax(180px, .8fr); align-items: center; gap: 9px; border-bottom: 1px solid var(--console-border); font-size: 12px; min-width: 620px; outline: 0; } .agent-observer-tree-row:hover, .agent-observer-tree-row[aria-selected="true"] { background: var(--console-accent-soft); } -.agent-observer-tree-row .tree-line { position: absolute; left: 8px; top: 0; bottom: 0; width: 10px; border-left: 1px solid var(--console-border-strong); border-bottom: 1px solid var(--console-border-strong); } -.agent-observer-tree-row > span:not(.tree-line), .agent-observer-tree-row strong, .agent-observer-tree-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agent-observer-tree-row:focus-visible { box-shadow: inset 0 0 0 2px var(--console-accent); } +.agent-observer-tree-row::before { content: ""; position: absolute; left: max(8px, calc(8px + (var(--tree-depth, 0) * 22px))); top: 0; bottom: 0; border-left: 1px solid var(--console-border-subtle, var(--console-border)); } +.agent-observer-tree-toggle { width: 18px; height: 18px; padding: 0; display: inline-grid; place-items: center; border: 0; background: transparent; color: var(--console-muted); } +.agent-observer-tree-toggle svg { transition: transform .14s ease; }.agent-observer-tree-toggle svg[data-expanded="true"] { transform: rotate(90deg); } +.agent-observer-tree-spacer { width: 18px; }.agent-observer-relation-kind { color: var(--console-muted); font-size: 10px; font-weight: 700; text-transform: uppercase; } +.agent-observer-tree-row > span, .agent-observer-tree-row strong, .agent-observer-tree-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .agent-observer-inspector { min-height: 0; border-left: 1px solid var(--console-border); } .agent-observer-inspector-grid { margin: 0; display: grid; gap: 0; } .agent-observer-inspector-grid div { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--console-border); } @@ -68,6 +80,6 @@ .agent-observer-event-tail li { display: grid; grid-template-columns: 70px 1fr; gap: 4px 8px; padding: 9px 0; border-bottom: 1px solid var(--console-border); } .agent-observer-event-tail span { color: var(--console-muted); font-size: 10px; }.agent-observer-event-tail strong { font-size: 11px; }.agent-observer-event-tail small { grid-column: 2; color: var(--console-muted); overflow-wrap: anywhere; } @media (max-width: 1200px) { .agent-observer-card-list { grid-template-columns: repeat(2, minmax(240px, 1fr)); } } -@media (max-width: 960px) { .agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr); }.agent-observer-inspector { position: fixed; z-index: 40; inset: 64px 0 0 auto; width: min(420px, 92vw); background: var(--console-surface); box-shadow: -12px 0 36px rgb(0 0 0 / .16); }.agent-observer-page .page-command-filters { display: grid; grid-template-columns: minmax(210px, 1fr) repeat(3, minmax(130px, auto)); }.agent-observer-search { min-width: 0; } } -@media (max-width: 560px) { .agent-observer-page { height: calc(100dvh - 52px); }.agent-observer-page .page-command-description, .agent-observer-target { display: none; }.agent-observer-page .page-command-filters { grid-template-columns: minmax(0, 1fr) auto; }.agent-observer-page select:nth-of-type(n+2), .agent-observer-density, .agent-observer-segment span { display: none; }.agent-observer-status-strip > span:nth-child(n+5) { display: none; }.agent-observer-workspace.has-inspector { display: block; }.agent-observer-inspector { inset: auto 0 0; width: 100%; height: min(68dvh, 620px); border-left: 0; border-top: 1px solid var(--console-border); box-shadow: 0 -14px 36px rgb(0 0 0 / .18); }.agent-observer-card-list { grid-template-columns: minmax(0, 1fr); }.agent-observer-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }.agent-observer-tree-row { grid-template-columns: 90px minmax(150px, 1fr) minmax(120px, .8fr); min-width: 520px; }.agent-observer-tree-row span:nth-of-type(n+3) { display: none; } } +@media (max-width: 960px) { .agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr); }.agent-observer-inspector { position: fixed; z-index: 40; inset: 64px 0 0 auto; width: min(420px, 92vw); background: var(--console-surface); box-shadow: -12px 0 36px rgb(0 0 0 / .16); }.agent-observer-page .page-command-filters { display: grid; grid-template-columns: minmax(210px, 1fr) auto auto; }.agent-observer-secondary-filters { grid-column: 1 / -1; }.agent-observer-search { min-width: 0; } } +@media (max-width: 560px) { .agent-observer-page { height: calc(100dvh - 52px); }.agent-observer-page .page-command-description, .agent-observer-target { display: none; }.agent-observer-page .page-command-filters { grid-template-columns: repeat(3, auto); }.agent-observer-search { grid-column: 1 / -1; }.agent-observer-secondary-filters { display: none; }.agent-observer-filter-trigger { display: inline-flex; }.agent-observer-segment span { display: none; }.agent-observer-status-strip > span:nth-child(n+5) { display: none; }.agent-observer-workspace.has-inspector { display: block; }.agent-observer-inspector { inset: auto 0 0; width: 100%; height: min(68dvh, 620px); border-left: 0; border-top: 1px solid var(--console-border); box-shadow: 0 -14px 36px rgb(0 0 0 / .18); }.agent-observer-card-list { grid-template-columns: minmax(0, 1fr); }.agent-observer-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }.agent-observer-tree-row { grid-template-columns: 18px 84px minmax(190px, 1fr); min-width: 430px; }.agent-observer-tree-row small { display: none; } } @media (prefers-reduced-motion: reduce) { .agent-observer-skeleton span { animation: none; }.agent-observer-table-row[data-recent="true"] { box-shadow: inset 2px 0 var(--console-accent); } } diff --git a/web/hwlab-cloud-web/src/views/agents/AgentRunsView.vue b/web/hwlab-cloud-web/src/views/agents/AgentRunsView.vue index 56efc9b0..f993aa97 100644 --- a/web/hwlab-cloud-web/src/views/agents/AgentRunsView.vue +++ b/web/hwlab-cloud-web/src/views/agents/AgentRunsView.vue @@ -1,20 +1,22 @@