refactor: 收口 AgentRun 观察页产品体验

This commit is contained in:
root
2026-07-15 07:39:20 +02:00
parent 4d10c59b35
commit ea1145c457
7 changed files with 435 additions and 101 deletions
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
calculateVirtualGridColumnCount,
calculateVirtualGridRowWindowLimit,
chunkVirtualGridItems,
} from "../src/components/common/virtual-grid.ts";
const viewSource = readSource("../src/views/agents/AgentRunsView.vue");
const inspectorSource = readSource("../src/components/agents/AgentRunInspector.vue");
const inspectorPanelSource = readSource("../src/components/console/InspectorPanel.vue");
const virtualGridSource = readSource("../src/components/common/VirtualGrid.vue");
const styleSource = readSource("../src/styles/agent-observer.css");
test("VirtualGrid calculates three responsive column tiers", () => {
assert.equal(calculateVirtualGridColumnCount(1920, 360, 3, 10), 3);
assert.equal(calculateVirtualGridColumnCount(960, 360, 3, 10), 2);
assert.equal(calculateVirtualGridColumnCount(390, 360, 3, 10), 1);
});
test("VirtualGrid chunks stable rows and bounds the rendered row window", () => {
const items = Array.from({ length: 10 }, (_, index) => ({ id: `run-${index}` }));
const rows = chunkVirtualGridItems(items, 3, (item) => item.id);
assert.deepEqual(rows.map((row) => row.items.length), [3, 3, 3, 1]);
assert.deepEqual(rows.map((row) => row.startIndex), [0, 3, 6, 9]);
assert.equal(rows[0]?.key, "0:run-0|run-1|run-2");
assert.equal(calculateVirtualGridRowWindowLimit(12, 3), 4);
assert.equal(calculateVirtualGridRowWindowLimit(12, 1), 12);
assert.match(virtualGridSource, /ResizeObserver/);
assert.match(virtualGridSource, /:window-limit="rowWindowLimit"/);
assert.match(virtualGridSource, /role="listitem"/);
});
test("AgentRun table exposes the eight-column product contract", () => {
const headers = [
"状态",
"Run / Task / Command identity",
"Project / Workspace",
"Agent / Profile",
"阶段",
"活动 / 耗时",
"Runner / Node / Lane",
"结果 / 错误",
];
let previousIndex = -1;
for (const header of headers) {
const index = viewSource.indexOf(`role="columnheader">${header}`);
assert.ok(index > previousIndex, `missing or unordered table header: ${header}`);
previousIndex = index;
}
});
test("AgentRun page reuses shared status, viewer and embedded inspector contracts", () => {
assert.match(viewSource, /import StatusStrip/);
assert.match(viewSource, /<StatusStrip :items="statusItems"/);
assert.match(viewSource, /import VirtualGrid/);
assert.match(viewSource, /<VirtualGrid/);
assert.match(viewSource, /<AgentRunInspector v-if="observer\.selectedRun" embedded/);
assert.match(inspectorSource, /import ContentViewer/);
assert.match(inspectorSource, /mode="log"/);
assert.match(inspectorSource, /mode="json"/);
assert.doesNotMatch(inspectorSource, /<pre/);
assert.match(inspectorPanelSource, /embedded\?: boolean/);
assert.match(inspectorPanelSource, /v-if="!embedded"/);
});
test("AgentRun command bar preserves compact desktop and mobile primary controls", () => {
assert.match(styleSource, /@media \(max-width: 1100px\)/);
assert.match(styleSource, /agent-observer-secondary-filters \{ order: 5; flex: 1 0 100%; \}/);
assert.match(styleSource, /@media \(max-width: 600px\)/);
assert.match(styleSource, /agent-observer-secondary-filters,[\s\S]*agent-observer-density \{ display: none; \}/);
assert.match(styleSource, /agent-observer-filter-trigger \{ grid-column: 2; display: inline-flex; \}/);
assert.match(styleSource, /agent-observer-segment \{ grid-column: 1 \/ -1; width: 100%; \}/);
assert.match(styleSource, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(viewSource, /class="agent-observer-search"/);
assert.match(viewSource, /class="agent-observer-primary-status"/);
assert.match(viewSource, /class="agent-observer-segment" role="group" aria-label="视图切换"/);
assert.match(viewSource, /class="agent-observer-stream"/);
});
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), "utf8");
}
@@ -3,20 +3,24 @@
<script setup lang="ts">
import { computed } from "vue";
import ContentViewer from "@/components/common/ContentViewer.vue";
import InspectorPanel from "@/components/console/InspectorPanel.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import { agentObserverStatusToken, type AgentObserverEvent, type AgentObserverRun } from "@/stores/agent-observer";
type InspectorTab = "overview" | "relations" | "events" | "logs" | "raw";
const props = defineProps<{
const props = withDefaults(defineProps<{
run: AgentObserverRun;
events: AgentObserverEvent[];
logs: AgentObserverEvent[];
inEventWindow: boolean;
nowMs: number;
tab: InspectorTab;
}>();
embedded?: boolean;
}>(), {
embedded: false,
});
const emit = defineEmits<{ close: []; "update:tab": [value: InspectorTab] }>();
const tabs: Array<{ value: InspectorTab; label: string }> = [
{ value: "overview", label: "概览" },
@@ -30,7 +34,10 @@ const runToken = computed(() => agentObserverStatusToken(props.run.runStatus));
const commandToken = computed(() => agentObserverStatusToken(props.run.commandState));
const attemptToken = computed(() => agentObserverStatusToken(props.run.attemptStatus));
const deepLink = computed(() => `/agents/runs/${encodeURIComponent(props.run.id)}?view=table`);
const rawFacts = computed(() => JSON.stringify({ run: props.run, events: props.events }, null, 2));
const rawFacts = computed(() => ({ run: props.run, events: props.events }));
const logContent = computed(() => props.logs.length
? props.logs.map((event) => `[${formatTime(event.createdAt)}] ${event.label}\n${event.message || "未知"}`).join("\n\n")
: "正式事件未提供日志事实。");
function unknown(value: string | number | null): string | number {
return value === null || value === "" ? "未知" : value;
@@ -59,7 +66,7 @@ function fact(value: boolean | null): string {
</script>
<template>
<InspectorPanel :title="run.id" :subtitle="`${runToken.label} · ${run.phase || '阶段未知'}`" @close="emit('close')">
<InspectorPanel :title="run.id" :subtitle="`${runToken.label} · ${run.phase || '阶段未知'}`" :embedded="embedded" @close="emit('close')">
<template #tabs>
<button v-for="item in tabs" :key="item.value" type="button" role="tab" :aria-selected="tab === item.value" @click="emit('update:tab', item.value)">{{ item.label }}</button>
</template>
@@ -96,11 +103,8 @@ function fact(value: boolean | null): string {
<li v-if="events.length === 0">当前有界 Kafka 事件窗口无可显示事件</li>
</ol>
<ol v-else-if="tab === 'logs'" class="agent-observer-event-tail agent-observer-log-tail">
<li v-for="event in logs" :key="event.id"><span>{{ formatTime(event.createdAt) }}</span><strong>{{ event.label }}</strong><pre>{{ event.message || "未知" }}</pre></li>
<li v-if="logs.length === 0">正式事件未提供日志事实</li>
</ol>
<ContentViewer v-else-if="tab === 'logs'" :content="logContent" mode="log" title="Kafka 日志事实" filename="agentrun-events.log" />
<pre v-else class="agent-observer-raw-facts">{{ rawFacts }}</pre>
<ContentViewer v-else :content="rawFacts" mode="json" title="AgentRun 原始事实" filename="agentrun-facts.json" />
</InspectorPanel>
</template>
@@ -0,0 +1,95 @@
<!-- SPEC: PJ2026-010405 云端控制台. -->
<!-- Responsibility: 提供按容器宽度分列按虚拟行窗口化的通用响应式网格 -->
<script setup lang="ts" generic="T extends object">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import VirtualList from "./VirtualList.vue";
import {
calculateVirtualGridColumnCount,
calculateVirtualGridRowWindowLimit,
chunkVirtualGridItems,
type VirtualGridRow,
} from "./virtual-grid";
const props = withDefaults(defineProps<{
items: T[];
itemKey: (item: T) => string;
rowHeight: number;
height?: number | string;
minimumColumnWidth?: number;
maximumColumns?: number;
gap?: number;
overscan?: number;
windowLimit?: number;
ariaLabel?: string;
}>(), {
height: 420,
minimumColumnWidth: 360,
maximumColumns: 3,
gap: 10,
overscan: 3,
ariaLabel: "响应式虚拟网格",
});
const root = ref<HTMLElement | null>(null);
const width = ref(0);
let resizeObserver: ResizeObserver | null = null;
const columns = computed(() => calculateVirtualGridColumnCount(width.value, props.minimumColumnWidth, props.maximumColumns, props.gap));
const rows = computed(() => chunkVirtualGridItems(props.items, columns.value, props.itemKey));
const rowWindowLimit = computed(() => calculateVirtualGridRowWindowLimit(props.windowLimit, columns.value));
const rootStyle = computed(() => ({ height: typeof props.height === "number" ? `${props.height}px` : props.height }));
function rowKey(row: VirtualGridRow<T>): string {
return row.key;
}
function readWidth(): void {
const nextWidth = root.value?.clientWidth ?? 0;
if (nextWidth > 0 && nextWidth !== width.value) width.value = nextWidth;
}
onMounted(() => {
readWidth();
if (!root.value) return;
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(readWidth);
resizeObserver.observe(root.value);
} else if (typeof window !== "undefined") {
window.addEventListener("resize", readWidth, { passive: true });
}
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
resizeObserver = null;
if (typeof window !== "undefined") window.removeEventListener("resize", readWidth);
});
</script>
<template>
<div ref="root" class="console-virtual-grid" :style="rootStyle" :data-columns="columns">
<VirtualList
:items="rows"
:item-key="rowKey"
:item-height="rowHeight + gap"
height="100%"
:overscan="overscan"
:window-limit="rowWindowLimit"
root-role="list"
item-role="presentation"
:aria-label="ariaLabel"
>
<template #default="{ item: row }">
<div
class="console-virtual-grid-row"
role="presentation"
:style="{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: `${gap}px`, height: `${rowHeight}px` }"
>
<div v-for="(item, localIndex) in row.items" :key="itemKey(item)" role="listitem" class="console-virtual-grid-cell">
<slot :item="item" :index="row.startIndex + localIndex" :columns="columns" />
</div>
</div>
</template>
</VirtualList>
</div>
</template>
@@ -0,0 +1,41 @@
export interface VirtualGridRow<T> {
key: string;
items: T[];
startIndex: number;
}
export function calculateVirtualGridColumnCount(
width: number,
minimumColumnWidth: number,
maximumColumns: number,
gap: number,
): number {
if (!Number.isFinite(width) || width <= 0) return 1;
const safeMinimum = Math.max(1, minimumColumnWidth);
const safeMaximum = Math.max(1, Math.floor(maximumColumns));
const safeGap = Math.max(0, gap);
return Math.max(1, Math.min(safeMaximum, Math.floor((width + safeGap) / (safeMinimum + safeGap))));
}
export function chunkVirtualGridItems<T>(
items: T[],
columns: number,
itemKey: (item: T) => string,
): Array<VirtualGridRow<T>> {
const safeColumns = Math.max(1, Math.floor(columns));
const rows: Array<VirtualGridRow<T>> = [];
for (let startIndex = 0; startIndex < items.length; startIndex += safeColumns) {
const rowItems = items.slice(startIndex, startIndex + safeColumns);
rows.push({
key: `${startIndex}:${rowItems.map(itemKey).join("|")}`,
items: rowItems,
startIndex,
});
}
return rows;
}
export function calculateVirtualGridRowWindowLimit(itemWindowLimit: number | undefined, columns: number): number | undefined {
if (!itemWindowLimit || itemWindowLimit <= 0) return undefined;
return Math.max(1, Math.ceil(itemWindowLimit / Math.max(1, Math.floor(columns))));
}
@@ -4,13 +4,16 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
defineProps<{ title: string; subtitle?: string }>();
withDefaults(defineProps<{ title: string; subtitle?: string; embedded?: boolean }>(), {
subtitle: "",
embedded: false,
});
defineEmits<{ close: [] }>();
</script>
<template>
<section class="operations-inspector-panel">
<header class="operations-inspector-header">
<section class="operations-inspector-panel" :data-embedded="embedded ? 'true' : 'false'">
<header v-if="!embedded" class="operations-inspector-header">
<div>
<h2>{{ title }}</h2>
<p v-if="subtitle">{{ subtitle }}</p>
+104 -59
View File
@@ -9,82 +9,91 @@
overflow: hidden;
}
.agent-observer-page .page-command-bar { padding-block: 12px; }
.agent-observer-page .page-command-bar { padding-block: 10px; }
.agent-observer-page .page-command-description { max-width: 78ch; }
.agent-observer-page .page-command-filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
.agent-observer-page select,
.agent-observer-search { min-height: 34px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); color: var(--console-text); }
.agent-observer-search { min-height: 34px; border: 1px solid var(--console-border); border-radius: 5px; background: var(--console-surface); color: var(--console-text); }
.agent-observer-page select { max-width: 230px; padding: 0 28px 0 9px; }
.agent-observer-search { min-width: min(330px, 100%); display: flex; align-items: center; gap: 7px; padding: 0 8px; }
.agent-observer-search input { width: 100%; min-width: 80px; border: 0; outline: 0; background: transparent; color: inherit; }
.agent-observer-search button { border: 0; background: transparent; color: var(--console-muted); display: inline-flex; }
.agent-observer-stream,
.agent-observer-target { min-height: 32px; display: inline-flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--console-border); border-radius: 6px; background: var(--console-surface); font-size: 12px; }
.agent-observer-target { min-height: 32px; display: inline-flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--console-border); border-radius: 5px; background: var(--console-surface); font-size: 12px; white-space: nowrap; }
.agent-observer-stream[data-phase="live"] svg { color: var(--console-success); }
.agent-observer-stream[data-phase="replay"] svg,
.agent-observer-stream[data-phase="handoff"] svg,
.agent-observer-stream[data-phase="reconnecting"] svg { color: var(--console-accent); }
.agent-observer-stream[data-phase="error"],
.agent-observer-stream[data-phase="stale"] { border-color: color-mix(in srgb, var(--console-danger) 45%, var(--console-border)); }
.agent-observer-target { color: var(--console-muted); font-size: 11px; }
.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-segment { display: inline-flex; border: 1px solid var(--console-border); border-radius: 6px; overflow: hidden; }
.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: 5px; background: var(--console-surface); color: var(--console-text); }
.agent-observer-segment { display: inline-flex; border: 1px solid var(--console-border); border-radius: 5px; overflow: hidden; }
.agent-observer-segment button,
.agent-observer-density { min-height: 32px; display: inline-flex; align-items: center; gap: 5px; padding: 0 9px; border: 0; border-right: 1px solid var(--console-border); background: var(--console-surface); color: var(--console-muted); }
.agent-observer-density { min-height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 0 9px; border: 0; border-right: 1px solid var(--console-border); background: var(--console-surface); color: var(--console-muted); }
.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-density { border: 1px solid var(--console-border); border-radius: 5px; }
.agent-observer-page > .console-status-strip { border-inline: 0; border-radius: 0; }
.agent-observer-page > .console-status-strip .console-status-item { min-width: 112px; padding-block: 6px; }
.agent-observer-status-strip { min-width: 0; min-height: 38px; display: flex; align-items: center; 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); color: var(--console-muted); font-size: 12px; }
.agent-observer-status-strip strong { color: var(--console-text); font-size: 14px; }
.agent-observer-status-strip [data-tone="danger"] strong { color: var(--console-danger); }
.agent-observer-inline-error,
.agent-observer-window-warning { padding: 7px 13px; border-bottom: 1px solid color-mix(in srgb, var(--console-danger) 35%, var(--console-border)); background: color-mix(in srgb, var(--console-danger) 8%, var(--console-surface)); color: var(--console-danger); font-size: 12px; overflow-wrap: anywhere; }
.agent-observer-window-warning,
.agent-observer-window-note { padding: 7px 13px; border-bottom: 1px solid var(--console-border); font-size: 12px; overflow-wrap: anywhere; }
.agent-observer-inline-error,
.agent-observer-window-warning { border-color: color-mix(in srgb, var(--console-danger) 35%, var(--console-border)); background: color-mix(in srgb, var(--console-danger) 8%, var(--console-surface)); color: var(--console-danger); }
.agent-observer-window-note { margin: 0; background: var(--console-surface-muted); color: var(--console-muted); }
.agent-observer-workspace { min-width: 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr); overflow: hidden; }
.agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr) minmax(340px, 420px); }
.agent-observer-content { min-width: 0; min-height: 0; overflow: hidden; background: var(--console-surface); }
.agent-observer-skeleton { height: 100%; padding: 12px; display: grid; align-content: start; gap: 7px; }
.agent-observer-skeleton span { height: 48px; border-radius: 4px; background: var(--console-surface-muted); }
.agent-observer-skeleton span { height: 48px; border-radius: 3px; background: var(--console-surface-muted); }
.agent-observer-empty { height: 100%; display: grid; place-content: center; justify-items: center; gap: 8px; padding: 24px; color: var(--console-muted); text-align: center; }
.agent-observer-empty strong { color: var(--console-text); }
.agent-observer-table { height: 100%; min-width: 0; display: grid; grid-template-rows: 38px minmax(0, 1fr); overflow: hidden; }
.agent-observer-table > .console-virtual-list { overflow: auto; }
.agent-observer-table-head,
.agent-observer-table-row { min-width: 980px; display: grid; grid-template-columns: 230px minmax(145px, 1.1fr) minmax(170px, 1.2fr) minmax(120px, .8fr) 110px 145px minmax(150px, 1fr); align-items: center; gap: 10px; padding-inline: 12px; }
.agent-observer-table-row { min-width: 1280px; display: grid; grid-template-columns: 190px minmax(190px, 1.15fr) minmax(170px, 1fr) minmax(145px, .85fr) 120px 120px 160px minmax(170px, 1fr); align-items: center; gap: 10px; padding-inline: 12px; }
.agent-observer-table-head { border-bottom: 1px solid var(--console-border); background: var(--console-surface-muted); color: var(--console-muted); font-size: 11px; font-weight: 700; }
.agent-observer-table .console-virtual-list { min-width: 0; overflow: auto; }
.agent-observer-table-row { height: calc(100% - 1px); border-bottom: 1px solid var(--console-border); outline: 0; cursor: default; }
.agent-observer-table-row:hover,
.agent-observer-table-row[data-selected="true"] { background: var(--console-accent-soft); }
.agent-observer-table-row:focus-visible { box-shadow: inset 0 0 0 2px var(--console-accent); }
.agent-observer-table-row[data-recent="true"] { box-shadow: inset 3px 0 var(--console-accent); }
.agent-observer-table-row > span { min-width: 0; display: grid; gap: 2px; overflow: hidden; }
.agent-observer-table-row[data-recent="true"] { animation: agent-observer-row-highlight 1.4s ease-out; }
.agent-observer-table-row > span { min-width: 0; display: grid; gap: 3px; }
.agent-observer-table-row strong,
.agent-observer-table-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-observer-table-row strong { font-size: 12px; font-weight: 650; }
.agent-observer-table-row small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-observer-table-row small { color: var(--console-muted); font-size: 11px; }
.agent-observer-status-stack { display: flex !important; align-items: center; gap: 4px !important; flex-wrap: wrap; }
.agent-observer-card-list { height: 100%; min-height: 0; padding: 8px; }
.agent-observer-card-list .console-virtual-list { overflow: auto; }
.agent-observer-card { height: calc(100% - 8px); margin: 4px; padding: 10px 12px; border: 1px solid var(--console-border); border-left: 4px solid var(--console-border-strong, var(--console-border)); background: var(--console-surface); display: grid; gap: 6px; overflow: hidden; outline: 0; }
.agent-observer-card-grid { height: 100%; min-height: 0; padding: 10px; }
.console-virtual-grid { min-width: 0; min-height: 0; }
.console-virtual-grid > .console-virtual-list { overflow: auto; }
.console-virtual-grid-row { display: grid; padding-bottom: 10px; }
.console-virtual-grid-cell { min-width: 0; min-height: 0; }
.agent-observer-card { height: 100%; min-width: 0; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 10px; padding: 11px 12px; border: 1px solid var(--console-border); border-left: 3px solid var(--console-muted); border-radius: 4px; background: var(--console-surface); outline: 0; }
.agent-observer-card[data-status="running"] { border-left-color: var(--console-accent); }
.agent-observer-card[data-status="failed"] { border-left-color: var(--console-danger); }
.agent-observer-card[data-status="completed"] { border-left-color: var(--console-success); }
.agent-observer-card[data-selected="true"] { box-shadow: inset 0 0 0 2px var(--console-accent); }
.agent-observer-card:focus-visible { outline: 2px solid var(--console-accent); outline-offset: -2px; }
.agent-observer-card header { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; color: var(--console-muted); font-size: 11px; }
.agent-observer-card-identity { min-width: 0; display: grid; gap: 4px; }
.agent-observer-card h2,
.agent-observer-card p,
.agent-observer-card footer { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-observer-card footer { margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-observer-card h2 { font-size: 14px; }
.agent-observer-card p,
.agent-observer-card footer { color: var(--console-muted); font-size: 12px; }
.agent-observer-card dl { margin: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.agent-observer-card p { color: var(--console-muted); font-size: 11px; }
.agent-observer-card dl { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; align-content: start; }
.agent-observer-card dl div { min-width: 0; }
.agent-observer-card dt { color: var(--console-muted); font-size: 10px; }
.agent-observer-card dt { color: var(--console-muted); font-size: 10px; text-transform: uppercase; }
.agent-observer-card dd { margin: 2px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.agent-observer-card footer { padding-top: 6px; border-top: 1px solid var(--console-border); }
.agent-observer-card footer { padding-top: 8px; display: grid; gap: 3px; border-top: 1px solid var(--console-border); color: var(--console-muted); font-size: 11px; }
.agent-observer-card footer strong { color: var(--console-text); }
.agent-observer-card footer span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-observer-tree { height: 100%; min-width: 0; min-height: 0; padding-block: 8px; }
.agent-observer-tree .console-virtual-list { overflow: auto; }
@@ -110,48 +119,84 @@
.agent-observer-inspector-grid dt { color: var(--console-muted); font-size: 10px; text-transform: uppercase; }
.agent-observer-inspector-grid dd { margin: 3px 0 0; overflow-wrap: anywhere; font-size: 12px; }
.agent-observer-inspector-wide { grid-column: 1 / -1; }
.agent-observer-event-tail { margin: 0; padding: 0; display: grid; list-style: none; }
.agent-observer-event-tail li { min-width: 0; display: grid; grid-template-columns: 118px minmax(130px, .7fr) minmax(0, 1fr); gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--console-border); font-size: 11px; }
.agent-observer-break { overflow-wrap: anywhere; word-break: break-all; }
.agent-observer-event-tail { margin: 0; padding: 0; display: grid; gap: 1px; list-style: none; background: var(--console-border); }
.agent-observer-event-tail li { min-width: 0; display: grid; grid-template-columns: 128px minmax(120px, .6fr) minmax(0, 1fr); gap: 8px; padding: 8px; background: var(--console-surface); font-size: 11px; }
.agent-observer-event-tail span,
.agent-observer-event-tail small { color: var(--console-muted); overflow-wrap: anywhere; }
.agent-observer-log-tail li { grid-template-columns: 118px minmax(130px, .5fr) minmax(0, 1fr); }
.agent-observer-log-tail pre,
.agent-observer-raw-facts { max-width: 100%; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.55 var(--console-font-mono, ui-monospace, monospace); }
.agent-observer-raw-facts { padding: 10px; overflow: auto; border: 1px solid var(--console-border); background: var(--console-surface-muted); }
.agent-observer-break { overflow-wrap: anywhere; word-break: break-word; }
.mono { font-family: var(--console-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); }
.agent-observer-drawer-fields { display: grid; gap: 14px; }
.agent-observer-drawer-fields label { display: grid; gap: 6px; color: var(--console-muted); font-size: 12px; }
.agent-observer-drawer-fields select { width: 100%; max-width: none; }
.agent-observer-inspector-drawer { width: min(560px, calc(100vw - 24px)); }
.agent-observer-inspector-drawer .console-overlay-body { padding: 0; }
.agent-observer-event-tail small { color: var(--console-muted); }
.agent-observer-inspector .content-viewer,
.agent-observer-inspector-drawer .content-viewer { min-height: 320px; max-height: min(62dvh, 620px); }
.agent-observer-inspector .content-viewer-body,
.agent-observer-inspector-drawer .content-viewer-body { min-height: 0; }
.agent-observer-inspector-drawer .operations-inspector-panel { min-height: 0; }
.operations-inspector-panel[data-embedded="true"] { min-height: 0; }
.operations-inspector-panel[data-embedded="true"] .operations-inspector-tabs { position: sticky; top: 0; z-index: 1; background: var(--console-surface); }
.agent-observer-drawer-fields { display: grid; gap: 12px; }
.agent-observer-drawer-fields label { display: grid; gap: 5px; color: var(--console-muted); font-size: 12px; }
.agent-observer-drawer-fields select { width: 100%; max-width: none; }
.agent-observer-help-content { display: grid; gap: 10px; }
.agent-observer-help-content h3,
.agent-observer-help-content p { margin: 0; }
.agent-observer-help-content h3 { padding-top: 6px; font-size: 13px; }
.agent-observer-help-content p { color: var(--console-muted); font-size: 12px; line-height: 1.6; }
@keyframes agent-observer-row-highlight {
from { background: color-mix(in srgb, var(--console-accent) 20%, var(--console-surface)); }
to { background: transparent; }
}
@media (max-width: 1199px) {
.agent-observer-secondary-filters { display: none; }
.agent-observer-filter-trigger { display: inline-flex; }
.agent-observer-workspace.has-inspector { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 720px) {
.agent-observer-page { grid-template-rows: auto auto auto auto minmax(0, 1fr); }
.agent-observer-search { min-width: 100%; }
.agent-observer-segment button { padding-inline: 7px; }
.agent-observer-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.agent-observer-inspector-grid,
.agent-observer-status-matrix { grid-template-columns: minmax(0, 1fr); }
.agent-observer-inspector-wide { grid-column: auto; }
.agent-observer-event-tail li,
.agent-observer-log-tail li { grid-template-columns: minmax(0, 1fr); }
@media (max-width: 1100px) {
.agent-observer-page .page-command-filters { align-items: stretch; }
.agent-observer-secondary-filters { order: 5; flex: 1 0 100%; }
.agent-observer-secondary-filters select { flex: 1 1 180px; }
.agent-observer-density { order: 4; }
.agent-observer-segment { order: 3; }
.agent-observer-primary-status { order: 2; }
.agent-observer-search { order: 1; flex: 1 1 320px; }
}
@media (max-width: 520px) {
.console-drawer-backdrop:has(.agent-observer-inspector-drawer) { place-items: end stretch; }
.console-drawer.agent-observer-inspector-drawer { width: 100vw; height: min(82dvh, 720px); max-height: 82dvh; align-self: end; border: 1px solid var(--console-border-strong); border-bottom: 0; border-radius: 14px 14px 0 0; }
@media (max-width: 600px) {
.agent-observer-page { grid-template-rows: auto auto auto auto minmax(0, 1fr); }
.agent-observer-page .page-command-bar { padding-block: 8px; }
.agent-observer-page .page-command-description { display: none; }
.agent-observer-page .page-command-actions { gap: 5px; }
.agent-observer-target,
.agent-observer-reconnect { display: none; }
.agent-observer-help { min-width: 34px; padding-inline: 8px; font-size: 0; }
.agent-observer-page .page-command-filters { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
.agent-observer-search { grid-column: 1 / -1; width: 100%; min-width: 0; }
.agent-observer-primary-status { grid-column: 1; width: 100%; max-width: none !important; }
.agent-observer-filter-trigger { grid-column: 2; display: inline-flex; }
.agent-observer-secondary-filters,
.agent-observer-density { display: none; }
.agent-observer-segment { grid-column: 1 / -1; width: 100%; }
.agent-observer-segment button { flex: 1; }
.agent-observer-page > .console-status-strip .console-status-item { min-width: 96px; }
.agent-observer-table-head,
.agent-observer-table-row { min-width: 690px; grid-template-columns: 118px minmax(190px, 1fr) 145px minmax(180px, 1fr); }
.agent-observer-table-head > :nth-child(3),
.agent-observer-table-head > :nth-child(5),
.agent-observer-table-head > :nth-child(6),
.agent-observer-table-head > :nth-child(7),
.agent-observer-table-row > :nth-child(3),
.agent-observer-table-row > :nth-child(5),
.agent-observer-table-row > :nth-child(6),
.agent-observer-table-row > :nth-child(7) { display: none; }
.agent-observer-card-grid { padding: 7px; }
.agent-observer-card dl { grid-template-columns: 1fr; gap: 6px; }
.agent-observer-tree-row { min-width: 560px; grid-template-columns: 18px 96px minmax(180px, 1fr) minmax(150px, .8fr); }
.agent-observer-inspector-grid { grid-template-columns: 1fr; }
.agent-observer-inspector-wide { grid-column: auto; }
.agent-observer-event-tail li { grid-template-columns: 1fr; }
.agent-observer-inspector-drawer .content-viewer { min-height: 260px; max-height: 52dvh; }
}
@media (prefers-reduced-motion: reduce) {
.agent-observer-table-row[data-recent="true"] { animation: none; }
.agent-observer-tree-toggle svg { transition: none; }
.agent-observer-table-row[data-recent="true"] { box-shadow: none; }
}
@@ -4,13 +4,33 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Activity, ChevronRight, Columns3, LayoutGrid, List, Network, RefreshCw, Search, SlidersHorizontal, X } from "lucide-vue-next";
import {
Activity,
ChevronRight,
Columns3,
HelpCircle,
LayoutGrid,
List,
Network,
RefreshCw,
Search,
SlidersHorizontal,
X,
} from "lucide-vue-next";
import AgentRunInspector from "@/components/agents/AgentRunInspector.vue";
import BaseDrawer from "@/components/common/BaseDrawer.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import VirtualGrid from "@/components/common/VirtualGrid.vue";
import VirtualList from "@/components/common/VirtualList.vue";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import { agentObserverStatusToken, useAgentObserverStore, type AgentObserverRelationRow, type AgentObserverRun, type AgentObserverView } from "@/stores/agent-observer";
import StatusStrip, { type ConsoleStatusItem } from "@/components/layout/StatusStrip.vue";
import {
agentObserverStatusToken,
useAgentObserverStore,
type AgentObserverRelationRow,
type AgentObserverRun,
type AgentObserverView,
} from "@/stores/agent-observer";
type InspectorTab = "overview" | "relations" | "events" | "logs" | "raw";
type VirtualListHandle = { scrollToIndex: (index: number, align?: "nearest" | "start" | "end") => Promise<void> };
@@ -21,6 +41,7 @@ const observer = useAgentObserverStore();
const inspectorTab = ref<InspectorTab>("overview");
const urlApplying = ref(false);
const filtersOpen = ref(false);
const helpOpen = ref(false);
const compactInspector = ref(false);
const treeList = ref<VirtualListHandle | null>(null);
const activeRelationId = ref<string | null>(null);
@@ -28,6 +49,22 @@ const treeRows = computed(() => observer.relationRows);
const ageLabel = computed(() => formatAge(observer.dataAgeMs));
const streamLabel = computed(() => ({ idle: "未连接", replay: "回放", handoff: "交接", live: "Live", reconnecting: "重连中", stale: "陈旧", error: "传输错误" }[observer.phase]));
const selectedMissing = computed(() => Boolean(observer.selectedRunId && !observer.selectedRun));
const loading = computed(() => observer.runs.length === 0 && ["idle", "replay", "handoff"].includes(observer.phase));
const statusItems = computed<ConsoleStatusItem[]>(() => [
{ id: "running", label: "运行中", value: observer.stats.running, tone: "info" },
{ id: "waiting", label: "等待", value: observer.stats.waiting, tone: "warn" },
{ id: "failed", label: "失败", value: observer.stats.failed, tone: "error" },
{ id: "stale", label: "陈旧", value: observer.stats.stale, tone: observer.stats.stale ? "warn" : "neutral" },
{ id: "terminal", label: "终态", value: observer.stats.terminal, tone: "ok" },
{ id: "unknown", label: "未知", value: observer.stats.unknown },
{ id: "runners", label: "Runner", value: observer.stats.runners },
{
id: "capacity",
label: "Runner 容量",
value: observer.stats.runnerCapacityKnown ? `${observer.stats.runnerCapacityUsed}/${observer.stats.runnerCapacityTotal}` : "未知",
},
{ id: "total", label: "筛选结果", value: observer.stats.total },
]);
let inspectorMedia: MediaQueryList | null = null;
onMounted(() => {
@@ -46,7 +83,7 @@ onBeforeUnmount(() => {
watch(() => route.fullPath, applyRouteState);
watch(
() => [observer.view, observer.density, observer.search, observer.statusFilter, observer.projectFilter, observer.agentFilter, observer.timeWindow, observer.sort, observer.selectedRunId],
() => { if (!urlApplying.value) void syncUrl(); }
() => { if (!urlApplying.value) void syncUrl(); },
);
watch(treeRows, (rows) => {
if (rows.length === 0) activeRelationId.value = null;
@@ -80,7 +117,7 @@ async function syncUrl(): Promise<void> {
project: observer.projectFilter === "all" ? "" : observer.projectFilter,
agent: observer.agentFilter === "all" ? "" : observer.agentFilter,
window: observer.timeWindow === "all" ? "" : observer.timeWindow,
sort: observer.sort === "updated-desc" ? "" : observer.sort
sort: observer.sort === "updated-desc" ? "" : observer.sort,
});
await router.replace(observer.selectedRunId
? { name: "AgentRunDetail", params: { runId: observer.selectedRunId }, query }
@@ -100,6 +137,14 @@ function closeInspector(): void {
observer.selectRun(null);
}
function clearFilters(): void {
observer.search = "";
observer.statusFilter = "all";
observer.projectFilter = "all";
observer.agentFilter = "all";
observer.timeWindow = "all";
}
function activateRelation(row: AgentObserverRelationRow): void {
activeRelationId.value = row.id;
if (row.childCount > 0 && !row.runId) observer.toggleRelation(row.id, !row.expanded);
@@ -143,8 +188,8 @@ async function onRelationKeydown(event: KeyboardEvent, row: AgentObserverRelatio
}
}
function status(status: string | null) {
return agentObserverStatusToken(status);
function status(value: string | null) {
return agentObserverStatusToken(value);
}
function rowKey(run: AgentObserverRun): string { return run.id; }
@@ -160,60 +205,76 @@ function compactQuery(value: Record<string, string>): Record<string, string> { r
<template>
<section class="agent-observer-page" :data-density="observer.density">
<PageCommandBar eyebrow="智能体管理" title="AgentRun 运行观察" description="纯 Kafka retention replay → live;三视图与统一 Inspector 共享同一 orderedIds、筛选、排序和选择。" sticky>
<PageCommandBar eyebrow="智能体管理" title="AgentRun 运行观察" description="纯 Kafka retention replay → handoff → live;三视图与 Inspector 共享同一 orderedIds、筛选、排序和选择。" sticky>
<template #actions>
<span class="agent-observer-stream" :data-phase="observer.phase" aria-live="polite"><Activity :size="15" aria-hidden="true" /><strong>{{ streamLabel }}</strong><span>{{ ageLabel }}</span></span>
<span v-if="observer.retentionWindowAdvanced" class="agent-observer-target">当前 retention 窗口已前移</span>
<span v-if="observer.retentionWindowAdvanced" class="agent-observer-target">Retention 窗口已前移</span>
<span v-if="observer.config" class="agent-observer-target mono">{{ observer.config.nodeId }}/{{ observer.config.lane }}</span>
<button class="btn btn-secondary" type="button" @click="observer.reconnect"><RefreshCw :size="15" aria-hidden="true" />重连</button>
<button class="btn btn-secondary agent-observer-help" type="button" aria-label="打开 AgentRun 观察帮助" @click="helpOpen = true"><HelpCircle :size="15" aria-hidden="true" />帮助</button>
<button class="btn btn-secondary agent-observer-reconnect" type="button" @click="observer.reconnect"><RefreshCw :size="15" aria-hidden="true" />重连</button>
</template>
<template #filters>
<label class="agent-observer-search"><Search :size="16" aria-hidden="true" /><span class="sr-only">搜索 runtask session</span><input v-model="observer.search" type="search" placeholder="搜索 run / task / session / error" /><button v-if="observer.search" type="button" title="清除搜索" aria-label="清除搜索" @click="observer.search = ''"><X :size="14" /></button></label>
<label class="agent-observer-search"><Search :size="16" aria-hidden="true" /><span class="sr-only">搜索 runtask session</span><input v-model="observer.search" type="search" placeholder="搜索 run / task / session / error" /><button v-if="observer.search" type="button" title="清除搜索" aria-label="清除搜索" @click="observer.search = ''"><X :size="14" aria-hidden="true" /></button></label>
<select v-model="observer.statusFilter" class="agent-observer-primary-status" aria-label="运行状态"><option value="all">全部状态</option><option value="running">运行中</option><option value="waiting">等待</option><option value="failed">失败</option><option value="terminal">终态</option><option value="unknown">未知</option></select>
<div class="agent-observer-secondary-filters">
<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><option value="unknown">未知</option></select>
<select v-model="observer.projectFilter" aria-label="项目或 Workspace"><option value="all">全部 Project / Workspace</option><option v-for="item in observer.scopeOptions" :key="item.value" :value="item.value">{{ item.label }}</option></select>
<select v-model="observer.agentFilter" aria-label="Agent 或 Profile"><option value="all">全部 Agent / Profile</option><option v-for="item in observer.agents" :key="item" :value="item">{{ item }}</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">ID</option></select>
</div>
<button class="agent-observer-filter-trigger" type="button" @click="filtersOpen = true"><SlidersHorizontal :size="15" />筛选</button>
<div class="agent-observer-segment" aria-label="视图切换"><button type="button" :aria-pressed="observer.view === 'table'" @click="observer.view = 'table'"><List :size="15" />表格</button><button type="button" :aria-pressed="observer.view === 'cards'" @click="observer.view = 'cards'"><LayoutGrid :size="15" />卡片</button><button type="button" :aria-pressed="observer.view === 'tree'" @click="observer.view = 'tree'"><Network :size="15" /></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 class="agent-observer-filter-trigger" type="button" aria-label="打开次级筛选" @click="filtersOpen = true"><SlidersHorizontal :size="15" aria-hidden="true" />更多</button>
<div class="agent-observer-segment" role="group" aria-label="视图切换"><button type="button" :aria-pressed="observer.view === 'table'" @click="observer.view = 'table'"><List :size="15" aria-hidden="true" />表格</button><button type="button" :aria-pressed="observer.view === 'cards'" @click="observer.view = 'cards'"><LayoutGrid :size="15" aria-hidden="true" />卡片</button><button type="button" :aria-pressed="observer.view === 'tree'" @click="observer.view = 'tree'"><Network :size="15" aria-hidden="true" /></button></div>
<button class="agent-observer-density" type="button" :aria-pressed="observer.density === 'compact'" @click="observer.density = observer.density === 'compact' ? 'standard' : 'compact'"><Columns3 :size="15" aria-hidden="true" />{{ observer.density === "compact" ? "紧凑" : "标准" }}</button>
</template>
</PageCommandBar>
<BaseDrawer :open="filtersOpen" title="AgentRun 筛选" description="与桌面命令栏共享同一查询状态。" surface-class="agent-observer-filter-drawer" @close="filtersOpen = false">
<div class="agent-observer-drawer-fields"><label>运行状态<select v-model="observer.statusFilter"><option value="all">全部状态</option><option value="running">运行中</option><option value="waiting">等待</option><option value="failed">失败</option><option value="terminal">终态</option><option value="unknown">未知</option></select></label><label>项目 / Workspace<select v-model="observer.projectFilter"><option value="all">全部</option><option v-for="item in observer.scopeOptions" :key="item.value" :value="item.value">{{ item.label }}</option></select></label><label>Agent / Profile<select v-model="observer.agentFilter"><option value="all">全部</option><option v-for="item in observer.agents" :key="item" :value="item">{{ item }}</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></div>
<BaseDrawer :open="filtersOpen" title="次级筛选与显示" description="Project、Agent、时间、排序和密度与桌面命令栏共享同一 URL 状态。" surface-class="agent-observer-filter-drawer" @close="filtersOpen = false">
<div class="agent-observer-drawer-fields">
<label>项目 / Workspace<select v-model="observer.projectFilter"><option value="all">全部</option><option v-for="item in observer.scopeOptions" :key="item.value" :value="item.value">{{ item.label }}</option></select></label>
<label>Agent / Profile<select v-model="observer.agentFilter"><option value="all">全部</option><option v-for="item in observer.agents" :key="item" :value="item">{{ item }}</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">ID</option></select></label>
<label>密度<select v-model="observer.density"><option value="compact">紧凑</option><option value="standard">标准</option></select></label>
</div>
</BaseDrawer>
<div class="agent-observer-status-strip" aria-label="运行状态摘要">
<span><strong>{{ observer.stats.running }}</strong>运行</span><span><strong>{{ observer.stats.waiting }}</strong>等待</span><span data-tone="danger"><strong>{{ observer.stats.failed }}</strong>失败</span><span><strong>{{ observer.stats.unknown }}</strong>未知</span><span><strong>{{ observer.stats.stale }}</strong>陈旧</span><span><strong>{{ observer.stats.terminal }}</strong>终态</span><span><strong>{{ observer.stats.runners }}</strong>Runner</span><span><strong>{{ observer.stats.runnerCapacityKnown ? `${observer.stats.runnerCapacityUsed}/${observer.stats.runnerCapacityTotal}` : "未知" }}</strong>容量</span><span><strong>{{ observer.stats.total }}</strong>结果</span>
</div>
<BaseDrawer :open="helpOpen" title="AgentRun 观察帮助" description="本页只有 Kafka 事件投影这一条 authority。" surface-class="agent-observer-help-drawer" @close="helpOpen = false">
<div class="agent-observer-help-content">
<h3>事件流</h3>
<p>连接先从 Kafka retention 窗口回放经过 handoff barrier 后进入共享 live 重连继续使用正式事件 identity 去重不读取 snapshot轮询或 HTTP fallback</p>
<h3>三种视图</h3>
<p>表格用于高密度扫描卡片用于并行状态辨识关系树用于 Task Run Command/Attempt 追踪三者共享筛选排序选择URL Inspector 页签</p>
<h3>窗口提示</h3>
<p>Retention advancedtree limited 或深链实体暂时离开有界窗口时页面保留明确提示不静默改选也不制造第二事实源</p>
</div>
</BaseDrawer>
<StatusStrip :items="statusItems" label="AgentRun 运行状态摘要" live="polite" />
<div v-if="observer.error" class="agent-observer-inline-error" role="status">{{ observer.error }}</div>
<div v-if="selectedMissing" class="agent-observer-window-warning" role="status">深链所选实体不在当前 reducer 实体窗口选择保持不变等待正式 Kafka 事件重新进入窗口</div>
<div class="agent-observer-workspace" :class="{ 'has-inspector': observer.selectedRun && !compactInspector }">
<main class="agent-observer-content">
<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="observer.visibleRuns.length === 0" class="agent-observer-empty"><Activity :size="28" aria-hidden="true" /><strong>当前事件窗口没有匹配运行</strong><span>保留最后确认投影;可清除筛选或等待 Kafka live 事件。</span><button class="btn btn-secondary" type="button" @click="observer.search = ''; observer.statusFilter = 'all'; observer.projectFilter = 'all'; observer.agentFilter = 'all'">清除筛选</button></div>
<div v-if="loading" class="agent-observer-skeleton" :aria-label="`正在${streamLabel} Kafka retention`"><span v-for="index in 8" :key="index" /></div>
<div v-else-if="observer.visibleRuns.length === 0" class="agent-observer-empty"><Activity :size="28" aria-hidden="true" /><strong>当前事件窗口没有匹配运行</strong><span>保留最后确认投影;可清除筛选或等待 Kafka live 事件。</span><button class="btn btn-secondary" type="button" @click="clearFilters">清除筛选</button></div>
<section v-else-if="observer.view === 'table'" class="agent-observer-table" role="grid" aria-label="AgentRun 运行表格">
<div class="agent-observer-table-head" role="row"><span role="columnheader">Task / Run / Command</span><span role="columnheader">运行</span><span role="columnheader">上下文</span><span role="columnheader">阶段</span><span role="columnheader">活动</span><span role="columnheader">Runner</span><span role="columnheader">结果</span></div>
<div class="agent-observer-table-head" role="row"><span role="columnheader">状态</span><span role="columnheader">Run / Task / Command identity</span><span role="columnheader">Project / Workspace</span><span role="columnheader">Agent / Profile</span><span role="columnheader">阶段</span><span role="columnheader">活动 / 耗时</span><span role="columnheader">Runner / Node / Lane</span><span role="columnheader">结果 / 错误</span></div>
<VirtualList :items="observer.visibleRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 62 : 76" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" root-role="presentation" item-role="presentation" 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="observer.nowMs - run.changedAtMs < (observer.config?.changeHighlightMs ?? 0)" @click="selectRun(run)" @dblclick="openRun(run)" @keydown.enter="openRun(run)"><span role="gridcell" class="agent-observer-status-stack"><StatusBadge :status="status(run.taskStatus).tone" :label="`T ${status(run.taskStatus).label}`" /><StatusBadge :status="status(run.runStatus).tone" :label="`R ${status(run.runStatus).label}`" /><StatusBadge :status="status(run.commandState).tone" :label="`C ${status(run.commandState).label}`" /></span><span role="gridcell"><strong class="mono" :title="run.id">{{ shortId(run.id) }}</strong><small>{{ shortId(run.taskId) }} · {{ shortId(run.attemptId) }}</small></span><span role="gridcell"><strong>{{ run.projectId || "未知" }}</strong><small :title="run.workspace || ''">{{ shortId(run.workspace) }} · {{ shortId(run.sessionId) }}</small></span><span role="gridcell"><strong>{{ run.phase || "未知" }}</strong><small>{{ run.backend || run.providerId || "未知" }}</small></span><span role="gridcell"><strong>{{ duration(run) }}</strong><small>{{ formatTime(run.updatedAt) }}</small></span><span role="gridcell"><strong class="mono">{{ shortId(run.runnerId) }}</strong><small>{{ run.nodeId || "未知" }}/{{ run.lane || "未知" }}</small></span><span role="gridcell"><strong>{{ run.errorCode || run.result || "未知" }}</strong><small :title="run.lastMessage || ''">{{ shortId(run.lastMessage) }}</small></span></div></template>
<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="observer.nowMs - run.changedAtMs < (observer.config?.changeHighlightMs ?? 0)" @click="selectRun(run)" @dblclick="openRun(run)" @keydown.enter="openRun(run)"><span role="gridcell" class="agent-observer-status-stack"><StatusBadge :status="status(run.taskStatus).tone" :label="`T ${status(run.taskStatus).label}`" /><StatusBadge :status="status(run.runStatus).tone" :label="`R ${status(run.runStatus).label}`" /><StatusBadge :status="status(run.commandState).tone" :label="`C ${status(run.commandState).label}`" /></span><span role="gridcell"><strong class="mono" :title="run.id">{{ shortId(run.id) }}</strong><small class="mono">T {{ shortId(run.taskId) }} · C {{ shortId(run.commandId) }}</small></span><span role="gridcell"><strong>{{ run.projectId || "未知" }}</strong><small class="mono" :title="run.workspace || ''">{{ shortId(run.workspace) }}</small></span><span role="gridcell"><strong>{{ run.backend || "Agent 未知" }}</strong><small>{{ run.providerId || "Profile 未知" }}</small></span><span role="gridcell"><strong>{{ run.phase || "未知" }}</strong><small class="mono">Attempt {{ shortId(run.attemptId) }}</small></span><span role="gridcell"><strong>{{ duration(run) }}</strong><small>{{ formatTime(run.updatedAt) }}</small></span><span role="gridcell"><strong class="mono">{{ shortId(run.runnerId) }}</strong><small>{{ run.nodeId || "未知" }} / {{ run.lane || "未知" }}</small></span><span role="gridcell"><strong>{{ run.errorCode || run.result || "未知" }}</strong><small :title="run.lastMessage || ''">{{ shortId(run.lastMessage) }}</small></span></div></template>
</VirtualList>
</section>
<section v-else-if="observer.view === 'cards'" class="agent-observer-card-list" aria-label="AgentRun 虚拟卡片窗口">
<VirtualList :items="observer.visibleRuns" :item-key="rowKey" :item-height="observer.density === 'compact' ? 158 : 184" height="100%" :overscan="5" :window-limit="observer.config?.virtualListWindowLimit" root-role="list" item-role="listitem" aria-label="所有 AgentRun 卡片结果">
<template #default="{ item: run }"><article class="agent-observer-card" tabindex="0" :data-status="status(run.runStatus).tone" :data-selected="observer.selectedRunId === run.id" @click="selectRun(run)" @keydown.enter="openRun(run)"><header><span class="agent-observer-status-stack"><StatusBadge :status="status(run.taskStatus).tone" :label="`Task ${status(run.taskStatus).label}`" /><StatusBadge :status="status(run.runStatus).tone" :label="`Run ${status(run.runStatus).label}`" /><StatusBadge :status="status(run.commandState).tone" :label="`Command ${status(run.commandState).label}`" /></span><span>{{ duration(run) }}</span></header><h2 class="mono" :title="run.id">{{ shortId(run.id) }}</h2><p :title="[run.taskId, run.sessionId, run.workspace].filter(Boolean).join(' · ')">{{ shortId(run.taskId) }} · {{ shortId(run.sessionId) }} · {{ shortId(run.workspace) }}</p><dl><div><dt>Agent</dt><dd>{{ run.backend || run.providerId || "未知" }}</dd></div><div><dt>Runner</dt><dd class="mono">{{ shortId(run.runnerId) }}</dd></div><div><dt>阶段</dt><dd>{{ run.phase || "未知" }}</dd></div><div><dt>活动</dt><dd>{{ formatTime(run.updatedAt) }}</dd></div></dl><footer :title="run.lastMessage || ''">{{ run.errorCode || run.lastMessage || "正式事件未提供结果" }}</footer></article></template>
</VirtualList>
<section v-else-if="observer.view === 'cards'" class="agent-observer-card-grid" aria-label="AgentRun 响应式虚拟卡片网格">
<VirtualGrid :items="observer.visibleRuns" :item-key="rowKey" :row-height="observer.density === 'compact' ? 206 : 230" height="100%" :minimum-column-width="360" :maximum-columns="3" :gap="10" :overscan="4" :window-limit="observer.config?.virtualListWindowLimit" aria-label="所有 AgentRun 卡片结果">
<template #default="{ item: run }"><article class="agent-observer-card" tabindex="0" :data-status="status(run.runStatus).tone" :data-selected="observer.selectedRunId === run.id" @click="selectRun(run)" @dblclick="openRun(run)" @keydown.enter="openRun(run)"><header><span class="agent-observer-status-stack"><StatusBadge :status="status(run.taskStatus).tone" :label="`Task ${status(run.taskStatus).label}`" /><StatusBadge :status="status(run.runStatus).tone" :label="`Run ${status(run.runStatus).label}`" /><StatusBadge :status="status(run.commandState).tone" :label="`Command ${status(run.commandState).label}`" /></span><span>{{ duration(run) }}</span></header><div class="agent-observer-card-identity"><h2 class="mono" :title="run.id">{{ shortId(run.id) }}</h2><p class="mono" :title="[run.taskId, run.commandId, run.sessionId].filter(Boolean).join(' · ')">T {{ shortId(run.taskId) }} · C {{ shortId(run.commandId) }} · S {{ shortId(run.sessionId) }}</p></div><dl><div><dt>Agent / Profile</dt><dd>{{ run.backend || "未知" }} · {{ run.providerId || "未知" }}</dd></div><div><dt>Runner / Node</dt><dd class="mono">{{ shortId(run.runnerId) }} · {{ run.nodeId || "未知" }}</dd></div><div><dt>阶段</dt><dd>{{ run.phase || "未知" }}</dd></div><div><dt>最近活动</dt><dd>{{ formatTime(run.updatedAt) }}</dd></div></dl><footer><strong>{{ run.errorCode || run.result || "无终态结果" }}</strong><span :title="run.lastMessage || ''">{{ shortId(run.lastMessage, "正式事件未提供结果摘要") }}</span></footer></article></template>
</VirtualGrid>
</section>
<section v-else class="agent-observer-tree" role="tree" aria-label="AgentRun 关系树">
<p v-if="observer.relationWindowLimited" class="agent-observer-window-note">当前事件窗口关系树按 YAML 上限显示 {{ observer.relationNodeCount }} 个节点另有 {{ observer.relationOmittedRunCount }} 个运行未展开</p>
<VirtualList ref="treeList" :items="treeRows" :item-key="relationKey" :item-height="44" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" root-role="presentation" item-role="presentation" aria-label="AgentRun 虚拟关系树">
<template #default="{ item: node, index }"><div class="agent-observer-tree-row" role="treeitem" :data-relation-id="node.id" :data-tree-index="index" :aria-level="node.depth + 1" :aria-posinset="node.position" :aria-setsize="node.setSize" :aria-expanded="node.childCount > 0 ? node.expanded : undefined" :aria-selected="observer.selectedRunId === node.runId" :tabindex="activeRelationId === node.id || (!activeRelationId && index === 0) ? 0 : -1" :style="{ '--tree-depth': node.depth }" @focus="activeRelationId = node.id" @click="activateRelation(node)" @keydown="onRelationKeydown($event, node, index)"><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="status(node.status).tone" :label="status(node.status).label" /><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>
<template #default="{ item: node, index }"><div class="agent-observer-tree-row" role="treeitem" :data-relation-id="node.id" :data-tree-index="index" :aria-level="node.depth + 1" :aria-posinset="node.position" :aria-setsize="node.setSize" :aria-expanded="node.childCount > 0 ? node.expanded : undefined" :aria-selected="observer.selectedRunId === node.runId" :tabindex="activeRelationId === node.id || (!activeRelationId && index === 0) ? 0 : -1" :style="{ '--tree-depth': node.depth }" @focus="activeRelationId = node.id" @click="activateRelation(node)" @keydown="onRelationKeydown($event, node, index)"><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" aria-hidden="true" /></button><span v-else class="agent-observer-tree-spacer" aria-hidden="true" /><StatusBadge v-if="node.status" :status="status(node.status).tone" :label="status(node.status).label" /><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>
</section>
</main>
@@ -221,8 +282,8 @@ function compactQuery(value: Record<string, string>): Record<string, string> { r
<AgentRunInspector v-if="observer.selectedRun && !compactInspector" class="agent-observer-inspector" :run="observer.selectedRun" :events="observer.selectedEvents" :logs="observer.selectedLogs" :in-event-window="observer.selectedInEventWindow" :now-ms="observer.nowMs" :tab="inspectorTab" @update:tab="inspectorTab = $event" @close="closeInspector" />
</div>
<BaseDrawer :open="Boolean(observer.selectedRun && compactInspector)" title="AgentRun Inspector" description="选择与页签在桌面、Drawer 和 bottom sheet 间保持。" surface-class="agent-observer-inspector-drawer" @close="closeInspector">
<AgentRunInspector v-if="observer.selectedRun" :run="observer.selectedRun" :events="observer.selectedEvents" :logs="observer.selectedLogs" :in-event-window="observer.selectedInEventWindow" :now-ms="observer.nowMs" :tab="inspectorTab" @update:tab="inspectorTab = $event" @close="closeInspector" />
<BaseDrawer :open="Boolean(observer.selectedRun && compactInspector)" :title="observer.selectedRun ? shortId(observer.selectedRun.id) : 'AgentRun Inspector'" :description="observer.selectedRun ? `${status(observer.selectedRun.runStatus).label} · ${observer.selectedRun.phase || '阶段未知'}` : 'AgentRun Inspector'" surface-class="agent-observer-inspector-drawer" @close="closeInspector">
<AgentRunInspector v-if="observer.selectedRun" embedded :run="observer.selectedRun" :events="observer.selectedEvents" :logs="observer.selectedLogs" :in-event-window="observer.selectedInEventWindow" :now-ms="observer.nowMs" :tab="inspectorTab" @update:tab="inspectorTab = $event" @close="closeInspector" />
</BaseDrawer>
</section>
</template>