Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,15 @@ graph LR
analytics_token_drain_graphs --> analytics_default_landing

classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff;
classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff;
classDef in_review fill:#9a6700,stroke:#5c3d00,color:#ffffff;
class analytics_token_drain_graphs done;
class analytics_default_landing todo;
class analytics_default_landing in_review;
```

| Task | Status | Refs |
| --- | --- | --- |
| Per-server / per-tool token-drain graphs | 🟢 Done | — |
| Make dashboard the default landing page | ⚪ Todo | — |
| Make dashboard the default landing page | 🟡 In review | — |

</details>

Expand Down Expand Up @@ -804,3 +804,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [098-tools-preflight](./specs/098-tools-preflight/) | `in-flight` | 26/33 (79%) |
| [099-describe-check-mode](./specs/099-describe-check-mode/) | `in-flight` | 9/10 (90%) |
| [100-prompt-rugpull-baseline](./specs/100-prompt-rugpull-baseline/) | — | — |
| [102-schema-deferred](./specs/102-schema-deferred/) | — | — |
6 changes: 5 additions & 1 deletion frontend/src/components/SidebarNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -655,9 +655,13 @@ const userInitials = computed(() => {
return name.substring(0, 2).toUpperCase()
})

// Dashboard panels are deep-linkable routes that all render the Dashboard, so
// the "Dashboard" entry stays highlighted on each of them.
const DASHBOARD_PATHS = ['/', '/usage', '/overview']

function isActiveRoute(path: string): boolean {
if (path === '/') {
return route.path === '/'
return DASHBOARD_PATHS.includes(route.path)
}
return route.path.startsWith(path)
}
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,37 @@ const router = createRouter({
meta: { title: 'Sign In', public: true },
},
// Existing routes (admin/personal)
//
// The landing page (`/`) opens the Dashboard on its Usage (analytics)
// panel — that is the "analytics dashboard as default landing page"
// behaviour. `/usage` and `/overview` render the same Dashboard component
// so each panel is deep-linkable and survives a reload; `meta.dashboardView`
// is what the component reads to pick the active panel.
{
path: '/',
name: 'dashboard',
component: Dashboard,
meta: {
title: 'Dashboard',
dashboardView: 'usage',
},
},
{
path: '/usage',
name: 'usage',
component: Dashboard,
meta: {
title: 'Usage Analytics',
dashboardView: 'usage',
},
},
{
path: '/overview',
name: 'dashboard-overview',
component: Dashboard,
meta: {
title: 'Overview',
dashboardView: 'overview',
},
},
{
Expand Down
45 changes: 40 additions & 5 deletions frontend/src/stores/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export const useServersStore = defineStore('servers', () => {
const servers = ref<Server[]>([])
const loading = ref<LoadingState>({ loading: false, error: null })

// True once a server list has been fetched successfully at least once — the
// only reliable way to tell "no servers configured" from "we don't know yet".
// `servers` starts empty, and `loading.error` cannot stand in for this: it is
// shared state that any caller (including silent background refreshes and
// other components fetching concurrently) can write, and a success never
// clears it.
const loaded = ref(false)

// Computed
const serverCount = computed(() => ({
total: servers.value.length,
Expand Down Expand Up @@ -87,17 +95,35 @@ export const useServersStore = defineStore('servers', () => {
return result.sort((a, b) => a.name.localeCompare(b.name))
}

// The server list has three independent writers: a fetch from any component
// (App.vue and Dashboard.vue both issue one on mount), a silent background
// refresh, and the Spec 047 SSE full-list payload. None of them cancel the
// others, so every write takes a ticket in issue order and a response that
// was already in flight when a newer list was applied is dropped — otherwise
// a stale list can resurrect servers the user just deleted, or blank out ones
// they just added.
let issueSeq = 0
let appliedSeq = 0

function applyServerList(list: Server[], ticket: number) {
if (ticket < appliedSeq) return
appliedSeq = ticket
// Smart merge preserves object references and avoids unnecessary re-renders
servers.value = mergeServers(servers.value, list)
loaded.value = true
}

// Actions
async function fetchServers(silent = false) {
if (!silent) {
loading.value = { loading: true, error: null }
}
const ticket = ++issueSeq

try {
const response = await api.getServers()
if (response.success && response.data) {
// Use smart merge to preserve object references and avoid unnecessary re-renders
servers.value = mergeServers(servers.value, response.data.servers)
applyServerList(response.data.servers, ticket)
} else {
loading.value.error = response.error || 'Failed to fetch servers'
}
Expand Down Expand Up @@ -309,8 +335,14 @@ export const useServersStore = defineStore('servers', () => {
try {
const response = await api.deleteServer(serverName)
if (response.success) {
// Remove server from local state
servers.value = servers.value.filter(s => s.name !== serverName)
// Removing the server locally is itself an authoritative list state, so
// it claims the newest ticket: a GET that was already in flight when the
// delete landed would otherwise pass the sequencing check and restore
// the server the user just deleted.
applyServerList(
servers.value.filter(s => s.name !== serverName),
++issueSeq
)
return true
} else {
throw new Error(response.error || 'Failed to delete server')
Expand Down Expand Up @@ -370,7 +402,9 @@ export const useServersStore = defineStore('servers', () => {
// when running against an older core that publishes notify-only events.
const payload = customEvent.detail?.payload
if (payload && Array.isArray(payload.servers)) {
servers.value = mergeServers(servers.value, payload.servers as Server[])
// Authoritative and newer than anything currently in flight, so it takes
// the newest ticket — and it counts as a successful load in its own right.
applyServerList(payload.servers as Server[], ++issueSeq)
return
}

Expand All @@ -392,6 +426,7 @@ export const useServersStore = defineStore('servers', () => {
// State
servers,
loading,
loaded,

// Computed
serverCount,
Expand Down
173 changes: 154 additions & 19 deletions frontend/src/views/Dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,83 @@
<!-- Upgrade nudge (Spec 079): dismissible per-version update banner -->
<UpdateBanner />

<!-- Overview ↔ Usage switcher (Spec 069 T016) -->
<!-- Usage ↔ Overview switcher (Spec 069 T016). Usage is the default panel
(analytics-as-landing-page); each tab maps to a deep-linkable route
(/usage, /overview) so the panel survives a reload or a shared link. -->
<div role="tablist" class="tabs tabs-boxed w-fit" data-test="dashboard-view-switcher">
<a
role="tab"
class="tab"
:class="activeView === 'overview' ? 'tab-active' : ''"
data-test="dashboard-tab-overview"
@click="activeView = 'overview'"
>Overview</a>
<a
role="tab"
class="tab"
:class="activeView === 'usage' ? 'tab-active' : ''"
data-test="dashboard-tab-usage"
@click="selectUsage"
>Usage</a>
<a
role="tab"
class="tab"
:class="activeView === 'overview' ? 'tab-active' : ''"
data-test="dashboard-tab-overview"
@click="selectOverview"
>Overview</a>
</div>

<!-- Usage view: the panel wrapper is always in the DOM (kept hidden with
v-show so switching back is instant and the Overview subtree is never
torn down, SC-006). The heavy chart bundle + the usage fetch inside
UsageView are lazy-mounted only on first switch (v-if="usageEverActive")
so they never block Dashboard first paint (SC-004). -->
UsageView are mounted only once the panel has been active
(usageEverActive) AND the server list has arrived, and stay code-split
behind Suspense, so the Dashboard shell still paints immediately
(SC-004). -->
<div v-show="activeView === 'usage'" data-test="dashboard-usage-panel">
<Suspense v-if="usageEverActive">
<!-- First run: no upstream servers configured, so the analytics panel
would only ever show "no data". Point the new user at the one action
that makes the dashboard useful instead. -->
<div
v-if="showFirstRunCta"
class="card bg-base-200 border border-base-300"
data-test="dashboard-usage-first-run"
>
<div class="card-body items-center text-center py-12">
<svg class="w-12 h-12 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<h3 class="font-semibold text-lg mt-2">Add your first server to see usage</h3>
<p class="text-sm opacity-60 max-w-md">
No upstream MCP servers are configured yet, so there is nothing to chart.
Add one and this page will show call volume, token sinks, error rates and a timeline.
</p>
<div class="flex flex-wrap gap-2 justify-center mt-2">
<button
class="btn btn-primary btn-sm"
data-test="dashboard-first-run-add-server"
@click="showAddServer = true"
>
Add your first server
</button>
<router-link to="/repositories" class="btn btn-sm btn-ghost">Browse Registry</router-link>
<button
class="btn btn-sm btn-ghost"
data-test="dashboard-first-run-overview"
@click="selectOverview"
>
Go to Overview
</button>
</div>
</div>
</div>
<!-- Hold the charts back until the server list has arrived: mounting
UsageView first would fire a usage aggregate request and flash the
"no usage data yet" card on a fresh install, only to be replaced by
the CTA above a moment later. Both requests are issued together in
onMounted, so this costs no extra round-trip. -->
<div
v-else-if="!serversFetchSettled && !serversStore.loaded"
class="flex justify-center py-16"
data-test="dashboard-usage-pending"
>
<span class="loading loading-spinner loading-lg"></span>
</div>
<Suspense v-else-if="usageEverActive">
<UsageView />
<template #fallback>
<div class="flex justify-center py-16"><span class="loading loading-spinner loading-lg"></span></div>
Expand Down Expand Up @@ -426,6 +478,7 @@
<script setup lang="ts">
import { serverDetailPath } from '@/utils/serverRoute'
import { computed, ref, watch, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useServersStore } from '@/stores/servers'
import { useSystemStore } from '@/stores/system'
import { useSecurityScannerStatus, refreshSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
Expand All @@ -450,15 +503,73 @@ const serversStore = useServersStore()
const systemStore = useSystemStore()
const onboardingStore = useOnboardingStore()

// Overview ↔ Usage switcher state (Spec 069 T016). `usageEverActive` gates the
// Usage ↔ Overview switcher state (Spec 069 T016). `usageEverActive` gates the
// first mount; `activeView` then toggles via v-show so both panels keep state.
const activeView = ref<'overview' | 'usage'>('overview')
const usageEverActive = ref(false)
function selectUsage() {
usageEverActive.value = true
activeView.value = 'usage'
//
// The active panel comes from the route (`meta.dashboardView`): `/` and
// `/usage` land on the analytics panel, `/overview` on the hub overview.
// Clicking a tab rewrites the URL (replace, so the switcher does not pile up
// history entries) — the routes share this component, so switching never
// remounts the Dashboard and both panels keep their state.
type DashboardView = 'overview' | 'usage'

const route = useRoute()
const router = useRouter()

function routeView(): DashboardView {
return (route.meta?.dashboardView as DashboardView | undefined) === 'overview' ? 'overview' : 'usage'
}

const activeView = ref<DashboardView>(routeView())
const usageEverActive = ref(activeView.value === 'usage')

// The panel a navigation is currently heading for. `route` only reflects a
// *confirmed* navigation, so comparing against it alone loses a fast second tab
// click: it would still see the pre-navigation route, decide it has nothing to
// do, and then be overwritten when the first navigation lands. Tracking the
// in-flight target makes the newest click win — the router cancels the
// superseded navigation for us.
//
// `navSeq` identifies which navigation owns the marker. Comparing the panel
// value alone is not enough: two navigations to the same panel (Overview →
// Usage → Overview) share a value, so the older one's settlement would clear a
// marker still owned by the newer one, and the next click would again compare
// against the stale current route and be swallowed.
let pendingView: DashboardView | null = null
let navSeq = 0

function selectView(view: DashboardView) {
if (view === 'usage') {
usageEverActive.value = true
}
activeView.value = view
// `/` is also a usage route — don't rewrite it to `/usage` needlessly.
if ((pendingView ?? routeView()) === view) {
return
}
const seq = ++navSeq
pendingView = view
const targetRoute = view === 'usage' ? 'usage' : 'dashboard-overview'
void router.replace({ name: targetRoute }).finally(() => {
// Only the newest navigation clears the marker; a superseded one must not.
if (seq === navSeq) {
pendingView = null
}
})
}
const selectUsage = () => selectView('usage')
const selectOverview = () => selectView('overview')

// Keep the panel in sync when the route changes underneath us (sidebar link,
// browser back/forward, a pasted deep link).
watch(() => route.meta?.dashboardView, () => {
const view = routeView()
if (view === 'usage') {
usageEverActive.value = true
}
activeView.value = view
})

// Modal state
const showConnectModal = ref(false)
const showAddServer = ref(false)
Expand Down Expand Up @@ -624,6 +735,25 @@ const loadTokenSavings = async () => {
}
}

// --- First run (no servers configured) ---
// Two different questions, deliberately answered by two different flags:
//
// `serversFetchSettled` — has our own initial request finished, successfully or
// not? Together with `loaded` it gates the chart mount below, so a failed fetch
// falls through to the usage panel rather than spinning forever, and an
// authoritative list arriving by SSE first clears the spinner immediately.
//
// `serversStore.loaded` — has a server list ever arrived successfully? Only
// that justifies telling the user they have no servers. It cannot be inferred
// from `loading.error`: that field is shared, other components (App.vue) and
// silent background refreshes write it concurrently, and a success never clears
// it — so an unrelated failure would suppress the CTA, and a later successful
// refresh could never bring it back.
const serversFetchSettled = ref(false)
const showFirstRunCta = computed(
() => serversStore.loaded && serversStore.serverCount.total === 0
)

// --- Disabled server count ---
const disabledCount = computed(() => {
return serversStore.serverCount.total - serversStore.serverCount.connected - serversStore.serverCount.quarantined
Expand Down Expand Up @@ -839,7 +969,10 @@ onMounted(() => {
loadSecurityStatus()
// Populate security scanner totals for the Security Scan chip (F-12).
void refreshSecurityScannerStatus()
serversStore.fetchServers().then(() => loadPendingTools())
serversStore.fetchServers().then(() => {
serversFetchSettled.value = true
loadPendingTools()
})

// Auto-refresh every 30 seconds
refreshInterval = setInterval(() => {
Expand All @@ -853,7 +986,9 @@ onMounted(() => {
}, 30000)

systemStore.connectEventSource()
serversStore.fetchServers()
// NOTE: no second fetchServers() here — it duplicated the request issued
// above and, being unsequenced against it, made the first-run gate depend on
// which of the two responses landed last.

// Adaptive onboarding wizard (Spec 046): auto-show on first Web UI load
// when the user has not yet engaged with the wizard and at least one
Expand Down
Loading
Loading