feat: add TrainResultsView with training history table and Fleet registration links
This commit is contained in:
parent
53b25b27ab
commit
b077371107
2 changed files with 383 additions and 4 deletions
101
web/src/views/TrainResultsView.test.ts
Normal file
101
web/src/views/TrainResultsView.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import TrainResultsView from './TrainResultsView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/fleet', component: { template: '<div />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const sampleResult = {
|
||||||
|
id: 'run-xyz',
|
||||||
|
job_id: 'job-abc123',
|
||||||
|
model_type: 'classifier',
|
||||||
|
base_model: 'microsoft/deberta-v3-small',
|
||||||
|
val_macro_f1: 0.847,
|
||||||
|
val_accuracy: 0.891,
|
||||||
|
sample_count: 1240,
|
||||||
|
duration_seconds: 842,
|
||||||
|
created_at: '2026-05-01T11:30:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFetch(results: unknown[] = []) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ results }),
|
||||||
|
text: async () => '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', makeFetch([sampleResult]))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TrainResultsView', () => {
|
||||||
|
it('renders page title "Training Results"', async () => {
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.find('h1.page-title').text()).toContain('Training Results')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty notice when there are no results', async () => {
|
||||||
|
vi.stubGlobal('fetch', makeFetch([]))
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.find('.empty-notice').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders results table when results exist', async () => {
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.find('table.results-table').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows base_model in table', async () => {
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.text()).toContain('deberta-v3-small')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows val_macro_f1 formatted as percentage', async () => {
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.text()).toContain('84.7%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows val_accuracy formatted as percentage', async () => {
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.text()).toContain('89.1%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows duration formatted as minutes and seconds', async () => {
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
// 842 seconds = 14m 2s
|
||||||
|
expect(w.text()).toContain('14m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Register in Fleet button for classifier results', async () => {
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.find('a.register-btn').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT show Register in Fleet button for llm-sft results', async () => {
|
||||||
|
vi.stubGlobal('fetch', makeFetch([{ ...sampleResult, model_type: 'llm-sft' }]))
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.find('a.register-btn').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error notice when API fails', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500, text: async () => '' }))
|
||||||
|
const w = mount(TrainResultsView, { global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(w.find('.error-notice').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,10 +1,288 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="view-placeholder">
|
<div class="train-results-view">
|
||||||
<h2>TrainResultsView</h2>
|
<header class="view-header">
|
||||||
<p>Coming soon.</p>
|
<h1 class="page-title">Training Results</h1>
|
||||||
|
<button class="refresh-btn" :disabled="loading" @click="loadResults" aria-label="Refresh">🔄</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-notice" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
<button class="btn-retry" @click="loadResults">Retry</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!error && results.length === 0 && !loading" class="empty-notice">
|
||||||
|
No training results yet. Completed jobs will appear here.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="results.length > 0" class="results-table-wrap">
|
||||||
|
<table class="results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Run</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Base Model</th>
|
||||||
|
<th class="th-numeric">Macro F1</th>
|
||||||
|
<th class="th-numeric">Accuracy</th>
|
||||||
|
<th class="th-numeric">Samples</th>
|
||||||
|
<th class="th-numeric">Duration</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in results" :key="r.id">
|
||||||
|
<td class="td-id" :title="r.id">{{ r.id.slice(0, 8) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="type-chip">{{ r.model_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="td-model" :title="r.base_model">{{ shortModel(r.base_model) }}</td>
|
||||||
|
<td class="td-numeric">
|
||||||
|
<span class="metric-val" :class="scoreClass(r.val_macro_f1)">
|
||||||
|
{{ formatPct(r.val_macro_f1) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="td-numeric">{{ formatPct(r.val_accuracy) }}</td>
|
||||||
|
<td class="td-numeric">{{ r.sample_count.toLocaleString() }}</td>
|
||||||
|
<td class="td-numeric">{{ formatDuration(r.duration_seconds) }}</td>
|
||||||
|
<td class="td-actions">
|
||||||
|
<RouterLink
|
||||||
|
v-if="r.model_type === 'classifier'"
|
||||||
|
:to="`/fleet?model=${encodeURIComponent(r.base_model)}`"
|
||||||
|
class="register-btn btn-sm-link"
|
||||||
|
>
|
||||||
|
Register in Fleet
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Stub — will be implemented in a later task
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
interface TrainResult {
|
||||||
|
id: string
|
||||||
|
job_id: string
|
||||||
|
model_type: string
|
||||||
|
base_model: string
|
||||||
|
val_macro_f1: number
|
||||||
|
val_accuracy: number
|
||||||
|
sample_count: number
|
||||||
|
duration_seconds: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = ref<TrainResult[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadResults() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/train/results')
|
||||||
|
if (!res.ok) {
|
||||||
|
error.value = `Failed to load results (HTTP ${res.status}).`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json() as { results: TrainResult[] }
|
||||||
|
results.value = data.results ?? []
|
||||||
|
} catch {
|
||||||
|
error.value = 'Network error loading results.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(v: number | null | undefined): string {
|
||||||
|
if (v == null) return '—'
|
||||||
|
return `${(v * 100).toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number | null | undefined): string {
|
||||||
|
if (seconds == null) return '—'
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
if (mins === 0) return `${secs}s`
|
||||||
|
return `${mins}m ${secs}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortModel(model: string): string {
|
||||||
|
const parts = model.split('/')
|
||||||
|
return parts[parts.length - 1] ?? model
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreClass(f1: number | null | undefined): string {
|
||||||
|
if (f1 == null) return ''
|
||||||
|
if (f1 >= 0.85) return 'score-great'
|
||||||
|
if (f1 >= 0.75) return 'score-good'
|
||||||
|
return 'score-fair'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadResults())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.train-results-view {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-primary, #2A6080);
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border, #d0d7e8);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover:not(:disabled) { background: var(--color-surface-raised, #e4ebf5); }
|
||||||
|
.refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.error-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: color-mix(in srgb, var(--color-error, #c0392b) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-error, #c0392b) 30%, transparent);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
color: var(--color-error, #c0392b);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retry {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid var(--color-error, #c0392b);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-error, #c0392b);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notice {
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px dashed var(--color-border, #a8b8d0);
|
||||||
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
.results-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--color-surface-raised, #f5f7fc);
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
border-bottom: 1px solid var(--color-border, #a8b8d0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.th-numeric { text-align: right; }
|
||||||
|
|
||||||
|
.results-table td {
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-light, #ccd5e6);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-id {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted, #4a5c7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-model {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
max-width: 16ch;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-numeric {
|
||||||
|
text-align: right;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-actions { text-align: right; }
|
||||||
|
|
||||||
|
.metric-val { font-weight: 600; }
|
||||||
|
.score-great { color: var(--color-success, #3a7a32); }
|
||||||
|
.score-good { color: var(--color-warning, #d4891a); }
|
||||||
|
.score-fair { color: var(--color-text-muted, #4a5c7a); }
|
||||||
|
|
||||||
|
.type-chip {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-surface-alt, #dde4f0);
|
||||||
|
color: var(--color-text, #1a2338);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm-link {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
border: 1px solid var(--app-primary, #2A6080);
|
||||||
|
color: var(--app-primary, #2A6080);
|
||||||
|
background: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-block;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm-link:hover {
|
||||||
|
background: color-mix(in srgb, var(--app-primary, #2A6080) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.results-table th:nth-child(6),
|
||||||
|
.results-table td:nth-child(6),
|
||||||
|
.results-table th:nth-child(7),
|
||||||
|
.results-table td:nth-child(7) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue