feat: restructure AppSidebar into two-domain nav with section headers and flywheel signal badges
This commit is contained in:
parent
5bdb095235
commit
6ef6f06023
2 changed files with 385 additions and 36 deletions
124
web/src/components/AppSidebar.test.ts
Normal file
124
web/src/components/AppSidebar.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
|
||||
// Minimal router so RouterLink renders without warnings
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/fleet', component: { template: '<div />' } },
|
||||
{ path: '/data/label', component: { template: '<div />' } },
|
||||
{ path: '/data/fetch', component: { template: '<div />' } },
|
||||
{ path: '/data/corrections', component: { template: '<div />' } },
|
||||
{ path: '/data/imitate', component: { template: '<div />' } },
|
||||
{ path: '/eval/benchmark', component: { template: '<div />' } },
|
||||
{ path: '/eval/compare', component: { template: '<div />' } },
|
||||
{ path: '/train/jobs', component: { template: '<div />' } },
|
||||
{ path: '/train/results', component: { template: '<div />' } },
|
||||
{ path: '/settings', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
|
||||
function makeFetch(signals: Record<string, boolean> = {}) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
labeled_since_last_eval: 0,
|
||||
last_eval_timestamp: null,
|
||||
last_eval_best_score: null,
|
||||
active_jobs: [],
|
||||
corrections_export_ready: 0,
|
||||
signals,
|
||||
}),
|
||||
text: async () => '',
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('fetch', makeFetch())
|
||||
})
|
||||
|
||||
describe('AppSidebar structure', () => {
|
||||
it('renders section headers for Data, Eval, Train', async () => {
|
||||
const w = mount(AppSidebar, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const text = w.text()
|
||||
expect(text).toContain('Data')
|
||||
expect(text).toContain('Eval')
|
||||
expect(text).toContain('Train')
|
||||
})
|
||||
|
||||
it('renders all sub-links', async () => {
|
||||
const w = mount(AppSidebar, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const anchors = w.findAll('a')
|
||||
const hrefs = anchors.map(a => a.attributes('href') ?? '')
|
||||
expect(hrefs.some(h => h.includes('/data/label'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/data/fetch'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/data/corrections'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/data/imitate'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/eval/benchmark'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/eval/compare'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/train/jobs'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/train/results'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/fleet'))).toBe(true)
|
||||
expect(hrefs.some(h => h.includes('/settings'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT render the old /benchmark or /models links', async () => {
|
||||
const w = mount(AppSidebar, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const anchors = w.findAll('a')
|
||||
const hrefs = anchors.map(a => a.attributes('href') ?? '')
|
||||
// Old paths must not appear as direct links (they're only redirects)
|
||||
expect(hrefs.every(h => !h.endsWith('/#/benchmark'))).toBe(true)
|
||||
expect(hrefs.every(h => !h.endsWith('/#/models'))).toBe(true)
|
||||
expect(hrefs.every(h => !h.endsWith('/#/stats'))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows no signal badges when all signals are false', async () => {
|
||||
vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: false, train_to_fleet: false }))
|
||||
const w = mount(AppSidebar, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
expect(w.findAll('.signal-badge').length).toBe(0)
|
||||
})
|
||||
|
||||
it('shows signal badge on Data section when data_to_eval is true', async () => {
|
||||
vi.stubGlobal('fetch', makeFetch({ data_to_eval: true, eval_to_train: false, train_to_fleet: false }))
|
||||
const w = mount(AppSidebar, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const badges = w.findAll('.signal-badge')
|
||||
expect(badges.length).toBe(1)
|
||||
// It should be inside the Data section header
|
||||
const dataHeader = w.find('[data-section="data"]')
|
||||
expect(dataHeader.find('.signal-badge').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows signal badge on Eval section when eval_to_train is true', async () => {
|
||||
vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: true, train_to_fleet: false }))
|
||||
const w = mount(AppSidebar, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const evalHeader = w.find('[data-section="eval"]')
|
||||
expect(evalHeader.find('.signal-badge').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows signal badge on Train section when train_to_fleet is true', async () => {
|
||||
vi.stubGlobal('fetch', makeFetch({ data_to_eval: false, eval_to_train: false, train_to_fleet: true }))
|
||||
const w = mount(AppSidebar, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const trainHeader = w.find('[data-section="train"]')
|
||||
expect(trainHeader.find('.signal-badge').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('stow toggle still works', async () => {
|
||||
const w = mount(AppSidebar, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
const nav = w.find('nav')
|
||||
expect(nav.classes()).not.toContain('stowed')
|
||||
await w.find('.stow-btn').trigger('click')
|
||||
expect(nav.classes()).toContain('stowed')
|
||||
})
|
||||
})
|
||||
|
|
@ -28,12 +28,59 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nav items -->
|
||||
<!-- Nav -->
|
||||
<ul class="nav-list" role="list">
|
||||
<li v-for="item in navItems" :key="item.path">
|
||||
<!-- Top-level links -->
|
||||
<li>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="nav-item"
|
||||
:title="stowed ? 'Dashboard' : ''"
|
||||
@click="isMobile && stow()"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">📊</span>
|
||||
<span v-if="!stowed" class="nav-label">Dashboard</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink
|
||||
to="/fleet"
|
||||
class="nav-item"
|
||||
:title="stowed ? 'Fleet' : ''"
|
||||
@click="isMobile && stow()"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">⚡</span>
|
||||
<span v-if="!stowed" class="nav-label">Fleet</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
|
||||
<!-- ① Data section -->
|
||||
<li>
|
||||
<div class="section-header" data-section="data" aria-hidden="true">
|
||||
<template v-if="!stowed">
|
||||
<span class="section-label">① Data</span>
|
||||
<span
|
||||
v-if="signals.data_to_eval"
|
||||
class="signal-badge"
|
||||
title="Enough new labels to run eval"
|
||||
aria-label="Eval recommended"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="section-icon">①</span>
|
||||
<span
|
||||
v-if="signals.data_to_eval"
|
||||
class="signal-badge signal-badge-stowed"
|
||||
title="Eval recommended"
|
||||
aria-label="Eval recommended"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
<li v-for="item in dataItems" :key="item.path">
|
||||
<RouterLink
|
||||
:to="item.path"
|
||||
class="nav-item"
|
||||
class="nav-item nav-subitem"
|
||||
:title="stowed ? item.label : ''"
|
||||
@click="isMobile && stow()"
|
||||
>
|
||||
|
|
@ -41,10 +88,94 @@
|
|||
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
|
||||
<!-- ② Eval section -->
|
||||
<li>
|
||||
<div class="section-header" data-section="eval" aria-hidden="true">
|
||||
<template v-if="!stowed">
|
||||
<span class="section-label">② Eval</span>
|
||||
<span
|
||||
v-if="signals.eval_to_train"
|
||||
class="signal-badge"
|
||||
title="Strong eval result — consider finetuning"
|
||||
aria-label="Finetune recommended"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="section-icon">②</span>
|
||||
<span
|
||||
v-if="signals.eval_to_train"
|
||||
class="signal-badge signal-badge-stowed"
|
||||
title="Finetune recommended"
|
||||
aria-label="Finetune recommended"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
<li v-for="item in evalItems" :key="item.path">
|
||||
<RouterLink
|
||||
:to="item.path"
|
||||
class="nav-item nav-subitem"
|
||||
:title="stowed ? item.label : ''"
|
||||
@click="isMobile && stow()"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
|
||||
<!-- ③ Train section -->
|
||||
<li>
|
||||
<div class="section-header" data-section="train" aria-hidden="true">
|
||||
<template v-if="!stowed">
|
||||
<span class="section-label">③ Train</span>
|
||||
<span
|
||||
v-if="signals.train_to_fleet"
|
||||
class="signal-badge"
|
||||
title="Trained model ready for fleet registration"
|
||||
aria-label="Fleet registration recommended"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="section-icon">③</span>
|
||||
<span
|
||||
v-if="signals.train_to_fleet"
|
||||
class="signal-badge signal-badge-stowed"
|
||||
title="Fleet registration recommended"
|
||||
aria-label="Fleet registration recommended"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
<li v-for="item in trainItems" :key="item.path">
|
||||
<RouterLink
|
||||
:to="item.path"
|
||||
class="nav-item nav-subitem"
|
||||
:title="stowed ? item.label : ''"
|
||||
@click="isMobile && stow()"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
<span v-if="!stowed" class="nav-label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
|
||||
<!-- Divider + Settings -->
|
||||
<li class="nav-divider" aria-hidden="true" />
|
||||
<li>
|
||||
<RouterLink
|
||||
to="/settings"
|
||||
class="nav-item"
|
||||
:title="stowed ? 'Settings' : ''"
|
||||
@click="isMobile && stow()"
|
||||
>
|
||||
<span class="nav-icon" aria-hidden="true">⚙️</span>
|
||||
<span v-if="!stowed" class="nav-label">Settings</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile hamburger button rendered outside the sidebar so it's visible when stowed -->
|
||||
<!-- Mobile hamburger button — visible when sidebar is stowed on mobile -->
|
||||
<button
|
||||
v-if="isMobile && stowed"
|
||||
class="mobile-hamburger"
|
||||
|
|
@ -61,25 +192,66 @@ import { RouterLink } from 'vue-router'
|
|||
|
||||
const LS_KEY = 'cf-avocet-nav-stowed'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: '🃏', label: 'Label' },
|
||||
{ path: '/fetch', icon: '📥', label: 'Fetch' },
|
||||
{ path: '/stats', icon: '📊', label: 'Stats' },
|
||||
{ path: '/benchmark', icon: '🏁', label: 'Benchmark' },
|
||||
{ path: '/models', icon: '🤗', label: 'Models' },
|
||||
{ path: '/imitate', icon: '🪞', label: 'Imitate' },
|
||||
{ path: '/corrections', icon: '✍️', label: 'Corrections' },
|
||||
{ path: '/settings', icon: '⚙️', label: 'Settings' },
|
||||
interface NavItem {
|
||||
path: string
|
||||
icon: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface DashboardSignals {
|
||||
data_to_eval: boolean
|
||||
eval_to_train: boolean
|
||||
train_to_fleet: boolean
|
||||
}
|
||||
|
||||
const dataItems: NavItem[] = [
|
||||
{ path: '/data/label', icon: '🏷', label: 'Label' },
|
||||
{ path: '/data/fetch', icon: '📬', label: 'Fetch' },
|
||||
{ path: '/data/corrections', icon: '✏️', label: 'Corrections' },
|
||||
{ path: '/data/imitate', icon: '🪞', label: 'Imitate' },
|
||||
]
|
||||
|
||||
const stowed = ref(localStorage.getItem(LS_KEY) === 'true')
|
||||
const winWidth = ref(window.innerWidth)
|
||||
const isMobile = computed(() => winWidth.value < 640)
|
||||
const evalItems: NavItem[] = [
|
||||
{ path: '/eval/benchmark', icon: '📊', label: 'Benchmark' },
|
||||
{ path: '/eval/compare', icon: '🔍', label: 'Compare' },
|
||||
]
|
||||
|
||||
const trainItems: NavItem[] = [
|
||||
{ path: '/train/jobs', icon: '🧠', label: 'Jobs' },
|
||||
{ path: '/train/results', icon: '📈', label: 'Results' },
|
||||
]
|
||||
|
||||
const stowed = ref(localStorage.getItem(LS_KEY) === 'true')
|
||||
const winWidth = ref(window.innerWidth)
|
||||
const isMobile = computed(() => winWidth.value < 640)
|
||||
|
||||
const signals = ref<DashboardSignals>({
|
||||
data_to_eval: false,
|
||||
eval_to_train: false,
|
||||
train_to_fleet: false,
|
||||
})
|
||||
|
||||
async function loadSignals() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard')
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { signals?: DashboardSignals }
|
||||
if (data.signals) {
|
||||
signals.value = {
|
||||
data_to_eval: data.signals.data_to_eval ?? false,
|
||||
eval_to_train: data.signals.eval_to_train ?? false,
|
||||
train_to_fleet: data.signals.train_to_fleet ?? false,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: badges simply stay hidden if API is unreachable
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
stowed.value = !stowed.value
|
||||
localStorage.setItem(LS_KEY, String(stowed.value))
|
||||
// Update CSS variable on :root so .app-main margin-left syncs
|
||||
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
|
||||
}
|
||||
|
||||
|
|
@ -93,13 +265,12 @@ function onResize() { winWidth.value = window.innerWidth }
|
|||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
// Apply persisted sidebar width to :root on mount
|
||||
document.documentElement.style.setProperty('--sidebar-width', stowed.value ? '56px' : '200px')
|
||||
// On mobile, default to stowed
|
||||
if (isMobile.value && !localStorage.getItem(LS_KEY)) {
|
||||
stowed.value = true
|
||||
document.documentElement.style.setProperty('--sidebar-width', '56px')
|
||||
}
|
||||
loadSignals()
|
||||
})
|
||||
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
|
@ -121,18 +292,15 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar.stowed {
|
||||
width: 56px;
|
||||
}
|
||||
.sidebar.stowed { width: 56px; }
|
||||
|
||||
/* Mobile: slide in/out from left */
|
||||
.sidebar.mobile {
|
||||
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.sidebar.mobile.stowed {
|
||||
transform: translateX(-100%);
|
||||
width: 200px; /* keep width so slide-in looks right */
|
||||
width: 200px;
|
||||
transition: transform 250ms ease, width 250ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -165,10 +333,7 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-icon { font-size: 1.25rem; flex-shrink: 0; }
|
||||
|
||||
.logo-name {
|
||||
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||
|
|
@ -193,16 +358,76 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
|||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.stow-btn:hover {
|
||||
background: var(--color-border, #d0d7e8);
|
||||
}
|
||||
.stow-btn:hover { background: var(--color-border, #d0d7e8); }
|
||||
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
padding: 0.5rem 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Section headers ── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.75rem 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--color-text-muted, #4a5c7a);
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #4a5c7a);
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Signal badges ── */
|
||||
.signal-badge {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning, #d4891a);
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.signal-badge-stowed {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
/* Make the stowed section header container position:relative for the badge */
|
||||
.sidebar.stowed .section-header {
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
padding: 0.55rem 0 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Nav divider ── */
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border, #d0d7e8);
|
||||
margin: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Nav items ── */
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -238,6 +463,9 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
|||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Sub-items are indented slightly in expanded state */
|
||||
.nav-subitem { padding-left: 1.1rem; font-size: 0.875rem; }
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
|
|
@ -245,12 +473,9 @@ onUnmounted(() => window.removeEventListener('resize', onResize))
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.nav-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Mobile hamburger — visible when sidebar is stowed on mobile */
|
||||
/* Mobile hamburger */
|
||||
.mobile-hamburger {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
|
|
|
|||
Loading…
Reference in a new issue