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:
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Shadow Run Jobs Composable
|
||||||
|
* Fetch and manage shadow run job list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { ShadowRunJob, ShadowRunJobFilter } from '../types'
|
||||||
|
|
||||||
|
export function useShadowRunJobs() {
|
||||||
|
const jobs = ref<ShadowRunJob[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const filter = ref<ShadowRunJobFilter>({})
|
||||||
|
|
||||||
|
// Mock data for demo — replace with actual API call
|
||||||
|
const mockJobs: ShadowRunJob[] = [
|
||||||
|
{
|
||||||
|
jobId: '893',
|
||||||
|
modelId: '00000000-0000-0000-0000-000000000001',
|
||||||
|
modelName: 'Hawkeye-Alpha (v2.1)',
|
||||||
|
status: 'running',
|
||||||
|
windowStart: '2024-01-02',
|
||||||
|
windowEnd: '2024-09-10',
|
||||||
|
tradingDays: 252,
|
||||||
|
startedAt: '2026-08-11T08:30:00Z',
|
||||||
|
progress: 67,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: '892',
|
||||||
|
modelId: '00000000-0000-0000-0000-000000000002',
|
||||||
|
modelName: 'Falcon-Beta (v1.8)',
|
||||||
|
status: 'completed',
|
||||||
|
windowStart: '2024-01-02',
|
||||||
|
windowEnd: '2024-09-10',
|
||||||
|
tradingDays: 252,
|
||||||
|
startedAt: '2026-08-05T10:15:00Z',
|
||||||
|
completedAt: '2026-08-08T14:22:00Z',
|
||||||
|
progress: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jobId: '891',
|
||||||
|
modelId: '00000000-0000-0000-0000-000000000003',
|
||||||
|
modelName: 'Eagle-Gamma (v3.0)',
|
||||||
|
status: 'failed',
|
||||||
|
windowStart: '2024-01-02',
|
||||||
|
windowEnd: '2024-09-10',
|
||||||
|
tradingDays: 252,
|
||||||
|
startedAt: '2026-08-03T09:00:00Z',
|
||||||
|
completedAt: '2026-08-03T12:45:00Z',
|
||||||
|
progress: 0,
|
||||||
|
errorMessage: 'Market data fetch timeout (KRX OpenAPI unavailable)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredJobs = computed(() => {
|
||||||
|
let result = jobs.value
|
||||||
|
|
||||||
|
if (filter.value.status) {
|
||||||
|
result = result.filter(j => j.status === filter.value.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value.modelId) {
|
||||||
|
result = result.filter(j => j.modelId === filter.value.modelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value.search) {
|
||||||
|
const q = filter.value.search.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
j => j.modelName.toLowerCase().includes(q) || j.jobId.includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusStats = computed(() => ({
|
||||||
|
running: jobs.value.filter(j => j.status === 'running').length,
|
||||||
|
completed: jobs.value.filter(j => j.status === 'completed').length,
|
||||||
|
failed: jobs.value.filter(j => j.status === 'failed').length,
|
||||||
|
total: jobs.value.length,
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function fetchJobs() {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate API call delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
|
jobs.value = mockJobs
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Failed to fetch jobs'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilter(newFilter: ShadowRunJobFilter) {
|
||||||
|
filter.value = newFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs,
|
||||||
|
filteredJobs,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
statusStats,
|
||||||
|
fetchJobs,
|
||||||
|
setFilter,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Define all screens in the shadow-run feature module
|
* Define all screens in the shadow-run feature module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { KbxScreenDefinition } from '@shared/contracts/kbx-types'
|
import type { ScreenDefinition } from '@kbx/contracts'
|
||||||
|
|
||||||
export const shadowRunListScreen: KbxScreenDefinition = {
|
export const shadowRunListScreen: KbxScreenDefinition = {
|
||||||
screenId: 'model-ops.shadow-run.list',
|
screenId: 'model-ops.shadow-run.list',
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Shadow Run Feature Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ShadowRunJob {
|
||||||
|
jobId: string
|
||||||
|
modelId: string
|
||||||
|
modelName: string
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
windowStart: string
|
||||||
|
windowEnd: string
|
||||||
|
tradingDays: number
|
||||||
|
startedAt: string
|
||||||
|
completedAt?: string
|
||||||
|
progress: number
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShadowRunJobFilter {
|
||||||
|
status?: string
|
||||||
|
modelId?: string
|
||||||
|
search?: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user