feat(quotas): per-user resource limits for VM/LXC deployment
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 54s
Deploy to Production / Trigger production update (push) Successful in 8s

Add per-user quotas (max instances, vCPU, RAM, disk) enforced at deploy
time and surfaced to users self-service.

Backend:
- UserQuota model (one-to-one, NULL = unlimited) + migration #28
- quota_service: usage from vm_instances cache, check_quota raises 429
- enforce in VM (templates.py) and LXC (lxc.py) deploy paths
- RBAC quota:view / quota:manage, granted to admin
- admin API GET/PUT /admin/api/users/{id}/quota
- self-service GET /settings/api/quota
- tighten VM list isolation via can_view_all_instances

Frontend:
- admin quota dialog in UsersPage with usage bars
- quota card on Settings page (own limits)
- remaining-quota hint in CreateInstanceWizard config step
- useUserQuota/useSetUserQuota, useMyQuota hooks; RU/EN locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 18:15:02 +05:00
parent d59d787bed
commit 35dcbfd18f
21 changed files with 707 additions and 67 deletions

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound } from 'lucide-react';
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound, Gauge } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,7 +14,7 @@ import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
import { useIPAMNetworks } from '@/hooks/use-ipam';
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
import { useProfile } from '@/hooks/use-settings';
import { useProfile, useMyQuota } from '@/hooks/use-settings';
import { useUsers } from '@/hooks/use-users';
import { apiClient } from '@/lib/api-client';
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
@@ -418,6 +418,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
{step === 'config' && kind === 'vm' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<QuotaHint addCores={config.cores} addMemoryMb={config.memory} addDiskGb={config.disk} />
<div className="grid gap-6 sm:grid-cols-2">
{/* Basic */}
<Card>
@@ -566,6 +567,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
{step === 'config' && kind === 'lxc' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
<QuotaHint addCores={lxcConfig.cores} addMemoryMb={lxcConfig.memory} addDiskGb={lxcConfig.disk} />
<div className="grid gap-6 sm:grid-cols-2">
{/* Basic */}
<Card>
@@ -788,6 +790,48 @@ function Row({ label, value }: { label: string; value: string }) {
);
}
/**
* Shows the deploying user's remaining quota at the config step, projecting the
* currently-entered resources. Hidden when the user has no quota set.
* Note: reflects the current user's quota (the common self-deploy case), not an
* admin-selected owner's quota.
*/
function QuotaHint({ addCores, addMemoryMb, addDiskGb }: { addCores: number; addMemoryMb: number; addDiskGb: number }) {
const { t } = useTranslation();
const { data: q } = useMyQuota();
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 (
<Card className="border-amber-500/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>
<CardContent className="grid gap-2 sm:grid-cols-2">
{rows.filter(r => r.limit !== null).map(r => {
const projected = r.used + r.add;
const over = projected > (r.limit as number);
return (
<div key={r.label} 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={over ? 'text-destructive font-medium' : ''}>{projected} / {r.limit}</span>
</div>
);
})}
</CardContent>
</Card>
);
}
function SSHOwnerSelector({
users, value, onChange,
}: {

View File

@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import {
useProfile, useUpdateProfile, useChangePassword,
useMyQuota,
usePanelSettings, useUpdatePanelSettings,
useSecuritySettings, useUpdateSecuritySettings,
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
@@ -111,10 +112,57 @@ function ProfileTab() {
}}>{t('settings.change_password')}</Button>
</CardContent>
</Card>
<QuotaCard />
</div>
);
}
function QuotaCard() {
const { t } = useTranslation();
const { data: quota, isLoading } = useMyQuota();
// Hide the card entirely if the user has no limits set (all unlimited)
const hasAnyLimit = quota && (
quota.max_instances !== null || quota.max_cores !== null ||
quota.max_memory_mb !== null || quota.max_disk_gb !== null
);
if (isLoading || !quota || !hasAnyLimit) return null;
const rows: Array<{ label: string; used: number; limit: number | null }> = [
{ label: t('users.quota.instances'), used: quota.used_instances, limit: quota.max_instances },
{ label: t('users.quota.cores'), used: quota.used_cores, limit: quota.max_cores },
{ label: t('users.quota.memory_mb'), used: quota.used_memory_mb, limit: quota.max_memory_mb },
{ label: t('users.quota.disk_gb'), used: quota.used_disk_gb, limit: quota.max_disk_gb },
];
return (
<Card>
<CardHeader><CardTitle className="text-sm">{t('users.quota.manage')}</CardTitle></CardHeader>
<CardContent className="space-y-4">
{rows.map(r => {
const pct = r.limit && r.limit > 0 ? Math.min(100, (r.used / r.limit) * 100) : 0;
const over = r.limit !== null && r.used > r.limit;
return (
<div key={r.label} className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span>{r.label}</span>
<span className={over ? 'text-destructive' : 'text-muted-foreground'}>
{r.used}{r.limit !== null ? ` / ${r.limit}` : ` / ${t('users.quota.unlimited')}`}
</span>
</div>
{r.limit !== null && (
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className={`h-full ${over ? 'bg-destructive' : 'bg-primary'}`} style={{ width: `${pct}%` }} />
</div>
)}
</div>
);
})}
</CardContent>
</Card>
);
}
function PanelTab() {
const { t } = useTranslation();
const { data: settings } = usePanelSettings();

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
Search, Plus, Shield, Trash2, RotateCcw, Unlock, Pencil, Server, LogOut, Ban, KeyRound,
Search, Plus, Shield, Trash2, RotateCcw, Unlock, Pencil, Server, LogOut, Ban, KeyRound, Gauge,
} from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -24,6 +24,7 @@ import {
useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions,
useSessions, useTerminateSession, useTerminateAllSessions,
useUserServerAssignments, useSetUserServers,
useUserQuota, useSetUserQuota,
useSecuritySettings, useUpdateSecuritySettings,
useBlockedIps, useBlockIp, useUnblockIp, useSecurityEvents,
type Role, type SecuritySettings,
@@ -86,6 +87,7 @@ function UsersTab() {
const [editUser, setEditUser] = useState<User | null>(null);
const [resetUser, setResetUser] = useState<User | null>(null);
const [serversUser, setServersUser] = useState<User | null>(null);
const [quotaUser, setQuotaUser] = useState<User | null>(null);
const [sshUser, setSshUser] = useState<User | null>(null);
const { data: users = [] } = useUsers();
@@ -175,6 +177,13 @@ function UsersTab() {
>
<Server className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
title={t('users.quota.manage')}
onClick={() => setQuotaUser(u)}
>
<Gauge className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
title={t('ssh_keys.manage_for_user')}
@@ -258,6 +267,13 @@ function UsersTab() {
onOpenChange={o => !o && setServersUser(null)}
/>
)}
{quotaUser && (
<UserQuotaDialog
user={quotaUser}
open={!!quotaUser}
onOpenChange={o => !o && setQuotaUser(null)}
/>
)}
{sshUser && (
<UserSSHKeysDialog
user={sshUser}
@@ -607,6 +623,123 @@ function UserServersForm({
);
}
// ==================== User Quota Dialog ====================
function UserQuotaDialog({
user, open, onOpenChange,
}: { user: User; open: boolean; onOpenChange: (o: boolean) => void }) {
const { t } = useTranslation();
const { data, isLoading } = useUserQuota(user.id);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('users.quota.manage')}: {user.username}</DialogTitle>
</DialogHeader>
{isLoading || !data ? (
<p className="text-sm text-muted-foreground py-4">{t('common.loading')}</p>
) : (
<UserQuotaForm user={user} data={data} onClose={() => onOpenChange(false)} />
)}
</DialogContent>
</Dialog>
);
}
function UserQuotaForm({
user, data, onClose,
}: {
user: User;
data: NonNullable<ReturnType<typeof useUserQuota>['data']>;
onClose: () => void;
}) {
const { t } = useTranslation();
const setQuota = useSetUserQuota();
// Empty string = unlimited
const toField = (v: number | null) => (v === null || v === undefined ? '' : String(v));
const [instances, setInstances] = useState(toField(data.max_instances));
const [cores, setCores] = useState(toField(data.max_cores));
const [memory, setMemory] = useState(toField(data.max_memory_mb));
const [disk, setDisk] = useState(toField(data.max_disk_gb));
const parse = (v: string): number | null => {
const trimmed = v.trim();
if (trimmed === '') return null;
const n = Number(trimmed);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
};
const submit = () => {
setQuota.mutate(
{
userId: user.id,
max_instances: parse(instances),
max_cores: parse(cores),
max_memory_mb: parse(memory),
max_disk_gb: parse(disk),
},
{
onSuccess: () => { onClose(); toast.success(t('users.quota.saved')); },
onError: e => toast.error(getErrMsg(e)),
}
);
};
const fields: Array<{
label: string; value: string; set: (v: string) => void; used: number; limit: number | null;
}> = [
{ label: t('users.quota.instances'), value: instances, set: setInstances, used: data.used_instances, limit: parse(instances) },
{ label: t('users.quota.cores'), value: cores, set: setCores, used: data.used_cores, limit: parse(cores) },
{ label: t('users.quota.memory_mb'), value: memory, set: setMemory, used: data.used_memory_mb, limit: parse(memory) },
{ label: t('users.quota.disk_gb'), value: disk, set: setDisk, used: data.used_disk_gb, limit: parse(disk) },
];
return (
<>
<div className="space-y-4">
<p className="text-xs text-muted-foreground">{t('users.quota.hint')}</p>
{fields.map(f => {
const pct = f.limit && f.limit > 0 ? Math.min(100, (f.used / f.limit) * 100) : 0;
const over = f.limit !== null && f.used > f.limit;
return (
<div key={f.label} className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-sm">{f.label}</Label>
<span className={`text-xs ${over ? 'text-destructive' : 'text-muted-foreground'}`}>
{t('users.quota.used')}: {f.used}
{f.limit !== null ? ` / ${f.limit}` : ` / ${t('users.quota.unlimited')}`}
</span>
</div>
<div className="flex items-center gap-3">
<Input
type="number"
min={0}
value={f.value}
onChange={e => f.set(e.target.value)}
placeholder={t('users.quota.unlimited')}
className="w-40"
/>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={`h-full ${over ? 'bg-destructive' : 'bg-primary'}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
);
})}
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={submit} disabled={setQuota.isPending}>{t('common.save')}</Button>
</DialogFooter>
</>
);
}
function UserSSHKeysDialog({
user, open, onOpenChange,
}: { user: User; open: boolean; onOpenChange: (o: boolean) => void }) {

View File

@@ -3,6 +3,7 @@ import { apiClient } from '@/lib/api-client';
export const settingsKeys = {
profile: ['settings-profile'] as const,
quota: ['settings-quota'] as const,
panel: ['settings-panel'] as const,
security: ['settings-security'] as const,
channels: ['notification-channels'] as const,
@@ -59,6 +60,25 @@ export function useProfile() {
});
}
export interface MyQuota {
user_id: number;
max_instances: number | null;
max_cores: number | null;
max_memory_mb: number | null;
max_disk_gb: number | null;
used_instances: number;
used_cores: number;
used_memory_mb: number;
used_disk_gb: number;
}
export function useMyQuota() {
return useQuery({
queryKey: settingsKeys.quota,
queryFn: () => apiClient.get<MyQuota>('/settings/api/quota'),
});
}
export function useUpdateProfile() {
const qc = useQueryClient();
return useMutation({

View File

@@ -6,6 +6,7 @@ export const userKeys = {
users: ['users'] as const,
user: (id: number) => ['user', id] as const,
userServers: (id: number) => ['user-server-assignments', id] as const,
userQuota: (id: number) => ['user-quota', id] as const,
roles: ['roles'] as const,
permissions: ['permissions-v2'] as const,
sessions: ['sessions'] as const,
@@ -208,6 +209,47 @@ export function useSetUserServers() {
});
}
// ==================== User → Quota ====================
export interface UserQuota {
user_id: number;
max_instances: number | null;
max_cores: number | null;
max_memory_mb: number | null;
max_disk_gb: number | null;
used_instances: number;
used_cores: number;
used_memory_mb: number;
used_disk_gb: number;
}
export interface SetUserQuotaPayload {
max_instances?: number | null;
max_cores?: number | null;
max_memory_mb?: number | null;
max_disk_gb?: number | null;
}
export function useUserQuota(userId: number) {
return useQuery({
queryKey: userKeys.userQuota(userId),
queryFn: () => apiClient.get<UserQuota>(`/admin/api/users/${userId}/quota`),
enabled: userId > 0,
});
}
export function useSetUserQuota() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, ...data }: { userId: number } & SetUserQuotaPayload) =>
apiClient.put<UserQuota>(`/admin/api/users/${userId}/quota`, data),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: userKeys.userQuota(vars.userId) });
qc.invalidateQueries({ queryKey: userKeys.users });
},
});
}
// ==================== Roles ====================
export function useRoles() {

View File

@@ -46,6 +46,10 @@ interface NavItem {
label: string;
icon: React.ComponentType<{ className?: string }>;
path: string;
// Permission ("resource:action") the destination page requires. When set, the
// item is hidden unless the user is an admin or has that permission granted.
// When omitted, the item is admin-only.
permission?: string;
}
interface NavGroup {
@@ -53,41 +57,65 @@ interface NavGroup {
items: NavItem[];
}
// Each item is gated by the permission its page's API actually requires, so a
// menu only appears when the user can use the page behind it. Admins bypass all
// checks. Items without a `permission` are admin-only (no user-facing API).
const navGroups: NavGroup[] = [
{
title: 'Infrastructure',
items: [
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard' },
{ label: 'Instances', icon: Monitor, path: '/instances' },
{ label: 'Nodes', icon: Server, path: '/nodes' },
{ label: 'Cluster', icon: Network, path: '/cluster' },
{ label: 'Templates', icon: HardDrive, path: '/templates' },
{ label: 'Images', icon: HardDriveDownload, path: '/images' },
{ label: 'Backups', icon: Archive, path: '/backups' },
{ label: 'Tasks', icon: ClipboardList, path: '/tasks' },
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard', permission: 'dashboard:view' },
{ label: 'Instances', icon: Monitor, path: '/instances', permission: 'vm:view' },
{ label: 'Nodes', icon: Server, path: '/nodes', permission: 'server:view' },
{ label: 'Cluster', icon: Network, path: '/cluster', permission: 'cluster:manage' },
{ label: 'Templates', icon: HardDrive, path: '/templates', permission: 'template:view' },
{ label: 'Images', icon: HardDriveDownload, path: '/images', permission: 'template:manage' },
{ label: 'Backups', icon: Archive, path: '/backups', permission: 'backup:view' },
{ label: 'Tasks', icon: ClipboardList, path: '/tasks', permission: 'vm:view' },
],
},
{
title: 'Network',
items: [
{ label: 'IPAM', icon: Globe, path: '/ipam' },
{ label: 'Networks', icon: Network, path: '/networks' },
{ label: 'IPAM', icon: Globe, path: '/ipam', permission: 'ipam:view' },
{ label: 'Networks', icon: Network, path: '/networks', permission: 'server:manage' },
],
},
{
title: 'Management',
items: [
{ label: 'Users', icon: Users, path: '/users' },
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' },
{ label: 'Logs', icon: FileText, path: '/logs' },
{ label: 'Settings', icon: Settings, path: '/settings' },
{ label: 'Users', icon: Users, path: '/users', permission: 'user:view' },
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' }, // admin-only
{ label: 'Logs', icon: FileText, path: '/logs', permission: 'log:view' },
{ label: 'Settings', icon: Settings, path: '/settings', permission: 'setting:view' },
],
},
];
/** Whether the current user may see a given nav item. Admins see everything. */
function canSeeNavItem(
item: NavItem,
isAdmin: boolean,
permissions: Record<string, boolean> | undefined,
): boolean {
if (isAdmin) return true;
if (!item.permission) return false; // admin-only item
return !!permissions?.[item.permission];
}
function SidebarContent() {
const location = useLocation();
const { t } = useTranslation();
const { user } = useAuthStore();
const isAdmin = !!user?.is_admin;
const permissions = user?.permissions;
const visibleGroups = navGroups
.map((group) => ({
...group,
items: group.items.filter((item) => canSeeNavItem(item, isAdmin, permissions)),
}))
.filter((group) => group.items.length > 0);
return (
<div className="flex h-full flex-col">
@@ -100,7 +128,7 @@ function SidebarContent() {
{/* Navigation */}
<nav className="flex-1 space-y-4 overflow-y-auto px-3 py-4">
{navGroups.map((group) => (
{visibleGroups.map((group) => (
<div key={group.title}>
<p className="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t(`nav.${group.title.toLowerCase()}`, group.title)}

View File

@@ -849,7 +849,18 @@
"event_type": "Event Type",
"workspaces": "Workspaces",
"no_workspaces": "No workspaces",
"workspace_required": "User must belong to the server's workspace"
"workspace_required": "User must belong to the server's workspace",
"quota": {
"manage": "Quotas (limits)",
"hint": "Leave a field empty for no limit on that metric.",
"instances": "Max instances (VM + LXC)",
"cores": "Max vCPU (cores)",
"memory_mb": "Max RAM (MB)",
"disk_gb": "Max disk (GB)",
"used": "Used",
"unlimited": "unlimited",
"saved": "Quota saved"
}
},
"workspaces": {
"create": "Create Workspace",

View File

@@ -849,7 +849,18 @@
"event_type": "Тип события",
"workspaces": "Рабочие области",
"no_workspaces": "Нет рабочих областей",
"workspace_required": "Пользователь должен принадлежать рабочей области сервера"
"workspace_required": "Пользователь должен принадлежать рабочей области сервера",
"quota": {
"manage": "Квоты (лимиты)",
"hint": "Пустое поле — без ограничения по этой метрике.",
"instances": "Макс. инстансов (VM + LXC)",
"cores": "Макс. vCPU (ядер)",
"memory_mb": "Макс. RAM (МБ)",
"disk_gb": "Макс. диск (ГБ)",
"used": "Использовано",
"unlimited": "без лимита",
"saved": "Квоты сохранены"
}
},
"workspaces": {
"create": "Создать рабочую область",

View File

@@ -15,7 +15,8 @@ export interface User {
failed_login_attempts?: number;
locked_until?: string | null;
require_password_change?: boolean;
permissions?: string[];
// Map of "resource:action" -> granted. Admins receive every permission.
permissions?: Record<string, boolean>;
}
export interface LoginRequest {