This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 22cd775 ui topbar: replace breadcrumb+search with framework-event
ticker
22cd775 is described below
commit 22cd7754f4aeb9d81a4073d8dad4cd4c5f108c55
Author: Wu Sheng <[email protected]>
AuthorDate: Sun May 17 11:37:11 2026 +0800
ui topbar: replace breadcrumb+search with framework-event ticker
Removes the inert breadcrumb + unimplemented search from the left
half of the topbar and replaces them with a one-line event ticker
that streams what the SPA is actually doing right now: route
changed, services loading, instances loaded (84ms), dashboard
querying, etc. Click the caret to expand the per-page event
history.
Plumbing:
- `controls/eventLog.ts` — module-scoped event store with
start/ok/err/info kinds. Start events are paired by topic so the
matching ok/err computes duration; repeated starts on the same
topic (refetch) supersede the prior open entry rather than
stacking up.
- `shell/EventTicker.vue` — single-line ticker + expand popover,
goes in the left half of the topbar.
- Router `afterEach` clears the log and posts "Navigated to X" on
every successful navigation so the ticker resets per page.
- `useLayerLanding` / `useLayerInstances` / `useLayerEndpoints` /
`useLayerDashboard` now wrap their queryFn with start/ok/err
pushEvent calls — operator sees "Loading services for
so11y_java_agent…" → "Loaded 2 services" → "Querying instance
dashboard for agent::gateway / a0da…" → "Rendered 3/7 widgets
with data".
- OAP chip drops the version text (per request) and keeps the
status dot + TZ label.
---
apps/ui/src/controls/eventLog.ts | 117 ++++++++++++++
apps/ui/src/layer/useLayerEndpoints.ts | 16 +-
apps/ui/src/layer/useLayerInstances.ts | 14 +-
apps/ui/src/layer/useLayerLanding.ts | 14 +-
.../render/layer-dashboard/useLayerDashboard.ts | 47 ++++--
apps/ui/src/shell/AppTopbar.vue | 85 +---------
apps/ui/src/shell/EventTicker.vue | 179 +++++++++++++++++++++
apps/ui/src/shell/router/index.ts | 11 ++
8 files changed, 391 insertions(+), 92 deletions(-)
diff --git a/apps/ui/src/controls/eventLog.ts b/apps/ui/src/controls/eventLog.ts
new file mode 100644
index 0000000..6b178e3
--- /dev/null
+++ b/apps/ui/src/controls/eventLog.ts
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Page-loading event log — a single shared store of "what the framework
+ * is doing right now". Composables (data loaders, route guards,
+ * widgets) push events; the topbar's EventTicker reads them and shows
+ * the latest line plus an expandable history.
+ *
+ * Each event carries a `topic` (the unit of work — `"services"`,
+ * `"dashboard"`, …) so paired start/end events are matched on the
+ * topic and the ticker can de-duplicate (a refetch on the same topic
+ * replaces the prior open entry rather than stacking up forever).
+ *
+ * No Pinia dep on purpose — the store is a tiny module-scoped ref +
+ * helpers so composables can `pushEvent(...)` without paying the
+ * pinia ceremony for a glorified queue.
+ */
+import { computed, ref, type ComputedRef, type Ref } from 'vue';
+
+export type EventKind = 'start' | 'ok' | 'err' | 'info';
+export interface FrameworkEvent {
+ /** Monotonic id — lets the UI key v-for stably + diff highlights. */
+ id: number;
+ /** Logical work unit, e.g. `"services"`, `"instances"`, `"dashboard"`. */
+ topic: string;
+ /** Severity / phase. `start` is the "doing X…" line; the matching
+ * `ok` / `err` resolves it. `info` is a fire-and-forget note. */
+ kind: EventKind;
+ /** Display text — kept short, suitable for a one-line ticker. */
+ text: string;
+ /** Wall-clock for the history pop-over. */
+ ts: number;
+ /** Optional duration (ms) — set on `ok`/`err` when there was a
+ * matching `start`; the ticker shows it as `· 123ms`. */
+ durationMs?: number;
+}
+
+const HISTORY_CAP = 50;
+const events = ref<FrameworkEvent[]>([]);
+let nextId = 1;
+/** Open `start` events keyed by topic — used to compute duration when
+ * the matching `ok` / `err` arrives, and to dedupe repeated starts
+ * on the same topic (a Vue Query refetch shouldn't stack a new
+ * "Loading services…" each tick). */
+const openStarts = new Map<string, FrameworkEvent>();
+
+function append(ev: FrameworkEvent): void {
+ events.value = [...events.value, ev].slice(-HISTORY_CAP);
+}
+
+/**
+ * Push a new event. Convenience signature: `pushEvent(topic, kind, text)`.
+ * For `start` the topic is recorded so the later `ok`/`err` on the same
+ * topic can compute duration. For repeated `start`s on the same topic
+ * the previous open entry is dropped from the visible log — the new
+ * one supersedes it.
+ */
+export function pushEvent(topic: string, kind: EventKind, text: string): void {
+ if (kind === 'start') {
+ const prior = openStarts.get(topic);
+ if (prior) {
+ events.value = events.value.filter((e) => e.id !== prior.id);
+ }
+ const ev: FrameworkEvent = { id: nextId++, topic, kind, text, ts:
Date.now() };
+ openStarts.set(topic, ev);
+ append(ev);
+ return;
+ }
+ if (kind === 'ok' || kind === 'err') {
+ const open = openStarts.get(topic);
+ const durationMs = open ? Date.now() - open.ts : undefined;
+ openStarts.delete(topic);
+ const ev: FrameworkEvent = { id: nextId++, topic, kind, text, ts:
Date.now(), durationMs };
+ append(ev);
+ return;
+ }
+ append({ id: nextId++, topic, kind, text, ts: Date.now() });
+}
+
+/** Drop every recorded event — call on route navigation so the
+ * ticker doesn't show stale events from the previous page. */
+export function resetEventLog(): void {
+ events.value = [];
+ openStarts.clear();
+}
+
+/**
+ * Read-only handle for components. `latest` is the head of the queue
+ * (the most recent event — what the ticker shows) and `all` is the
+ * full bounded history for the expand pop-over.
+ */
+export function useEventLog(): {
+ all: Ref<FrameworkEvent[]>;
+ latest: ComputedRef<FrameworkEvent | null>;
+} {
+ return {
+ all: events,
+ latest: computed<FrameworkEvent | null>(() =>
+ events.value.length > 0 ? events.value[events.value.length - 1] : null,
+ ),
+ };
+}
diff --git a/apps/ui/src/layer/useLayerEndpoints.ts
b/apps/ui/src/layer/useLayerEndpoints.ts
index 6315c4c..cc221f2 100644
--- a/apps/ui/src/layer/useLayerEndpoints.ts
+++ b/apps/ui/src/layer/useLayerEndpoints.ts
@@ -26,6 +26,7 @@
import { computed, type Ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { bffClient } from '@/api/client';
+import { pushEvent } from '@/controls/eventLog';
export function useLayerEndpoints(
layerKey: Ref<string>,
@@ -35,8 +36,19 @@ export function useLayerEndpoints(
) {
const q = useQuery({
queryKey: ['layer-endpoints', layerKey, service, query, limit],
- queryFn: () =>
- bffClient.layer.endpoints(layerKey.value, service.value ?? '',
query.value, limit.value),
+ queryFn: async () => {
+ const label = query.value ? `"${query.value}"` : 'top';
+ pushEvent('endpoints', 'start', `Loading endpoints (${label}) for
${service.value}…`);
+ try {
+ const r = await bffClient.layer.endpoints(layerKey.value,
service.value ?? '', query.value, limit.value);
+ const n = r.endpoints?.length ?? 0;
+ pushEvent('endpoints', 'ok', `Loaded ${n} endpoint${n === 1 ? '' :
's'}`);
+ return r;
+ } catch (err) {
+ pushEvent('endpoints', 'err', `Endpoint list failed: ${err instanceof
Error ? err.message : String(err)}`);
+ throw err;
+ }
+ },
enabled: computed(() => layerKey.value.length > 0 && !!service.value),
staleTime: 30_000,
});
diff --git a/apps/ui/src/layer/useLayerInstances.ts
b/apps/ui/src/layer/useLayerInstances.ts
index 2f7977a..2e1e3c2 100644
--- a/apps/ui/src/layer/useLayerInstances.ts
+++ b/apps/ui/src/layer/useLayerInstances.ts
@@ -25,11 +25,23 @@
import { computed, type Ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { bffClient } from '@/api/client';
+import { pushEvent } from '@/controls/eventLog';
export function useLayerInstances(layerKey: Ref<string>, service: Ref<string |
null>) {
const q = useQuery({
queryKey: ['layer-instances', layerKey, service],
- queryFn: () => bffClient.layer.instances(layerKey.value, service.value ??
''),
+ queryFn: async () => {
+ pushEvent('instances', 'start', `Loading instances for
${service.value}…`);
+ try {
+ const r = await bffClient.layer.instances(layerKey.value,
service.value ?? '');
+ const n = r.instances?.length ?? 0;
+ pushEvent('instances', 'ok', `Loaded ${n} instance${n === 1 ? '' :
's'}`);
+ return r;
+ } catch (err) {
+ pushEvent('instances', 'err', `Instance list failed: ${err instanceof
Error ? err.message : String(err)}`);
+ throw err;
+ }
+ },
enabled: computed(() => layerKey.value.length > 0 && !!service.value),
staleTime: 30_000,
});
diff --git a/apps/ui/src/layer/useLayerLanding.ts
b/apps/ui/src/layer/useLayerLanding.ts
index b22b199..f2d71b2 100644
--- a/apps/ui/src/layer/useLayerLanding.ts
+++ b/apps/ui/src/layer/useLayerLanding.ts
@@ -18,6 +18,7 @@
import { computed, type Ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
import { useAutoRefreshSubscribe } from '../controls/useAutoRefreshSubscribe';
+import { pushEvent } from '@/controls/eventLog';
import type { LandingConfig, LandingResponse, LayerDef } from
'@skywalking-horizon-ui/api-client';
import { bffClient } from '@/api/client';
@@ -48,7 +49,18 @@ export function useLayerLanding(
const q = useQuery({
queryKey: ['layer-landing', layerKey, cfgHash],
- queryFn: () => bffClient.layer.landing(layerKey.value, cfg.value),
+ queryFn: async () => {
+ pushEvent('services', 'start', `Loading services for
${layerKey.value}…`);
+ try {
+ const r = await bffClient.layer.landing(layerKey.value, cfg.value);
+ const count = (r.sampledRows ?? r.rows ?? []).length;
+ pushEvent('services', 'ok', `Loaded ${count} service${count === 1 ? ''
: 's'} for ${layerKey.value}`);
+ return r;
+ } catch (err) {
+ pushEvent('services', 'err', `Service list failed: ${err instanceof
Error ? err.message : String(err)}`);
+ throw err;
+ }
+ },
staleTime: 45_000,
refetchInterval: 60_000,
refetchOnWindowFocus: true,
diff --git a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
index 0c6f449..4c6ea38 100644
--- a/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
+++ b/apps/ui/src/render/layer-dashboard/useLayerDashboard.ts
@@ -29,6 +29,7 @@
import { computed, type Ref } from 'vue';
import { useQuery } from '@tanstack/vue-query';
+import { pushEvent } from '@/controls/eventLog';
import { useAutoRefreshSubscribe } from
'../../controls/useAutoRefreshSubscribe';
import { bffClient } from '@/api/client';
@@ -88,17 +89,41 @@ export function useLayerDashboard(
entityRefs.instance ?? computed(() => null),
entityRefs.endpoint ?? computed(() => null),
],
- queryFn: () =>
- bffClient.layer.dashboard(
- layerKey.value,
- {
- ...(service.value ? { service: service.value } : {}),
- ...(scope?.value ? { scope: scope.value } : {}),
- ...(entityRefs.instance?.value ? { serviceInstance:
entityRefs.instance.value } : {}),
- ...(entityRefs.endpoint?.value ? { endpoint:
entityRefs.endpoint.value } : {}),
- },
- mockTop?.value ? { mockTop: mockTop.value } : {},
- ),
+ queryFn: async () => {
+ const s = scope?.value ?? 'service';
+ const label = entityRefs.instance?.value
+ ? `${service.value} / ${entityRefs.instance.value}`
+ : entityRefs.endpoint?.value
+ ? `${service.value} / ${entityRefs.endpoint.value}`
+ : service.value ?? layerKey.value;
+ pushEvent('dashboard', 'start', `Querying ${s} dashboard for ${label}…`);
+ try {
+ const r = await bffClient.layer.dashboard(
+ layerKey.value,
+ {
+ ...(service.value ? { service: service.value } : {}),
+ ...(scope?.value ? { scope: scope.value } : {}),
+ ...(entityRefs.instance?.value ? { serviceInstance:
entityRefs.instance.value } : {}),
+ ...(entityRefs.endpoint?.value ? { endpoint:
entityRefs.endpoint.value } : {}),
+ },
+ mockTop?.value ? { mockTop: mockTop.value } : {},
+ );
+ const total = r.widgets?.length ?? 0;
+ const withData = (r.widgets ?? []).filter(
+ (w) =>
+ !w.error &&
+ (w.value != null ||
+ (w.series?.length ?? 0) > 0 ||
+ (w.topList?.length ?? 0) > 0 ||
+ (w.topGroups?.length ?? 0) > 0),
+ ).length;
+ pushEvent('dashboard', 'ok', `Rendered ${withData}/${total}
widget${total === 1 ? '' : 's'} with data`);
+ return r;
+ } catch (err) {
+ pushEvent('dashboard', 'err', `Dashboard query failed: ${err
instanceof Error ? err.message : String(err)}`);
+ throw err;
+ }
+ },
// Gate the metric query on the entity actually being resolved.
// Otherwise the dashboard fires twice on landing: once with
// `service: null` (BFF then auto-picks the first service by
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 6db97c3..b0173fe 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -18,75 +18,14 @@
import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import Icon from '@/components/icons/Icon.vue';
+import EventTicker from '@/shell/EventTicker.vue';
import { useOapInfo } from '@/shell/useOapInfo';
-import { useLayers } from '@/shell/useLayers';
import { useAutoRefreshStore } from '@/controls/autoRefresh';
import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type
TimeStep } from '@/controls/timeRange';
const route = useRoute();
-const { layers } = useLayers();
-/**
- * Breadcrumb derived from the route path PLUS the layer's display
- * config so the trail reads the same as the sidebar. For
- * `/layer/<key>/<scope>` we:
- * - Replace the layer key with its alias (`activemq` → `ActiveMQ`).
- * - Replace the scope segment with the layer's slot alias when one
- * exists (`instance` → `Brokers` for ActiveMQ, `Sidecars` for
- * mesh_dp, `Pages` for browser, …). Falls back to the
- * capitalized URL segment when no alias applies.
- *
- * The mapping lives here (and not in the route definition) because
- * the layer JSON is the source of truth for the operator-facing
- * terms; the route segments stay in the canonical `instance` /
- * `endpoint` / etc. shape for back-compat with bookmarks.
- */
-const SCOPE_SLOT_KEY: Record<string, 'instances' | 'endpoints' | 'services' |
'endpointDependency'> = {
- instance: 'instances',
- endpoint: 'endpoints',
- service: 'services',
- dependency: 'endpointDependency',
-};
-const SCOPE_LITERAL: Record<string, string> = {
- topology: 'Topology',
- trace: 'Traces',
- logs: 'Logs',
- 'trace-profiling': 'Trace Profiling',
- 'ebpf-profiling': 'eBPF Profiling',
- 'async-profiling': 'Async Profiling',
- 'network-profiling': 'Network Profiling',
- pprof: 'pprof (Go)',
-};
-const crumbs = computed<string[]>(() => {
- const segs = route.path.split('/').filter(Boolean);
- if (segs.length === 0) return ['Home'];
- // Layer-aware path: `/layer/<key>/<scope?>/...`
- if (segs[0] === 'layer' && segs[1]) {
- const layerKey = segs[1];
- const layer = layers.value.find((l) => l.key === layerKey);
- const out: string[] = [layer?.name ?? layerKey.replace(/-/g, '
').replace(/^./, (c) => c.toUpperCase())];
- for (let i = 2; i < segs.length; i++) {
- const seg = segs[i];
- // Slot alias (services/instances/endpoints/dependency).
- const slotKey = SCOPE_SLOT_KEY[seg];
- if (slotKey && layer?.slots?.[slotKey]) {
- out.push(String(layer.slots[slotKey]));
- continue;
- }
- // Known literal scope (topology / trace / logs / profilings).
- if (SCOPE_LITERAL[seg]) {
- out.push(SCOPE_LITERAL[seg]);
- continue;
- }
- // Fallback: capitalize the segment.
- out.push(seg.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()));
- }
- return out;
- }
- return segs.map((s) => s.replace(/-/g, ' ').replace(/^./, (c) =>
c.toUpperCase()));
-});
-
-const { info, reachable, version, tzOffsetLabel, healthState } = useOapInfo();
+const { info, reachable, tzOffsetLabel, healthState } = useOapInfo();
const oapChipTooltip = computed<string>(() => {
if (!info.value) return 'OAP status — loading…';
@@ -386,23 +325,15 @@ function formatRangeStamp(ms: number, step: TimeStep):
string {
<template>
<header class="sw-top">
- <div class="sw-crumbs">
- <template v-for="(c, i) in crumbs" :key="i">
- <Icon v-if="i > 0" name="chev" :size="10" />
- <b v-if="i === crumbs.length - 1">{{ c }}</b>
- <span v-else>{{ c }}</span>
- </template>
- </div>
- <div class="sw-top-search">
- <Icon name="search" :size="12" />
- <span>Search services, endpoints, traceId…</span>
- <kbd>⌘K</kbd>
- </div>
+ <!-- Event ticker replaces the old breadcrumb + search. Shows what
+ the framework is currently doing (loading services, rendering
+ widgets, …) so the operator gets feedback while the page
+ assembles instead of looking at a static breadcrumb. -->
+ <EventTicker />
<div class="sw-top-actions">
<RouterLink class="sw-btn oap-chip" :class="`is-${healthState}`"
:title="oapChipTooltip" to="/operate/cluster">
<span class="dot" />
- <span v-if="reachable && version" class="ver">v{{ version }}</span>
- <span v-else-if="reachable" class="ver">OAP</span>
+ <span v-if="reachable" class="ver">OAP</span>
<span v-else class="ver">offline</span>
<span v-if="reachable && tzOffsetLabel" class="tz" :class="{ mismatch:
tzMismatch }">
{{ tzOffsetLabel }}
diff --git a/apps/ui/src/shell/EventTicker.vue
b/apps/ui/src/shell/EventTicker.vue
new file mode 100644
index 0000000..2f635f2
--- /dev/null
+++ b/apps/ui/src/shell/EventTicker.vue
@@ -0,0 +1,179 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Single-line event ticker that lives in the left half of the topbar.
+ Shows the latest framework event ("Loading services…", "✓ services
+ ready · 84ms") so the operator can see what the SPA is actually
+ doing while a page assembles. Click the ▾ caret to expand the full
+ history of the current page's events; resets on every route change
+ via the eventLog store.
+-->
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useEventLog } from '@/controls/eventLog';
+
+const { latest, all } = useEventLog();
+const open = ref(false);
+function toggle(): void {
+ open.value = !open.value;
+}
+
+function fmtTime(ts: number): string {
+ const d = new Date(ts);
+ return `${String(d.getHours()).padStart(2,
'0')}:${String(d.getMinutes()).padStart(2,
'0')}:${String(d.getSeconds()).padStart(2, '0')}`;
+}
+function kindGlyph(k: 'start' | 'ok' | 'err' | 'info'): string {
+ return k === 'ok' ? '✓' : k === 'err' ? '✕' : k === 'start' ? '…' : '·';
+}
+
+const latestText = computed<string>(() => {
+ const e = latest.value;
+ if (!e) return 'idle';
+ const dur = e.durationMs != null ? ` · ${e.durationMs}ms` : '';
+ return `${kindGlyph(e.kind)} ${e.text}${dur}`;
+});
+const latestKind = computed<string>(() => latest.value?.kind ?? 'info');
+const eventCount = computed<number>(() => all.value.length);
+</script>
+
+<template>
+ <div class="ev-zone" :class="['ev-kind-' + latestKind, { open }]">
+ <button class="ev-line" type="button" @click="toggle"
:aria-expanded="open">
+ <span class="ev-text">{{ latestText }}</span>
+ <span class="ev-count" v-if="eventCount > 0">({{ eventCount }})</span>
+ <span class="ev-caret" :class="{ open }">▾</span>
+ </button>
+ <div v-if="open" class="ev-popover">
+ <div v-if="all.length === 0" class="ev-empty">no events yet</div>
+ <ol v-else class="ev-list">
+ <li
+ v-for="e in [...all].reverse()"
+ :key="e.id"
+ :class="'ev-row ev-kind-' + e.kind"
+ >
+ <span class="ev-row-time">{{ fmtTime(e.ts) }}</span>
+ <span class="ev-row-glyph">{{ kindGlyph(e.kind) }}</span>
+ <span class="ev-row-topic">{{ e.topic }}</span>
+ <span class="ev-row-text">{{ e.text }}</span>
+ <span v-if="e.durationMs != null" class="ev-row-dur">{{ e.durationMs
}}ms</span>
+ </li>
+ </ol>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.ev-zone {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ font-variant-numeric: tabular-nums;
+}
+.ev-line {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ height: 28px;
+ padding: 0 10px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line);
+ border-radius: 4px;
+ color: var(--sw-fg-1);
+ font: inherit;
+ font-size: 11.5px;
+ cursor: pointer;
+ text-align: left;
+ overflow: hidden;
+}
+.ev-line:hover {
+ border-color: var(--sw-line-2);
+ background: var(--sw-bg-1);
+}
+.ev-text {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--sw-mono);
+}
+.ev-count {
+ color: var(--sw-fg-3);
+ font-size: 10.5px;
+}
+.ev-caret {
+ color: var(--sw-fg-3);
+ font-size: 10px;
+ transition: transform 0.12s;
+}
+.ev-caret.open {
+ transform: rotate(180deg);
+}
+/* Kind tint applies to the latest line so the operator can read state
+ * at a glance — error red, success green-ish, in-flight neutral. */
+.ev-kind-start .ev-text { color: var(--sw-accent-2); }
+.ev-kind-ok .ev-text { color: var(--sw-ok); }
+.ev-kind-err .ev-text { color: var(--sw-err); }
+.ev-kind-info .ev-text { color: var(--sw-fg-2); }
+
+.ev-popover {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ max-height: 360px;
+ overflow-y: auto;
+ z-index: 50;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 4px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+}
+.ev-empty {
+ padding: 12px;
+ font-size: 11px;
+ color: var(--sw-fg-3);
+ text-align: center;
+}
+.ev-list {
+ list-style: none;
+ margin: 0;
+ padding: 4px 0;
+}
+.ev-row {
+ display: grid;
+ grid-template-columns: auto auto auto 1fr auto;
+ align-items: baseline;
+ gap: 8px;
+ padding: 4px 10px;
+ font-size: 11px;
+ font-family: var(--sw-mono);
+ color: var(--sw-fg-1);
+ border-bottom: 1px solid var(--sw-line);
+}
+.ev-row:last-child { border-bottom: none; }
+.ev-row-time { color: var(--sw-fg-3); font-size: 10px; }
+.ev-row-glyph { color: var(--sw-fg-2); width: 12px; text-align: center; }
+.ev-row-topic { color: var(--sw-fg-3); font-size: 10px; text-transform:
uppercase; letter-spacing: 0.04em; }
+.ev-row-text { color: var(--sw-fg-1); overflow: hidden; text-overflow:
ellipsis; }
+.ev-row-dur { color: var(--sw-fg-3); font-size: 10px; }
+.ev-row.ev-kind-ok .ev-row-glyph { color: var(--sw-ok); }
+.ev-row.ev-kind-err .ev-row-glyph { color: var(--sw-err); }
+.ev-row.ev-kind-err .ev-row-text { color: var(--sw-err); }
+.ev-row.ev-kind-start .ev-row-glyph { color: var(--sw-accent-2); }
+</style>
diff --git a/apps/ui/src/shell/router/index.ts
b/apps/ui/src/shell/router/index.ts
index 7a36580..d292467 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -16,6 +16,7 @@
*/
import { createRouter, createWebHistory, type RouteRecordRaw } from
'vue-router';
import { useAuthStore } from '@/state/auth';
+import { pushEvent, resetEventLog } from '@/controls/eventLog';
const placeholder = () => import('@/shell/PlaceholderView.vue');
@@ -236,4 +237,14 @@ router.beforeEach(async (to) => {
}
});
+// Every successful navigation clears the event log + posts a single
+// "Navigated to X" line so the topbar EventTicker shows what page the
+// operator just opened. Subsequent data loaders push their own start /
+// ok / err events on top of this baseline.
+router.afterEach((to, from) => {
+ if (to.path === from.path) return;
+ resetEventLog();
+ pushEvent('route', 'info', `Navigated to ${to.path}`);
+});
+
export default router;