feat: per-account quota threshold protection (#212)
feat: per-account quota threshold protection Resolves #135 - Adds configurable quota protection with three-tier threshold resolution (per-model → per-account → global) - New global Minimum Quota Level slider in Settings - Per-account threshold settings via Account Settings modal - Draggable per-account threshold markers on model quota bars - Backend: PATCH /api/accounts/:email endpoint, globalQuotaThreshold config - i18n: quota protection keys for all 5 languages
This commit is contained in:
@@ -180,6 +180,9 @@
|
||||
<template x-if="quota.percent === null">
|
||||
<span class="text-xs text-gray-600">-</span>
|
||||
</template>
|
||||
<div x-show="acc.quotaThreshold !== undefined"
|
||||
class="text-[10px] font-mono text-neon-yellow/60 mt-0.5"
|
||||
x-text="'min: ' + getEffectiveThreshold(acc)"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
@@ -204,6 +207,17 @@
|
||||
x-text="$store.global.t('fix')">
|
||||
FIX
|
||||
</button>
|
||||
<!-- Settings Button (threshold) -->
|
||||
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-neon-yellow transition-colors"
|
||||
@click="openThresholdModal(acc)"
|
||||
title="Account Settings">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors"
|
||||
@click="refreshAccount(acc.email)"
|
||||
:disabled="refreshing"
|
||||
@@ -357,4 +371,154 @@
|
||||
<button x-text="$store.global.t('close')">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Threshold Settings Modal -->
|
||||
<dialog id="threshold_modal" class="modal backdrop-blur-sm">
|
||||
<div class="modal-box max-w-md w-full bg-space-900 border border-space-border text-gray-300 shadow-2xl p-6">
|
||||
<h3 class="font-bold text-xl text-white mb-2 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-neon-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Account Settings</span>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 font-mono mb-6" x-text="thresholdDialog.email"></p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Info Alert -->
|
||||
<div class="bg-space-800/50 border border-space-border/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-400 leading-relaxed">
|
||||
Set a minimum quota level for this account. When the account's quota falls below this threshold,
|
||||
the proxy will switch to another account with more quota remaining.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Threshold Input -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-gray-400">Minimum Quota</span>
|
||||
<span class="label-text-alt font-mono text-neon-yellow"
|
||||
x-text="thresholdDialog.quotaThreshold !== null ? thresholdDialog.quotaThreshold + '%' : 'Default'"></span>
|
||||
</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
<input type="range" min="0" max="99" step="1"
|
||||
class="custom-range custom-range-yellow flex-1"
|
||||
:value="thresholdDialog.quotaThreshold || 0"
|
||||
:style="`background-size: ${thresholdDialog.quotaThreshold || 0}% 100%`"
|
||||
@input="thresholdDialog.quotaThreshold = parseInt($event.target.value)"
|
||||
aria-label="Minimum quota threshold slider">
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="number" min="0" max="99" step="1"
|
||||
class="input input-sm input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
|
||||
:value="thresholdDialog.quotaThreshold ?? ''"
|
||||
placeholder="-"
|
||||
@input="thresholdDialog.quotaThreshold = $event.target.value === '' ? null : parseInt($event.target.value)"
|
||||
aria-label="Minimum quota threshold value">
|
||||
<span class="text-gray-500 text-xs">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-600 mt-1">
|
||||
Leave empty to use the global threshold from Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Clear Button -->
|
||||
<button class="btn btn-sm btn-ghost text-gray-500 hover:text-gray-300 w-full"
|
||||
@click="clearAccountThreshold()"
|
||||
x-show="thresholdDialog.quotaThreshold !== null">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>Reset to Default</span>
|
||||
</button>
|
||||
|
||||
<!-- Per-Model Thresholds -->
|
||||
<div class="pt-4 border-t border-space-border/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs text-gray-400 font-semibold">Per-Model Overrides</span>
|
||||
<button class="btn btn-xs btn-ghost text-gray-500 hover:text-neon-yellow gap-1"
|
||||
@click="addModelThreshold()">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Model threshold list -->
|
||||
<div class="space-y-2 max-h-40 overflow-y-auto custom-scrollbar">
|
||||
<template x-for="(threshold, modelId) in thresholdDialog.modelQuotaThresholds" :key="modelId">
|
||||
<div class="flex items-center gap-2 bg-space-800/50 rounded px-2 py-1.5">
|
||||
<span class="text-xs text-gray-300 flex-1 truncate font-mono" x-text="modelId"></span>
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="number" min="0" max="99" step="1"
|
||||
class="input input-xs input-bordered w-14 bg-space-900 border-space-border text-white font-mono text-center"
|
||||
:value="threshold"
|
||||
@input="updateModelThreshold(modelId, $event.target.value)"
|
||||
aria-label="Model threshold">
|
||||
<span class="text-gray-500 text-[10px]">%</span>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-ghost text-gray-500 hover:text-red-400 px-1"
|
||||
@click="removeModelThreshold(modelId)">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="Object.keys(thresholdDialog.modelQuotaThresholds).length === 0">
|
||||
<p class="text-[10px] text-gray-600 text-center py-2">
|
||||
No per-model overrides configured.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Model Dialog (inline) -->
|
||||
<div x-show="thresholdDialog.addingModel" class="pt-3 border-t border-space-border/30" x-cloak>
|
||||
<div class="flex gap-2">
|
||||
<select class="select select-sm select-bordered flex-1 bg-space-800 border-space-border text-white text-xs"
|
||||
x-model="thresholdDialog.newModelId">
|
||||
<option value="">Select model...</option>
|
||||
<template x-for="model in getAvailableModelsForThreshold()" :key="model">
|
||||
<option :value="model" x-text="model"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="number" min="0" max="99" step="1"
|
||||
class="input input-sm input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
|
||||
x-model="thresholdDialog.newModelThreshold"
|
||||
placeholder="10">
|
||||
<span class="text-gray-500 text-xs self-center">%</span>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-xs btn-ghost text-gray-500 flex-1"
|
||||
@click="thresholdDialog.addingModel = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-xs btn-warning text-black flex-1"
|
||||
@click="confirmAddModelThreshold()"
|
||||
:disabled="!thresholdDialog.newModelId">
|
||||
Add Override
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-ghost hover:bg-white/10">Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-warning border-none text-black font-semibold"
|
||||
@click="saveAccountThreshold()"
|
||||
:disabled="thresholdDialog.saving"
|
||||
:class="{ 'loading': thresholdDialog.saving }">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -190,9 +190,41 @@
|
||||
<span class="text-gray-500 text-[10px]"
|
||||
x-text="row.quotaInfo.filter(q => q.pct > 0).length + '/' + row.quotaInfo.length"></span>
|
||||
</div>
|
||||
<progress class="progress w-full h-1 bg-space-800"
|
||||
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
|
||||
:value="row.avgQuota" max="100"></progress>
|
||||
<div class="relative">
|
||||
<progress class="progress w-full h-1.5 bg-space-800"
|
||||
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
|
||||
:value="row.avgQuota" max="100"></progress>
|
||||
<!-- Per-account draggable quota threshold markers -->
|
||||
<template x-for="(q, qIdx) in row.quotaInfo.filter(q => q.thresholdPct > 0 || isDragging(q, row))" :key="q.fullEmail + '-threshold'">
|
||||
<div class="absolute -top-1 -bottom-1 group/marker"
|
||||
:class="isDragging(q, row) ? 'cursor-grabbing z-30' : 'cursor-grab'"
|
||||
:style="'left: calc(' + getMarkerPct(q, row) + '% - 4px); width: 9px; transform: translateX(' + (isDragging(q, row) ? '0px' : getMarkerOffset(q, row, qIdx)) + ');'"
|
||||
:title="q.email + ' min: ' + getMarkerPct(q, row) + '%'"
|
||||
@mousedown.prevent="startDrag($event, q, row)"
|
||||
@touchstart.prevent="startDrag($event, q, row)">
|
||||
<!-- Visible marker line -->
|
||||
<div class="absolute top-1 bottom-1 left-1 w-[3px] rounded-full"
|
||||
:class="isDragging(q, row) ? 'scale-y-150 brightness-150' : 'transition-all group-hover/marker:scale-y-150 group-hover/marker:brightness-125'"
|
||||
:style="'background-color: ' + getThresholdColor(qIdx).bg + '; box-shadow: 0 0 ' + (isDragging(q, row) ? '10px' : '6px') + ' ' + getThresholdColor(qIdx).shadow"></div>
|
||||
<!-- Tooltip popup (visible on hover or during drag) -->
|
||||
<div class="absolute -top-8 left-1/2 -translate-x-1/2 items-center gap-1.5
|
||||
whitespace-nowrap text-xs font-mono font-semibold px-2.5 py-1 rounded-md bg-space-900 border border-space-border shadow-xl shadow-black/40 z-20"
|
||||
:class="isDragging(q, row) ? 'flex' : 'hidden group-hover/marker:flex'"
|
||||
:style="'color: ' + getThresholdColor(qIdx).bg + '; border-color: ' + getThresholdColor(qIdx).bg + '40'">
|
||||
<span class="w-2 h-2 rounded-full flex-shrink-0" :style="'background-color: ' + getThresholdColor(qIdx).bg"></span>
|
||||
<span x-text="q.email"></span>
|
||||
<span class="text-gray-500">min</span>
|
||||
<span x-text="getMarkerPct(q, row) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Threshold text under bar -->
|
||||
<div x-show="row.effectiveThresholdPct > 0"
|
||||
class="text-[10px] font-mono text-gray-500 -mt-0.5"
|
||||
x-text="row.hasVariedThresholds
|
||||
? 'min: ' + Math.min(...row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0)) + '–' + row.effectiveThresholdPct + '%'
|
||||
: 'min: ' + row.effectiveThresholdPct + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono text-xs">
|
||||
@@ -219,7 +251,8 @@
|
||||
<div class="flex flex-wrap gap-1 justify-start">
|
||||
<!-- Visible accounts (limited to maxVisible) -->
|
||||
<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}%)` + (q.thresholdPct > 0 ? ` · min: ${q.thresholdPct}%` : '')">
|
||||
<div class="w-3 h-3 rounded-[2px] transition-all hover:scale-125 cursor-help"
|
||||
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
|
||||
</div>
|
||||
@@ -228,7 +261,7 @@
|
||||
<!-- Overflow indicator -->
|
||||
<template x-if="row.quotaInfo.length > maxVisible">
|
||||
<div class="tooltip tooltip-left"
|
||||
:data-tip="row.quotaInfo.slice(maxVisible).map(q => `${q.fullEmail} (${q.pct}%)`).join('\n')">
|
||||
:data-tip="row.quotaInfo.slice(maxVisible).map(q => `${q.fullEmail} (${q.pct}%)` + (q.thresholdPct > 0 ? ` · min: ${q.thresholdPct}%` : '')).join('\n')">
|
||||
<div class="w-3 h-3 rounded-[2px] bg-gray-700/50 border border-gray-600 flex items-center justify-center cursor-help hover:bg-gray-600/70 transition-colors">
|
||||
<span class="text-[8px] text-gray-400 font-bold leading-none" x-text="`+${row.quotaInfo.length - maxVisible}`"></span>
|
||||
</div>
|
||||
|
||||
@@ -1222,6 +1222,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota Protection -->
|
||||
<div class="space-y-4 pt-2 border-t border-space-border/10">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-[10px] text-gray-500 font-bold uppercase tracking-widest"
|
||||
x-text="$store.global.t('quotaProtection')">Quota Protection</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pt-0">
|
||||
<span class="label-text text-gray-400 text-xs"
|
||||
x-text="$store.global.t('minimumQuotaLevel')">Minimum Quota Level</span>
|
||||
<span class="label-text-alt font-mono text-neon-green text-xs font-semibold"
|
||||
x-text="Math.round((serverConfig.globalQuotaThreshold || 0) * 100) > 0 ? Math.round((serverConfig.globalQuotaThreshold || 0) * 100) + '%' : $store.global.t('quotaDisabled')"></span>
|
||||
</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
<input type="range" min="0" max="99" step="1"
|
||||
class="custom-range custom-range-green flex-1"
|
||||
:value="Math.round((serverConfig.globalQuotaThreshold || 0) * 100)"
|
||||
:style="`background-size: ${Math.round((serverConfig.globalQuotaThreshold || 0) * 100) / 99 * 100}% 100%`"
|
||||
@input="toggleGlobalQuotaThreshold($event.target.value)"
|
||||
aria-label="Minimum quota level slider">
|
||||
<input type="number" min="0" max="99"
|
||||
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
|
||||
:value="Math.round((serverConfig.globalQuotaThreshold || 0) * 100)"
|
||||
@change="toggleGlobalQuotaThreshold($event.target.value)"
|
||||
aria-label="Minimum quota level value">
|
||||
</div>
|
||||
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
|
||||
x-text="$store.global.t('minimumQuotaLevelDesc')">Switch accounts when quota drops below this level. Per-account overrides take priority.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Handling Tuning -->
|
||||
<div class="space-y-4 pt-2 border-t border-space-border/10">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
|
||||
Reference in New Issue
Block a user