refactor: 收口 AgentRun 观察页产品体验
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">搜索 run、task 或 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">搜索 run、task 或 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 advanced、tree 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>
|
||||
|
||||
Reference in New Issue
Block a user