feat(instances): tell users which quota is in the way before the wizard
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s

Quotas were only enforced by check_quota() on the backend, which answers 429
from the deploy endpoint — so a user walked all five wizard steps and learned
about the limit on the very last click. QuotaHint coloured the offending number
red but blocked nothing.

Split the two situations the UI never distinguished:

  * No headroom at all (used >= limit): no configuration would ever fit, so the
    wizard does not open. It is replaced by a message naming exactly which
    limits are used up, with their numbers.
  * Headroom left but the chosen configuration does not fit (used + requested >
    limit): the wizard opens, and from the config step onwards it names the
    metric and disables Next/Deploy until the resources are reduced.

The overrun check deliberately stays quiet until the config step. Gating every
step meant a default size that did not fit the remaining quota stranded the user
on the server picker, with no way to reach the fields and shrink it.

Neither block applies to admins: their own quota says nothing about an instance
they are creating on behalf of someone else, and blocking on it would lock an
admin with a quota out of the wizard entirely. The figures are still shown.

Also label the quota card as a projection. It renders used + the instance being
created, so a user with 3 of 4 instances saw "4 / 4" and reasonably read it as
already exhausted — which is what prompted this.

The arithmetic lives in features/instances/quota.ts as pure functions rather
than inline in JSX, so the two thresholds can be reasoned about on their own.
This commit is contained in:
2026-08-11 18:21:51 +05:00
parent c4fda1b0bb
commit c40611bedb
4 changed files with 205 additions and 31 deletions

View File

@@ -2,7 +2,8 @@ import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound, Gauge, Disc, Download, RefreshCw } from 'lucide-react'; import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound, Gauge, Disc, Download, RefreshCw, AlertTriangle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -24,6 +25,10 @@ import { useUsers } from '@/hooks/use-users';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store'; import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
import type { VMDeployRequest, OSTemplate, ProxmoxServer, LXCTemplate } from '@/types'; import type { VMDeployRequest, OSTemplate, ProxmoxServer, LXCTemplate } from '@/types';
import {
buildQuotaMetrics, exceededMetrics, exhaustedMetrics,
type QuotaMetric, type WizardResources,
} from './quota';
import { toast } from 'sonner'; import { toast } from 'sonner';
type InstanceKind = 'vm' | 'lxc' | 'iso'; type InstanceKind = 'vm' | 'lxc' | 'iso';
@@ -161,6 +166,34 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
const toggleKey = (id: number) => const toggleKey = (id: number) =>
setSelectedKeyIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); setSelectedKeyIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
// ── Quotas ────────────────────────────────────────────────────────────────
// Раньше о превышении квоты сообщал только бэкенд (429) — уже после того, как
// пользователь прошёл весь мастер. Считаем то же самое здесь, чтобы сказать
// заранее и назвать конкретную метрику.
//
// Блокировки применяются только к не-админам: квота у нас загружается своя, а
// админ может создавать инстанс на другого пользователя, и его собственный
// лимит тут ни при чём. Подсказку с цифрами при этом видят все.
const { data: myQuota } = useMyQuota();
const enforceQuota = !isAdmin;
const activeResources: WizardResources =
kind === 'vm'
? { cores: config.cores, memoryMb: config.memory, diskGb: config.disk }
: kind === 'iso'
? { cores: isoConfig.cores, memoryMb: isoConfig.memory, diskGb: isoConfig.disk }
: { cores: lxcConfig.cores, memoryMb: lxcConfig.memory, diskGb: lxcConfig.disk };
// Метрики, по которым пользователь ограничен. Показываем их всем, включая
// админов, — блокируем только не-админов (см. выше).
const quotaMetrics = buildQuotaMetrics(myQuota, activeResources);
const quotaBlockers = enforceQuota ? exhaustedMetrics(quotaMetrics) : [];
const quotaOverruns = enforceQuota ? exceededMetrics(quotaMetrics) : [];
// Придерживаем превышение до шага, где поля ресурсов на экране: иначе
// конфигурация по умолчанию, не влезающая в остаток квоты, заблокировала бы
// «Далее» ещё на выборе сервера — и уменьшить её было бы негде.
const blockOnOverrun = quotaOverruns.length > 0 && (step === 'config' || step === 'confirm');
const deploy = useMutation({ const deploy = useMutation({
mutationFn: (data: VMDeployRequest) => mutationFn: (data: VMDeployRequest) =>
apiClient.post<{ task_id: number; status: string; name: string }>('/templates/api/deploy', data), apiClient.post<{ task_id: number; status: string; name: string }>('/templates/api/deploy', data),
@@ -360,6 +393,38 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
})); }));
}; };
// Места нет ни под какой инстанс — мастер не открываем: настраивать нечего,
// деплой всё равно завершится 429 в самом конце.
if (quotaBlockers.length > 0) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t('wizard.quota_blocked_title')}</AlertTitle>
<AlertDescription>
<p>{t('wizard.quota_blocked_desc')}</p>
<ul className="mt-3 space-y-1.5">
{quotaBlockers.map(m => (
<li key={m.key} className="flex items-center justify-between gap-4 rounded-md border px-2.5 py-1.5 text-xs">
<span>{t(m.labelKey)}</span>
<span className="font-medium">
{t('wizard.quota_used_of', { used: m.used, limit: m.limit })}
</span>
</li>
))}
</ul>
<p className="mt-3 text-xs">{t('wizard.quota_blocked_hint')}</p>
</AlertDescription>
</Alert>
<div className="flex border-t pt-4">
<Button variant="outline" onClick={handleClose}>
<ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}
</Button>
</div>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Step indicator */} {/* Step indicator */}
@@ -615,7 +680,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
{step === 'config' && kind === 'vm' && ( {step === 'config' && kind === 'vm' && (
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3> <h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<QuotaHint addCores={config.cores} addMemoryMb={config.memory} addDiskGb={config.disk} /> <QuotaHint metrics={quotaMetrics} />
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
{/* Basic */} {/* Basic */}
<Card> <Card>
@@ -770,7 +835,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
{step === 'config' && kind === 'iso' && ( {step === 'config' && kind === 'iso' && (
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3> <h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<QuotaHint addCores={isoConfig.cores} addMemoryMb={isoConfig.memory} addDiskGb={isoConfig.disk} /> <QuotaHint metrics={quotaMetrics} />
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
{/* Basic */} {/* Basic */}
<Card> <Card>
@@ -940,7 +1005,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
{step === 'config' && kind === 'lxc' && ( {step === 'config' && kind === 'lxc' && (
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3> <h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<QuotaHint addCores={lxcConfig.cores} addMemoryMb={lxcConfig.memory} addDiskGb={lxcConfig.disk} /> <QuotaHint metrics={quotaMetrics} />
<div className="grid gap-6 sm:grid-cols-2"> <div className="grid gap-6 sm:grid-cols-2">
{/* Basic */} {/* Basic */}
<Card> <Card>
@@ -1169,18 +1234,45 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
/> />
)} )}
{/* Выбранная конфигурация не влезает в квоту — говорим об этом до отправки */}
{blockOnOverrun && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t('wizard.quota_exceeded_title')}</AlertTitle>
<AlertDescription>
<ul className="mt-2 space-y-1.5">
{quotaOverruns.map(m => (
<li key={m.key} className="flex items-center justify-between gap-4 rounded-md border px-2.5 py-1.5 text-xs">
<span>{t(m.labelKey)}</span>
<span className="font-medium">
{t('wizard.quota_used_of', { used: m.used + m.add, limit: m.limit })}
</span>
</li>
))}
</ul>
<p className="mt-3 text-xs">{t('wizard.quota_exceeded_hint')}</p>
</AlertDescription>
</Alert>
)}
{/* Navigation */} {/* Navigation */}
<div className="flex items-center justify-between border-t pt-4"> <div className="flex items-center justify-between border-t pt-4">
<Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}> <Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}>
{stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>} {stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>}
</Button> </Button>
{step === 'confirm' ? ( {step === 'confirm' ? (
<Button onClick={handleDeploy} disabled={deploy.isPending || deployLXC.isPending || createIsoVM.isPending}> <Button
onClick={handleDeploy}
disabled={
deploy.isPending || deployLXC.isPending || createIsoVM.isPending ||
blockOnOverrun
}
>
{(deploy.isPending || deployLXC.isPending || createIsoVM.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {(deploy.isPending || deployLXC.isPending || createIsoVM.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('wizard.deploy')} {t('wizard.deploy')}
</Button> </Button>
) : ( ) : (
<Button onClick={next} disabled={!canNext()}> <Button onClick={next} disabled={!canNext() || blockOnOverrun}>
{t('wizard.next')}<ChevronRight className="ml-1 h-4 w-4" /> {t('wizard.next')}<ChevronRight className="ml-1 h-4 w-4" />
</Button> </Button>
)} )}
@@ -1199,38 +1291,33 @@ function Row({ label, value }: { label: string; value: string }) {
/** /**
* Shows the deploying user's remaining quota at the config step, projecting the * Shows the deploying user's remaining quota at the config step, projecting the
* currently-entered resources. Hidden when the user has no quota set. * currently-entered resources. Renders nothing when the user has no quota set.
* Note: reflects the current user's quota (the common self-deploy case), not an * Note: reflects the current user's quota (the common self-deploy case), not an
* admin-selected owner's quota. * admin-selected owner's quota.
*/ */
function QuotaHint({ addCores, addMemoryMb, addDiskGb }: { addCores: number; addMemoryMb: number; addDiskGb: number }) { function QuotaHint({ metrics }: { metrics: QuotaMetric[] }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: q } = useMyQuota(); if (metrics.length === 0) return null;
const hasAnyLimit = q && (
q.max_instances !== null || q.max_cores !== null ||
q.max_memory_mb !== null || q.max_disk_gb !== null
);
if (!q || !hasAnyLimit) return null;
const rows: Array<{ label: string; used: number; add: number; limit: number | null }> = [
{ label: t('users.quota.instances'), used: q.used_instances, add: 1, limit: q.max_instances },
{ label: t('common.vcpu'), used: q.used_cores, add: addCores, limit: q.max_cores },
{ label: t('wizard.memory_mb'), used: q.used_memory_mb, add: addMemoryMb, limit: q.max_memory_mb },
{ label: t('wizard.disk_gb'), used: q.used_disk_gb, add: addDiskGb, limit: q.max_disk_gb },
];
return ( return (
<Card className="border-warning/40"> <Card className="border-warning/40">
<CardHeader className="pb-3"><CardTitle className="text-sm flex items-center gap-2"><Gauge className="h-4 w-4" />{t('users.quota.manage')}</CardTitle></CardHeader> <CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Gauge className="h-4 w-4" />{t('users.quota.manage')}
</CardTitle>
{/* Цифры — прогноз с учётом создаваемого инстанса, а не текущий расход:
без подписи «4 / 4» читается как «лимит уже исчерпан». */}
<p className="text-xs text-muted-foreground">{t('wizard.quota_projection_note')}</p>
</CardHeader>
<CardContent className="grid gap-2 sm:grid-cols-2"> <CardContent className="grid gap-2 sm:grid-cols-2">
{rows.filter(r => r.limit !== null).map(r => { {metrics.map(m => {
const projected = r.used + r.add; const projected = m.used + m.add;
const over = projected > (r.limit as number);
return ( return (
<div key={r.label} className="flex items-center justify-between text-xs rounded-md border px-2.5 py-1.5"> <div key={m.key} className="flex items-center justify-between text-xs rounded-md border px-2.5 py-1.5">
<span className="text-muted-foreground">{r.label}</span> <span className="text-muted-foreground">{t(m.labelKey)}</span>
<span className={over ? 'text-destructive font-medium' : ''}>{projected} / {r.limit}</span> <span className={projected > m.limit ? 'text-destructive font-medium' : ''}>
{projected} / {m.limit}
</span>
</div> </div>
); );
})} })}

View File

@@ -0,0 +1,73 @@
import type { MyQuota } from '@/hooks/use-settings';
/**
* Quota arithmetic for the create-instance wizard.
*
* The backend enforces the same rules in check_quota() and answers with HTTP
* 429, but only once the deploy is submitted — at the very end of the wizard.
* These helpers let the UI say up front which limit is in the way.
*/
export type QuotaKey = 'instances' | 'cores' | 'memory' | 'disk';
export interface QuotaMetric {
key: QuotaKey;
/** i18n key for the metric's label. */
labelKey: string;
used: number;
limit: number;
/** What the instance being configured would add. */
add: number;
}
export interface WizardResources {
cores: number;
memoryMb: number;
diskGb: number;
}
/**
* Metrics the user is actually limited on. A null limit means "unlimited" and
* is left out entirely, so a user without a quota row yields an empty list.
*/
export function buildQuotaMetrics(
quota: MyQuota | undefined,
res: WizardResources,
): QuotaMetric[] {
if (!quota) return [];
const all: QuotaMetric[] = [
{
key: 'instances', labelKey: 'users.quota.instances',
used: quota.used_instances, limit: quota.max_instances as number, add: 1,
},
{
key: 'cores', labelKey: 'common.vcpu',
used: quota.used_cores, limit: quota.max_cores as number, add: res.cores,
},
{
key: 'memory', labelKey: 'wizard.memory_mb',
used: quota.used_memory_mb, limit: quota.max_memory_mb as number, add: res.memoryMb,
},
{
key: 'disk', labelKey: 'wizard.disk_gb',
used: quota.used_disk_gb, limit: quota.max_disk_gb as number, add: res.diskGb,
},
];
return all.filter(m => m.limit !== null && m.limit !== undefined);
}
/**
* Metrics with no headroom left at all: not a single instance of any size can
* be created, so there is nothing worth configuring. Note this is `used >=
* limit`, not the `projected > limit` test used for a specific configuration.
*/
export function exhaustedMetrics(metrics: QuotaMetric[]): QuotaMetric[] {
return metrics.filter(m => m.used >= m.limit);
}
/** Metrics the currently entered configuration would push over the limit. */
export function exceededMetrics(metrics: QuotaMetric[]): QuotaMetric[] {
return metrics.filter(m => m.used + m.add > m.limit);
}

View File

@@ -619,7 +619,14 @@
"iso_ip_hint": "The IP is only reserved in IPAM — there is no cloud-init here, so enter it manually in the installer.", "iso_ip_hint": "The IP is only reserved in IPAM — there is no cloud-init here, so enter it manually in the installer.",
"iso_next_steps": "The VM boots from the ISO. Open the console to run the installer; when it is done, click Eject in the ISO dialog — the disk becomes first in the boot order automatically.", "iso_next_steps": "The VM boots from the ISO. Open the console to run the installer; when it is done, click Eject in the ISO dialog — the disk becomes first in the boot order automatically.",
"iso_download": "Download an ISO to this node", "iso_download": "Download an ISO to this node",
"iso_refresh": "Refresh the ISO list" "iso_refresh": "Refresh the ISO list",
"quota_blocked_title": "Quota limits reached",
"quota_blocked_desc": "You cannot create another instance — the following limits are fully used:",
"quota_blocked_hint": "Delete an instance you no longer need, or ask an administrator to raise your quota.",
"quota_exceeded_title": "This configuration does not fit your quota",
"quota_exceeded_hint": "Reduce the resources above to continue.",
"quota_used_of": "{{used}} of {{limit}}",
"quota_projection_note": "Including the instance being created."
}, },
"nodes": { "nodes": {
"title": "Servers", "title": "Servers",

View File

@@ -619,7 +619,14 @@
"iso_ip_hint": "IP только резервируется в IPAM — cloud-init здесь нет, адрес нужно ввести вручную в установщике.", "iso_ip_hint": "IP только резервируется в IPAM — cloud-init здесь нет, адрес нужно ввести вручную в установщике.",
"iso_next_steps": "ВМ загрузится с ISO. Откройте консоль и пройдите установку; после неё нажмите «Извлечь» в диалоге ISO — диск автоматически станет первым в порядке загрузки.", "iso_next_steps": "ВМ загрузится с ISO. Откройте консоль и пройдите установку; после неё нажмите «Извлечь» в диалоге ISO — диск автоматически станет первым в порядке загрузки.",
"iso_download": "Загрузить ISO на эту ноду", "iso_download": "Загрузить ISO на эту ноду",
"iso_refresh": "Обновить список ISO" "iso_refresh": "Обновить список ISO",
"quota_blocked_title": "Лимиты исчерпаны",
"quota_blocked_desc": "Создать ещё один инстанс нельзя — полностью израсходованы следующие лимиты:",
"quota_blocked_hint": "Удалите ненужный инстанс или попросите администратора увеличить квоту.",
"quota_exceeded_title": "Конфигурация не укладывается в квоту",
"quota_exceeded_hint": "Уменьшите ресурсы выше, чтобы продолжить.",
"quota_used_of": "{{used}} из {{limit}}",
"quota_projection_note": "С учётом создаваемого инстанса."
}, },
"nodes": { "nodes": {
"title": "Серверы", "title": "Серверы",