fix(web): 修正 AgentRun 观察页审查阻塞
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user