feat(instances): disable Create Instance when the quota is used up
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s

The wizard already refuses to open once a limit has no headroom left, but the
button leading to it still looked available, so the only way to find out was to
click it. Reuse exhaustedMetrics() from the wizard's quota module, so the button
and the wizard cannot disagree about what counts as exhausted, and skip the
check for admins for the same reason the wizard does — their own quota says
nothing about an instance they create for someone else.

The button is rendered as a Link, and disabled does nothing to an <a>: it would
still navigate. So the exhausted case renders a plain button with no Link, and a
tooltip naming the limits that are full. The trigger wraps it in a span because
a disabled button receives no pointer events of its own.

The wizard keeps its own block, which is what still covers navigating straight
to /instances/create.
This commit is contained in:
2026-08-12 14:44:42 +05:00
parent 91fa1b9971
commit 9e4120bd8d

View File

@@ -83,7 +83,8 @@ import { StatusDot } from '@/components/shared/status-dot';
import { ColumnFilter, multiSelectFilter } from '@/components/shared/column-filter'; import { ColumnFilter, multiSelectFilter } from '@/components/shared/column-filter';
import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync, useBulkTasksSync, vmKeys } from '@/hooks/use-instances'; import { useVirtualMachines, useBulkOperation, usePowerAction, useVMStatusSync, useInstancesMetricsSync, useBulkTasksSync, vmKeys } from '@/hooks/use-instances';
import { useServers } from '@/hooks/use-nodes'; import { useServers } from '@/hooks/use-nodes';
import { useProfile } from '@/hooks/use-settings'; import { useProfile, useMyQuota } from '@/hooks/use-settings';
import { buildQuotaMetrics, exhaustedMetrics } from './quota';
import { formatBytes, vmTypeLabel, formatUptime, formatVmConfig } from '@/lib/format'; import { formatBytes, vmTypeLabel, formatUptime, formatVmConfig } from '@/lib/format';
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';
@@ -339,6 +340,16 @@ export default function InstancesPage() {
const { data: servers = [] } = useServers(); const { data: servers = [] } = useServers();
const { data: profile } = useProfile(); const { data: profile } = useProfile();
// Квота исчерпана — мастер создания всё равно откажется открываться, так что
// не ведём туда пользователя вхолостую. Условие то же, что в мастере:
// размер будущего инстанса не важен (сравниваем использовано/лимит), а на
// админов не распространяется — их лимит не про инстанс, который они создают
// другому пользователю.
const { data: myQuota } = useMyQuota();
const quotaBlockers = profile?.is_admin
? []
: exhaustedMetrics(buildQuotaMetrics(myQuota, { cores: 0, memoryMb: 0, diskGb: 0 }));
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [serverFilter, setServerFilter] = useState<string>(() => { const [serverFilter, setServerFilter] = useState<string>(() => {
@@ -1070,10 +1081,34 @@ export default function InstancesPage() {
{t('snap_archive.title')} {t('snap_archive.title')}
</Button> </Button>
)} )}
<Button render={<Link to="/instances/create" />} size="sm"> {quotaBlockers.length > 0 ? (
<Plus className="mr-1.5 h-4 w-4" /> // Кнопка-ссылка с disabled всё равно навигирует (<a> не знает про
{t('instances.create', 'Create Instance')} // disabled), поэтому при исчерпанной квоте рендерим обычную кнопку
</Button> // без Link, а причину показываем в подсказке.
<Tooltip>
<TooltipTrigger render={<span />}>
<Button size="sm" disabled>
<Plus className="mr-1.5 h-4 w-4" />
{t('instances.create', 'Create Instance')}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('wizard.quota_blocked_title')}</p>
<ul className="mt-1">
{quotaBlockers.map(m => (
<li key={m.key}>
{t(m.labelKey)}: {t('wizard.quota_used_of', { used: m.used, limit: m.limit })}
</li>
))}
</ul>
</TooltipContent>
</Tooltip>
) : (
<Button render={<Link to="/instances/create" />} size="sm">
<Plus className="mr-1.5 h-4 w-4" />
{t('instances.create', 'Create Instance')}
</Button>
)}
</div> </div>
</div> </div>