fix(web): 修正 AgentRun 观察页审查阻塞

This commit is contained in:
root
2026-07-15 04:43:13 +02:00
parent f33604757b
commit cbefb988e4
4 changed files with 342 additions and 45 deletions
@@ -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<HTMLElement | null>(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);
+219 -12
View File
@@ -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<Record<string, AgentObserverRun>>({});
const orderedIds = ref<string[]>([]);
@@ -70,6 +98,8 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
const error = ref<string | null>(null);
const nowMs = ref(Date.now());
const config = ref<AgentObserverRuntimeConfig | null>(null);
const expandedRelationIds = ref<string[]>([]);
const collapsedRelationIds = ref<string[]>([]);
const seenEventIds = new Set<string>();
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<string>,
collapsedIds: Set<string>
): AgentObserverRelationRow[] {
const nodes = new Map<string, AgentObserverRelationNode>();
const rootIds: string[] = [];
const selectedPath = new Set<string>();
const activePath = new Set<string>();
const unassociatedId = "scope:unassociated";
const ensureNode = (node: Omit<AgentObserverRelationNode, "children">): 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 | null>): 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;
@@ -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); } }
@@ -1,20 +1,22 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Activity, Columns3, LayoutGrid, List, Network, RefreshCw, Search, X } from "lucide-vue-next";
import { Activity, ChevronRight, Columns3, LayoutGrid, List, Network, RefreshCw, Search, SlidersHorizontal, X } from "lucide-vue-next";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import InspectorPanel from "@/components/console/InspectorPanel.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import VirtualList from "@/components/common/VirtualList.vue";
import { useAgentObserverStore, type AgentObserverRun, type AgentObserverView } from "@/stores/agent-observer";
import { useAgentObserverStore, type AgentObserverRelationRow, type AgentObserverRun, type AgentObserverView } from "@/stores/agent-observer";
const route = useRoute();
const router = useRouter();
const observer = useAgentObserverStore();
const inspectorTab = ref<"summary" | "events" | "lineage">("summary");
const urlApplying = ref(false);
const windowedRuns = computed(() => observer.visibleRuns.slice(0, observer.config?.virtualListWindowLimit ?? 0));
const treeRuns = computed(() => windowedRuns.value.slice(0, observer.config?.treeNodeLimit ?? 0));
const filtersOpen = ref(false);
const filterTrigger = ref<HTMLButtonElement | null>(null);
const filterDrawer = ref<HTMLElement | null>(null);
const treeRows = computed(() => observer.relationRows.slice(0, observer.config?.treeNodeLimit ?? observer.relationRows.length));
const ageLabel = computed(() => formatAge(observer.dataAgeMs));
const streamLabel = computed(() => ({ idle: "未连接", replay: "回放", live: "Live", reconnecting: "重连中", stale: "陈旧", error: "传输错误" }[observer.phase]));
@@ -79,10 +81,62 @@ function setView(value: AgentObserverView): void {
observer.view = value;
}
async function openFilters(): Promise<void> {
filtersOpen.value = true;
await nextTick();
filterDrawer.value?.focus();
}
function closeFilters(): void {
filtersOpen.value = false;
queueMicrotask(() => filterTrigger.value?.focus());
}
function rowKey(run: AgentObserverRun): string {
return run.id;
}
function relationKey(row: AgentObserverRelationRow): string {
return row.id;
}
function activateRelation(row: AgentObserverRelationRow): void {
if (row.childCount > 0 && !row.runId) observer.toggleRelation(row.id, !row.expanded);
if (row.runId) observer.selectRun(row.runId);
}
function onRelationKeydown(event: KeyboardEvent, row: AgentObserverRelationRow): void {
const current = event.currentTarget;
if (!(current instanceof HTMLElement)) return;
const tree = current.closest('[role="tree"]');
const items = tree ? [...tree.querySelectorAll<HTMLElement>('[role="treeitem"]')] : [];
const index = items.indexOf(current);
if (event.key === "ArrowRight" && row.childCount > 0) {
event.preventDefault();
if (!row.expanded) observer.toggleRelation(row.id, true);
else items[index + 1]?.focus();
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
if (row.childCount > 0 && row.expanded) observer.toggleRelation(row.id, false);
else if (row.parentId) items.find((item) => item.dataset.relationId === row.parentId)?.focus();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
event.preventDefault();
const target = event.key === "Home" ? items[0]
: event.key === "End" ? items.at(-1)
: items[index + (event.key === "ArrowDown" ? 1 : -1)];
target?.focus();
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activateRelation(row);
}
}
function statusTone(status: string): string {
if (["failed", "error", "rejected"].includes(status)) return "failed";
if (["completed", "succeeded"].includes(status)) return "completed";
@@ -152,25 +206,40 @@ function compactQuery(value: Record<string, string>): Record<string, string> {
<select v-model="observer.statusFilter" aria-label="运行状态">
<option value="all">全部状态</option><option value="running">运行中</option><option value="waiting">等待</option><option value="failed">失败</option><option value="terminal">终态</option>
</select>
<select v-model="observer.projectFilter" aria-label="项目">
<option value="all">全部项目</option><option v-for="project in observer.projects" :key="project" :value="project">{{ project }}</option>
</select>
<select v-model="observer.agentFilter" aria-label="Agent 或 Profile">
<option value="all">全部 Agent</option><option v-for="agent in observer.agents" :key="agent" :value="agent">{{ agent }}</option>
</select>
<select v-model="observer.timeWindow" aria-label="时间窗口"><option value="all">全部窗口</option><option value="15m">最近 15 分钟</option><option value="1h">最近 1 小时</option><option value="6h">最近 6 小时</option></select>
<select v-model="observer.sort" aria-label="排序">
<option value="updated-desc">最近活动</option><option value="updated-asc">最早活动</option><option value="status-asc">状态</option><option value="id-asc">Run ID</option>
</select>
<div class="agent-observer-segment" role="group" aria-label="视图">
<button type="button" :aria-pressed="observer.view === 'table'" title="表格" @click="setView('table')"><List :size="16" /><span>表格</span></button>
<button type="button" :aria-pressed="observer.view === 'cards'" title="卡片" @click="setView('cards')"><LayoutGrid :size="16" /><span>卡片</span></button>
<button type="button" :aria-pressed="observer.view === 'tree'" title="关系树" @click="setView('tree')"><Network :size="16" /><span></span></button>
</div>
<button class="agent-observer-density" type="button" @click="observer.density = observer.density === 'compact' ? 'standard' : 'compact'"><Columns3 :size="15" />{{ observer.density === "compact" ? "紧凑" : "标准" }}</button>
<button ref="filterTrigger" class="agent-observer-filter-trigger" type="button" aria-haspopup="dialog" :aria-expanded="filtersOpen" @click="openFilters"><SlidersHorizontal :size="16" />筛选</button>
<div class="agent-observer-secondary-filters">
<select v-model="observer.projectFilter" aria-label="项目或 Workspace">
<option value="all">全部项目 / Workspace</option><option v-for="scope in observer.scopeOptions" :key="scope.value" :value="scope.value">{{ scope.label }}</option>
</select>
<select v-model="observer.agentFilter" aria-label="Agent 或 Profile">
<option value="all">全部 Agent</option><option v-for="agent in observer.agents" :key="agent" :value="agent">{{ agent }}</option>
</select>
<select v-model="observer.timeWindow" aria-label="时间窗口"><option value="all">全部窗口</option><option value="15m">最近 15 分钟</option><option value="1h">最近 1 小时</option><option value="6h">最近 6 小时</option></select>
<select v-model="observer.sort" aria-label="排序">
<option value="updated-desc">最近活动</option><option value="updated-asc">最早活动</option><option value="status-asc">状态</option><option value="id-asc">Run ID</option>
</select>
<button class="agent-observer-density" type="button" @click="observer.density = observer.density === 'compact' ? 'standard' : 'compact'"><Columns3 :size="15" />{{ observer.density === "compact" ? "紧凑" : "标准" }}</button>
</div>
</template>
</PageCommandBar>
<div v-if="filtersOpen" class="agent-observer-filter-backdrop" @click.self="closeFilters">
<aside ref="filterDrawer" class="agent-observer-filter-drawer" role="dialog" aria-modal="true" aria-labelledby="agent-observer-filter-title" tabindex="-1" @keydown.esc="closeFilters">
<header><strong id="agent-observer-filter-title">更多筛选</strong><button type="button" aria-label="关闭筛选" @click="closeFilters"><X :size="18" /></button></header>
<label>项目 / Workspace<select v-model="observer.projectFilter"><option value="all">全部项目 / Workspace</option><option v-for="scope in observer.scopeOptions" :key="scope.value" :value="scope.value">{{ scope.label }}</option></select></label>
<label>Agent / Profile<select v-model="observer.agentFilter"><option value="all">全部 Agent</option><option v-for="agent in observer.agents" :key="agent" :value="agent">{{ agent }}</option></select></label>
<label>时间窗口<select v-model="observer.timeWindow"><option value="all">全部窗口</option><option value="15m">最近 15 分钟</option><option value="1h">最近 1 小时</option><option value="6h">最近 6 小时</option></select></label>
<label>排序<select v-model="observer.sort"><option value="updated-desc">最近活动</option><option value="updated-asc">最早活动</option><option value="status-asc">状态</option><option value="id-asc">Run ID</option></select></label>
<button class="agent-observer-density" type="button" @click="observer.density = observer.density === 'compact' ? 'standard' : 'compact'"><Columns3 :size="15" />密度{{ observer.density === "compact" ? "紧凑" : "标准" }}</button>
<button class="btn btn-primary" type="button" @click="closeFilters">完成</button>
</aside>
</div>
<div class="agent-observer-status-strip" aria-label="运行摘要">
<span><strong>{{ observer.stats.running }}</strong>运行中</span>
<span><strong>{{ observer.stats.waiting }}</strong>等待</span>
@@ -178,7 +247,7 @@ function compactQuery(value: Record<string, string>): Record<string, string> {
<span><strong>{{ observer.stats.terminal }}</strong>终态</span>
<span><strong>{{ observer.stats.runners }}</strong>Runner</span>
<span><strong>{{ observer.stats.total }}</strong>筛选结果</span>
<span v-if="windowedRuns.length < observer.visibleRuns.length" class="agent-observer-window-note">DOM 窗口 {{ windowedRuns.length }}/{{ observer.visibleRuns.length }}</span>
<span v-if="observer.view === 'tree' && treeRows.length < observer.relationRows.length" class="agent-observer-window-note">关系树当前事件窗口 {{ treeRows.length }}/{{ observer.relationRows.length }} 节点</span>
</div>
<div v-if="observer.error" class="agent-observer-inline-error" role="status">{{ observer.error }}</div>
@@ -188,7 +257,7 @@ function compactQuery(value: Record<string, string>): Record<string, string> {
<div v-if="observer.phase === 'replay' && observer.runs.length === 0" class="agent-observer-skeleton" aria-label="正在回放 Kafka retention">
<span v-for="index in 8" :key="index" />
</div>
<div v-else-if="windowedRuns.length === 0" class="agent-observer-empty">
<div v-else-if="observer.visibleRuns.length === 0" class="agent-observer-empty">
<Activity :size="28" aria-hidden="true" />
<strong>当前事件窗口没有匹配运行</strong>
<span>保留最后确认投影可清除筛选或等待 Kafka live 事件</span>
@@ -199,7 +268,7 @@ function compactQuery(value: Record<string, string>): Record<string, string> {
<div class="agent-observer-table-head" role="row">
<span role="columnheader">状态</span><span role="columnheader">运行</span><span role="columnheader">Agent</span><span role="columnheader">上下文</span><span role="columnheader">当前阶段</span><span role="columnheader">活动</span><span role="columnheader">Runner</span><span role="columnheader">结果</span>
</div>
<VirtualList :items="windowedRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 54 : 68" height="100%" :overscan="8" aria-label="AgentRun 虚拟表格行">
<VirtualList :items="observer.visibleRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 54 : 68" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" aria-label="AgentRun 虚拟表格行">
<template #default="{ item: run }">
<div class="agent-observer-table-row" role="row" tabindex="0" :aria-selected="observer.selectedRunId === run.id" :data-selected="observer.selectedRunId === run.id" :data-recent="Date.now() - run.changedAtMs < (observer.config?.changeHighlightMs ?? 0)" @click="selectRun(run)" @dblclick="openRun(run)" @keydown.enter="openRun(run)">
<span role="gridcell"><StatusBadge :status="statusTone(run.runStatus)" :label="run.runStatus" /></span>
@@ -216,7 +285,7 @@ function compactQuery(value: Record<string, string>): Record<string, string> {
</section>
<section v-else-if="observer.view === 'cards'" class="agent-observer-card-list" aria-label="AgentRun 卡片">
<article v-for="run in windowedRuns" :key="run.id" class="agent-observer-card" tabindex="0" :data-status="statusTone(run.runStatus)" :data-selected="observer.selectedRunId === run.id" @click="selectRun(run)" @keydown.enter="openRun(run)">
<article v-for="run in observer.visibleRuns" :key="run.id" class="agent-observer-card" tabindex="0" :data-status="statusTone(run.runStatus)" :data-selected="observer.selectedRunId === run.id" @click="selectRun(run)" @keydown.enter="openRun(run)">
<header><StatusBadge :status="statusTone(run.runStatus)" :label="run.runStatus" /><span>{{ duration(run) }}</span></header>
<h2 class="mono">{{ shortId(run.id) }}</h2>
<p>{{ shortId(run.taskId, "task 未提供") }} · {{ shortId(run.sessionId) }} · {{ run.projectId || "—" }}</p>
@@ -226,13 +295,15 @@ function compactQuery(value: Record<string, string>): Record<string, string> {
</section>
<section v-else class="agent-observer-tree" role="tree" aria-label="AgentRun 关系树">
<VirtualList :items="treeRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 44 : 54" height="100%" :overscan="8" aria-label="AgentRun 关系树节点">
<template #default="{ item: run }">
<div class="agent-observer-tree-row" role="treeitem" :aria-selected="observer.selectedRunId === run.id" tabindex="0" @click="selectRun(run)" @keydown.enter="openRun(run)">
<span class="tree-line" aria-hidden="true" />
<StatusBadge :status="statusTone(run.runStatus)" :label="run.runStatus" />
<strong class="mono">{{ shortId(run.id) }}</strong>
<span>task {{ shortId(run.taskId) }}</span><span>command {{ shortId(run.commandId) }}</span><span>attempt {{ shortId(run.attemptId) }}</span><span>runner {{ shortId(run.runnerId) }}</span><small>{{ run.phase }}</small>
<VirtualList :items="treeRows" :item-key="relationKey" :item-height="observer.density === 'compact' ? 44 : 54" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" aria-label="AgentRun 关系树节点">
<template #default="{ item: node }">
<div class="agent-observer-tree-row" role="treeitem" :data-relation-id="node.id" :aria-level="node.depth + 1" :aria-posinset="node.position" :aria-setsize="node.setSize" :aria-expanded="node.childCount > 0 ? node.expanded : undefined" :aria-selected="Boolean(node.runId && observer.selectedRunId === node.runId)" tabindex="0" :style="{ paddingInlineStart: `${14 + node.depth * 22}px` }" @click="activateRelation(node)" @keydown="onRelationKeydown($event, node)">
<button v-if="node.childCount > 0" class="agent-observer-tree-toggle" type="button" :aria-label="node.expanded ? '折叠' : '展开'" tabindex="-1" @click.stop="observer.toggleRelation(node.id, !node.expanded)"><ChevronRight :size="15" :data-expanded="node.expanded" /></button>
<span v-else class="agent-observer-tree-spacer" aria-hidden="true" />
<StatusBadge v-if="node.status" :status="statusTone(node.status)" :label="node.status" />
<span v-else class="agent-observer-relation-kind">{{ node.kind }}</span>
<strong class="mono" :title="node.label">{{ shortId(node.label) }}</strong>
<small :title="node.detail || ''">{{ node.detail || `${node.childCount} 个子节点` }}</small>
</div>
</template>
</VirtualList>