feat(ui): add sortable columns to models table
Add sorting functionality to the models table with clickable headers for columns like Stat, Model Identity, Global Quota, Next Reset, and Account Distribution. Includes dynamic sort icons and logic to handle ascending/descending order with appropriate defaults.
This commit is contained in:
@@ -22,7 +22,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
filters: {
|
filters: {
|
||||||
account: 'all',
|
account: 'all',
|
||||||
family: 'all',
|
family: 'all',
|
||||||
search: ''
|
search: '',
|
||||||
|
sortCol: 'avgQuota',
|
||||||
|
sortAsc: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// Settings for calculation
|
// Settings for calculation
|
||||||
@@ -177,20 +179,52 @@ document.addEventListener('alpine:init', () => {
|
|||||||
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
|
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
|
||||||
quotaInfo,
|
quotaInfo,
|
||||||
pinned: !!config.pinned,
|
pinned: !!config.pinned,
|
||||||
hidden: !!isHidden // Use computed visibility
|
hidden: !!isHidden, // Use computed visibility
|
||||||
|
activeCount: quotaInfo.filter(q => q.pct > 0).length
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort: Pinned first, then by avgQuota (descending)
|
// Sort: Pinned first, then by selected column
|
||||||
|
const sortCol = this.filters.sortCol;
|
||||||
|
const sortAsc = this.filters.sortAsc;
|
||||||
|
|
||||||
this.quotaRows = rows.sort((a, b) => {
|
this.quotaRows = rows.sort((a, b) => {
|
||||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||||
return b.avgQuota - a.avgQuota;
|
|
||||||
|
let valA = a[sortCol];
|
||||||
|
let valB = b[sortCol];
|
||||||
|
|
||||||
|
// Handle nulls (always push to bottom)
|
||||||
|
if (valA === valB) return 0;
|
||||||
|
if (valA === null || valA === undefined) return 1;
|
||||||
|
if (valB === null || valB === undefined) return -1;
|
||||||
|
|
||||||
|
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||||
|
return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortAsc ? valA - valB : valB - valA;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger Dashboard Update if active
|
// Trigger Dashboard Update if active
|
||||||
// Ideally dashboard watches this store.
|
// Ideally dashboard watches this store.
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSort(col) {
|
||||||
|
if (this.filters.sortCol === col) {
|
||||||
|
this.filters.sortAsc = !this.filters.sortAsc;
|
||||||
|
} else {
|
||||||
|
this.filters.sortCol = col;
|
||||||
|
// Default sort direction: Descending for numbers/stats, Ascending for text/time
|
||||||
|
if (['avgQuota', 'activeCount'].includes(col)) {
|
||||||
|
this.filters.sortAsc = false;
|
||||||
|
} else {
|
||||||
|
this.filters.sortAsc = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.computeQuotaRows();
|
||||||
|
},
|
||||||
|
|
||||||
getModelFamily(modelId) {
|
getModelFamily(modelId) {
|
||||||
const lower = modelId.toLowerCase();
|
const lower = modelId.toLowerCase();
|
||||||
if (lower.includes('claude')) return 'claude';
|
if (lower.includes('claude')) return 'claude';
|
||||||
|
|||||||
@@ -94,14 +94,60 @@
|
|||||||
:class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}">
|
:class="{'table-xs': $store.settings.compact, 'table-sm': !$store.settings.compact}">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-14 py-3 pl-4 whitespace-nowrap" x-text="$store.global.t('stat')">Stat</th>
|
<th class="w-14 py-3 pl-4 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
<th class="py-3 whitespace-nowrap" x-text="$store.global.t('modelIdentity')">Model Identity</th>
|
@click="$store.data.setSort('avgQuota')">
|
||||||
<th class="min-w-[12rem] py-3 whitespace-nowrap" x-text="$store.global.t('globalQuota')">Global
|
<div class="flex items-center justify-center">
|
||||||
Quota</th>
|
<span x-text="$store.global.t('stat')">Stat</span>
|
||||||
<th class="min-w-[8rem] py-3 whitespace-nowrap" x-text="$store.global.t('nextReset')">Next Reset
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="py-3 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
|
@click="$store.data.setSort('modelId')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span x-text="$store.global.t('modelIdentity')">Model Identity</span>
|
||||||
|
<svg class="w-3 h-3 transition-colors"
|
||||||
|
:class="$store.data.filters.sortCol === 'modelId' ? 'text-neon-purple' : 'text-gray-700 opacity-0 group-hover:opacity-100'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
:d="$store.data.filters.sortCol === 'modelId' ? ($store.data.filters.sortAsc ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7') : 'M8 9l4-4 4 4m0 6l-4 4-4-4'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="min-w-[12rem] py-3 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
|
@click="$store.data.setSort('avgQuota')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span x-text="$store.global.t('globalQuota')">Global Quota</span>
|
||||||
|
<svg class="w-3 h-3 transition-colors"
|
||||||
|
:class="$store.data.filters.sortCol === 'avgQuota' ? 'text-neon-purple' : 'text-gray-700 opacity-0 group-hover:opacity-100'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
:d="$store.data.filters.sortCol === 'avgQuota' ? ($store.data.filters.sortAsc ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7') : 'M8 9l4-4 4 4m0 6l-4 4-4-4'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="min-w-[8rem] py-3 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
|
@click="$store.data.setSort('minResetTime')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span x-text="$store.global.t('nextReset')">Next Reset</span>
|
||||||
|
<svg class="w-3 h-3 transition-colors"
|
||||||
|
:class="$store.data.filters.sortCol === 'minResetTime' ? 'text-neon-purple' : 'text-gray-700 opacity-0 group-hover:opacity-100'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
:d="$store.data.filters.sortCol === 'minResetTime' ? ($store.data.filters.sortAsc ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7') : 'M8 9l4-4 4 4m0 6l-4 4-4-4'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="py-3 whitespace-nowrap cursor-pointer hover:text-white transition-colors select-none group"
|
||||||
|
@click="$store.data.setSort('activeCount')">
|
||||||
|
<div class="flex items-center gap-1 justify-start">
|
||||||
|
<span x-text="$store.global.t('distribution')">Account Distribution</span>
|
||||||
|
<svg class="w-3 h-3 transition-colors"
|
||||||
|
:class="$store.data.filters.sortCol === 'activeCount' ? 'text-neon-purple' : 'text-gray-700 opacity-0 group-hover:opacity-100'"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
:d="$store.data.filters.sortCol === 'activeCount' ? ($store.data.filters.sortAsc ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7') : 'M8 9l4-4 4 4m0 6l-4 4-4-4'" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th class="py-3 whitespace-nowrap" x-text="$store.global.t('distribution')">Account
|
|
||||||
Distribution</th>
|
|
||||||
<th class="w-20 py-3 pr-4 text-right whitespace-nowrap" x-text="$store.global.t('actions')">Actions
|
<th class="w-20 py-3 pr-4 text-right whitespace-nowrap" x-text="$store.global.t('actions')">Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -143,20 +189,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center justify-end gap-3">
|
<div class="flex items-center justify-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="text-[10px] font-mono text-gray-500 hidden xl:block text-right leading-tight opacity-70">
|
class="text-[10px] font-mono text-gray-500 hidden xl:block text-left leading-tight opacity-70">
|
||||||
<div
|
<div
|
||||||
x-text="$store.global.t('activeCount', {count: row.quotaInfo?.filter(q => q.pct > 0).length || 0})">
|
x-text="$store.global.t('activeCount', {count: row.quotaInfo?.filter(q => q.pct > 0).length || 0})">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Account Status Indicators -->
|
<!-- Account Status Indicators -->
|
||||||
<div class="flex flex-wrap gap-1 justify-end max-w-[200px]" x-data="{ maxVisible: 12 }">
|
<div class="flex flex-wrap gap-1 justify-start max-w-[200px]" x-data="{ maxVisible: 12 }">
|
||||||
<template x-if="!row.quotaInfo || row.quotaInfo.length === 0">
|
<template x-if="!row.quotaInfo || row.quotaInfo.length === 0">
|
||||||
<div class="text-[10px] text-gray-600 italic">No data</div>
|
<div class="text-[10px] text-gray-600 italic">No data</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="row.quotaInfo && row.quotaInfo.length > 0">
|
<template x-if="row.quotaInfo && row.quotaInfo.length > 0">
|
||||||
<div class="flex flex-wrap gap-1 justify-end">
|
<div class="flex flex-wrap gap-1 justify-start">
|
||||||
<!-- Visible accounts (limited to maxVisible) -->
|
<!-- Visible accounts (limited to maxVisible) -->
|
||||||
<template x-for="(q, idx) in row.quotaInfo.slice(0, maxVisible)" :key="q.fullEmail">
|
<template x-for="(q, idx) in row.quotaInfo.slice(0, maxVisible)" :key="q.fullEmail">
|
||||||
<div class="tooltip tooltip-left" :data-tip="`${q.fullEmail} (${q.pct}%)`">
|
<div class="tooltip tooltip-left" :data-tip="`${q.fullEmail} (${q.pct}%)`">
|
||||||
|
|||||||
Reference in New Issue
Block a user