feat(quotas): per-user resource limits for VM/LXC deployment
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:
@@ -20,43 +20,46 @@ from ...ipam_service import IPAMService
|
|||||||
|
|
||||||
# ==================== Helper Functions for User Isolation ====================
|
# ==================== 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:
|
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.
|
Check if user has access to a specific VM instance.
|
||||||
|
|
||||||
Admins and users with 'vms:view' permission can access all VMs.
|
Admins (and roles granting ``vm:manage``) can access all VMs. Everyone else
|
||||||
Users with 'user' role can only access VMs where they are the owner.
|
can only access VMs where they are the owner.
|
||||||
|
|
||||||
Returns True if access is allowed, False otherwise.
|
Returns True if access is allowed, False otherwise.
|
||||||
"""
|
"""
|
||||||
# Admin role has full access
|
if can_view_all_instances(current_user):
|
||||||
if current_user.role and current_user.role.name == 'admin':
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check permissions
|
instance = db.query(VMInstance).filter(
|
||||||
if current_user.role and current_user.role.permissions:
|
VMInstance.server_id == server_id,
|
||||||
perms = current_user.role.permissions
|
VMInstance.vmid == vmid,
|
||||||
# If user has full vms:view, they can access all VMs
|
VMInstance.deleted_at.is_(None)
|
||||||
has_full_view = perms.get('vm:view', False) or perms.get('vms:view', False)
|
).first()
|
||||||
has_own_only = perms.get('vms:view:own', False) or perms.get('vms.view.own', False)
|
|
||||||
|
|
||||||
if has_full_view and not has_own_only:
|
return bool(instance and instance.owner_id == current_user.id)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int):
|
def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int):
|
||||||
|
|||||||
@@ -448,6 +448,10 @@ async def deploy_lxc_from_template(
|
|||||||
raise HTTPException(status_code=404, detail="Owner user not found")
|
raise HTTPException(status_code=404, detail="Owner user not found")
|
||||||
instance_owner_id = owner.id
|
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:
|
if req.ssh_key_ids:
|
||||||
from ..ssh_keys import resolve_ssh_keys_for_deploy
|
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)
|
resolved = resolve_ssh_keys_for_deploy(db, current_user, req.ssh_key_ids, instance_owner_id)
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from ...logging_service import LoggingService
|
|||||||
from ...ipam_service import IPAMService
|
from ...ipam_service import IPAMService
|
||||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||||
get_next_vmid, archive_and_delete_snapshots,
|
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
|
from ...services.metrics_history import query_instance_metrics, timeframe_to_range
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -146,21 +147,11 @@ def get_all_virtual_machines(
|
|||||||
if workspace_server_ids is not None:
|
if workspace_server_ids is not None:
|
||||||
query = query.filter(VMInstance.server_id.in_(workspace_server_ids))
|
query = query.filter(VMInstance.server_id.in_(workspace_server_ids))
|
||||||
|
|
||||||
# VPS-style user isolation: users with 'user' role see only their own instances
|
# VPS-style user isolation: only admins (or roles granting vm:manage) see
|
||||||
# Check if user has 'vm:view' but not 'vms.view:all' (or is user role)
|
# every instance. Everyone else — including users with no role or a custom
|
||||||
user_role = current_user.role.name if current_user.role else None
|
# role — is restricted to instances they own. See can_view_all_instances.
|
||||||
is_limited_user = user_role == 'user'
|
if not can_view_all_instances(current_user):
|
||||||
|
query = query.filter(VMInstance.owner_id == current_user.id)
|
||||||
# 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)
|
|
||||||
|
|
||||||
cached_vms = query.order_by(VMInstance.name).all()
|
cached_vms = query.order_by(VMInstance.name).all()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
@router.put("/api/profile")
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
data: UpdateProfileRequest,
|
data: UpdateProfileRequest,
|
||||||
|
|||||||
@@ -1044,6 +1044,10 @@ async def deploy_vm_from_template(
|
|||||||
raise HTTPException(status_code=404, detail="Owner user not found")
|
raise HTTPException(status_code=404, detail="Owner user not found")
|
||||||
instance_owner_id = owner.id
|
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:
|
if deploy_data.ssh_key_ids:
|
||||||
from .ssh_keys import resolve_ssh_keys_for_deploy
|
from .ssh_keys import resolve_ssh_keys_for_deploy
|
||||||
resolved = resolve_ssh_keys_for_deploy(
|
resolved = resolve_ssh_keys_for_deploy(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, EmailStr
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from ..db import get_db
|
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 (
|
from ..auth import (
|
||||||
get_password_hash,
|
get_password_hash,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
@@ -115,6 +115,27 @@ class SecuritySettingsUpdate(BaseModel):
|
|||||||
password_require_special: Optional[bool] = None
|
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 ====================
|
# ==================== Role API ====================
|
||||||
|
|
||||||
@router.get("/api/roles", response_model=List[RoleResponse])
|
@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]}
|
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")
|
@router.post("/api/users/{user_id}/reset-password")
|
||||||
async def reset_user_password(
|
async def reset_user_password(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from .base import *
|
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 .proxmox import OSTemplateGroup, OSTemplate, ProxmoxServer, VMInstance, VMSnapshotArchive
|
||||||
from .ipam import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
|
from .ipam import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
|
||||||
from .tasks import TaskQueue, ProxmoxTask, DeployTask
|
from .tasks import TaskQueue, ProxmoxTask, DeployTask
|
||||||
@@ -10,7 +10,7 @@ from .catalog import ImageMirror
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Base', 'utcnow',
|
'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',
|
'OSTemplateGroup', 'OSTemplate', 'ProxmoxServer', 'VMInstance', 'VMSnapshotArchive',
|
||||||
'IPAMNetwork', 'IPAMPool', 'IPAMAllocation', 'IPAMHistory',
|
'IPAMNetwork', 'IPAMPool', 'IPAMAllocation', 'IPAMHistory',
|
||||||
'TaskQueue', 'ProxmoxTask', 'DeployTask',
|
'TaskQueue', 'ProxmoxTask', 'DeployTask',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from .base import *
|
from .base import *
|
||||||
|
from sqlalchemy.orm import backref
|
||||||
|
|
||||||
# Association table: User <-> ProxmoxServer (assigned servers)
|
# Association table: User <-> ProxmoxServer (assigned servers)
|
||||||
user_servers = Table(
|
user_servers = Table(
|
||||||
@@ -116,6 +117,30 @@ class User(Base):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<User(id={self.id}, username='{self.username}', is_admin={self.is_admin})>"
|
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):
|
class PasswordResetToken(Base):
|
||||||
"""One-time tokens for the self-service 'forgot password' flow.
|
"""One-time tokens for the self-service 'forgot password' flow.
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ NEW_DEFAULT_ROLES = [
|
|||||||
"user:update": True,
|
"user:update": True,
|
||||||
"user:delete": True,
|
"user:delete": True,
|
||||||
"user:manage": True,
|
"user:manage": True,
|
||||||
|
# Quotas
|
||||||
|
"quota:view": True,
|
||||||
|
"quota:manage": True,
|
||||||
# Roles
|
# Roles
|
||||||
"role:view": True,
|
"role:view": True,
|
||||||
"role:create": True,
|
"role:create": True,
|
||||||
|
|||||||
@@ -528,6 +528,22 @@ def _create_permissions() -> PermissionRegistry:
|
|||||||
requires=["user:view"]
|
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
|
# Role management permissions
|
||||||
registry.register(Permission(
|
registry.register(Permission(
|
||||||
resource="role", action="view",
|
resource="role", action="view",
|
||||||
|
|||||||
82
backend/app/services/quota_service.py
Normal file
82
backend/app/services/quota_service.py
Normal 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}."
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -61,6 +61,8 @@ DEFAULT_ROLES = [
|
|||||||
"users.create": True,
|
"users.create": True,
|
||||||
"users.edit": True,
|
"users.edit": True,
|
||||||
"users.delete": True,
|
"users.delete": True,
|
||||||
|
"quota:view": True,
|
||||||
|
"quota:manage": True,
|
||||||
"roles.view": True,
|
"roles.view": True,
|
||||||
"roles.manage": True,
|
"roles.manage": True,
|
||||||
"notifications.manage": True,
|
"notifications.manage": True,
|
||||||
@@ -1401,6 +1403,33 @@ def migrate_password_reset_tokens(conn):
|
|||||||
logger.info("✓ password_reset_tokens table created")
|
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):
|
def run_all_migrations(engine, db_session=None):
|
||||||
"""
|
"""
|
||||||
Run all migrations in order.
|
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}")
|
logger.warning(f"Password reset tokens migration: {e}")
|
||||||
conn.rollback()
|
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("=" * 50)
|
||||||
logger.info("✓ All migrations completed successfully")
|
logger.info("✓ All migrations completed successfully")
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 } 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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -14,7 +14,7 @@ import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
|||||||
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
||||||
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
||||||
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
|
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 { 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';
|
||||||
@@ -418,6 +418,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} />
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
{/* Basic */}
|
{/* Basic */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -566,6 +567,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} />
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
{/* Basic */}
|
{/* Basic */}
|
||||||
<Card>
|
<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({
|
function SSHOwnerSelector({
|
||||||
users, value, onChange,
|
users, value, onChange,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
useProfile, useUpdateProfile, useChangePassword,
|
useProfile, useUpdateProfile, useChangePassword,
|
||||||
|
useMyQuota,
|
||||||
usePanelSettings, useUpdatePanelSettings,
|
usePanelSettings, useUpdatePanelSettings,
|
||||||
useSecuritySettings, useUpdateSecuritySettings,
|
useSecuritySettings, useUpdateSecuritySettings,
|
||||||
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
|
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
|
||||||
@@ -111,10 +112,57 @@ function ProfileTab() {
|
|||||||
}}>{t('settings.change_password')}</Button>
|
}}>{t('settings.change_password')}</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<QuotaCard />
|
||||||
</div>
|
</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() {
|
function PanelTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: settings } = usePanelSettings();
|
const { data: settings } = usePanelSettings();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
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';
|
} from 'lucide-react';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions,
|
useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions,
|
||||||
useSessions, useTerminateSession, useTerminateAllSessions,
|
useSessions, useTerminateSession, useTerminateAllSessions,
|
||||||
useUserServerAssignments, useSetUserServers,
|
useUserServerAssignments, useSetUserServers,
|
||||||
|
useUserQuota, useSetUserQuota,
|
||||||
useSecuritySettings, useUpdateSecuritySettings,
|
useSecuritySettings, useUpdateSecuritySettings,
|
||||||
useBlockedIps, useBlockIp, useUnblockIp, useSecurityEvents,
|
useBlockedIps, useBlockIp, useUnblockIp, useSecurityEvents,
|
||||||
type Role, type SecuritySettings,
|
type Role, type SecuritySettings,
|
||||||
@@ -86,6 +87,7 @@ function UsersTab() {
|
|||||||
const [editUser, setEditUser] = useState<User | null>(null);
|
const [editUser, setEditUser] = useState<User | null>(null);
|
||||||
const [resetUser, setResetUser] = useState<User | null>(null);
|
const [resetUser, setResetUser] = useState<User | null>(null);
|
||||||
const [serversUser, setServersUser] = 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 [sshUser, setSshUser] = useState<User | null>(null);
|
||||||
|
|
||||||
const { data: users = [] } = useUsers();
|
const { data: users = [] } = useUsers();
|
||||||
@@ -175,6 +177,13 @@ function UsersTab() {
|
|||||||
>
|
>
|
||||||
<Server className="h-3.5 w-3.5" />
|
<Server className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost" size="icon" className="h-7 w-7"
|
variant="ghost" size="icon" className="h-7 w-7"
|
||||||
title={t('ssh_keys.manage_for_user')}
|
title={t('ssh_keys.manage_for_user')}
|
||||||
@@ -258,6 +267,13 @@ function UsersTab() {
|
|||||||
onOpenChange={o => !o && setServersUser(null)}
|
onOpenChange={o => !o && setServersUser(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{quotaUser && (
|
||||||
|
<UserQuotaDialog
|
||||||
|
user={quotaUser}
|
||||||
|
open={!!quotaUser}
|
||||||
|
onOpenChange={o => !o && setQuotaUser(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{sshUser && (
|
{sshUser && (
|
||||||
<UserSSHKeysDialog
|
<UserSSHKeysDialog
|
||||||
user={sshUser}
|
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({
|
function UserSSHKeysDialog({
|
||||||
user, open, onOpenChange,
|
user, open, onOpenChange,
|
||||||
}: { user: User; open: boolean; onOpenChange: (o: boolean) => void }) {
|
}: { user: User; open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiClient } from '@/lib/api-client';
|
|||||||
|
|
||||||
export const settingsKeys = {
|
export const settingsKeys = {
|
||||||
profile: ['settings-profile'] as const,
|
profile: ['settings-profile'] as const,
|
||||||
|
quota: ['settings-quota'] as const,
|
||||||
panel: ['settings-panel'] as const,
|
panel: ['settings-panel'] as const,
|
||||||
security: ['settings-security'] as const,
|
security: ['settings-security'] as const,
|
||||||
channels: ['notification-channels'] 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() {
|
export function useUpdateProfile() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const userKeys = {
|
|||||||
users: ['users'] as const,
|
users: ['users'] as const,
|
||||||
user: (id: number) => ['user', id] as const,
|
user: (id: number) => ['user', id] as const,
|
||||||
userServers: (id: number) => ['user-server-assignments', id] as const,
|
userServers: (id: number) => ['user-server-assignments', id] as const,
|
||||||
|
userQuota: (id: number) => ['user-quota', id] as const,
|
||||||
roles: ['roles'] as const,
|
roles: ['roles'] as const,
|
||||||
permissions: ['permissions-v2'] as const,
|
permissions: ['permissions-v2'] as const,
|
||||||
sessions: ['sessions'] 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 ====================
|
// ==================== Roles ====================
|
||||||
|
|
||||||
export function useRoles() {
|
export function useRoles() {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ interface NavItem {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
path: 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 {
|
interface NavGroup {
|
||||||
@@ -53,41 +57,65 @@ interface NavGroup {
|
|||||||
items: NavItem[];
|
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[] = [
|
const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
title: 'Infrastructure',
|
title: 'Infrastructure',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard' },
|
{ label: 'Dashboard', icon: LayoutDashboard, path: '/dashboard', permission: 'dashboard:view' },
|
||||||
{ label: 'Instances', icon: Monitor, path: '/instances' },
|
{ label: 'Instances', icon: Monitor, path: '/instances', permission: 'vm:view' },
|
||||||
{ label: 'Nodes', icon: Server, path: '/nodes' },
|
{ label: 'Nodes', icon: Server, path: '/nodes', permission: 'server:view' },
|
||||||
{ label: 'Cluster', icon: Network, path: '/cluster' },
|
{ label: 'Cluster', icon: Network, path: '/cluster', permission: 'cluster:manage' },
|
||||||
{ label: 'Templates', icon: HardDrive, path: '/templates' },
|
{ label: 'Templates', icon: HardDrive, path: '/templates', permission: 'template:view' },
|
||||||
{ label: 'Images', icon: HardDriveDownload, path: '/images' },
|
{ label: 'Images', icon: HardDriveDownload, path: '/images', permission: 'template:manage' },
|
||||||
{ label: 'Backups', icon: Archive, path: '/backups' },
|
{ label: 'Backups', icon: Archive, path: '/backups', permission: 'backup:view' },
|
||||||
{ label: 'Tasks', icon: ClipboardList, path: '/tasks' },
|
{ label: 'Tasks', icon: ClipboardList, path: '/tasks', permission: 'vm:view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Network',
|
title: 'Network',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'IPAM', icon: Globe, path: '/ipam' },
|
{ label: 'IPAM', icon: Globe, path: '/ipam', permission: 'ipam:view' },
|
||||||
{ label: 'Networks', icon: Network, path: '/networks' },
|
{ label: 'Networks', icon: Network, path: '/networks', permission: 'server:manage' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Management',
|
title: 'Management',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Users', icon: Users, path: '/users' },
|
{ label: 'Users', icon: Users, path: '/users', permission: 'user:view' },
|
||||||
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' },
|
{ label: 'Workspaces', icon: FolderKanban, path: '/workspaces' }, // admin-only
|
||||||
{ label: 'Logs', icon: FileText, path: '/logs' },
|
{ label: 'Logs', icon: FileText, path: '/logs', permission: 'log:view' },
|
||||||
{ label: 'Settings', icon: Settings, path: '/settings' },
|
{ 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() {
|
function SidebarContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@@ -100,7 +128,7 @@ function SidebarContent() {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 space-y-4 overflow-y-auto px-3 py-4">
|
<nav className="flex-1 space-y-4 overflow-y-auto px-3 py-4">
|
||||||
{navGroups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<div key={group.title}>
|
<div key={group.title}>
|
||||||
<p className="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="mb-1.5 px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{t(`nav.${group.title.toLowerCase()}`, group.title)}
|
{t(`nav.${group.title.toLowerCase()}`, group.title)}
|
||||||
|
|||||||
@@ -849,7 +849,18 @@
|
|||||||
"event_type": "Event Type",
|
"event_type": "Event Type",
|
||||||
"workspaces": "Workspaces",
|
"workspaces": "Workspaces",
|
||||||
"no_workspaces": "No 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": {
|
"workspaces": {
|
||||||
"create": "Create Workspace",
|
"create": "Create Workspace",
|
||||||
|
|||||||
@@ -849,7 +849,18 @@
|
|||||||
"event_type": "Тип события",
|
"event_type": "Тип события",
|
||||||
"workspaces": "Рабочие области",
|
"workspaces": "Рабочие области",
|
||||||
"no_workspaces": "Нет рабочих областей",
|
"no_workspaces": "Нет рабочих областей",
|
||||||
"workspace_required": "Пользователь должен принадлежать рабочей области сервера"
|
"workspace_required": "Пользователь должен принадлежать рабочей области сервера",
|
||||||
|
"quota": {
|
||||||
|
"manage": "Квоты (лимиты)",
|
||||||
|
"hint": "Пустое поле — без ограничения по этой метрике.",
|
||||||
|
"instances": "Макс. инстансов (VM + LXC)",
|
||||||
|
"cores": "Макс. vCPU (ядер)",
|
||||||
|
"memory_mb": "Макс. RAM (МБ)",
|
||||||
|
"disk_gb": "Макс. диск (ГБ)",
|
||||||
|
"used": "Использовано",
|
||||||
|
"unlimited": "без лимита",
|
||||||
|
"saved": "Квоты сохранены"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"create": "Создать рабочую область",
|
"create": "Создать рабочую область",
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export interface User {
|
|||||||
failed_login_attempts?: number;
|
failed_login_attempts?: number;
|
||||||
locked_until?: string | null;
|
locked_until?: string | null;
|
||||||
require_password_change?: boolean;
|
require_password_change?: boolean;
|
||||||
permissions?: string[];
|
// Map of "resource:action" -> granted. Admins receive every permission.
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user