Files
KArtSell.Aegis/frontend/src/features/shadow-run/pages/ShadowRunQueue.vue
T
kjh2064 8974d6087c 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>
2026-08-15 10:45:13 +09:00

386 lines
8.9 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>