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

@@ -20,43 +20,46 @@ from ...ipam_service import IPAMService
# ==================== Helper Functions for User Isolation ====================
def can_view_all_instances(current_user: User) -> bool:
"""
Whether the user may see/manage *every* instance, not just their own.
Only administrators — or roles that explicitly grant ``vm:manage`` — get the
global view. Every other authenticated user (including users with no role,
custom roles, or the stock ``user`` role) is scoped to instances they own.
Note: ``vm:view`` is deliberately NOT treated as a global view permission.
The stock ``user`` role carries ``vm:view`` simply so the instances feature
is usable at all; granting it would let any tenant see every VM.
"""
if getattr(current_user, 'is_admin', False):
return True
role = current_user.role
if role and role.name == 'admin':
return True
perms = (role.permissions if role else None) or {}
return bool(perms.get('vm:manage', False))
def check_vm_access(db: Session, current_user: User, server_id: int, vmid: int) -> bool:
"""
Check if user has access to a specific VM instance.
Admins and users with 'vms:view' permission can access all VMs.
Users with 'user' role can only access VMs where they are the owner.
Admins (and roles granting ``vm:manage``) can access all VMs. Everyone else
can only access VMs where they are the owner.
Returns True if access is allowed, False otherwise.
"""
# Admin role has full access
if current_user.role and current_user.role.name == 'admin':
if can_view_all_instances(current_user):
return True
# Check permissions
if current_user.role and current_user.role.permissions:
perms = current_user.role.permissions
# If user has full vms:view, they can access all VMs
has_full_view = perms.get('vm:view', False) or perms.get('vms:view', False)
has_own_only = perms.get('vms:view:own', False) or perms.get('vms.view.own', False)
instance = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first()
if has_full_view and not has_own_only:
return True
# For limited users, check ownership
if current_user.role and current_user.role.name == 'user':
instance = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first()
if instance and instance.owner_id == current_user.id:
return True
return False
# Default: allow access (for backwards compatibility)
return True
return bool(instance and instance.owner_id == current_user.id)
def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int):

View File

@@ -448,6 +448,10 @@ async def deploy_lxc_from_template(
raise HTTPException(status_code=404, detail="Owner user not found")
instance_owner_id = owner.id
# Enforce per-user resource quota (no-op if owner has no quota set)
from ...services.quota_service import check_quota
check_quota(db, instance_owner_id, add_cores=req.cores, add_memory_mb=req.memory, add_disk_gb=req.disk)
if req.ssh_key_ids:
from ..ssh_keys import resolve_ssh_keys_for_deploy
resolved = resolve_ssh_keys_for_deploy(db, current_user, req.ssh_key_ids, instance_owner_id)

View File

@@ -20,7 +20,8 @@ from ...logging_service import LoggingService
from ...ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
get_next_vmid, archive_and_delete_snapshots,
save_vm_instance, get_vm_instance, soft_delete_vm_instance)
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
can_view_all_instances)
from ...services.metrics_history import query_instance_metrics, timeframe_to_range
router = APIRouter()
@@ -146,21 +147,11 @@ def get_all_virtual_machines(
if workspace_server_ids is not None:
query = query.filter(VMInstance.server_id.in_(workspace_server_ids))
# VPS-style user isolation: users with 'user' role see only their own instances
# Check if user has 'vm:view' but not 'vms.view:all' (or is user role)
user_role = current_user.role.name if current_user.role else None
is_limited_user = user_role == 'user'
# Also check permission-based isolation
if hasattr(current_user, 'role') and current_user.role:
perms = current_user.role.permissions or {}
# If user has vms:view:own but not vms:view (full), filter by owner
has_view_own = perms.get('vms:view:own', False) or perms.get('vms.view.own', False)
has_view_all = perms.get('vms:view', False) or perms.get('vm:view', False)
# Limited users can only see their own VMs
if is_limited_user or (has_view_own and not has_view_all):
query = query.filter(VMInstance.owner_id == current_user.id)
# VPS-style user isolation: only admins (or roles granting vm:manage) see
# every instance. Everyone else — including users with no role or a custom
# role — is restricted to instances they own. See can_view_all_instances.
if not can_view_all_instances(current_user):
query = query.filter(VMInstance.owner_id == current_user.id)
cached_vms = query.order_by(VMInstance.name).all()

View File

@@ -123,6 +123,30 @@ async def get_profile(
}
@router.get("/api/quota")
async def get_my_quota(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get the current user's own resource quota and usage (self-service)."""
from ..models import UserQuota
from ..services.quota_service import get_user_usage
quota = db.query(UserQuota).filter(UserQuota.user_id == current_user.id).first()
usage = get_user_usage(db, current_user.id)
return {
"user_id": current_user.id,
"max_instances": quota.max_instances if quota else None,
"max_cores": quota.max_cores if quota else None,
"max_memory_mb": quota.max_memory_mb if quota else None,
"max_disk_gb": quota.max_disk_gb if quota else None,
"used_instances": usage["instances"],
"used_cores": usage["cores"],
"used_memory_mb": usage["memory_mb"],
"used_disk_gb": usage["disk_gb"],
}
@router.put("/api/profile")
async def update_profile(
data: UpdateProfileRequest,

View File

@@ -1044,6 +1044,10 @@ async def deploy_vm_from_template(
raise HTTPException(status_code=404, detail="Owner user not found")
instance_owner_id = owner.id
# Enforce per-user resource quota (no-op if owner has no quota set)
from ..services.quota_service import check_quota
check_quota(db, instance_owner_id, add_cores=cores, add_memory_mb=memory, add_disk_gb=disk)
if deploy_data.ssh_key_ids:
from .ssh_keys import resolve_ssh_keys_for_deploy
resolved = resolve_ssh_keys_for_deploy(

View File

@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, EmailStr
from loguru import logger
from ..db import get_db
from ..models import User, Role, ProxmoxServer, Workspace, WorkspaceServer, WorkspaceUser
from ..models import User, Role, ProxmoxServer, Workspace, WorkspaceServer, WorkspaceUser, UserQuota
from ..auth import (
get_password_hash,
get_current_user,
@@ -115,6 +115,27 @@ class SecuritySettingsUpdate(BaseModel):
password_require_special: Optional[bool] = None
class QuotaUpdate(BaseModel):
"""All fields optional; None / omitted clears the limit (unlimited)."""
max_instances: Optional[int] = Field(None, ge=0)
max_cores: Optional[int] = Field(None, ge=0)
max_memory_mb: Optional[int] = Field(None, ge=0)
max_disk_gb: Optional[int] = Field(None, ge=0)
class QuotaResponse(BaseModel):
user_id: int
max_instances: Optional[int] = None
max_cores: Optional[int] = None
max_memory_mb: Optional[int] = None
max_disk_gb: Optional[int] = None
# Current usage from the vm_instances cache
used_instances: int = 0
used_cores: int = 0
used_memory_mb: int = 0
used_disk_gb: int = 0
# ==================== Role API ====================
@router.get("/api/roles", response_model=List[RoleResponse])
@@ -717,6 +738,98 @@ async def set_user_servers(
return {"message": "Servers assigned successfully", "server_ids": [s.id for s in servers]}
# ==================== User Quota API ====================
@router.get("/api/users/{user_id}/quota", response_model=QuotaResponse)
async def get_user_quota(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("quota:view"))
):
"""Get a user's resource quota plus current usage.
If the user has no quota row, all limits are returned as null (unlimited).
"""
from ..services.quota_service import get_user_usage
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
quota = db.query(UserQuota).filter(UserQuota.user_id == user_id).first()
usage = get_user_usage(db, user_id)
return QuotaResponse(
user_id=user_id,
max_instances=quota.max_instances if quota else None,
max_cores=quota.max_cores if quota else None,
max_memory_mb=quota.max_memory_mb if quota else None,
max_disk_gb=quota.max_disk_gb if quota else None,
used_instances=usage["instances"],
used_cores=usage["cores"],
used_memory_mb=usage["memory_mb"],
used_disk_gb=usage["disk_gb"],
)
@router.put("/api/users/{user_id}/quota", response_model=QuotaResponse)
async def set_user_quota(
user_id: int,
data: QuotaUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("quota:manage"))
):
"""Upsert a user's resource quota. A null/omitted field clears that limit."""
from ..services.quota_service import get_user_usage
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
quota = db.query(UserQuota).filter(UserQuota.user_id == user_id).first()
if quota is None:
quota = UserQuota(user_id=user_id)
db.add(quota)
quota.max_instances = data.max_instances
quota.max_cores = data.max_cores
quota.max_memory_mb = data.max_memory_mb
quota.max_disk_gb = data.max_disk_gb
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="user_quota_updated",
message=(
f"Quota set for '{user.username}' by {current_user.username}: "
f"instances={data.max_instances}, cores={data.max_cores}, "
f"memory_mb={data.max_memory_mb}, disk_gb={data.max_disk_gb}"
),
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
usage = get_user_usage(db, user_id)
return QuotaResponse(
user_id=user_id,
max_instances=quota.max_instances,
max_cores=quota.max_cores,
max_memory_mb=quota.max_memory_mb,
max_disk_gb=quota.max_disk_gb,
used_instances=usage["instances"],
used_cores=usage["cores"],
used_memory_mb=usage["memory_mb"],
used_disk_gb=usage["disk_gb"],
)
@router.post("/api/users/{user_id}/reset-password")
async def reset_user_password(
user_id: int,

View File

@@ -1,5 +1,5 @@
from .base import *
from .auth import Role, user_servers, User, ActiveSession, LoginAttempt, BlockedIP, UserSSHKey, PasswordResetToken
from .auth import Role, user_servers, User, ActiveSession, LoginAttempt, BlockedIP, UserSSHKey, PasswordResetToken, UserQuota
from .proxmox import OSTemplateGroup, OSTemplate, ProxmoxServer, VMInstance, VMSnapshotArchive
from .ipam import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
from .tasks import TaskQueue, ProxmoxTask, DeployTask
@@ -10,7 +10,7 @@ from .catalog import ImageMirror
__all__ = [
'Base', 'utcnow',
'Role', 'user_servers', 'User', 'ActiveSession', 'LoginAttempt', 'BlockedIP', 'UserSSHKey', 'PasswordResetToken',
'Role', 'user_servers', 'User', 'ActiveSession', 'LoginAttempt', 'BlockedIP', 'UserSSHKey', 'PasswordResetToken', 'UserQuota',
'OSTemplateGroup', 'OSTemplate', 'ProxmoxServer', 'VMInstance', 'VMSnapshotArchive',
'IPAMNetwork', 'IPAMPool', 'IPAMAllocation', 'IPAMHistory',
'TaskQueue', 'ProxmoxTask', 'DeployTask',

View File

@@ -1,4 +1,5 @@
from .base import *
from sqlalchemy.orm import backref
# Association table: User <-> ProxmoxServer (assigned servers)
user_servers = Table(
@@ -116,6 +117,30 @@ class User(Base):
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', is_admin={self.is_admin})>"
class UserQuota(Base):
"""Per-user resource quota (limits).
A row is created only when an admin sets at least one limit. Absence of a
row — or a ``NULL`` value in any column — means *unlimited* for that metric.
Memory is stored in MB and disk in GB, matching the units used by the
``vm_instances`` cache (see monitoring worker conversions).
"""
__tablename__ = "user_quotas"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
max_instances = Column(Integer, nullable=True) # NULL = unlimited
max_cores = Column(Integer, nullable=True) # sum of vCPUs
max_memory_mb = Column(Integer, nullable=True) # sum of RAM in MB
max_disk_gb = Column(Integer, nullable=True) # sum of disk in GB
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", backref=backref("quota", uselist=False, cascade="all, delete-orphan"))
def __repr__(self):
return f"<UserQuota(user_id={self.user_id})>"
class PasswordResetToken(Base):
"""One-time tokens for the self-service 'forgot password' flow.

View File

@@ -71,6 +71,9 @@ NEW_DEFAULT_ROLES = [
"user:update": True,
"user:delete": True,
"user:manage": True,
# Quotas
"quota:view": True,
"quota:manage": True,
# Roles
"role:view": True,
"role:create": True,

View File

@@ -528,6 +528,22 @@ def _create_permissions() -> PermissionRegistry:
requires=["user:view"]
))
# Quota permissions
registry.register(Permission(
resource="quota", action="view",
display_name="View Quotas",
description="View user resource quotas",
category="User Management",
requires=["user:view"]
))
registry.register(Permission(
resource="quota", action="manage",
display_name="Manage Quotas",
description="Set user resource quotas",
category="User Management",
requires=["user:view"]
))
# Role management permissions
registry.register(Permission(
resource="role", action="view",

View File

@@ -0,0 +1,82 @@
"""
Quota Service - Per-user resource limits (quotas)
Enforces per-user limits on the number of instances and the summed vCPU, RAM
and disk across a user's VMs/LXC. Current usage is computed from the
``vm_instances`` cache (column ``owner_id``), where ``memory`` is stored in MB
and ``disk_size`` in GB by convention (see the monitoring worker conversions),
so no unit conversion is needed here.
A missing ``UserQuota`` row — or a ``NULL`` column — means *unlimited* for that
metric, so users without an explicit quota (including admins) are never blocked.
"""
from typing import Dict
from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import VMInstance, UserQuota
def get_user_usage(db: Session, user_id: int) -> Dict[str, int]:
"""Return current resource usage for a user from the vm_instances cache.
Only non-deleted instances owned by the user are counted.
"""
row = (
db.query(
func.count(VMInstance.id),
func.coalesce(func.sum(VMInstance.cores), 0),
func.coalesce(func.sum(VMInstance.memory), 0),
func.coalesce(func.sum(VMInstance.disk_size), 0),
)
.filter(
VMInstance.owner_id == user_id,
VMInstance.deleted_at.is_(None),
VMInstance.is_template.isnot(True),
)
.one()
)
return {
"instances": int(row[0] or 0),
"cores": int(row[1] or 0),
"memory_mb": int(row[2] or 0),
"disk_gb": int(row[3] or 0),
}
def check_quota(
db: Session,
owner_id: int,
add_cores: int = 0,
add_memory_mb: int = 0,
add_disk_gb: int = 0,
) -> None:
"""Raise HTTP 429 if deploying a new instance would exceed the user's quota.
Each metric is checked independently; a ``NULL`` limit (or no quota row)
skips that metric.
"""
quota = db.query(UserQuota).filter(UserQuota.user_id == owner_id).first()
if quota is None:
return
usage = get_user_usage(db, owner_id)
checks = [
(quota.max_instances, usage["instances"] + 1, "инстансов", "instances"),
(quota.max_cores, usage["cores"] + (add_cores or 0), "vCPU (ядер)", "vCPU"),
(quota.max_memory_mb, usage["memory_mb"] + (add_memory_mb or 0), "MB памяти", "MB of RAM"),
(quota.max_disk_gb, usage["disk_gb"] + (add_disk_gb or 0), "GB диска", "GB of disk"),
]
for limit, projected, unit_ru, unit_en in checks:
if limit is not None and projected > limit:
raise HTTPException(
status_code=429,
detail=(
f"Превышена квота: {projected} > {limit} {unit_ru}. "
f"Quota exceeded: {projected} > {limit} {unit_en}."
),
)

View File

@@ -61,6 +61,8 @@ DEFAULT_ROLES = [
"users.create": True,
"users.edit": True,
"users.delete": True,
"quota:view": True,
"quota:manage": True,
"roles.view": True,
"roles.manage": True,
"notifications.manage": True,
@@ -1401,6 +1403,33 @@ def migrate_password_reset_tokens(conn):
logger.info("✓ password_reset_tokens table created")
def migrate_user_quotas(conn):
"""Migration 28: Create user_quotas table for per-user resource limits.
No default rows are inserted — absence of a row (or a NULL column) means
unlimited for that metric.
"""
if table_exists(conn, 'user_quotas'):
logger.info("✓ user_quotas table already exists")
return
logger.info("Creating user_quotas table...")
conn.execute(text("""
CREATE TABLE user_quotas (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
max_instances INTEGER,
max_cores INTEGER,
max_memory_mb INTEGER,
max_disk_gb INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""))
conn.execute(text("CREATE INDEX idx_user_quotas_user ON user_quotas(user_id)"))
logger.info("✓ user_quotas table created")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1631,6 +1660,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"Password reset tokens migration: {e}")
conn.rollback()
# Migration 28: user_quotas table
try:
migrate_user_quotas(conn)
conn.commit()
except Exception as e:
logger.warning(f"User quotas migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)

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 {