Skip to content

Commit 90bab4c

Browse files
authored
Merge pull request #314 from HIRO-MicroDataCenters-BV/bug/model-server-bug-v2
Bug/model server bug v2
2 parents 1cda62c + edd130e commit 90bab4c

6 files changed

Lines changed: 441 additions & 63 deletions

File tree

components/app/ModelServingCard.vue

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,10 @@
241241
</div>
242242
</div>
243243

244-
<!-- Traffic split (stop propagation so slider doesn't open sheet) -->
244+
<!-- Traffic split: section-level clicks bubble (open the sheet); only
245+
the interactive slider/button row stops propagation. -->
245246
<div
246247
class="px-4 py-3 border-t border-border/50 dark:border-zinc-700/50 bg-muted/10 dark:bg-zinc-800/20"
247-
@click.stop
248248
>
249249
<div class="flex items-center justify-between gap-2 mb-2">
250250
<span class="text-xs font-medium text-muted-foreground"
@@ -277,7 +277,7 @@
277277
</div>
278278

279279
<!-- Slider, step controls, and update (enabled only when has_canary) -->
280-
<div class="flex items-center gap-1.5">
280+
<div class="flex items-center gap-1.5" @click.stop>
281281
<Button
282282
variant="outline"
283283
size="icon"
@@ -393,6 +393,8 @@ const localCanaryPercent = ref(initialCanaryPercent);
393393
const statusDotClass = computed(() => {
394394
const s = props.serving.status?.toLowerCase();
395395
if (s === 'ready') return 'bg-green-500 shadow-green-500/50 shadow-sm';
396+
if (s === 'not_ready')
397+
return 'bg-orange-500 shadow-orange-500/50 shadow-sm animate-pulse';
396398
if (s === 'pending')
397399
return 'bg-amber-500 shadow-amber-500/50 shadow-sm animate-pulse';
398400
if (s === 'failed') return 'bg-red-500 shadow-red-500/50 shadow-sm';
@@ -406,6 +408,8 @@ const statusBadgeClass = computed(() => {
406408
const s = props.serving.status?.toLowerCase();
407409
if (s === 'ready')
408410
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
411+
if (s === 'not_ready')
412+
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300';
409413
if (s === 'pending')
410414
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
411415
if (s === 'failed')

components/app/ModelServingSheet.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ const statusBadgeClass = computed(() => {
266266
const s = props.serving.status?.toLowerCase();
267267
if (s === 'ready')
268268
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
269+
if (s === 'not_ready')
270+
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300';
269271
if (s === 'pending')
270272
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300';
271273
if (s === 'failed')

composables/useFilteredSortedPagination.ts

Lines changed: 104 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,95 @@
11
import { computed, type ComputedRef, type Ref } from 'vue';
22

3+
interface ExactFilter<T> {
4+
/** Field on `T` to match against. */
5+
field: keyof T;
6+
/** Reactive value to match. Empty / null / `allValue` means "no filter". */
7+
value: Ref<string | null | undefined>;
8+
/** Sentinel meaning "show all" (skip this filter). Defaults to `'all'`. */
9+
allValue?: string;
10+
}
11+
312
interface Options<T> {
413
/** Fields on `T` whose lowercased string value should match the search query. */
514
searchFields: Array<keyof T>;
615
/** Field on `T` to sort by — must be a string parseable by `new Date()` or numeric. */
716
sortField: keyof T;
17+
/**
18+
* Optional exact-match (case-insensitive) pre-filters applied before search.
19+
* Useful for dropdown filters like a status selector.
20+
*/
21+
exactFilters?: Array<ExactFilter<T>>;
22+
}
23+
24+
/**
25+
* Pure (non-reactive) version of the same pipeline. Takes a plain array and
26+
* primitive options, returns the filtered+sorted+paginated slice plus the
27+
* total count of items after filter/search (i.e., the count pagination should
28+
* display, not the raw input length).
29+
*
30+
* Use this when you need the same client-side processing in a non-reactive
31+
* context (e.g. an `AppTable` `dataSource` callback that's called with params
32+
* by the table itself).
33+
*/
34+
export function filterSortPaginate<T>(
35+
items: T[],
36+
opts: {
37+
search?: string;
38+
searchFields: Array<keyof T>;
39+
sortField: keyof T;
40+
sortOrder: 'asc' | 'desc';
41+
page: number;
42+
pageSize: number;
43+
exactFilters?: Array<{
44+
field: keyof T;
45+
value: string | null | undefined;
46+
allValue?: string;
47+
}>;
48+
},
49+
): { data: T[]; total: number } {
50+
let result = [...items];
51+
52+
for (const f of opts.exactFilters ?? []) {
53+
const allValue = f.allValue ?? 'all';
54+
if (!f.value || f.value === allValue) continue;
55+
const target = String(f.value).toLowerCase();
56+
result = result.filter((item) => {
57+
const v = item[f.field];
58+
return typeof v === 'string' ? v.toLowerCase() === target : false;
59+
});
60+
}
61+
62+
const q = opts.search?.trim().toLowerCase();
63+
if (q) {
64+
result = result.filter((item) =>
65+
opts.searchFields.some((field) => {
66+
const value = item[field];
67+
return typeof value === 'string'
68+
? value.toLowerCase().includes(q)
69+
: false;
70+
}),
71+
);
72+
}
73+
74+
result.sort((a, b) => {
75+
const av = a[opts.sortField] as unknown;
76+
const bv = b[opts.sortField] as unknown;
77+
const ad = typeof av === 'string' ? Date.parse(av) : NaN;
78+
const bd = typeof bv === 'string' ? Date.parse(bv) : NaN;
79+
if (!Number.isNaN(ad) && !Number.isNaN(bd)) {
80+
return opts.sortOrder === 'desc' ? bd - ad : ad - bd;
81+
}
82+
if (av == null && bv == null) return 0;
83+
if (av == null) return opts.sortOrder === 'desc' ? 1 : -1;
84+
if (bv == null) return opts.sortOrder === 'desc' ? -1 : 1;
85+
if (av < bv) return opts.sortOrder === 'desc' ? 1 : -1;
86+
if (av > bv) return opts.sortOrder === 'desc' ? -1 : 1;
87+
return 0;
88+
});
89+
90+
const total = result.length;
91+
const start = (opts.page - 1) * opts.pageSize;
92+
return { data: result.slice(start, start + opts.pageSize), total };
893
}
994

1095
interface Result<T> {
@@ -35,43 +120,25 @@ export function useFilteredSortedPagination<T>(
35120
pageSize: Ref<number>,
36121
options: Options<T>,
37122
): Result<T> {
38-
const filteredAndSorted = computed(() => {
39-
let result = [...items.value];
40-
41-
const q = searchQuery.value?.trim().toLowerCase();
42-
if (q) {
43-
result = result.filter((item) =>
44-
options.searchFields.some((field) => {
45-
const value = item[field];
46-
return typeof value === 'string'
47-
? value.toLowerCase().includes(q)
48-
: false;
49-
}),
50-
);
51-
}
52-
53-
const order = sortOrder.value;
54-
result.sort((a, b) => {
55-
const av = a[options.sortField] as unknown;
56-
const bv = b[options.sortField] as unknown;
57-
58-
// Try date parsing first; fall back to numeric/string compare.
59-
const ad = typeof av === 'string' ? Date.parse(av) : NaN;
60-
const bd = typeof bv === 'string' ? Date.parse(bv) : NaN;
61-
if (!Number.isNaN(ad) && !Number.isNaN(bd)) {
62-
return order === 'desc' ? bd - ad : ad - bd;
63-
}
64-
65-
if (av == null && bv == null) return 0;
66-
if (av == null) return order === 'desc' ? 1 : -1;
67-
if (bv == null) return order === 'desc' ? -1 : 1;
68-
if (av < bv) return order === 'desc' ? 1 : -1;
69-
if (av > bv) return order === 'desc' ? -1 : 1;
70-
return 0;
71-
});
72-
73-
return result;
74-
});
123+
// Filter+sort step (without pagination) — a sufficiently large pageSize
124+
// returns everything so we can derive `totalItems` and re-slice for
125+
// `paginated` separately.
126+
const filteredAndSorted = computed(
127+
() =>
128+
filterSortPaginate(items.value, {
129+
search: searchQuery.value,
130+
searchFields: options.searchFields,
131+
sortField: options.sortField,
132+
sortOrder: sortOrder.value,
133+
page: 1,
134+
pageSize: Number.MAX_SAFE_INTEGER,
135+
exactFilters: (options.exactFilters ?? []).map((f) => ({
136+
field: f.field,
137+
value: f.value.value,
138+
allValue: f.allValue,
139+
})),
140+
}).data,
141+
);
75142

76143
const totalItems = computed(() => filteredAndSorted.value.length);
77144

mocks/get.models-serving.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,24 @@
727727
"canary_revision": null,
728728
"stable_traffic_percent": 100,
729729
"canary_traffic_percent": null
730+
},
731+
{
732+
"isvc_name": "session-tracker",
733+
"served_model_url": "http://session-tracker.admin.dashboard.cog.hiro-develop.nl",
734+
"status": "not_ready",
735+
"model_id": "f0a1b2c3-d4e5-6789-1234-012345678901",
736+
"model_name": "session-classifier",
737+
"model_version": "2",
738+
"dataset_id": null,
739+
"creation_timestamp": "2025-06-20T14:00:00Z",
740+
"age": "258 days, 1:38:30",
741+
"latest_ready_revision": "session-tracker-predictor-00001",
742+
"traffic_percentage": 0,
743+
"has_canary": false,
744+
"stable_revision": null,
745+
"canary_revision": null,
746+
"stable_traffic_percent": 100,
747+
"canary_traffic_percent": null
730748
}
731749
]
732750
}

pages/model-serving/index.vue

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ const sortOrder = ref<'asc' | 'desc'>(
7272
? route.query.sort_order
7373
: 'desc') as 'asc' | 'desc',
7474
);
75-
// Search + sort + pagination run client-side over the fetched `list`
76-
// (backend doesn't support these query params). Status is still backend-driven
77-
// via fetchList.
75+
// Backend /models-serving doesn't support any filter/sort/page params, so the
76+
// full pipeline runs client-side: status (exact match) → search → sort →
77+
// paginate.
7878
const {
7979
filteredAndSorted: filteredAndSortedList,
8080
paginated: paginatedList,
@@ -88,6 +88,7 @@ const {
8888
{
8989
searchFields: ['isvc_name', 'model_name', 'status'],
9090
sortField: 'creation_timestamp',
91+
exactFilters: [{ field: 'status', value: statusFilter, allValue: 'all' }],
9192
},
9293
);
9394
@@ -99,6 +100,7 @@ const SORT_OPTIONS = [
99100
const STATUS_OPTIONS = [
100101
{ value: 'all', label: 'All status' },
101102
{ value: 'ready', label: 'Ready' },
103+
{ value: 'not_ready', label: 'Not ready' },
102104
{ value: 'pending', label: 'Pending' },
103105
{ value: 'failed', label: 'Failed' },
104106
{ value: 'terminating', label: 'Terminating' },
@@ -251,16 +253,6 @@ watch(
251253
{ deep: true },
252254
);
253255
254-
// Status filter still goes to the backend, so refetch when it changes.
255-
watch(
256-
() => route.query.status,
257-
() => {
258-
if (viewMode.value !== 'cards') return;
259-
isRefreshing.value = true;
260-
fetchList();
261-
},
262-
);
263-
264256
// Watch status filter - update route
265257
watch(statusFilter, () => {
266258
const query = { ...route.query } as Record<string, string>;
@@ -368,6 +360,8 @@ const tableColumns = [
368360
const statusClasses: Record<string, string> = {
369361
ready:
370362
'bg-green-100 text-green-700 dark:bg-green-800 dark:text-green-100',
363+
not_ready:
364+
'bg-orange-100 text-orange-700 dark:bg-orange-800 dark:text-orange-100',
371365
pending:
372366
'bg-yellow-100 text-yellow-700 dark:bg-yellow-800 dark:text-yellow-100',
373367
failed: 'bg-red-100 text-red-700 dark:bg-red-800 dark:text-red-100',
@@ -447,13 +441,9 @@ async function fetchList() {
447441
if (!isRefreshing.value) loading.value = true;
448442
error.value = null;
449443
try {
450-
// Backend /models-serving does not support page/limit/search/sort_by/sort_order
451-
// query params, so we don't send them. Status is kept conditionally because
452-
// it's only added when the user picks a non-"all" filter.
453-
const res = await getModelsServing({
454-
...(statusFilter.value &&
455-
statusFilter.value !== 'all' && { status: statusFilter.value }),
456-
});
444+
// Backend /models-serving doesn't support any filter/sort/page params,
445+
// so we just fetch the full list and do everything client-side.
446+
const res = await getModelsServing({});
457447
const data = res?.data;
458448
list.value = Array.isArray(data) ? data : [];
459449
} catch (e) {
@@ -465,6 +455,53 @@ async function fetchList() {
465455
}
466456
}
467457
458+
// Table view's `dataSource`. AppTable calls this with route-derived params
459+
// (page, limit, search, sort_by, sort_order, status, isvc_name, etc.); we
460+
// run the same client-side pipeline as the cards view over `list.value` and
461+
// return the slice in AppTable's expected shape. Performs a lazy fetch on
462+
// the first call so the table view also works on direct entry.
463+
async function tableDataSource(params: Record<string, unknown> = {}): Promise<{
464+
status_code: number;
465+
data: ModelServing[];
466+
pagination: {
467+
total_items: number;
468+
page: number;
469+
limit: number;
470+
total_pages: number;
471+
};
472+
}> {
473+
if (list.value.length === 0) await fetchList();
474+
475+
const page = parseInt(String(params.page ?? 1)) || 1;
476+
const pageSizeParam = parseInt(String(params.limit ?? 10)) || 10;
477+
const order: 'asc' | 'desc' =
478+
String(params.sort_order ?? 'desc') === 'asc' ? 'asc' : 'desc';
479+
const search =
480+
(params.isvc_name as string) || (params.search as string) || '';
481+
const status = (params.status as string) || 'all';
482+
483+
const { data, total } = filterSortPaginate<ModelServing>(list.value, {
484+
search,
485+
searchFields: ['isvc_name', 'model_name', 'status'],
486+
sortField: 'creation_timestamp',
487+
sortOrder: order,
488+
page,
489+
pageSize: pageSizeParam,
490+
exactFilters: [{ field: 'status', value: status, allValue: 'all' }],
491+
});
492+
493+
return {
494+
status_code: 200,
495+
data,
496+
pagination: {
497+
total_items: total,
498+
page,
499+
limit: pageSizeParam,
500+
total_pages: Math.max(1, Math.ceil(total / pageSizeParam)),
501+
},
502+
};
503+
}
504+
468505
const cardsTotalPages = computed(() =>
469506
Math.ceil(cardsTotalItems.value / cardsPageSize.value),
470507
);
@@ -548,7 +585,7 @@ onMounted(() => {
548585
v-if="viewMode === 'table'"
549586
ref="tableRef"
550587
:columns="tableColumns"
551-
:data-source="getModelsServing"
588+
:data-source="tableDataSource"
552589
:sortable-columns="['creation_timestamp']"
553590
:filterable-columns="['status']"
554591
:page-size="10"

0 commit comments

Comments
 (0)