fix: 渐进加载 AgentRun 分页快照
This commit is contained in:
@@ -1,5 +1,50 @@
|
||||
import { fetchJson } from "@/api/client";
|
||||
|
||||
export interface AgentObserverRunPageQuery {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
workspace?: string;
|
||||
backendProfile?: string;
|
||||
providerId?: string;
|
||||
search?: string;
|
||||
updatedAfter?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface AgentObserverRunPagePayload {
|
||||
items: Array<Record<string, unknown>>;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
facets: Record<string, unknown>;
|
||||
authority: string;
|
||||
elapsedMs: number;
|
||||
valuesPrinted: false;
|
||||
}
|
||||
|
||||
export async function fetchAgentObserverRuns(query: AgentObserverRunPageQuery) {
|
||||
const search = new URLSearchParams({ page: String(query.page), pageSize: String(query.pageSize) });
|
||||
if (query.status) search.set("status", query.status);
|
||||
if (query.projectId) search.set("projectId", query.projectId);
|
||||
if (query.workspace) search.set("workspace", query.workspace);
|
||||
if (query.backendProfile) search.set("backendProfile", query.backendProfile);
|
||||
if (query.providerId) search.set("providerId", query.providerId);
|
||||
if (query.search) search.set("search", query.search);
|
||||
if (query.updatedAfter) search.set("updatedAfter", query.updatedAfter);
|
||||
if (query.sort) search.set("sort", query.sort);
|
||||
return await fetchJson<AgentObserverRunPagePayload>(`/v1/agent-observer/runs?${search.toString()}`, {
|
||||
timeoutMs: 10_000,
|
||||
timeoutName: "AgentRun 分页快照",
|
||||
});
|
||||
}
|
||||
|
||||
export interface AgentObserverStreamCallbacks {
|
||||
afterId?: string | null;
|
||||
liveOnly?: boolean;
|
||||
onEnvelope: (payload: unknown, eventId: string | null) => void;
|
||||
onHandoff: (payload: unknown) => void;
|
||||
onConnected: (payload: unknown) => void;
|
||||
@@ -11,6 +56,7 @@ export interface AgentObserverStreamCallbacks {
|
||||
export function openAgentObserverStream(callbacks: AgentObserverStreamCallbacks): EventSource {
|
||||
const url = new URL("/v1/agent-observer/events", window.location.origin);
|
||||
if (callbacks.afterId) url.searchParams.set("afterId", callbacks.afterId);
|
||||
if (callbacks.liveOnly) url.searchParams.set("mode", "live");
|
||||
const source = new EventSource(`${url.pathname}${url.search}`, { withCredentials: true });
|
||||
source.addEventListener("hwlab.event.v1", (event) => callbacks.onEnvelope(parseEvent(event), event instanceof MessageEvent ? event.lastEventId || null : null));
|
||||
source.addEventListener("agent-observer.handoff", (event) => callbacks.onHandoff(parseEvent(event)));
|
||||
|
||||
@@ -81,6 +81,7 @@ function fact(value: boolean | null): string {
|
||||
<span><small>Attempt</small><StatusBadge :status="attemptToken.tone" :label="attemptToken.label" /></span>
|
||||
</div>
|
||||
<dl class="agent-observer-inspector-grid">
|
||||
<div class="agent-observer-inspector-wide"><dt>任务标题</dt><dd>{{ unknown(run.title) }}</dd></div>
|
||||
<div><dt>阶段</dt><dd>{{ unknown(run.phase) }}</dd></div><div><dt>错误分类</dt><dd>{{ unknown(run.errorCode) }}</dd></div>
|
||||
<div><dt>开始</dt><dd>{{ formatTime(run.startedAt) }}</dd></div><div><dt>结束</dt><dd>{{ formatTime(run.finishedAt) }}</dd></div>
|
||||
<div><dt>耗时</dt><dd>{{ duration() }}</dd></div><div><dt>最后活动</dt><dd>{{ formatTime(run.updatedAt) }}</dd></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { openAgentObserverStream } from "@/api/agentObserver";
|
||||
import { fetchAgentObserverRuns, openAgentObserverStream } from "@/api/agentObserver";
|
||||
import { agentObserverRuntimeConfig, type AgentObserverRuntimeConfig } from "@/config/agent-observer";
|
||||
import { asRecord, nonEmptyString } from "@/utils";
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface AgentObserverEvent {
|
||||
|
||||
export interface AgentObserverRun {
|
||||
id: string;
|
||||
title: string | null;
|
||||
taskId: string | null;
|
||||
taskStatus: string | null;
|
||||
commandId: string | null;
|
||||
@@ -113,6 +114,16 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
const lastConfirmedEventId = ref<string | null>(null);
|
||||
const retentionWindowAdvanced = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(25);
|
||||
const total = ref(0);
|
||||
const totalPages = ref(0);
|
||||
const facetProjects = ref<string[]>([]);
|
||||
const facetWorkspaces = ref<string[]>([]);
|
||||
const facetBackends = ref<string[]>([]);
|
||||
const facetProviders = ref<string[]>([]);
|
||||
const snapshotLoaded = ref(false);
|
||||
const loadingPage = ref(false);
|
||||
const nowMs = ref(Date.now());
|
||||
const config = ref<AgentObserverRuntimeConfig | null>(null);
|
||||
const expandedRelationIds = ref<string[]>([]);
|
||||
@@ -123,30 +134,23 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
let source: EventSource | null = null;
|
||||
let reconnectTimer: number | null = null;
|
||||
let staleTimer: number | null = null;
|
||||
let snapshotRefreshTimer: number | null = null;
|
||||
let snapshotRequest = 0;
|
||||
let flushScheduled = false;
|
||||
let manuallyClosed = false;
|
||||
|
||||
const runs = computed(() => orderedIds.value.map((id) => entities.value[id]).filter((item): item is AgentObserverRun => Boolean(item)));
|
||||
const selectedRun = computed(() => selectedRunId.value ? entities.value[selectedRunId.value] ?? null : null);
|
||||
const visibleRuns = computed(() => {
|
||||
const needle = search.value.trim().toLowerCase();
|
||||
const result = runs.value.filter((run) => {
|
||||
if (statusFilter.value !== "all" && statusGroup(run.runStatus) !== statusFilter.value) return false;
|
||||
if (projectFilter.value !== "all" && !matchesScopeFilter(run, projectFilter.value)) return false;
|
||||
if (agentFilter.value !== "all" && (run.backend ?? run.providerId) !== agentFilter.value) return false;
|
||||
if (!insideTimeWindow(run.updatedAt ?? run.startedAt, timeWindow.value, nowMs.value)) return false;
|
||||
if (!needle) return true;
|
||||
return [run.id, run.taskId, run.commandId, run.sessionId, run.traceId, run.projectId, run.workspace, run.backend, run.providerId, run.errorCode, run.lastMessage]
|
||||
.some((value) => value?.toLowerCase().includes(needle));
|
||||
});
|
||||
return result.sort((left, right) => compareRuns(left, right, sort.value));
|
||||
});
|
||||
const visibleRuns = computed(() => runs.value);
|
||||
const projects = computed(() => unique(runs.value.map((run) => run.projectId)));
|
||||
const scopeOptions = computed(() => [
|
||||
...unique(runs.value.map((run) => run.projectId)).map((value) => ({ value: `project:${value}`, label: `项目 · ${value}` })),
|
||||
...unique(runs.value.map((run) => run.workspace)).map((value) => ({ value: `workspace:${value}`, label: `Workspace · ${value}` }))
|
||||
...facetProjects.value.map((value) => ({ value: `project:${value}`, label: `项目 · ${value}` })),
|
||||
...facetWorkspaces.value.map((value) => ({ value: `workspace:${value}`, label: `Workspace · ${value}` }))
|
||||
]);
|
||||
const agents = computed(() => [
|
||||
...facetBackends.value.map((value) => ({ value: `backend:${value}`, label: `Agent · ${value}` })),
|
||||
...facetProviders.value.map((value) => ({ value: `provider:${value}`, label: `Profile · ${value}` })),
|
||||
]);
|
||||
const agents = computed(() => unique(runs.value.map((run) => run.backend ?? run.providerId)));
|
||||
const selectedEvents = computed(() => {
|
||||
const run = selectedRun.value;
|
||||
if (!run || !config.value) return [];
|
||||
@@ -156,10 +160,7 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
const selectedLogs = computed(() => selectedEvents.value.filter((event) => ["output", "tool", "error"].includes(event.type) || event.label.includes(":output:")));
|
||||
const selectedInEventWindow = computed(() => {
|
||||
if (!selectedRunId.value) return true;
|
||||
const run = selectedRun.value;
|
||||
if (!run) return false;
|
||||
const retained = new Set(events.value.map((event) => event.id));
|
||||
return run.eventIds.some((id) => retained.has(id));
|
||||
return Boolean(selectedRun.value);
|
||||
});
|
||||
const stats = computed(() => {
|
||||
const values = visibleRuns.value;
|
||||
@@ -169,7 +170,7 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
capacityByRunner.set(run.runnerId, { used: run.runnerCapacityUsed, total: run.runnerCapacityTotal });
|
||||
}
|
||||
return {
|
||||
total: values.length,
|
||||
total: total.value,
|
||||
running: values.filter((run) => statusGroup(run.runStatus) === "running").length,
|
||||
waiting: values.filter((run) => statusGroup(run.runStatus) === "waiting").length,
|
||||
failed: values.filter((run) => statusGroup(run.runStatus) === "failed").length,
|
||||
@@ -201,10 +202,12 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
return;
|
||||
}
|
||||
manuallyClosed = false;
|
||||
phase.value = "replay";
|
||||
phase.value = "reconnecting";
|
||||
error.value = null;
|
||||
void loadPage();
|
||||
source = openAgentObserverStream({
|
||||
afterId: lastConfirmedEventId.value,
|
||||
liveOnly: true,
|
||||
onEnvelope: enqueueEnvelope,
|
||||
onHandoff: () => {
|
||||
phase.value = "handoff";
|
||||
@@ -244,6 +247,68 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
reconnectTimer = null;
|
||||
if (manual && staleTimer !== null) window.clearInterval(staleTimer);
|
||||
if (manual) staleTimer = null;
|
||||
if (manual && snapshotRefreshTimer !== null) window.clearTimeout(snapshotRefreshTimer);
|
||||
if (manual) snapshotRefreshTimer = null;
|
||||
}
|
||||
|
||||
async function loadPage(): Promise<void> {
|
||||
const request = ++snapshotRequest;
|
||||
loadingPage.value = true;
|
||||
if (statusFilter.value === "unknown") {
|
||||
entities.value = {};
|
||||
orderedIds.value = [];
|
||||
total.value = 0;
|
||||
totalPages.value = 0;
|
||||
snapshotLoaded.value = true;
|
||||
loadingPage.value = false;
|
||||
return;
|
||||
}
|
||||
const scope = splitFilter(projectFilter.value);
|
||||
const agent = splitFilter(agentFilter.value);
|
||||
const result = await fetchAgentObserverRuns({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
status: statusQuery(statusFilter.value),
|
||||
projectId: scope.kind === "project" ? scope.value : undefined,
|
||||
workspace: scope.kind === "workspace" ? scope.value : undefined,
|
||||
backendProfile: agent.kind === "backend" ? agent.value : undefined,
|
||||
providerId: agent.kind === "provider" ? agent.value : undefined,
|
||||
search: search.value.trim() || undefined,
|
||||
updatedAfter: updatedAfterQuery(timeWindow.value, nowMs.value),
|
||||
sort: sort.value,
|
||||
});
|
||||
if (request !== snapshotRequest) return;
|
||||
loadingPage.value = false;
|
||||
if (!result.ok || !result.data) {
|
||||
error.value = result.error ?? "AgentRun 分页快照加载失败。";
|
||||
snapshotLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
const incoming = result.data.items
|
||||
.map((item) => snapshotRun(item, nonEmptyString(item.id) ? entities.value[nonEmptyString(item.id) as string] : undefined))
|
||||
.filter((item): item is AgentObserverRun => Boolean(item));
|
||||
const next: Record<string, AgentObserverRun> = {};
|
||||
for (const item of incoming) next[item.id] = item;
|
||||
entities.value = next;
|
||||
orderedIds.value = incoming.map((item) => item.id);
|
||||
total.value = result.data.total;
|
||||
totalPages.value = result.data.totalPages;
|
||||
const facets = asRecord(result.data.facets);
|
||||
facetProjects.value = stringArray(facets?.projects);
|
||||
facetWorkspaces.value = stringArray(facets?.workspaces);
|
||||
facetBackends.value = stringArray(facets?.backends);
|
||||
facetProviders.value = stringArray(facets?.providers);
|
||||
snapshotLoaded.value = true;
|
||||
lastEventAt.value = incoming.reduce<string | null>((latest, item) => latestTimestamp(latest, item.updatedAt), lastEventAt.value);
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
function refreshSnapshotSoon(): void {
|
||||
if (snapshotRefreshTimer !== null) return;
|
||||
snapshotRefreshTimer = window.setTimeout(() => {
|
||||
snapshotRefreshTimer = null;
|
||||
void loadPage();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function reconnect(): void {
|
||||
@@ -323,6 +388,7 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
}
|
||||
|
||||
const previous = entities.value[runId];
|
||||
if (!previous && page.value !== 1) return true;
|
||||
const sequenceIsCurrent = !previous
|
||||
|| (sourceSeq !== null && (previous.lastSourceSeq === null || sourceSeq > previous.lastSourceSeq));
|
||||
lastEventAt.value = latestTimestamp(lastEventAt.value, createdAt) ?? new Date().toISOString();
|
||||
@@ -332,6 +398,7 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
const runStatus = normalizeRunStatus(nonEmptyString(body?.runStatus), terminalStatus, previous?.runStatus);
|
||||
const next: AgentObserverRun = {
|
||||
id: runId,
|
||||
title: nonEmptyString(body?.title) ?? previous?.title ?? null,
|
||||
taskId: nonEmptyString(body?.taskId) ?? previous?.taskId ?? null,
|
||||
taskStatus: nonEmptyString(body?.taskStatus) ?? nonEmptyString(body?.taskState) ?? previous?.taskStatus ?? null,
|
||||
commandId: event.commandId ?? previous?.commandId ?? null,
|
||||
@@ -367,6 +434,7 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
entities.value = { ...entities.value, [runId]: next };
|
||||
orderedIds.value = [runId, ...orderedIds.value.filter((id) => id !== runId)];
|
||||
trimEntities();
|
||||
refreshSnapshotSoon();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -386,8 +454,9 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
}
|
||||
|
||||
function trimEntities(): void {
|
||||
if (!config.value || orderedIds.value.length <= config.value.reducerEntityLimit) return;
|
||||
const keep = orderedIds.value.slice(0, config.value.reducerEntityLimit);
|
||||
const limit = Math.min(config.value?.reducerEntityLimit ?? pageSize.value, pageSize.value);
|
||||
if (orderedIds.value.length <= limit) return;
|
||||
const keep = orderedIds.value.slice(0, limit);
|
||||
if (selectedRunId.value && !keep.includes(selectedRunId.value) && entities.value[selectedRunId.value]) keep[keep.length - 1] = selectedRunId.value;
|
||||
const next: Record<string, AgentObserverRun> = {};
|
||||
for (const id of keep) if (entities.value[id]) next[id] = entities.value[id];
|
||||
@@ -412,13 +481,80 @@ export const useAgentObserverStore = defineStore("agentObserver", () => {
|
||||
return {
|
||||
entities, orderedIds, events, runs, visibleRuns, selectedRun, selectedEvents, selectedLogs, selectedInEventWindow, selectedRunId,
|
||||
view, density, search, statusFilter, projectFilter, agentFilter, timeWindow, sort, phase, lastEventAt,
|
||||
page, pageSize, total, totalPages, snapshotLoaded, loadingPage,
|
||||
connectedAt, lastConfirmedEventId, retentionWindowAdvanced, error, nowMs, config, projects, scopeOptions, agents, stats, dataAgeMs, relationRows,
|
||||
relationWindowLimited: computed(() => relationWindow.value.limited), relationNodeCount: computed(() => relationWindow.value.nodeCount),
|
||||
relationOmittedRunCount: computed(() => relationWindow.value.omittedRunCount),
|
||||
connect, disconnect, reconnect, selectRun, toggleRelation
|
||||
connect, disconnect, reconnect, loadPage, selectRun, toggleRelation
|
||||
};
|
||||
});
|
||||
|
||||
function snapshotRun(value: Record<string, unknown>, previous?: AgentObserverRun): AgentObserverRun | null {
|
||||
const id = nonEmptyString(value.id);
|
||||
if (!id) return null;
|
||||
const runStatus = nonEmptyString(value.runStatus) ?? "unknown";
|
||||
const updatedAt = nonEmptyString(value.updatedAt);
|
||||
return {
|
||||
id,
|
||||
title: nonEmptyString(value.title),
|
||||
taskId: nonEmptyString(value.taskId),
|
||||
taskStatus: nonEmptyString(value.taskStatus) ?? runStatus,
|
||||
commandId: nonEmptyString(value.commandId),
|
||||
attemptId: nonEmptyString(value.attemptId),
|
||||
attemptStatus: nonEmptyString(value.attemptStatus) ?? runStatus,
|
||||
runnerId: nonEmptyString(value.runnerId),
|
||||
runnerCapacityUsed: integer(value.runnerCapacityUsed),
|
||||
runnerCapacityTotal: integer(value.runnerCapacityTotal),
|
||||
nodeId: nonEmptyString(value.nodeId),
|
||||
lane: nonEmptyString(value.lane),
|
||||
sessionId: nonEmptyString(value.sessionId),
|
||||
traceId: nonEmptyString(value.traceId),
|
||||
projectId: nonEmptyString(value.projectId),
|
||||
workspace: nonEmptyString(value.workspace),
|
||||
backend: nonEmptyString(value.backend),
|
||||
providerId: nonEmptyString(value.providerId),
|
||||
runStatus,
|
||||
commandState: nonEmptyString(value.commandState),
|
||||
commandType: nonEmptyString(value.commandType),
|
||||
phase: nonEmptyString(value.phase) ?? runStatus,
|
||||
result: nonEmptyString(value.result) ?? nonEmptyString(value.terminalStatus),
|
||||
replyAuthority: null,
|
||||
final: ["completed", "failed", "blocked", "cancelled"].includes(runStatus),
|
||||
errorCode: nonEmptyString(value.errorCode),
|
||||
lastMessage: nonEmptyString(value.lastMessage),
|
||||
startedAt: nonEmptyString(value.startedAt),
|
||||
finishedAt: nonEmptyString(value.finishedAt),
|
||||
updatedAt,
|
||||
lastSourceSeq: previous?.lastSourceSeq ?? null,
|
||||
eventIds: previous?.eventIds ?? [],
|
||||
changedAtMs: previous?.updatedAt === updatedAt ? previous.changedAtMs : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function splitFilter(value: string): { kind: string | null; value: string | null } {
|
||||
const separator = value.indexOf(":");
|
||||
if (value === "all" || separator < 1) return { kind: null, value: null };
|
||||
return { kind: value.slice(0, separator), value: value.slice(separator + 1) || null };
|
||||
}
|
||||
|
||||
function statusQuery(value: string): string | undefined {
|
||||
if (value === "running") return "claimed,running";
|
||||
if (value === "waiting") return "pending";
|
||||
if (value === "failed") return "failed,blocked";
|
||||
if (value === "terminal") return "completed,cancelled";
|
||||
if (value === "unknown") return "unknown";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function updatedAfterQuery(value: string, now: number): string | undefined {
|
||||
const duration = value === "15m" ? 15 * 60_000 : value === "1h" ? 60 * 60_000 : value === "6h" ? 6 * 60 * 60_000 : 0;
|
||||
return duration > 0 ? new Date(now - duration).toISOString() : undefined;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? unique(value.map(nonEmptyString)) : [];
|
||||
}
|
||||
|
||||
export function buildRelationWindow(
|
||||
runs: AgentObserverRun[],
|
||||
selectedRunId: string | null,
|
||||
@@ -589,7 +725,7 @@ function latestTimestamp(previous: string | null, next: string | null): string |
|
||||
|
||||
export function normalizeRunStatus(runStatus: string | null, terminalStatus: string | null, previous = "unknown"): string {
|
||||
if (terminalStatus) return terminalStatus === "cancelled" ? "canceled" : terminalStatus;
|
||||
if (["completed", "failed", "canceled", "cancelled"].includes(previous)) return previous;
|
||||
if (["completed", "failed", "blocked", "canceled", "cancelled"].includes(previous)) return previous;
|
||||
return runStatus ?? previous ?? "unknown";
|
||||
}
|
||||
|
||||
@@ -600,20 +736,20 @@ export function mergeMonotonicBoolean(previous: boolean | null | undefined, next
|
||||
|
||||
export function agentObserverStatusToken(status: string | null): AgentObserverStatusToken {
|
||||
const value = status?.trim().toLowerCase() || "unknown";
|
||||
if (["failed", "error", "rejected"].includes(value)) return { value, label: status || "未知", tone: "failed" };
|
||||
if (["failed", "blocked", "error", "rejected"].includes(value)) return { value, label: status || "未知", tone: "failed" };
|
||||
if (["completed", "succeeded"].includes(value)) return { value, label: status || "未知", tone: "completed" };
|
||||
if (["canceled", "cancelled"].includes(value)) return { value, label: status || "未知", tone: "canceled" };
|
||||
if (["queued", "pending", "waiting", "admitted"].includes(value)) return { value, label: status || "未知", tone: "pending" };
|
||||
if (["running", "active", "started", "in_progress"].includes(value)) return { value, label: status || "未知", tone: "running" };
|
||||
if (["claimed", "running", "active", "started", "in_progress"].includes(value)) return { value, label: status || "未知", tone: "running" };
|
||||
return { value, label: status || "未知", tone: "unknown" };
|
||||
}
|
||||
|
||||
function statusGroup(status: string): string {
|
||||
const value = status.toLowerCase();
|
||||
if (["failed", "error", "rejected"].includes(value)) return "failed";
|
||||
if (["failed", "blocked", "error", "rejected"].includes(value)) return "failed";
|
||||
if (["completed", "succeeded", "canceled", "cancelled"].includes(value)) return "terminal";
|
||||
if (["queued", "pending", "waiting", "admitted"].includes(value)) return "waiting";
|
||||
if (["running", "active", "started", "in_progress"].includes(value)) return "running";
|
||||
if (["claimed", "running", "active", "started", "in_progress"].includes(value)) return "running";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@@ -622,32 +758,10 @@ function stableEnvelopeIdentity(payload: unknown): string | null {
|
||||
return nonEmptyString(envelope?.eventId) ?? nonEmptyString(envelope?.sourceEventId);
|
||||
}
|
||||
|
||||
function compareRuns(left: AgentObserverRun, right: AgentObserverRun, sort: string): number {
|
||||
if (sort === "id-asc") return left.id.localeCompare(right.id);
|
||||
if (sort === "status-asc") return left.runStatus.localeCompare(right.runStatus) || left.id.localeCompare(right.id);
|
||||
const leftTime = Date.parse(left.updatedAt ?? left.startedAt ?? "") || 0;
|
||||
const rightTime = Date.parse(right.updatedAt ?? right.startedAt ?? "") || 0;
|
||||
return sort === "updated-asc" ? leftTime - rightTime : rightTime - leftTime;
|
||||
}
|
||||
|
||||
function unique(values: Array<string | null>): string[] {
|
||||
return [...new Set(values.filter((value): value is string => Boolean(value)))].sort();
|
||||
}
|
||||
|
||||
function matchesScopeFilter(run: AgentObserverRun, filter: string): boolean {
|
||||
if (filter.startsWith("project:")) return run.projectId === filter.slice("project:".length);
|
||||
if (filter.startsWith("workspace:")) return run.workspace === filter.slice("workspace:".length);
|
||||
return run.projectId === filter;
|
||||
}
|
||||
|
||||
function insideTimeWindow(value: string | null, window: string, nowMs: number): boolean {
|
||||
if (window === "all") return true;
|
||||
const duration = window === "15m" ? 15 * 60_000 : window === "1h" ? 60 * 60_000 : window === "6h" ? 6 * 60 * 60_000 : 0;
|
||||
if (!duration || !value) return false;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) && timestamp >= nowMs - duration;
|
||||
}
|
||||
|
||||
function explicitBoolean(value: unknown): boolean | null {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
|
||||
.agent-observer-workspace { grid-row: -2 / -1; 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-content { min-width: 0; min-height: 0; overflow: hidden; background: var(--console-surface); display: grid; grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.agent-observer-content > .table-pagination { border-top: 1px solid var(--console-border); 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: 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; }
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "lucide-vue-next";
|
||||
import AgentRunInspector from "@/components/agents/AgentRunInspector.vue";
|
||||
import BaseDrawer from "@/components/common/BaseDrawer.vue";
|
||||
import Pagination from "@/components/common/Pagination.vue";
|
||||
import StatusBadge from "@/components/common/StatusBadge.vue";
|
||||
import VirtualGrid from "@/components/common/VirtualGrid.vue";
|
||||
import VirtualList from "@/components/common/VirtualList.vue";
|
||||
@@ -49,7 +50,7 @@ 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 loading = computed(() => observer.loadingPage && !observer.snapshotLoaded);
|
||||
const statusItems = computed<ConsoleStatusItem[]>(() => [
|
||||
{ id: "running", label: "运行中", value: observer.stats.running, tone: "info" },
|
||||
{ id: "waiting", label: "等待", value: observer.stats.waiting, tone: "warn" },
|
||||
@@ -66,6 +67,7 @@ const statusItems = computed<ConsoleStatusItem[]>(() => [
|
||||
{ id: "total", label: "筛选结果", value: observer.stats.total },
|
||||
]);
|
||||
let inspectorMedia: MediaQueryList | null = null;
|
||||
let filterTimer: number | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
applyRouteState();
|
||||
@@ -77,14 +79,27 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer.disconnect();
|
||||
if (filterTimer !== null) window.clearTimeout(filterTimer);
|
||||
inspectorMedia?.removeEventListener("change", updateInspectorMode);
|
||||
});
|
||||
|
||||
watch(() => route.fullPath, applyRouteState);
|
||||
watch(
|
||||
() => [observer.view, observer.density, observer.search, observer.statusFilter, observer.projectFilter, observer.agentFilter, observer.timeWindow, observer.sort, observer.selectedRunId],
|
||||
() => [observer.view, observer.density, observer.search, observer.statusFilter, observer.projectFilter, observer.agentFilter, observer.timeWindow, observer.sort, observer.page, observer.selectedRunId],
|
||||
() => { if (!urlApplying.value) void syncUrl(); },
|
||||
);
|
||||
watch(
|
||||
() => [observer.search, observer.statusFilter, observer.projectFilter, observer.agentFilter, observer.timeWindow, observer.sort],
|
||||
() => {
|
||||
if (urlApplying.value) return;
|
||||
const pageChanged = observer.page !== 1;
|
||||
observer.page = 1;
|
||||
if (pageChanged) return;
|
||||
if (filterTimer !== null) window.clearTimeout(filterTimer);
|
||||
filterTimer = window.setTimeout(() => { filterTimer = null; void observer.loadPage(); }, 250);
|
||||
},
|
||||
);
|
||||
watch(() => observer.page, () => { if (!urlApplying.value) void observer.loadPage(); });
|
||||
watch(treeRows, (rows) => {
|
||||
if (rows.length === 0) activeRelationId.value = null;
|
||||
else if (!activeRelationId.value || !rows.some((row) => row.id === activeRelationId.value)) activeRelationId.value = rows[0]?.id ?? null;
|
||||
@@ -104,6 +119,7 @@ function applyRouteState(): void {
|
||||
observer.agentFilter = textQuery(route.query.agent) || "all";
|
||||
observer.timeWindow = textQuery(route.query.window) || "all";
|
||||
observer.sort = textQuery(route.query.sort) || "updated-desc";
|
||||
observer.page = positivePage(route.query.page);
|
||||
observer.selectRun(typeof route.params.runId === "string" ? route.params.runId : null);
|
||||
queueMicrotask(() => { urlApplying.value = false; });
|
||||
}
|
||||
@@ -118,6 +134,7 @@ async function syncUrl(): Promise<void> {
|
||||
agent: observer.agentFilter === "all" ? "" : observer.agentFilter,
|
||||
window: observer.timeWindow === "all" ? "" : observer.timeWindow,
|
||||
sort: observer.sort === "updated-desc" ? "" : observer.sort,
|
||||
page: observer.page > 1 ? String(observer.page) : "",
|
||||
});
|
||||
await router.replace(observer.selectedRunId
|
||||
? { name: "AgentRunDetail", params: { runId: observer.selectedRunId }, query }
|
||||
@@ -201,11 +218,12 @@ function formatAge(value: number | null): string { if (value === null || !Number
|
||||
function parseView(value: unknown): AgentObserverView { return value === "cards" || value === "tree" ? value : "table"; }
|
||||
function textQuery(value: unknown): string { return typeof value === "string" ? value : ""; }
|
||||
function compactQuery(value: Record<string, string>): Record<string, string> { return Object.fromEntries(Object.entries(value).filter(([, item]) => item)); }
|
||||
function positivePage(value: unknown): number { const page = Number(value); return Number.isInteger(page) && page > 0 ? page : 1; }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="agent-observer-page" :data-density="observer.density">
|
||||
<PageCommandBar eyebrow="智能体管理" title="AgentRun 运行观察" description="纯 Kafka retention replay → handoff → live;三视图与 Inspector 共享同一 orderedIds、筛选、排序和选择。" sticky>
|
||||
<PageCommandBar eyebrow="智能体管理" title="AgentRun 运行观察" description="AgentRun 权威分页快照 → Kafka live;三视图与 Inspector 共享同一筛选、排序和选择。" 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>
|
||||
@@ -218,7 +236,7 @@ function compactQuery(value: Record<string, string>): Record<string, string> { r
|
||||
<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.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.agentFilter" aria-label="Agent 或 Profile"><option value="all">全部 Agent / Profile</option><option v-for="item in observer.agents" :key="item.value" :value="item.value">{{ item.label }}</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>
|
||||
@@ -231,17 +249,17 @@ function compactQuery(value: Record<string, string>): Record<string, string> { r
|
||||
<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>Agent / Profile<select v-model="observer.agentFilter"><option value="all">全部</option><option v-for="item in observer.agents" :key="item.value" :value="item.value">{{ item.label }}</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>
|
||||
|
||||
<BaseDrawer :open="helpOpen" title="AgentRun 观察帮助" description="本页只有 Kafka 事件投影这一条 authority。" surface-class="agent-observer-help-drawer" @close="helpOpen = false">
|
||||
<BaseDrawer :open="helpOpen" title="AgentRun 观察帮助" description="列表读取 AgentRun 权威快照,实时活动来自 Kafka。" 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>
|
||||
<p>列表先读取 AgentRun durable store 的分页快照,再连接 Kafka live 流;实时事件会触发当前页轻量刷新。</p>
|
||||
<h3>三种视图</h3>
|
||||
<p>表格用于高密度扫描,卡片用于并行状态辨识,关系树用于 Task → Run → Command/Attempt 追踪。三者共享筛选、排序、选择、URL 和 Inspector 页签。</p>
|
||||
<h3>窗口提示</h3>
|
||||
@@ -261,13 +279,13 @@ function compactQuery(value: Record<string, string>): Record<string, string> { r
|
||||
<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">状态</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 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>
|
||||
<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 :title="run.title || run.id">{{ run.title || shortId(run.id) }}</strong><small class="mono">R {{ shortId(run.id) }} · 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-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>
|
||||
<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 :title="run.title || run.id">{{ run.title || shortId(run.id) }}</h2><p class="mono" :title="[run.id, run.taskId, run.commandId, run.sessionId].filter(Boolean).join(' · ')">R {{ shortId(run.id) }} · T {{ shortId(run.taskId) }} · C {{ shortId(run.commandId) }}</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>
|
||||
|
||||
@@ -277,6 +295,7 @@ function compactQuery(value: Record<string, string>): Record<string, string> { r
|
||||
<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>
|
||||
<Pagination v-if="observer.total > 0" v-model:page="observer.page" :page-size="observer.pageSize" :total="observer.total" />
|
||||
</main>
|
||||
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user