b7ee740f71
Add composables for API integration with TanStack Query: 1. useShadowRuns.ts (shadow-run feature) - useShadowRunsList() with pagination & filtering - useShadowRunDetail() for detail view - useCreateShadowRun() mutation - useRefreshShadowRuns() for manual refresh - shadowRunQueryKeys factory for cache management 2. useModels.ts (models feature) - useModelsList() with filtering by phase/active - useModelDetail() for detail view - useActivateModel() / useDeactivateModel() mutations - useTransitionPhase() for lifecycle transitions - modelQueryKeys factory 3. Updated pages (ShadowRunList, ShadowRunDetail, ModelsList, ModelDetail) - Replace mock data with useQuery hooks - Auto-refetch on filter changes - Optimistic updates (activate/deactivate/transition) - Computed state management (idle/pending/ready/error/empty) - Cache invalidation on mutations Features: - Stale time: 5 minutes, garbage collection: 10 minutes - Query key factories for cache management - Mock API client (replace with real HTTP endpoints) - Support for pagination, filtering, sorting - Keyboard shortcuts still functional Mock API client provides realistic data for testing. Replace apiClient.* functions with actual HTTP calls in next phase. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
232 lines
6.2 KiB
Vue
232 lines
6.2 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import KbxListPage from '@shared/ui/adapter/KbxListPage.vue'
|
|
import KbxDataGrid from '@shared/ui/adapter/KbxDataGrid.vue'
|
|
import KbxButton from '@shared/ui/adapter/KbxButton.vue'
|
|
import KbxInput from '@shared/ui/adapter/KbxInput.vue'
|
|
import { useKbxRegistry } from '@shared/composables/useKbxRegistry'
|
|
import { useModelsList } from '../composables/useModels'
|
|
import type { ModelListParams } 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.list'),
|
|
)
|
|
|
|
// Search and filter state
|
|
const searchQuery = ref('')
|
|
const phaseFilter = ref('all')
|
|
const activeFilter = ref('all')
|
|
|
|
// Pagination
|
|
const currentPage = ref(1)
|
|
const pageSize = ref(50)
|
|
|
|
// Query parameters
|
|
const queryParams = computed<ModelListParams>(() => ({
|
|
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 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)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="screenDef" class="models-list">
|
|
<KbxListPage
|
|
:screen="screenDef"
|
|
:data-state="dataState"
|
|
:loading="isLoading"
|
|
:summary-items="summaryItems"
|
|
:quick-filters="quickFilters"
|
|
@quick-filter="handleQuickFilter"
|
|
@refresh="handleRefresh"
|
|
>
|
|
<!-- Header Actions -->
|
|
<template #header-actions>
|
|
<KbxButton
|
|
label="New Model"
|
|
variant="primary"
|
|
@click="handleNewModel"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Search Panel -->
|
|
<template #search>
|
|
<div class="models-search">
|
|
<div class="search-row">
|
|
<KbxInput
|
|
v-model="searchQuery"
|
|
placeholder="Search by model name..."
|
|
@keydown.enter="handleSearch"
|
|
/>
|
|
<KbxButton
|
|
label="Search"
|
|
variant="default"
|
|
@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>
|
|
|
|
<!-- Content Area -->
|
|
<template #content>
|
|
<KbxDataGrid
|
|
v-if="screenDef.grid && modelsQuery.data.value?.items"
|
|
:columns="screenDef.grid.columnDefs"
|
|
:rows="modelsQuery.data.value.items"
|
|
:loading="modelsQuery.isPending.value"
|
|
@row-click="handleRowClick"
|
|
/>
|
|
</template>
|
|
</KbxListPage>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.models-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.models-search {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
background: var(--kbx-color-surface, #f5f5f5);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.search-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.phase-filter,
|
|
.active-filter {
|
|
flex: 0 0 140px;
|
|
}
|
|
|
|
.badge {
|
|
background: var(--kbx-color-primary, #3b82f6);
|
|
color: white;
|
|
padding: 2px 6px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
margin-left: 4px;
|
|
}
|
|
</style>
|