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 ====================
|
||||
|
||||
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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
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.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)
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Создать рабочую область",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user