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&hellip;</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;

Reply via email to