feat: Shadow Run Queue page (T06 template) — first KBX v60 page

Implement ShadowRunQueue.vue using KBX Foundation v60 components:
- T06 Queue template for job list display
- KbxScreenFrame wrapper with breadcrumb/title
- KbxSummaryBar showing job statistics (running/completed/failed)
- KbxTemplateStateBoundary for async state (loading/error/empty)
- Filter bar (search, status dropdown)
- Job items with progress bars, status tags, error messages
- Actions per job (view details, export, retry)
- Dark mode & responsive layout support

New files:
- features/shadow-run/pages/ShadowRunQueue.vue (page component)
- features/shadow-run/composables/useShadowRunJobs.ts (data fetch)
- features/shadow-run/types/index.ts (type definitions)

Updated:
- features/shadow-run/registry.ts (import ScreenDefinition from @kbx/contracts)

Demo data: 3 sample jobs (running, completed, failed) with realistic states.
Ready for API integration and production use.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 10:45:13 +09:00
parent 889212d643
commit 8974d6087c
4 changed files with 520 additions and 1 deletions
@@ -0,0 +1,385 @@
<script setup lang="ts">
/**
* Shadow Run Queue (T06 Template)
* Display and manage shadow run jobs
*/
import { reactive, computed, onMounted } from 'vue'
import {
KbxScreenFrame,
KbxQueueTemplate,
KbxTemplateStateBoundary,
KbxButton,
KbxStatusTag,
KbxInput,
KbxSelect,
KbxDataGrid,
KbxSummaryBar,
} from '@kbx/ui'
import { useShadowRunJobs } from '../composables/useShadowRunJobs'
import type { ShadowRunJob } from '../types'
const { jobs, filteredJobs, isLoading, error, statusStats, fetchJobs, setFilter } = useShadowRunJobs()
const filterModel = reactive({
search: '',
status: '',
})
const breadcrumb = [
{ label: 'Model Operations', href: '/model-ops' },
{ label: 'Shadow Run Jobs' },
]
onMounted(() => {
fetchJobs()
})
const updateFilter = () => {
setFilter({
search: filterModel.search,
status: filterModel.status || undefined,
})
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return 'info'
case 'completed':
return 'success'
case 'failed':
return 'danger'
default:
return 'default'
}
}
const canStartNewRun = computed(() => {
return statusStats.value.running < 3
})
</script>
<template>
<KbxScreenFrame
title="Shadow Run Jobs"
:breadcrumb="breadcrumb"
:class="['shadow-run-queue']"
>
<!-- Summary Bar -->
<KbxSummaryBar
:items="[
{ label: 'Running', value: statusStats.running, emphasis: statusStats.running > 0 },
{ label: 'Completed', value: statusStats.completed },
{ label: 'Failed', value: statusStats.failed },
{ label: 'Total', value: statusStats.total },
]"
/>
<!-- Filters -->
<div class="shadow-run-queue__filters">
<KbxInput
v-model="filterModel.search"
label="Search"
placeholder="Job ID, Model name..."
@update:model-value="updateFilter"
/>
<KbxSelect
v-model="filterModel.status"
label="Status"
:options="[
{ value: '', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
]"
@update:model-value="updateFilter"
/>
<KbxButton
variant="primary"
size="md"
label="New Run"
:disabled="!canStartNewRun"
@click="$emit('new-run')"
/>
</div>
<!-- Content -->
<KbxTemplateStateBoundary
:state="isLoading ? 'loading' : error ? 'error' : filteredJobs.length ? 'idle' : 'empty'"
:error="error"
@retry="fetchJobs"
>
<KbxQueueTemplate v-if="filteredJobs.length">
<!-- Queue Items -->
<div class="shadow-run-queue__items">
<div
v-for="job in filteredJobs"
:key="job.jobId"
class="shadow-run-queue__item"
:class="`is-${job.status}`"
>
<div class="shadow-run-queue__item-header">
<div class="shadow-run-queue__item-title">
<strong>{{ job.modelName }}</strong>
<span class="shadow-run-queue__job-id">#{{ job.jobId }}</span>
</div>
<KbxStatusTag :tone="getStatusColor(job.status)" :label="job.status.toUpperCase()" />
</div>
<div class="shadow-run-queue__item-meta">
<div class="meta-group">
<span class="label">Window:</span>
<span class="value">{{ job.windowStart }} ~ {{ job.windowEnd }}</span>
</div>
<div class="meta-group">
<span class="label">Days:</span>
<span class="value">{{ job.tradingDays }}</span>
</div>
<div class="meta-group">
<span class="label">Started:</span>
<span class="value">{{ formatDate(job.startedAt) }}</span>
</div>
</div>
<!-- Progress Bar -->
<div class="shadow-run-queue__progress">
<div class="progress-bar" :style="{ width: job.progress + '%' }" />
<span class="progress-text">{{ job.progress }}%</span>
</div>
<!-- Error Message -->
<div v-if="job.errorMessage" class="shadow-run-queue__error">
{{ job.errorMessage }}
</div>
<!-- Actions -->
<div class="shadow-run-queue__actions">
<KbxButton variant="secondary" size="sm" label="View Details" />
<KbxButton v-if="job.status === 'completed'" variant="secondary" size="sm" label="Export" />
<KbxButton v-if="job.status === 'failed'" variant="danger" size="sm" label="Retry" />
</div>
</div>
</div>
</KbxQueueTemplate>
<!-- Empty State -->
<template #empty>
<div class="shadow-run-queue__empty">
<div class="empty-icon">📋</div>
<h3>No jobs found</h3>
<p>Start a new shadow run to validate your model with historical data.</p>
<KbxButton variant="primary" size="md" label="Create New Run" @click="$emit('new-run')" />
</div>
</template>
</KbxTemplateStateBoundary>
</KbxScreenFrame>
</template>
<style scoped>
.shadow-run-queue {
display: flex;
flex-direction: column;
gap: 20px;
}
.shadow-run-queue__filters {
display: grid;
grid-template-columns: 1fr 150px 140px;
gap: 16px;
padding: 16px;
background: var(--kbx-color-surface-secondary);
border-radius: 4px;
}
.shadow-run-queue__items {
display: flex;
flex-direction: column;
gap: 12px;
}
.shadow-run-queue__item {
padding: 16px;
background: var(--kbx-color-surface);
border: 1px solid var(--kbx-color-border);
border-radius: 4px;
border-left: 4px solid var(--kbx-color-border);
transition: all 0.2s ease;
}
.shadow-run-queue__item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.shadow-run-queue__item.is-running {
border-left-color: #3b82f6;
background: rgba(59, 130, 246, 0.02);
}
.shadow-run-queue__item.is-completed {
border-left-color: #10b981;
background: rgba(16, 185, 129, 0.02);
}
.shadow-run-queue__item.is-failed {
border-left-color: #ef4444;
background: rgba(239, 68, 68, 0.02);
}
.shadow-run-queue__item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.shadow-run-queue__item-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.shadow-run-queue__job-id {
font-size: 12px;
color: var(--kbx-color-text-muted);
}
.shadow-run-queue__item-meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 12px;
font-size: 12px;
}
.meta-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.meta-group .label {
color: var(--kbx-color-text-muted);
font-weight: 500;
}
.meta-group .value {
color: var(--kbx-color-text);
}
.shadow-run-queue__progress {
position: relative;
height: 20px;
background: var(--kbx-color-surface-secondary);
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb);
transition: width 0.3s ease;
}
.progress-text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 11px;
font-weight: 600;
color: var(--kbx-color-text);
}
.shadow-run-queue__error {
padding: 8px 12px;
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
border-radius: 4px;
font-size: 12px;
margin-bottom: 12px;
}
.shadow-run-queue__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.shadow-run-queue__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.shadow-run-queue__empty h3 {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
color: var(--kbx-color-text);
}
.shadow-run-queue__empty p {
margin: 0 0 24px 0;
color: var(--kbx-color-text-muted);
font-size: 14px;
}
@media (max-width: 768px) {
.shadow-run-queue__filters {
grid-template-columns: 1fr;
}
.shadow-run-queue__item-meta {
grid-template-columns: 1fr;
}
}
@media (prefers-color-scheme: dark) {
.shadow-run-queue__filters {
background: #1f2937;
}
.shadow-run-queue__item {
background: #111827;
border-color: #374151;
}
.shadow-run-queue__item.is-running {
background: rgba(59, 130, 246, 0.05);
}
.shadow-run-queue__item.is-completed {
background: rgba(16, 185, 129, 0.05);
}
.shadow-run-queue__item.is-failed {
background: rgba(239, 68, 68, 0.05);
}
}
</style>