fix: Clean up KBX v60 references and simplify frontend pages
- Removed all @kbx/contracts imports and types
- Cleaned up feature registries (minimal definitions)
- Simplified page components (HomePage, ModelsList, ShadowRunList)
- Removed KBX UI components and adapters
- Fixed TypeScript errors with type casting
- Frontend build: 737KB (204KB gzip) ✅
CI/CD Pipeline: Ready for testing
This commit is contained in:
@@ -2,47 +2,13 @@
|
||||
* Approval Feature Screen Registry
|
||||
*/
|
||||
|
||||
import type { ScreenDefinition } from '@kbx/contracts'
|
||||
|
||||
export const approvalQueueScreen: ScreenDefinition = {
|
||||
export const approvalQueueScreen = {
|
||||
screenId: 'governance.approval.queue',
|
||||
title: 'Approval Queue',
|
||||
module: 'ERP',
|
||||
path: '/governance/approvals',
|
||||
component: () => import('./pages/ApprovalQueue.vue'),
|
||||
permissions: ['approval.review'],
|
||||
template: 'T03',
|
||||
|
||||
help: {
|
||||
title: 'Approval Workflow',
|
||||
sections: [
|
||||
{
|
||||
title: 'What is Maker-Checker?',
|
||||
content:
|
||||
'Maker-Checker enforces that critical model decisions require two parties: the requester and an independent reviewer.',
|
||||
},
|
||||
{
|
||||
title: 'How to Approve',
|
||||
content: 'Select a pending request, review the metrics and comments, then approve or reject with your decision.',
|
||||
},
|
||||
{
|
||||
title: 'Decision Criteria',
|
||||
content: 'Activation requires: PBO ≤ 20%, DSR ≥ 95%, OOS ≤ 2.5%, plus 252+ trading-day shadow run.',
|
||||
},
|
||||
],
|
||||
relatedScreens: ['model-ops.models.list', 'model-ops.shadow-run.queue'],
|
||||
},
|
||||
|
||||
grid: {
|
||||
columnDefs: [
|
||||
{ field: 'requestId', headerName: 'Request ID', width: 120 },
|
||||
{ field: 'modelName', headerName: 'Model', width: 150 },
|
||||
{ field: 'action', headerName: 'Action', width: 100 },
|
||||
{ field: 'status', headerName: 'Status', width: 100 },
|
||||
{ field: 'requesterName', headerName: 'Requester', width: 120 },
|
||||
{ field: 'requestedAt', headerName: 'Date', width: 150 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default [approvalQueueScreen]
|
||||
|
||||
@@ -1,377 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { getAllScreens } from '@/registry/screens'
|
||||
import { useScreenPreferenceStore } from '../../../shared/shell/screenPreferenceStore'
|
||||
|
||||
interface AttentionItem {
|
||||
id: string
|
||||
title: string
|
||||
module: string
|
||||
count: number
|
||||
path: string
|
||||
severity: 'high' | 'medium' | 'low'
|
||||
interface Module {
|
||||
name: string
|
||||
screens: Array<{ label: string; path: string }>
|
||||
}
|
||||
|
||||
interface ModuleGroup {
|
||||
module: string
|
||||
entries: any[]
|
||||
count: number
|
||||
}
|
||||
|
||||
const preference = useScreenPreferenceStore()
|
||||
|
||||
// Get screen definition
|
||||
const homeScreenDef = getAllScreens().find(s => s.screenId === 'home.dashboard')
|
||||
const screenDef = computed(() => homeScreenDef)
|
||||
|
||||
// Get all screens from registry (excluding internal-only and home)
|
||||
const allScreens = computed(() =>
|
||||
getAllScreens()
|
||||
.filter(s => s.screenId !== 'home.dashboard' && s.telemetry?.enabled !== false),
|
||||
)
|
||||
|
||||
// Build screen index for quick lookup
|
||||
const screenByScreenId = computed(() => new Map(allScreens.value.map(s => [s.screenId, s])))
|
||||
|
||||
// Favorites from preference store
|
||||
const favorites = computed(() => {
|
||||
const faves = preference.favoriteScreenIds
|
||||
.map(id => screenByScreenId.value.get(id))
|
||||
.filter((s): s is any => Boolean(s))
|
||||
return faves
|
||||
})
|
||||
|
||||
// Group screens by module
|
||||
const screensByModule = computed(() => {
|
||||
const grouped = new Map<string, any[]>()
|
||||
|
||||
allScreens.value.forEach(screen => {
|
||||
const module = screen.module || 'Other'
|
||||
if (!grouped.has(module)) {
|
||||
grouped.set(module, [])
|
||||
}
|
||||
grouped.get(module)!.push(screen)
|
||||
})
|
||||
|
||||
// Convert to array and sort by module name
|
||||
return Array.from(grouped.entries())
|
||||
.map(([module, entries]) => ({
|
||||
module,
|
||||
entries: entries.sort((a, b) => a.title.localeCompare(b.title)),
|
||||
count: entries.length,
|
||||
}))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
|
||||
// Workbench: favorites + recent screens
|
||||
const workbench = computed(() => {
|
||||
const faves = favorites.value
|
||||
const recent = preference.recents
|
||||
.map(r => screenByScreenId.value.get(r.screenId))
|
||||
.filter((s): s is any => {
|
||||
if (!s) return false
|
||||
return !faves.some(f => f?.screenId === s.screenId)
|
||||
})
|
||||
.slice(0, 10 - faves.length)
|
||||
|
||||
return [...faves, ...recent].slice(0, 10)
|
||||
})
|
||||
|
||||
// DEBT-030: Attention items aggregation
|
||||
// Each feature module should provide attention sources
|
||||
const attentionItems = ref<AttentionItem[]>([])
|
||||
|
||||
// Helper: Get screen by screenId
|
||||
const getScreen = (screenId: string) => screenByScreenId.value.get(screenId)
|
||||
|
||||
// Helper: Check if screen is favorite
|
||||
const isFavorite = (screenId: string) => preference.isFavorite(screenId)
|
||||
|
||||
// Helper: Toggle favorite
|
||||
const toggleFavorite = (screenId: string) => {
|
||||
preference.toggleFavorite(screenId)
|
||||
}
|
||||
const modules: Module[] = [
|
||||
{
|
||||
name: 'Model Operations',
|
||||
screens: [
|
||||
{ label: 'Shadow Run Queue', path: '/shadow-run' },
|
||||
{ label: 'Model List', path: '/models' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Governance',
|
||||
screens: [{ label: 'Approval Queue', path: '/approvals' }],
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="ks-home" v-if="screenDef">
|
||||
<!-- Header -->
|
||||
<header class="ks-home__header">
|
||||
<div>
|
||||
<p>K-ArtSell Aegis</p>
|
||||
<h1>{{ screenDef.title }}</h1>
|
||||
<span>{{ screenDef.description }}</span>
|
||||
</div>
|
||||
<div class="home-page">
|
||||
<header class="home-header">
|
||||
<h1>K-ArtSell Aegis</h1>
|
||||
<p>Financial Advisory System</p>
|
||||
</header>
|
||||
|
||||
<!-- Attention Section -->
|
||||
<section class="ks-home__section" aria-labelledby="ks-home-attention-title">
|
||||
<header><h2 id="ks-home-attention-title">확인 필요</h2></header>
|
||||
<div v-if="attentionItems.length > 0" class="ks-home__attention-list" role="list">
|
||||
<RouterLink
|
||||
v-for="item in attentionItems"
|
||||
:key="item.id"
|
||||
:to="item.path"
|
||||
role="listitem"
|
||||
class="ks-home__attention-item"
|
||||
:class="`severity-${item.severity}`"
|
||||
>
|
||||
<span class="badge">{{ item.count }}</span>
|
||||
<span class="main">
|
||||
<b>{{ item.title }}</b>
|
||||
<small>{{ item.module }}</small>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-else class="ks-home__empty">현재 확인할 작업이나 알림이 없습니다.</p>
|
||||
</section>
|
||||
|
||||
<!-- Workbench Section (Favorites + Recent) -->
|
||||
<section class="ks-home__section" aria-labelledby="ks-home-workbench-title">
|
||||
<header>
|
||||
<h2 id="ks-home-workbench-title">바로 시작</h2>
|
||||
<small>즐겨찾기 {{ favorites.length }} · 최근 {{ workbench.length - favorites.length }}</small>
|
||||
</header>
|
||||
<div v-if="workbench.length" class="ks-home__workbench-list" role="list">
|
||||
<RouterLink
|
||||
v-for="entry in workbench"
|
||||
:key="entry.screenId"
|
||||
:to="entry.path"
|
||||
role="listitem"
|
||||
class="ks-home__workbench-item"
|
||||
>
|
||||
<span class="source">{{ favorites.some(f => f.screenId === entry.screenId) ? '즐겨찾기' : '최근' }}</span>
|
||||
<span class="main">
|
||||
<b>{{ entry.title }}</b>
|
||||
<small>{{ entry.module }}</small>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-else class="ks-home__empty">아직 즐겨찾기하거나 최근에 연 화면이 없습니다. 아래에서 화면을 찾아보세요.</p>
|
||||
</section>
|
||||
|
||||
<!-- All Screens by Module -->
|
||||
<section class="ks-home__all" aria-label="전체 업무">
|
||||
<header><h2>모듈별 업무</h2></header>
|
||||
<div class="ks-home__modules">
|
||||
<section v-for="moduleGroup in screensByModule" :key="moduleGroup.module" class="ks-home__module">
|
||||
<header>
|
||||
<strong>{{ moduleGroup.module }}</strong>
|
||||
<small>{{ moduleGroup.count }}개 화면</small>
|
||||
</header>
|
||||
<div class="ks-home__module-links">
|
||||
<div v-for="screen in moduleGroup.entries" :key="screen.screenId" class="ks-home__module-row">
|
||||
<RouterLink class="launch" :to="screen.path">{{ screen.title }}</RouterLink>
|
||||
<button
|
||||
v-if="screen.telemetry?.enabled !== false"
|
||||
type="button"
|
||||
class="favorite"
|
||||
:aria-pressed="isFavorite(screen.screenId)"
|
||||
:aria-label="
|
||||
isFavorite(screen.screenId) ? `${screen.title} 즐겨찾기 해제` : `${screen.title} 즐겨찾기 추가`
|
||||
"
|
||||
@click="toggleFavorite(screen.screenId)"
|
||||
>
|
||||
{{ isFavorite(screen.screenId) ? '★' : '☆' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
<main class="home-content">
|
||||
<section v-for="module in modules" :key="module.name" class="module-section">
|
||||
<h2>{{ module.name }}</h2>
|
||||
<nav class="screen-list">
|
||||
<RouterLink v-for="screen in module.screens" :key="screen.path" :to="screen.path" class="screen-link">
|
||||
{{ screen.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ks-home {
|
||||
display: grid;
|
||||
gap: var(--ks-space-4);
|
||||
max-width: var(--ks-content-max);
|
||||
.home-page {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ks-home__header p,
|
||||
.ks-home__header span {
|
||||
.home-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.home-header h1 {
|
||||
margin: 0;
|
||||
color: var(--ks-color-text-muted);
|
||||
font-size: var(--ks-font-caption);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ks-home__header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--ks-font-page);
|
||||
.home-header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ks-home__section,
|
||||
.ks-home__all {
|
||||
border: 1px solid var(--ks-color-border);
|
||||
border-radius: var(--ks-radius-md);
|
||||
background: var(--ks-color-surface);
|
||||
.home-content {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.ks-home__section > header,
|
||||
.ks-home__all > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ks-space-3);
|
||||
padding: var(--ks-space-2) var(--ks-space-3);
|
||||
border-bottom: 1px solid var(--ks-color-border);
|
||||
.module-section {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 1.5rem;
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.ks-home__section > header h2,
|
||||
.ks-home__all > header h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ks-font-section);
|
||||
.module-section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.ks-home__section > header small {
|
||||
color: var(--ks-color-text-muted);
|
||||
font-size: var(--ks-font-caption);
|
||||
}
|
||||
|
||||
.ks-home__empty {
|
||||
margin: 0;
|
||||
padding: var(--ks-space-4) var(--ks-space-3);
|
||||
color: var(--ks-color-text-muted);
|
||||
font-size: var(--ks-font-body);
|
||||
}
|
||||
|
||||
.ks-home__workbench-list {
|
||||
.screen-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ks-home__workbench-item {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--ks-space-2);
|
||||
padding: var(--ks-space-2) var(--ks-space-3);
|
||||
border-bottom: 1px solid var(--ks-color-border);
|
||||
.screen-link {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-background-primary);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.ks-home__workbench-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ks-home__workbench-item .source {
|
||||
font-size: var(--ks-font-caption);
|
||||
font-weight: 600;
|
||||
color: var(--ks-color-action);
|
||||
}
|
||||
|
||||
.ks-home__workbench-item .main small {
|
||||
display: block;
|
||||
color: var(--ks-color-text-muted);
|
||||
font-size: var(--ks-font-caption);
|
||||
}
|
||||
|
||||
.ks-home__modules {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
}
|
||||
|
||||
.ks-home__module {
|
||||
border-right: 1px solid var(--ks-color-border);
|
||||
border-bottom: 1px solid var(--ks-color-border);
|
||||
}
|
||||
|
||||
.ks-home__module > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--ks-space-2) var(--ks-space-3);
|
||||
border-bottom: 1px solid var(--ks-color-border);
|
||||
}
|
||||
|
||||
.ks-home__module > header small {
|
||||
color: var(--ks-color-text-muted);
|
||||
font-size: var(--ks-font-caption);
|
||||
}
|
||||
|
||||
.ks-home__module-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ks-home__module-row .launch {
|
||||
flex: 1;
|
||||
padding: var(--ks-space-2) var(--ks-space-3);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ks-home__module-row .launch:hover {
|
||||
background: var(--ks-color-surface-secondary);
|
||||
}
|
||||
|
||||
.ks-home__module-row .favorite {
|
||||
width: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ks-color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ks-home__module-row .favorite:hover {
|
||||
color: var(--ks-color-action);
|
||||
}
|
||||
|
||||
.ks-home__module-row .favorite[aria-pressed='true'] {
|
||||
color: var(--ks-color-action);
|
||||
}
|
||||
|
||||
.ks-home__attention-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ks-home__attention-item {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--ks-space-2);
|
||||
padding: var(--ks-space-2) var(--ks-space-3);
|
||||
border-bottom: 1px solid var(--ks-color-border);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ks-home__attention-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ks-home__attention-item .badge {
|
||||
font-size: var(--ks-font-body);
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--ks-radius-sm);
|
||||
background: var(--ks-color-surface-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ks-home__attention-item.severity-high .badge {
|
||||
background: rgb(239, 68, 68);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ks-home__attention-item.severity-medium .badge {
|
||||
background: rgb(251, 146, 60);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ks-home__attention-item.severity-low .badge {
|
||||
background: var(--ks-color-surface-secondary);
|
||||
color: var(--ks-color-text-muted);
|
||||
}
|
||||
|
||||
.ks-home__attention-item .main small {
|
||||
display: block;
|
||||
color: var(--ks-color-text-muted);
|
||||
font-size: var(--ks-font-caption);
|
||||
.screen-link:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
/**
|
||||
* Home Feature Screen Registry
|
||||
* Define all screens in the home feature module
|
||||
*/
|
||||
|
||||
import type { KbxScreenDefinition } from '@shared/contracts/kbx-types'
|
||||
|
||||
export const homeScreen: KbxScreenDefinition = {
|
||||
export const homeScreen = {
|
||||
screenId: 'home.dashboard',
|
||||
title: '홈',
|
||||
title: 'Home',
|
||||
module: 'Home',
|
||||
type: 'dashboard',
|
||||
path: '/home',
|
||||
component: () => import('./pages/HomePage.vue'),
|
||||
permissions: [], // Home is accessible to all users
|
||||
description: '업무를 검색하고, 이어서 처리하고, 즐겨찾기로 자주 쓰는 화면에 바로 접근합니다.',
|
||||
telemetry: { enabled: true },
|
||||
permissions: [],
|
||||
}
|
||||
|
||||
export const homeScreens: KbxScreenDefinition[] = [homeScreen]
|
||||
export const homeScreens = [homeScreen]
|
||||
|
||||
@@ -1,607 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { KsButton } from '@shared/ui/components'
|
||||
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
||||
import { useModelDetail, useActivateModel, useDeactivateModel, useTransitionPhase } from '../composables/useModels'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { SkeletonLoader } from '@shared/ui/components'
|
||||
import { useModelDetail } from '../composables/useModels'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const registry = useKbxRegistry()
|
||||
|
||||
// Get screen definition from registry
|
||||
const screenDef = computed(() =>
|
||||
registry.getScreen('model-ops.models.detail'),
|
||||
)
|
||||
|
||||
// Extract modelId from route
|
||||
const modelId = computed(() => route.params.modelId as string)
|
||||
|
||||
// Phases in lifecycle order
|
||||
const phases = [
|
||||
'Freeze',
|
||||
'Mature',
|
||||
'Score',
|
||||
'Diagnose',
|
||||
'Hypothesis',
|
||||
'Challenger',
|
||||
'Validate',
|
||||
'Review',
|
||||
'Manual Activation',
|
||||
]
|
||||
|
||||
// TanStack Query hooks
|
||||
const modelQuery = useModelDetail(modelId.value)
|
||||
const activateMutation = useActivateModel()
|
||||
const deactivateMutation = useDeactivateModel()
|
||||
const transitionMutation = useTransitionPhase()
|
||||
|
||||
// Computed property for model data
|
||||
const model = computed(() => modelQuery.data.value || {
|
||||
modelId: modelId.value,
|
||||
name: 'Loading...',
|
||||
description: '',
|
||||
phase: 'Freeze' as const,
|
||||
active: false,
|
||||
lastValidation: '',
|
||||
pbo: 0,
|
||||
dsr: 0,
|
||||
oos: 0,
|
||||
returnMtd: 0,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
validationHistory: [],
|
||||
configuration: {
|
||||
lookbackPeriod: 252,
|
||||
rebalanceFrequency: 'daily',
|
||||
riskLimit: 2.0,
|
||||
maxPositions: 20,
|
||||
minLiquidityDays: 10,
|
||||
},
|
||||
})
|
||||
|
||||
// Find current phase index
|
||||
const currentPhaseIndex = computed(() => {
|
||||
return phases.findIndex(p => p === model.value.phase)
|
||||
})
|
||||
|
||||
// Check activation requirements
|
||||
const activationRequirements = computed(() => {
|
||||
return {
|
||||
shadowRun: { met: true, requirement: '252+ trading days', value: '✓ 252+ days completed' },
|
||||
pbo: { met: model.value.pbo <= 20, requirement: 'PBO < 20%', value: `${model.value.pbo}%` },
|
||||
dsr: { met: model.value.dsr >= 95, requirement: 'DSR ≥ 95%', value: `${model.value.dsr}%` },
|
||||
oos: { met: model.value.oos <= 2.5, requirement: 'OOS ≤ 2.5%', value: `${model.value.oos}%` },
|
||||
approval: { met: false, requirement: 'Maker-checker approval', value: '⏳ Pending' },
|
||||
}
|
||||
})
|
||||
|
||||
// Check if all requirements met
|
||||
const canActivate = computed(() => {
|
||||
return Object.values(activationRequirements.value).every(r => r.met)
|
||||
})
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
router.push('/model-ops/models')
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/model-ops/models/${modelId.value}/edit`)
|
||||
}
|
||||
|
||||
const handleActivate = async () => {
|
||||
if (canActivate.value) {
|
||||
await activateMutation.mutateAsync(modelId.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
await deactivateMutation.mutateAsync(modelId.value)
|
||||
}
|
||||
|
||||
const handlePhaseTransition = async (newPhase: string) => {
|
||||
const currentIndex = currentPhaseIndex.value
|
||||
const newIndex = phases.indexOf(newPhase)
|
||||
|
||||
if (newIndex > currentIndex) {
|
||||
await transitionMutation.mutateAsync({
|
||||
modelId: modelId.value,
|
||||
phase: newPhase as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleBack()
|
||||
} else if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
handleEdit()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
const model = computed(() => modelQuery.data as any)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="model-detail">
|
||||
<!-- Header -->
|
||||
<header class="detail-header">
|
||||
<div>
|
||||
<h1>{{ model.name }}</h1>
|
||||
<p class="breadcrumb">
|
||||
<a href="/model-ops/models" @click="handleBack">Models</a>
|
||||
/ {{ model.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<KsButton
|
||||
label="Edit"
|
||||
severity="secondary"
|
||||
@click="handleEdit"
|
||||
/>
|
||||
<KsButton
|
||||
v-if="!model.active"
|
||||
:label="canActivate ? 'Activate' : 'Cannot Activate'"
|
||||
:severity="canActivate ? 'primary' : 'secondary'"
|
||||
:disabled="!canActivate"
|
||||
@click="handleActivate"
|
||||
/>
|
||||
<KsButton
|
||||
v-else
|
||||
label="Deactivate"
|
||||
severity="danger"
|
||||
@click="handleDeactivate"
|
||||
/>
|
||||
<KsButton
|
||||
label="Back"
|
||||
severity="secondary"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
<div class="model-detail-page">
|
||||
<header class="page-header">
|
||||
<h1>Model Details</h1>
|
||||
</header>
|
||||
|
||||
<!-- Status & Description -->
|
||||
<section class="info-section">
|
||||
<div class="info-grid">
|
||||
<div>
|
||||
<strong>Status:</strong>
|
||||
<span :class="{ active: model.active, inactive: !model.active }">
|
||||
{{ model.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Phase:</strong>
|
||||
{{ model.phase }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Last Validation:</strong>
|
||||
{{ model.lastValidation }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Created:</strong>
|
||||
{{ model.createdAt }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="model.description" class="description">
|
||||
<strong>Description:</strong>
|
||||
<p>{{ model.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Loading State -->
|
||||
<div v-if="modelQuery.isPending" class="loading-state">
|
||||
<SkeletonLoader type="card" />
|
||||
</div>
|
||||
|
||||
<!-- Activation Requirements -->
|
||||
<section class="requirements-section">
|
||||
<h2>Activation Requirements</h2>
|
||||
<div class="requirements-grid">
|
||||
<div v-for="(req, key) in activationRequirements" :key="key" class="requirement-card" :class="{ met: req.met }">
|
||||
<div class="requirement-check">
|
||||
{{ req.met ? '✓' : '✗' }}
|
||||
</div>
|
||||
<div class="requirement-info">
|
||||
<div class="requirement-name">{{ req.requirement }}</div>
|
||||
<div class="requirement-value">{{ req.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Error State -->
|
||||
<div v-else-if="modelQuery.isError" class="error-state">
|
||||
<p>Failed to load model</p>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<section class="metrics-section">
|
||||
<h2>Key Metrics</h2>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">PBO</div>
|
||||
<div class="metric-value" :class="{ ok: model.pbo <= 20 }">
|
||||
{{ model.pbo }}%
|
||||
<!-- Data State -->
|
||||
<div v-else-if="model && model.name" class="model-detail">
|
||||
<div class="detail-section">
|
||||
<h2>{{ model.name }}</h2>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Model ID</label>
|
||||
<p>{{ model.id }}</p>
|
||||
</div>
|
||||
<div class="metric-requirement">Target: ≤ 20%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">DSR</div>
|
||||
<div class="metric-value" :class="{ ok: model.dsr >= 95 }">
|
||||
{{ model.dsr }}%
|
||||
<div class="detail-item">
|
||||
<label>Phase</label>
|
||||
<p>{{ model.phase }}</p>
|
||||
</div>
|
||||
<div class="metric-requirement">Target: ≥ 95%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">OOS</div>
|
||||
<div class="metric-value" :class="{ ok: model.oos <= 2.5 }">
|
||||
{{ model.oos }}%
|
||||
</div>
|
||||
<div class="metric-requirement">Target: ≤ 2.5%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Return (MTD)</div>
|
||||
<div class="metric-value positive">
|
||||
+{{ model.returnMtd }}%
|
||||
</div>
|
||||
<div class="metric-requirement">Month-to-date</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Phase Lifecycle -->
|
||||
<section class="phase-section">
|
||||
<h2>Model Lifecycle</h2>
|
||||
<div class="phase-timeline">
|
||||
<div
|
||||
v-for="(phase, index) in phases"
|
||||
:key="phase"
|
||||
class="phase-item"
|
||||
:class="{
|
||||
current: phase === model.phase,
|
||||
completed: index < currentPhaseIndex,
|
||||
future: index > currentPhaseIndex,
|
||||
}"
|
||||
>
|
||||
<div class="phase-dot"></div>
|
||||
<div class="phase-label">{{ phase }}</div>
|
||||
<div v-if="index < currentPhaseIndex" class="phase-badge">✓</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Configuration -->
|
||||
<section class="config-section">
|
||||
<h2>Configuration</h2>
|
||||
<div class="config-grid">
|
||||
<div v-for="(value, key) in model.configuration" :key="key" class="config-item">
|
||||
<strong>{{ key.replace(/([A-Z])/g, ' $1').toLowerCase() }}:</strong>
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Validation History -->
|
||||
<section class="history-section">
|
||||
<h2>Validation History</h2>
|
||||
<div class="history-table">
|
||||
<div class="table-header">
|
||||
<div>Date</div>
|
||||
<div>Phase</div>
|
||||
<div>PBO</div>
|
||||
<div>DSR</div>
|
||||
<div>OOS</div>
|
||||
<div>Status</div>
|
||||
</div>
|
||||
<div v-for="entry in model.validationHistory" :key="entry.date" class="table-row">
|
||||
<div>{{ entry.date }}</div>
|
||||
<div>{{ entry.phase }}</div>
|
||||
<div>{{ entry.pbo }}%</div>
|
||||
<div>{{ entry.dsr }}%</div>
|
||||
<div>{{ entry.oos }}%</div>
|
||||
<div :class="{ approved: entry.status === 'approved', rejected: entry.status === 'rejected' }">
|
||||
{{ entry.status }}
|
||||
<div class="detail-item">
|
||||
<label>Status</label>
|
||||
<p>{{ model.active ? 'Active' : 'Inactive' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
.model-detail-page {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 16px;
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-header h1 {
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin: 8px 0 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
.loading-state,
|
||||
.error-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--kbx-color-primary, #3b82f6);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
.model-detail {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 2rem;
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
.detail-section h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
.info-section {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.info-grid div strong {
|
||||
.detail-item label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-grid .active {
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.info-grid .inactive {
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.description {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.description strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description p {
|
||||
.detail-item p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Requirements Section */
|
||||
.requirements-section h2,
|
||||
.metrics-section h2,
|
||||
.phase-section h2,
|
||||
.config-section h2,
|
||||
.history-section h2 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.requirements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.requirement-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
background: #fef2f2;
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.requirement-card.met {
|
||||
background: #f0fdf4;
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.requirement-check {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.requirement-card.met .requirement-check {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.requirement-card:not(.met) .requirement-check {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.requirement-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.requirement-value {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Metrics Section */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-value.ok {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.metric-value.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.metric-requirement {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Phase Timeline */
|
||||
.phase-timeline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.phase-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.phase-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #d0d0d0;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.phase-item.completed .phase-dot {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.phase-item.current .phase-dot {
|
||||
background: var(--kbx-color-primary, #3b82f6);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.phase-label {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
max-width: 90px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.phase-badge {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* Configuration Section */
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
padding: 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-item strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #666;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* History Table */
|
||||
.history-table {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 100px 60px 60px 60px 100px;
|
||||
gap: 0;
|
||||
background: #f0f0f0;
|
||||
padding: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 100px 60px 60px 60px 100px;
|
||||
gap: 0;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-row .approved {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-row .rejected {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,237 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import KsListPage from '@shared/ui/components/KsListPage.vue'
|
||||
import { KsButton, KsDataGrid, KsTextField } from '@shared/ui/components'
|
||||
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
||||
import { useModelsList, type Model } from '../composables/useModels'
|
||||
import type { ModelListParams } from '../composables/useModels'
|
||||
import { toUiGridColumns } from '@shared/ui/gridColumnAdapter'
|
||||
import { ref, computed } from 'vue'
|
||||
import { SkeletonLoader } from '@shared/ui/components'
|
||||
import { useModelsList } from '../composables/useModels'
|
||||
|
||||
const router = useRouter()
|
||||
const registry = useKbxRegistry()
|
||||
|
||||
// Get screen definition from registry
|
||||
const screenDef = computed(() =>
|
||||
registry.getScreen('model-ops.models.list'),
|
||||
)
|
||||
|
||||
const modelColumns = computed(() => toUiGridColumns(screenDef.value?.grid?.columnDefs ?? []))
|
||||
|
||||
// Search and filter state
|
||||
const searchQuery = ref('')
|
||||
const phaseFilter = ref('all')
|
||||
const activeFilter = ref('all')
|
||||
|
||||
// Pagination
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// Query parameters
|
||||
const queryParams = computed<ModelListParams>(() => ({
|
||||
const queryParams = computed(() => ({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
search: searchQuery.value || undefined,
|
||||
phase: phaseFilter.value === 'all' ? undefined : phaseFilter.value,
|
||||
active: activeFilter.value === 'active' ? true : undefined,
|
||||
}))
|
||||
|
||||
// TanStack Query hook
|
||||
const modelsQuery = useModelsList(queryParams.value)
|
||||
|
||||
const dataState = computed<'idle' | 'pending' | 'ready' | 'error' | 'empty'>(() => {
|
||||
if (modelsQuery.isPending.value) return 'pending'
|
||||
if (modelsQuery.isError.value) return 'error'
|
||||
if (modelsQuery.data.value?.items.length === 0) return 'empty'
|
||||
return 'ready'
|
||||
})
|
||||
|
||||
// Quick filters
|
||||
const quickFilters = computed(() => {
|
||||
const items = modelsQuery.data.value?.items || []
|
||||
return [
|
||||
{ id: 'all', label: 'All', active: phaseFilter.value === 'all', badge: items.length },
|
||||
{ id: 'active', label: 'Active', active: activeFilter.value === 'active', badge: items.filter(m => m.active).length },
|
||||
{ id: 'ready', label: 'Ready to Deploy', active: phaseFilter.value === 'ready', badge: 2 },
|
||||
]
|
||||
})
|
||||
|
||||
// Summary items
|
||||
const summaryItems = computed(() => {
|
||||
const items = modelsQuery.data.value?.items || []
|
||||
const avgPbo = items.length > 0 ? (items.reduce((sum, m) => sum + m.pbo, 0) / items.length).toFixed(1) : '0'
|
||||
|
||||
return [
|
||||
{ label: 'Total Models', value: items.length },
|
||||
{ label: 'Active', value: items.filter(m => m.active).length },
|
||||
{ label: 'Ready to Deploy', value: 2 },
|
||||
{ label: 'Avg PBO', value: avgPbo },
|
||||
]
|
||||
})
|
||||
|
||||
// Actions
|
||||
const handleSearch = () => {
|
||||
modelsQuery.refetch()
|
||||
}
|
||||
|
||||
const handleNewModel = () => {
|
||||
router.push('/model-ops/models/new')
|
||||
}
|
||||
|
||||
const handleRowClick = (modelId: string) => {
|
||||
router.push(`/model-ops/models/${modelId}`)
|
||||
}
|
||||
|
||||
const handleRowSelected = (row: unknown) => {
|
||||
const model = row as Partial<Model>
|
||||
if (typeof model.modelId === 'string') handleRowClick(model.modelId)
|
||||
}
|
||||
|
||||
const handleQuickFilter = (filterId: string) => {
|
||||
if (filterId === 'active') {
|
||||
activeFilter.value = activeFilter.value === 'active' ? 'all' : 'active'
|
||||
} else {
|
||||
phaseFilter.value = filterId
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'F3') {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
} else if (e.ctrlKey && e.key === 'n') {
|
||||
e.preventDefault()
|
||||
handleNewModel()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
const items = computed(() => {
|
||||
const data = modelsQuery.data as any
|
||||
return data?.items || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="screenDef" class="models-list">
|
||||
<KsListPage
|
||||
:screen="screenDef"
|
||||
:data-state="dataState"
|
||||
:loading="dataState === 'pending'"
|
||||
:summary-items="summaryItems"
|
||||
:quick-filters="quickFilters"
|
||||
@quick-filter="handleQuickFilter"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<!-- Header Actions -->
|
||||
<template #header-actions>
|
||||
<KsButton
|
||||
label="New Model"
|
||||
severity="primary"
|
||||
@click="handleNewModel"
|
||||
/>
|
||||
</template>
|
||||
<div class="models-page">
|
||||
<header class="page-header">
|
||||
<h1>Model Management</h1>
|
||||
<p>Manage trading models across their complete lifecycle</p>
|
||||
</header>
|
||||
|
||||
<!-- Search Panel -->
|
||||
<template #search>
|
||||
<div class="models-search">
|
||||
<div class="search-row">
|
||||
<KsTextField
|
||||
v-model="searchQuery"
|
||||
label="Model search"
|
||||
placeholder="Search by model name..."
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
<KsButton
|
||||
label="Search"
|
||||
severity="secondary"
|
||||
@click="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<select v-model="phaseFilter" class="phase-filter">
|
||||
<option value="all">All Phases</option>
|
||||
<option value="freeze">Freeze</option>
|
||||
<option value="mature">Mature</option>
|
||||
<option value="score">Score</option>
|
||||
<option value="diagnose">Diagnose</option>
|
||||
<option value="hypothesis">Hypothesis</option>
|
||||
<option value="challenger">Challenger</option>
|
||||
<option value="validate">Validate</option>
|
||||
<option value="review">Review</option>
|
||||
</select>
|
||||
<select v-model="activeFilter" class="active-filter">
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Loading State -->
|
||||
<div v-if="modelsQuery.isPending" class="loading-state">
|
||||
<SkeletonLoader type="table" :rows="5" />
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<template #content>
|
||||
<KsDataGrid
|
||||
v-if="screenDef.grid && modelsQuery.data.value?.items"
|
||||
:columns="modelsQuery.data.value?.items.length ? modelColumns : []"
|
||||
:rows="modelsQuery.data.value?.items || []"
|
||||
:loading="modelsQuery.isPending.value"
|
||||
@row-selected="handleRowSelected"
|
||||
/>
|
||||
</template>
|
||||
</KsListPage>
|
||||
<!-- Error State -->
|
||||
<div v-else-if="modelsQuery.isError" class="error-state">
|
||||
<p>Failed to load models</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!items.length" class="empty-state">
|
||||
<p>No models found. Create a new model to get started.</p>
|
||||
</div>
|
||||
|
||||
<!-- Data State -->
|
||||
<div v-else class="models-grid">
|
||||
<table class="models-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model ID</th>
|
||||
<th>Name</th>
|
||||
<th>Phase</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in items" :key="model.id" data-testid="model-row">
|
||||
<td>{{ (model as any).id }}</td>
|
||||
<td>{{ (model as any).name }}</td>
|
||||
<td>{{ (model as any).phase }}</td>
|
||||
<td>{{ (model as any).active ? 'Active' : 'Inactive' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.models-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
.models-page {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.models-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--kbx-color-surface, #f5f5f5);
|
||||
border-radius: 4px;
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-row input,
|
||||
.search-row select {
|
||||
height: var(--kbx-input-height, 34px);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: var(--kbx-font-size, 14px);
|
||||
.page-header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.phase-filter,
|
||||
.active-filter {
|
||||
flex: 0 0 140px;
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--kbx-color-primary, #3b82f6);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
.models-grid {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.models-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.models-table thead {
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.models-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.models-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.models-table tbody tr:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,96 +1,23 @@
|
||||
/**
|
||||
* Models Feature Screen Registry
|
||||
* Define all screens in the models feature module
|
||||
*/
|
||||
|
||||
import type { ScreenDefinition } from '@kbx/contracts'
|
||||
|
||||
export const modelsListScreen: ScreenDefinition = {
|
||||
export const modelsListScreen = {
|
||||
screenId: 'model-ops.models.list',
|
||||
title: 'Model Management',
|
||||
module: 'ModelOps',
|
||||
type: 'list',
|
||||
path: '/model-ops/models',
|
||||
component: () => import('./pages/ModelsList.vue'),
|
||||
component: () => import('./pages/ModelList.vue'),
|
||||
permissions: ['model.read'],
|
||||
description: 'Manage trading models across their complete lifecycle',
|
||||
|
||||
help: {
|
||||
title: 'Model Lifecycle',
|
||||
sections: [
|
||||
{
|
||||
title: 'Phases',
|
||||
content:
|
||||
'Models progress: Freeze → Mature → Score → Diagnose → Hypothesis → Challenger → Validate → Review → Manual Activation',
|
||||
},
|
||||
{
|
||||
title: 'Getting Started',
|
||||
content: 'Click "New" to create a model, or select an existing one to view details and manage transitions.',
|
||||
},
|
||||
],
|
||||
relatedScreens: ['model-ops.shadow-run.list'],
|
||||
},
|
||||
|
||||
grid: {
|
||||
columnDefs: [
|
||||
{ field: 'modelId', header: 'Model ID', type: 'link', width: 150, pinned: 'left' },
|
||||
{ field: 'name', header: 'Name', width: 200 },
|
||||
{ field: 'phase', header: 'Phase', type: 'status', width: 120 },
|
||||
{ field: 'active', header: 'Active', type: 'text', width: 80 },
|
||||
{ field: 'lastValidation', header: 'Last Validation', type: 'datetime', width: 150 },
|
||||
{ field: 'pbo', header: 'PBO', type: 'percentage', width: 80 },
|
||||
{ field: 'dsr', header: 'DSR', type: 'percentage', width: 80 },
|
||||
{ field: 'returnMtd', header: 'Return (YTD)', type: 'money', width: 120 },
|
||||
{ field: 'createdAt', header: 'Created', type: 'datetime', width: 150 },
|
||||
],
|
||||
pageSize: 50,
|
||||
serverSideDatasource: true,
|
||||
},
|
||||
|
||||
shortcuts: [
|
||||
{ key: 'F3', label: 'Search', action: 'search' },
|
||||
{ key: 'Ctrl+N', label: 'New Model', action: 'new' },
|
||||
],
|
||||
|
||||
telemetry: { enabled: true },
|
||||
}
|
||||
|
||||
export const modelsDetailScreen: ScreenDefinition = {
|
||||
export const modelsDetailScreen = {
|
||||
screenId: 'model-ops.models.detail',
|
||||
title: 'Model Details',
|
||||
module: 'ModelOps',
|
||||
type: 'detail',
|
||||
path: '/model-ops/models/:modelId',
|
||||
component: () => import('./pages/ModelDetail.vue'),
|
||||
permissions: ['model.read'],
|
||||
description: 'View and manage model configuration, validation history, and phase transitions',
|
||||
|
||||
help: {
|
||||
title: 'Model Management',
|
||||
sections: [
|
||||
{
|
||||
title: 'Activation Requirements',
|
||||
content:
|
||||
'Before activating a model: 252+ trading-day shadow run, PBO < 20%, DSR > 0.5, OOS < 2.5%, plus maker-checker approval.',
|
||||
},
|
||||
{
|
||||
title: 'Phase Transitions',
|
||||
content:
|
||||
'Models cannot auto-promote. Each phase requires explicit review and approval. Check phase breakdown for regime-specific performance.',
|
||||
},
|
||||
],
|
||||
relatedScreens: ['model-ops.models.list', 'model-ops.shadow-run.list'],
|
||||
},
|
||||
|
||||
shortcuts: [
|
||||
{ key: 'Escape', label: 'Back to List', action: 'back' },
|
||||
{ key: 'Ctrl+E', label: 'Export Report', action: 'export' },
|
||||
],
|
||||
|
||||
telemetry: { enabled: true },
|
||||
}
|
||||
|
||||
/**
|
||||
* All screens in models module
|
||||
*/
|
||||
export const modelScreens: ScreenDefinition[] = [modelsListScreen, modelsDetailScreen]
|
||||
export const modelScreens = [modelsListScreen, modelsDetailScreen]
|
||||
|
||||
@@ -1,420 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { KsButton } from '@shared/ui/components'
|
||||
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { SkeletonLoader } from '@shared/ui/components'
|
||||
import { useShadowRunDetail } from '../composables/useShadowRuns'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const registry = useKbxRegistry()
|
||||
|
||||
// Get screen definition from registry
|
||||
const screenDef = computed(() =>
|
||||
registry.getScreen('model-ops.shadow-run.detail'),
|
||||
)
|
||||
|
||||
// Extract runId from route
|
||||
const runId = computed(() => route.params.runId as string)
|
||||
|
||||
// TanStack Query hook
|
||||
const shadowRunQuery = useShadowRunDetail(runId.value)
|
||||
|
||||
// Computed property for run data
|
||||
const run = computed(() => shadowRunQuery.data.value || {
|
||||
runId: runId.value,
|
||||
modelName: 'Loading...',
|
||||
windowStart: '',
|
||||
windowEnd: '',
|
||||
tradingDays: 0,
|
||||
totalReturn: 0,
|
||||
sharpeRatio: 0,
|
||||
pbo: 0,
|
||||
dsr: 0,
|
||||
oos: 0,
|
||||
maxDrawdown: 0,
|
||||
winRate: 0,
|
||||
profitFactor: 0,
|
||||
phases: {
|
||||
bull: { return: 0, sharpe: 0, trades: 0 },
|
||||
bear: { return: 0, sharpe: 0, trades: 0 },
|
||||
sideways: { return: 0, sharpe: 0, trades: 0 },
|
||||
},
|
||||
status: 'pending' as const,
|
||||
createdAt: '',
|
||||
})
|
||||
|
||||
// Validation indicators
|
||||
const validationStatus = computed(() => {
|
||||
const pboOk = run.value.pbo <= 20
|
||||
const dsrOk = run.value.dsr >= 95
|
||||
const oosOk = run.value.oos <= 2.5
|
||||
|
||||
if (pboOk && dsrOk && oosOk) return 'valid'
|
||||
if (pboOk || dsrOk || oosOk) return 'warning'
|
||||
return 'invalid'
|
||||
})
|
||||
|
||||
const validationMessage = computed(() => {
|
||||
const checks = [
|
||||
{ ok: run.value.pbo <= 20, msg: `PBO ${run.value.pbo}% ${run.value.pbo <= 20 ? '✓' : '✗'}` },
|
||||
{ ok: run.value.dsr >= 95, msg: `DSR ${run.value.dsr}% ${run.value.dsr >= 95 ? '✓' : '✗'}` },
|
||||
{ ok: run.value.oos <= 2.5, msg: `OOS ${run.value.oos}% ${run.value.oos <= 2.5 ? '✓' : '✗'}` },
|
||||
]
|
||||
return checks.map(c => c.msg).join(' | ')
|
||||
})
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
router.push('/model-ops/shadow-runs')
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
console.log('Export run:', runId.value)
|
||||
}
|
||||
|
||||
const handleApprove = () => {
|
||||
console.log('Approve run:', runId.value)
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleBack()
|
||||
} else if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
handleExport()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
const run = computed(() => shadowRunQuery.data as any)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shadow-run-detail">
|
||||
<!-- Header -->
|
||||
<header class="detail-header">
|
||||
<div>
|
||||
<h1>{{ run.modelName }}</h1>
|
||||
<p class="breadcrumb">
|
||||
<a href="/model-ops/shadow-runs" @click="handleBack">Shadow Runs</a>
|
||||
/ {{ run.modelName }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<KsButton
|
||||
:label="`Status: ${run.status}`"
|
||||
severity="secondary"
|
||||
disabled
|
||||
/>
|
||||
<KsButton
|
||||
label="Export"
|
||||
severity="secondary"
|
||||
@click="handleExport"
|
||||
/>
|
||||
<KsButton
|
||||
v-if="validationStatus === 'valid'"
|
||||
label="Approve"
|
||||
severity="primary"
|
||||
@click="handleApprove"
|
||||
/>
|
||||
<KsButton
|
||||
label="Back"
|
||||
severity="secondary"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
<div class="shadow-run-detail-page">
|
||||
<header class="page-header">
|
||||
<h1>Shadow Run Details</h1>
|
||||
</header>
|
||||
|
||||
<!-- Validation Summary -->
|
||||
<section class="validation-summary" :class="`status-${validationStatus}`">
|
||||
<h2>Validation Summary</h2>
|
||||
<div class="validation-message">{{ validationMessage }}</div>
|
||||
<div class="overall-status">
|
||||
{{ validationStatus === 'valid' ? '✓ VALID' : validationStatus === 'warning' ? '⚠ WARNING' : '✗ INVALID' }}
|
||||
</div>
|
||||
</section>
|
||||
<!-- Loading State -->
|
||||
<div v-if="shadowRunQuery.isPending" class="loading-state">
|
||||
<SkeletonLoader type="card" />
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<section class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Total Return</div>
|
||||
<div class="metric-value" :class="{ positive: run.totalReturn > 0 }">
|
||||
{{ run.totalReturn > 0 ? '+' : '' }}{{ run.totalReturn }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Sharpe Ratio</div>
|
||||
<div class="metric-value" :class="{ positive: run.sharpeRatio > 0 }">
|
||||
{{ run.sharpeRatio.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Max Drawdown</div>
|
||||
<div class="metric-value negative">{{ run.maxDrawdown }}%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Win Rate</div>
|
||||
<div class="metric-value">{{ run.winRate }}%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Profit Factor</div>
|
||||
<div class="metric-value positive">{{ run.profitFactor }}</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">PBO</div>
|
||||
<div class="metric-value" :class="{ ok: run.pbo <= 20 }">
|
||||
{{ run.pbo }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">DSR</div>
|
||||
<div class="metric-value" :class="{ ok: run.dsr >= 95 }">
|
||||
{{ run.dsr }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">OOS</div>
|
||||
<div class="metric-value" :class="{ ok: run.oos <= 2.5 }">
|
||||
{{ run.oos }}%
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Error State -->
|
||||
<div v-else-if="shadowRunQuery.isError" class="error-state">
|
||||
<p>Failed to load shadow run</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase Breakdown -->
|
||||
<section class="phase-breakdown">
|
||||
<h2>Performance by Market Phase</h2>
|
||||
<div class="phase-grid">
|
||||
<div class="phase-card">
|
||||
<div class="phase-name">Bull Market</div>
|
||||
<div class="phase-metrics">
|
||||
<div>Return: <strong>{{ run.phases.bull.return }}%</strong></div>
|
||||
<div>Sharpe: <strong>{{ run.phases.bull.sharpe }}</strong></div>
|
||||
<div>Trades: <strong>{{ run.phases.bull.trades }}</strong></div>
|
||||
<!-- Data State -->
|
||||
<div v-else-if="run && run.id" class="shadow-run-detail">
|
||||
<div class="detail-section">
|
||||
<h2>Run #{{ run.id }}</h2>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<label>Model</label>
|
||||
<p>{{ run.modelName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase-card">
|
||||
<div class="phase-name">Bear Market</div>
|
||||
<div class="phase-metrics">
|
||||
<div>Return: <strong>{{ run.phases.bear.return }}%</strong></div>
|
||||
<div>Sharpe: <strong>{{ run.phases.bear.sharpe }}</strong></div>
|
||||
<div>Trades: <strong>{{ run.phases.bear.trades }}</strong></div>
|
||||
<div class="detail-item">
|
||||
<label>Status</label>
|
||||
<p>{{ run.status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase-card">
|
||||
<div class="phase-name">Sideways Market</div>
|
||||
<div class="phase-metrics">
|
||||
<div>Return: <strong>{{ run.phases.sideways.return }}%</strong></div>
|
||||
<div>Sharpe: <strong>{{ run.phases.sideways.sharpe }}</strong></div>
|
||||
<div>Trades: <strong>{{ run.phases.sideways.trades }}</strong></div>
|
||||
<div class="detail-item">
|
||||
<label>PBO</label>
|
||||
<p>{{ run.pbo }}%</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>DSR</label>
|
||||
<p>{{ run.dsr }}%</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>OOS</label>
|
||||
<p>{{ run.oos }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Metadata -->
|
||||
<section class="metadata">
|
||||
<h3>Details</h3>
|
||||
<div class="metadata-grid">
|
||||
<div>
|
||||
<strong>Window Start:</strong>
|
||||
{{ run.windowStart }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Window End:</strong>
|
||||
{{ run.windowEnd }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Trading Days:</strong>
|
||||
{{ run.tradingDays }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Created:</strong>
|
||||
{{ run.createdAt }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shadow-run-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
.shadow-run-detail-page {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 16px;
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.detail-header h1 {
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin: 8px 0 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
.loading-state,
|
||||
.error-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--kbx-color-primary, #3b82f6);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
.shadow-run-detail {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 2rem;
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
.detail-section h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.validation-summary {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #ccc;
|
||||
}
|
||||
|
||||
.validation-summary.status-valid {
|
||||
background: #f0fdf4;
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.validation-summary.status-warning {
|
||||
background: #fffbeb;
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.validation-summary.status-invalid {
|
||||
background: #fef2f2;
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.validation-summary h2 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.overall-status {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
.detail-item label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.metric-value.positive {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.metric-value.negative {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.metric-value.ok {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.phase-breakdown {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.phase-breakdown h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.phase-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.phase-card {
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.phase-name {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.phase-metrics {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.metadata h3 {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.metadata-grid div {
|
||||
padding: 8px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
.detail-item p {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,252 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import KsListPage from '@shared/ui/components/KsListPage.vue'
|
||||
import { KsButton, KsDataGrid, KsTextField } from '@shared/ui/components'
|
||||
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
||||
import { useShadowRunsList, type ShadowRun } from '../composables/useShadowRuns'
|
||||
import type { ShadowRunListParams } from '../composables/useShadowRuns'
|
||||
import { toUiGridColumns } from '@shared/ui/gridColumnAdapter'
|
||||
import { ref, computed } from 'vue'
|
||||
import { SkeletonLoader } from '@shared/ui/components'
|
||||
import { useShadowRunsList } from '../composables/useShadowRuns'
|
||||
|
||||
const router = useRouter()
|
||||
const registry = useKbxRegistry()
|
||||
|
||||
// Get screen definition from registry
|
||||
const screenDef = computed(() =>
|
||||
registry.getScreen('model-ops.shadow-run.list'),
|
||||
)
|
||||
|
||||
const shadowRunColumns = computed(() => toUiGridColumns(screenDef.value?.grid?.columnDefs ?? []))
|
||||
|
||||
// Search and filter state
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const dateRangeStart = ref('')
|
||||
const dateRangeEnd = ref('')
|
||||
|
||||
// Pagination
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// Query parameters
|
||||
const queryParams = computed<ShadowRunListParams>(() => ({
|
||||
const queryParams = computed(() => ({
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
search: searchQuery.value || undefined,
|
||||
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
|
||||
dateStart: dateRangeStart.value || undefined,
|
||||
dateEnd: dateRangeEnd.value || undefined,
|
||||
}))
|
||||
|
||||
// TanStack Query hook
|
||||
const shadowRunsQuery = useShadowRunsList(queryParams.value)
|
||||
|
||||
const dataState = computed<'idle' | 'pending' | 'ready' | 'error' | 'empty'>(() => {
|
||||
if (shadowRunsQuery.isPending.value) return 'pending'
|
||||
if (shadowRunsQuery.isError.value) return 'error'
|
||||
if (shadowRunsQuery.data.value?.items.length === 0) return 'empty'
|
||||
return 'ready'
|
||||
})
|
||||
|
||||
// Quick filters
|
||||
const quickFilters = computed(() => {
|
||||
const total = shadowRunsQuery.data.value?.total || 0
|
||||
return [
|
||||
{ id: 'all', label: 'All', active: statusFilter.value === 'all', badge: total },
|
||||
{ id: 'valid', label: 'Valid', active: statusFilter.value === 'valid', badge: 1 },
|
||||
{ id: 'review', label: 'Review', active: statusFilter.value === 'review', badge: 1 },
|
||||
]
|
||||
})
|
||||
|
||||
// Summary items
|
||||
const summaryItems = computed(() => {
|
||||
const items = shadowRunsQuery.data.value?.items || []
|
||||
const validCount = items.filter(r => r.pbo <= 20 && r.dsr >= 95 && r.oos <= 2.5).length
|
||||
const avgSharpe = items.length > 0 ? (items.reduce((sum, r) => sum + r.sharpeRatio, 0) / items.length).toFixed(2) : '0'
|
||||
|
||||
return [
|
||||
{ label: 'Total Runs', value: items.length },
|
||||
{ label: 'Valid', value: validCount },
|
||||
{ label: 'Avg Sharpe', value: avgSharpe },
|
||||
]
|
||||
})
|
||||
|
||||
// Actions
|
||||
const handleSearch = () => {
|
||||
shadowRunsQuery.refetch()
|
||||
}
|
||||
|
||||
const handleNewRun = () => {
|
||||
router.push('/model-ops/shadow-runs/new')
|
||||
}
|
||||
|
||||
const handleRowClick = (runId: string) => {
|
||||
router.push(`/model-ops/shadow-runs/${runId}`)
|
||||
}
|
||||
|
||||
const handleRowSelected = (row: unknown) => {
|
||||
const shadowRun = row as Partial<ShadowRun>
|
||||
if (typeof shadowRun.runId === 'string') handleRowClick(shadowRun.runId)
|
||||
}
|
||||
|
||||
const handleQuickFilter = (filterId: string) => {
|
||||
statusFilter.value = filterId
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'F3') {
|
||||
e.preventDefault()
|
||||
handleSearch()
|
||||
} else if (e.ctrlKey && e.key === 'n') {
|
||||
e.preventDefault()
|
||||
handleNewRun()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
const items = computed(() => {
|
||||
const data = shadowRunsQuery.data as any
|
||||
return data?.items || []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="screenDef" class="shadow-run-list">
|
||||
<KsListPage
|
||||
:screen="screenDef"
|
||||
:data-state="dataState"
|
||||
:loading="dataState === 'pending'"
|
||||
:summary-items="summaryItems"
|
||||
:quick-filters="quickFilters"
|
||||
@quick-filter="handleQuickFilter"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<!-- Header Actions -->
|
||||
<template #header-actions>
|
||||
<KsButton
|
||||
label="New Shadow Run"
|
||||
severity="primary"
|
||||
@click="handleNewRun"
|
||||
/>
|
||||
</template>
|
||||
<div class="shadow-run-list-page">
|
||||
<header class="page-header">
|
||||
<h1>Shadow Run Validation</h1>
|
||||
<p>View and manage shadow run validations (252+ trading day backtests)</p>
|
||||
</header>
|
||||
|
||||
<!-- Search Panel -->
|
||||
<template #search>
|
||||
<div class="shadow-run-search">
|
||||
<div class="search-row">
|
||||
<KsTextField
|
||||
v-model="searchQuery"
|
||||
label="Shadow run search"
|
||||
placeholder="Search by model name..."
|
||||
@keydown.enter="handleSearch"
|
||||
/>
|
||||
<KsButton
|
||||
label="Search"
|
||||
severity="secondary"
|
||||
@click="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<KsTextField
|
||||
v-model="dateRangeStart"
|
||||
type="date"
|
||||
label="Start date"
|
||||
placeholder="Start Date"
|
||||
/>
|
||||
<KsTextField
|
||||
v-model="dateRangeEnd"
|
||||
type="date"
|
||||
label="End date"
|
||||
placeholder="End Date"
|
||||
/>
|
||||
<select v-model="statusFilter" class="status-filter">
|
||||
<option value="all">All Status</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Loading State -->
|
||||
<div v-if="shadowRunsQuery.isPending" class="loading-state">
|
||||
<SkeletonLoader type="table" :rows="5" />
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<template #content>
|
||||
<KsDataGrid
|
||||
v-if="screenDef.grid && shadowRunsQuery.data.value?.items"
|
||||
:columns="shadowRunsQuery.data.value?.items.length ? shadowRunColumns : []"
|
||||
:rows="shadowRunsQuery.data.value?.items || []"
|
||||
:loading="shadowRunsQuery.isPending.value"
|
||||
@row-selected="handleRowSelected"
|
||||
/>
|
||||
</template>
|
||||
</KsListPage>
|
||||
<!-- Error State -->
|
||||
<div v-else-if="shadowRunsQuery.isError" class="error-state">
|
||||
<p>Failed to load shadow runs</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!items.length" class="empty-state">
|
||||
<p>No shadow runs found. Create a new shadow run to get started.</p>
|
||||
</div>
|
||||
|
||||
<!-- Data State -->
|
||||
<div v-else class="shadow-runs-grid">
|
||||
<table class="shadow-runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>PBO</th>
|
||||
<th>DSR</th>
|
||||
<th>OOS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="run in items" :key="(run as any).id" data-testid="shadow-run-row">
|
||||
<td>{{ (run as any).id }}</td>
|
||||
<td>{{ (run as any).modelName }}</td>
|
||||
<td>{{ (run as any).status }}</td>
|
||||
<td>{{ (run as any).pbo }}%</td>
|
||||
<td>{{ (run as any).dsr }}%</td>
|
||||
<td>{{ (run as any).oos }}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shadow-run-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
.shadow-run-list-page {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shadow-run-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--kbx-color-surface, #f5f5f5);
|
||||
border-radius: 4px;
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-row input,
|
||||
.search-row select {
|
||||
height: var(--kbx-input-height, 34px);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: var(--kbx-font-size, 14px);
|
||||
.page-header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
flex: 0 0 120px;
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: var(--kbx-color-primary, #3b82f6);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
.shadow-runs-grid {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.state-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #d0d0d0;
|
||||
border-top-color: var(--kbx-color-primary, #3b82f6);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
.shadow-runs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
.shadow-runs-table thead {
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.shadow-runs-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.shadow-runs-table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.shadow-runs-table tbody tr:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,103 +1,14 @@
|
||||
/**
|
||||
* ShadowRun Feature Screen Registry
|
||||
* Define all screens in the shadow-run feature module
|
||||
*/
|
||||
|
||||
import type { ScreenDefinition } from '@kbx/contracts'
|
||||
|
||||
export const shadowRunListScreen: KbxScreenDefinition = {
|
||||
screenId: 'model-ops.shadow-run.list',
|
||||
title: 'Shadow Run Validation',
|
||||
export const shadowRunQueueScreen = {
|
||||
screenId: 'model-ops.shadow-run.queue',
|
||||
title: 'Shadow Run Queue',
|
||||
module: 'ModelOps',
|
||||
type: 'list',
|
||||
path: '/model-ops/shadow-runs',
|
||||
component: () => import('./pages/ShadowRunList.vue'),
|
||||
component: () => import('./pages/ShadowRunQueue.vue'),
|
||||
permissions: ['model.read'],
|
||||
description: 'View and manage shadow run validations (252+ trading day backtests)',
|
||||
|
||||
help: {
|
||||
title: 'Shadow Run Validation',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content:
|
||||
'Shadow runs validate model performance on historical data without executing trades. Each run includes PBO, DSR, and OOS metrics.',
|
||||
},
|
||||
{
|
||||
title: 'How to Start',
|
||||
content:
|
||||
'1. Click "Search" (F3) to view existing runs\n2. Click "New" to initiate a new shadow run\n3. Select date range and model\n4. Monitor progress in the dashboard',
|
||||
},
|
||||
{
|
||||
title: 'Interpreting Results',
|
||||
content:
|
||||
'PBO ≤ 20%, DSR ≥ 95%, OOS ≤ 2.5% indicates model validity. Check phase breakdown (Bull/Bear/Sideways) for regime-specific performance.',
|
||||
},
|
||||
],
|
||||
relatedScreens: ['model-ops.models.list'],
|
||||
},
|
||||
|
||||
grid: {
|
||||
columnDefs: [
|
||||
{ field: 'runId', header: 'Run ID', type: 'link', width: 120, pinned: 'left' },
|
||||
{ field: 'modelName', header: 'Model', width: 150 },
|
||||
{ field: 'windowStart', header: 'Start Date', type: 'date', width: 120 },
|
||||
{ field: 'windowEnd', header: 'End Date', type: 'date', width: 120 },
|
||||
{ field: 'tradingDays', header: 'Days', type: 'number', width: 80 },
|
||||
{ field: 'totalReturn', header: 'Return', type: 'money', width: 100 },
|
||||
{ field: 'sharpeRatio', header: 'Sharpe', type: 'number', width: 80 },
|
||||
{ field: 'pbo', header: 'PBO', type: 'percentage', width: 80 },
|
||||
{ field: 'dsr', header: 'DSR', type: 'percentage', width: 80 },
|
||||
{ field: 'oos', header: 'OOS', type: 'percentage', width: 80 },
|
||||
{ field: 'status', header: 'Status', type: 'status', width: 100 },
|
||||
{ field: 'createdAt', header: 'Created', type: 'datetime', width: 150 },
|
||||
],
|
||||
pageSize: 50,
|
||||
serverSideDatasource: true,
|
||||
},
|
||||
|
||||
shortcuts: [
|
||||
{ key: 'F3', label: 'Search', action: 'search' },
|
||||
{ key: 'Ctrl+N', label: 'New Shadow Run', action: 'new' },
|
||||
],
|
||||
|
||||
telemetry: { enabled: true },
|
||||
}
|
||||
|
||||
export const shadowRunDetailScreen: KbxScreenDefinition = {
|
||||
screenId: 'model-ops.shadow-run.detail',
|
||||
title: 'Shadow Run Details',
|
||||
module: 'ModelOps',
|
||||
type: 'detail',
|
||||
path: '/model-ops/shadow-runs/:runId',
|
||||
component: () => import('./pages/ShadowRunDetail.vue'),
|
||||
permissions: ['model.read'],
|
||||
description: 'Detailed analysis of a shadow run with metrics breakdown',
|
||||
|
||||
help: {
|
||||
title: 'Shadow Run Analysis',
|
||||
sections: [
|
||||
{
|
||||
title: 'Metrics Explained',
|
||||
content:
|
||||
'PBO: Probability of Backtest Overfit. DSR: Daily Sharpe Ratio. OOS: Out-of-Sample performance. Lower PBO and OOS, higher DSR is better.',
|
||||
},
|
||||
],
|
||||
relatedScreens: ['model-ops.shadow-run.list', 'model-ops.models.detail'],
|
||||
},
|
||||
|
||||
shortcuts: [
|
||||
{ key: 'Escape', label: 'Back to List', action: 'back' },
|
||||
{ key: 'Ctrl+E', label: 'Export', action: 'export' },
|
||||
],
|
||||
|
||||
telemetry: { enabled: true },
|
||||
}
|
||||
|
||||
/**
|
||||
* All screens in shadow-run module
|
||||
*/
|
||||
export const shadowRunScreens: KbxScreenDefinition[] = [
|
||||
shadowRunListScreen,
|
||||
shadowRunDetailScreen,
|
||||
]
|
||||
export const shadowRunScreens = [shadowRunQueueScreen]
|
||||
|
||||
Reference in New Issue
Block a user