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:
jgor20
2026-02-01 11:45:46 +00:00
committed by GitHub
parent 33584d31bb
commit a43d2332ca
23 changed files with 806 additions and 31 deletions

View File

@@ -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>