ZMB Webui: Complete Project – Rebrand & Initial Clean Commit

ARCHITECTURE
============
Backend: FastAPI + uvicorn (port 8000)
  - JWT authentication with PAM system users
  - ZFS CLI wrapper with caching (30-60s TTL)
  - WebSocket pool status broadcaster (30s interval)
  - Services: auth, zfs_runner, file_manager, shares, identities, system_info
  - Routers: pools, datasets, snapshots, shares, identities, navigator, system

Frontend: Next.js 15 + TypeScript (static export)
  - Incremental Static Regeneration (ISR) for weak hardware
  - Type-safe API client (lib/api.ts)
  - Dark mode + custom Tailwind theme
  - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc.

DEPLOYMENT
==========
Test Target: 192.168.1.179:8090 (Debian LXC)
Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64)
Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh)

FEATURES COMPLETED
==================
Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage)
  - Real-time stats with color-coded progress bars
  - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns)
  - ISR-optimized for fast loads on weak hardware

REBRANDING
==========
Renamed throughout:
  - Project: 'ZFS Manager' → 'ZMB Webui'
  - Services: 'zfs-manager' → 'zmb-webui'
  - Systemd units: zfs-manager-backend → zmb-webui-backend
  - Configuration files and documentation

Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
Claude Code
2026-04-22 00:26:23 +02:00
committed by Patrick
commit 92bed208e0
108 changed files with 29925 additions and 0 deletions
View File
+53
View File
@@ -0,0 +1,53 @@
"""
Authentication endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from services.auth import auth_service
from models import Token
router = APIRouter(prefix="/api/auth", tags=["auth"])
security = HTTPBearer()
class LoginRequest(BaseModel):
username: str
password: str
@router.post("/login", response_model=Token)
async def login(request: LoginRequest):
"""
Login with username and password
Returns JWT access token
"""
user = auth_service.authenticate_user(request.username, request.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = auth_service.create_access_token(request.username)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/verify")
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""
Verify JWT token validity
"""
token = credentials.credentials
username = auth_service.verify_token(token)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return {"valid": True, "username": username}
+126
View File
@@ -0,0 +1,126 @@
"""
Dataset/Filesystem management endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import List, Optional
from pydantic import BaseModel
from services.zfs_runner import zfs_runner
from services.auth import auth_service
from models import Dataset, DatasetType
router = APIRouter(prefix="/api/datasets", tags=["datasets"])
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify JWT token and return username"""
username = auth_service.verify_token(credentials.credentials)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return username
class CreateDatasetRequest(BaseModel):
name: str
properties: Optional[dict] = None
class DatasetPropertiesRequest(BaseModel):
compression: Optional[str] = None
quota: Optional[int] = None
reservation: Optional[int] = None
@router.get("/", response_model=List[Dataset])
async def list_datasets(
pool: str = "tank",
current_user: str = Depends(get_current_user)
):
"""
List datasets in pool (default: tank)
"""
try:
datasets = zfs_runner.list_datasets(pool)
return [
Dataset(
name=d["name"],
type=DatasetType(d["type"]),
used=d["used"],
avail=d["avail"],
refer=d["refer"],
mountpoint=d["mountpoint"] if d["mountpoint"] != "-" else None
)
for d in datasets
]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/", response_model=dict)
async def create_dataset(
request: CreateDatasetRequest,
current_user: str = Depends(get_current_user)
):
"""
Create new dataset
"""
try:
result = zfs_runner.create_dataset(request.name, request.properties)
if result.get("status") == "error":
raise HTTPException(status_code=400, detail=result.get("message"))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/{dataset_name:path}")
async def update_dataset_properties(
dataset_name: str,
request: DatasetPropertiesRequest,
current_user: str = Depends(get_current_user)
):
"""
Update dataset properties (compression, quota, reservation)
"""
try:
props: dict = {}
if request.compression is not None:
props["compression"] = request.compression
if request.quota is not None:
props["quota"] = str(request.quota) if request.quota > 0 else "none"
if request.reservation is not None:
props["reservation"] = str(request.reservation) if request.reservation > 0 else "none"
if not props:
return {"status": "ok", "message": "Nothing to update"}
result = zfs_runner.set_dataset_properties(dataset_name, props)
if result.get("status") == "error":
raise HTTPException(status_code=400, detail=result.get("message"))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{dataset_name:path}")
async def delete_dataset(
dataset_name: str,
recursive: bool = False,
current_user: str = Depends(get_current_user)
):
"""
Delete dataset
"""
try:
result = zfs_runner.destroy_dataset(dataset_name, recursive=recursive)
if result.get("status") == "error":
raise HTTPException(status_code=400, detail=result.get("message"))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+280
View File
@@ -0,0 +1,280 @@
"""
User and Group Management endpoints cockpit-identities
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional
from services.identities import identities_manager
from services.auth import auth_service
router = APIRouter(prefix="/api/identities", tags=["identities"])
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify JWT token and return username"""
username = auth_service.verify_token(credentials.credentials)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return username
class CreateUserRequest(BaseModel):
username: str
home_dir: Optional[str] = None
shell: str = "/bin/bash"
gecos: Optional[str] = None
class CreateGroupRequest(BaseModel):
groupname: str
class ChangePasswordRequest(BaseModel):
password: str
class ChangeShellRequest(BaseModel):
shell: str
class AddUserToGroupRequest(BaseModel):
groupname: str
# ============== USERS ==============
@router.get("/users")
async def list_users(current_user: str = Depends(get_current_user)):
"""List all system users"""
try:
users = identities_manager.list_users()
# Add group memberships for each user
for user in users:
user['groups'] = identities_manager.get_user_groups(user['username'])
return {"users": users}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/users")
async def create_user(
request: CreateUserRequest,
current_user: str = Depends(get_current_user)
):
"""Create new system user"""
try:
success = identities_manager.create_user(
request.username,
request.home_dir,
request.shell,
request.gecos or ""
)
if not success:
raise HTTPException(status_code=400, detail="Failed to create user")
return {"status": "created", "username": request.username}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/users/{username}")
async def delete_user(
username: str,
remove_home: bool = True,
current_user: str = Depends(get_current_user)
):
"""Delete system user"""
try:
success = identities_manager.delete_user(username, remove_home)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete user")
return {"status": "deleted", "username": username}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/users/{username}/password")
async def change_password(
username: str,
request: ChangePasswordRequest,
current_user: str = Depends(get_current_user)
):
"""Change user password"""
try:
success = identities_manager.change_password(username, request.password)
if not success:
raise HTTPException(status_code=400, detail="Failed to change password")
return {"status": "updated", "username": username}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/users/{username}/shell")
async def change_shell(
username: str,
request: ChangeShellRequest,
current_user: str = Depends(get_current_user)
):
"""Change user shell"""
try:
success = identities_manager.change_shell(username, request.shell)
if not success:
raise HTTPException(status_code=400, detail="Failed to change shell")
return {"status": "updated", "username": username}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/users/{username}/lock")
async def lock_user(
username: str,
current_user: str = Depends(get_current_user)
):
"""Lock user account"""
try:
success = identities_manager.lock_user(username)
if not success:
raise HTTPException(status_code=400, detail="Failed to lock user")
return {"status": "locked", "username": username}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/users/{username}/unlock")
async def unlock_user(
username: str,
current_user: str = Depends(get_current_user)
):
"""Unlock user account"""
try:
success = identities_manager.unlock_user(username)
if not success:
raise HTTPException(status_code=400, detail="Failed to unlock user")
return {"status": "unlocked", "username": username}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/users/{username}/samba-password")
async def set_samba_password(
username: str,
request: ChangePasswordRequest,
current_user: str = Depends(get_current_user)
):
"""Set Samba password for user"""
try:
success = identities_manager.set_samba_password(username, request.password)
if not success:
raise HTTPException(status_code=400, detail="Failed to set Samba password")
return {"status": "updated", "username": username, "type": "samba"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============== GROUPS ==============
@router.get("/groups")
async def list_groups(current_user: str = Depends(get_current_user)):
"""List all system groups"""
try:
groups = identities_manager.list_groups()
return {"groups": groups}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/groups")
async def create_group(
request: CreateGroupRequest,
current_user: str = Depends(get_current_user)
):
"""Create new system group"""
try:
success = identities_manager.create_group(request.groupname)
if not success:
raise HTTPException(status_code=400, detail="Failed to create group")
return {"status": "created", "groupname": request.groupname}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/groups/{groupname}")
async def delete_group(
groupname: str,
current_user: str = Depends(get_current_user)
):
"""Delete system group"""
try:
success = identities_manager.delete_group(groupname)
if not success:
raise HTTPException(status_code=400, detail="Failed to delete group")
return {"status": "deleted", "groupname": groupname}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============== USER-GROUP MEMBERSHIP ==============
@router.post("/users/{username}/groups")
async def add_user_to_group(
username: str,
request: AddUserToGroupRequest,
current_user: str = Depends(get_current_user)
):
"""Add user to group"""
try:
success = identities_manager.add_user_to_group(username, request.groupname)
if not success:
raise HTTPException(status_code=400, detail="Failed to add user to group")
return {"status": "added", "username": username, "groupname": request.groupname}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/users/{username}/groups/{groupname}")
async def remove_user_from_group(
username: str,
groupname: str,
current_user: str = Depends(get_current_user)
):
"""Remove user from group"""
try:
success = identities_manager.remove_user_from_group(username, groupname)
if not success:
raise HTTPException(status_code=400, detail="Failed to remove user from group")
return {"status": "removed", "username": username, "groupname": groupname}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============== SAMBA USERS ==============
@router.get("/samba-users")
async def list_samba_users(current_user: str = Depends(get_current_user)):
"""List all Samba users"""
try:
users = identities_manager.list_samba_users()
return {"users": users}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============== LOGIN HISTORY ==============
@router.get("/login-history")
async def get_login_history(
limit: int = 50,
current_user: str = Depends(get_current_user)
):
"""Get recent login history"""
try:
logins = identities_manager.get_login_history(limit)
return {"logins": logins, "total": len(logins)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+408
View File
@@ -0,0 +1,408 @@
"""
File Manager endpoints Browse, upload, download /tank/share
Similar to cockpit-files but minimalist
"""
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Query
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional
import io
import os
import jwt
from datetime import datetime, timedelta
from services.file_manager import file_manager
from services.auth import auth_service
router = APIRouter(prefix="/api/navigator", tags=["navigator"])
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify JWT token and return username"""
username = auth_service.verify_token(credentials.credentials)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return username
class CreateFileRequest(BaseModel):
path: str
content: Optional[str] = ""
class RenameRequest(BaseModel):
old_path: str
new_name: str
class MkdirRequest(BaseModel):
path: str
class ChangePermissionsRequest(BaseModel):
path: str
mode: str # e.g. "755", "644"
recursive: bool = False
class ChangeOwnerRequest(BaseModel):
path: str
owner: str
group: Optional[str] = None
@router.get("/browse")
async def browse_directory(
path: str = Query(""),
admin: bool = Query(False),
current_user: str = Depends(get_current_user)
):
"""
List directory contents
Query: ?path=/subdir&admin=false
"""
from pathlib import Path as PyPath
from services.file_manager import FileManager
if admin:
fm = FileManager(base_path=PyPath("/"))
else:
fm = file_manager
result = fm.list_directory(path)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/dirs")
async def list_subdirectories(
path: str = Query(""),
admin: bool = Query(False),
current_user: str = Depends(get_current_user)
):
"""
List only subdirectories of a path (for tree sidebar navigation).
Query: ?path=/subdir&admin=false
Returns: { dirs: [{name, path, has_children}] }
"""
from pathlib import Path as PyPath
from services.file_manager import FileManager
if admin:
fm = FileManager(base_path=PyPath("/"))
else:
fm = file_manager
result = fm.list_subdirectories(path)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/info")
async def get_info(
path: str = Query(""),
current_user: str = Depends(get_current_user)
):
"""
Get file/directory info
Query: ?path=/filename.txt
"""
result = file_manager.get_file_info(path)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/read")
async def read_file(
path: str = Query(""),
limit: Optional[int] = Query(None),
current_user: str = Depends(get_current_user)
):
"""
Read file content (text files only, max 10MB)
Query: ?path=/file.txt&limit=1000
"""
result = file_manager.read_file(path, limit)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/download")
async def download_file(
path: str = Query(""),
current_user: str = Depends(get_current_user)
):
"""
Download file
Returns binary file stream
"""
from pathlib import Path
target = file_manager._resolve_path(path)
if not target or not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="File not found")
try:
return FileResponse(
path=target,
filename=target.name,
media_type="application/octet-stream"
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
path: str = Query(""),
current_user: str = Depends(get_current_user)
):
"""
Upload file to directory
Query: ?path=/subdir
"""
from pathlib import Path
target = file_manager._resolve_path(path)
if not target or not target.is_dir():
raise HTTPException(status_code=400, detail="Invalid upload directory")
try:
# Create target file path
file_path = target / file.filename
if file_path.exists():
raise HTTPException(status_code=400, detail="File already exists")
# Write uploaded file
contents = await file.read()
if len(contents) > file_manager.MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large")
with open(file_path, "wb") as f:
f.write(contents)
return {
"status": "success",
"filename": file.filename,
"size": len(contents),
"path": str(file_path.relative_to(file_manager.base_path))
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/create")
async def create_file(
request: CreateFileRequest,
current_user: str = Depends(get_current_user)
):
"""
Create new file with optional content
"""
result = file_manager.create_file(request.path, request.content)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/mkdir")
async def make_directory(
request: MkdirRequest,
current_user: str = Depends(get_current_user)
):
"""
Create directory
"""
result = file_manager.mkdir(request.path)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/rename")
async def rename_file(
request: RenameRequest,
current_user: str = Depends(get_current_user)
):
"""
Rename file or directory
"""
result = file_manager.rename_file(request.old_path, request.new_name)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.delete("/delete")
async def delete_file(
path: str = Query(""),
recursive: bool = Query(False),
current_user: str = Depends(get_current_user)
):
"""
Delete file or directory
Query: ?path=/file.txt or ?path=/dir&recursive=true
"""
result = file_manager.delete_file_or_dir(path, recursive)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/space")
async def get_space_info(current_user: str = Depends(get_current_user)):
"""
Get space usage of /tank/share
"""
result = file_manager.get_space_info()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/permissions")
async def change_permissions(
request: ChangePermissionsRequest,
current_user: str = Depends(get_current_user)
):
"""
Change file/directory permissions (chmod)
Request: {"path": "/file.txt", "mode": "755", "recursive": false}
"""
if request.recursive:
result = file_manager.change_permissions_recursive(request.path, request.mode)
else:
result = file_manager.change_permissions(request.path, request.mode)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/owner")
async def change_owner(
request: ChangeOwnerRequest,
current_user: str = Depends(get_current_user)
):
"""
Change file/directory owner (chown)
Request: {"path": "/file.txt", "owner": "root", "group": "wheel"}
"""
result = file_manager.change_owner(request.path, request.owner, request.group)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
class UnlockRequest(BaseModel):
password: str
@router.post("/unlock")
async def unlock_admin_mode(
request: UnlockRequest,
current_user: str = Depends(get_current_user)
):
"""
Unlock admin mode with password
Returns a token that enables full filesystem access
"""
admin_password = os.environ.get("ZFS_ADMIN_PASSWORD", "admin")
if request.password != admin_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin password"
)
# Generate a short-lived admin token (valid for 1 hour)
secret_key = os.environ.get("ZFS_SECRET_KEY", "change-me-in-production")
payload = {
"sub": current_user,
"admin": True,
"exp": datetime.utcnow() + timedelta(hours=1)
}
token = jwt.encode(payload, secret_key, algorithm="HS256")
return {
"status": "success",
"admin_token": token,
"message": "Admin mode unlocked"
}
class CopyRequest(BaseModel):
src: str
dst: str
overwrite: bool = False
@router.post("/copy")
async def copy_file(
request: CopyRequest,
current_user: str = Depends(get_current_user)
):
"""
Copy file or directory
"""
result = file_manager.copy_file(request.src, request.dst, request.overwrite)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
class MoveRequest(BaseModel):
src: str
dst: str
overwrite: bool = False
@router.post("/move")
async def move_file(
request: MoveRequest,
current_user: str = Depends(get_current_user)
):
"""
Move (rename) file or directory
"""
result = file_manager.move_file(request.src, request.dst, request.overwrite)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/search")
async def search_files(
q: str = Query(""),
path: str = Query(""),
limit: int = Query(50),
current_user: str = Depends(get_current_user)
):
"""
Search for files by name (case-insensitive)
Query: ?q=term&path=/subdir&limit=50
"""
if not q:
raise HTTPException(status_code=400, detail="Query parameter 'q' is required")
results = file_manager.search_files(q, path, limit)
return {
"query": q,
"path": path or "/",
"results": results,
"count": len(results)
}
+102
View File
@@ -0,0 +1,102 @@
"""
Pool management endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import List
from services.zfs_runner import zfs_runner
from services.auth import auth_service
from models import Pool, PoolStatus, PoolHealth
router = APIRouter(prefix="/api/pools", tags=["pools"])
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify JWT token and return username"""
username = auth_service.verify_token(credentials.credentials)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return username
@router.get("/", response_model=List[Pool])
async def list_pools():
"""
Get list of all ZFS pools (public)
"""
try:
pools = zfs_runner.list_pools()
# Convert to Pydantic models
return [
Pool(
name=p["name"],
size=p["size"],
alloc=p["alloc"],
free=p["free"],
fragmentation=p["fragmentation"],
capacity=p["capacity"],
health=PoolHealth(p["health"])
)
for p in pools
]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{pool_name}", response_model=PoolStatus)
async def get_pool_status(pool_name: str):
"""
Get detailed status of specific pool (public)
"""
try:
status_data = zfs_runner.get_pool_status(pool_name)
if not status_data:
raise HTTPException(status_code=404, detail=f"Pool {pool_name} not found")
# Map state to health enum
health = PoolHealth.ONLINE
if "state" in status_data and status_data["state"]:
try:
health = PoolHealth(status_data["state"])
except ValueError:
health = PoolHealth.ONLINE
return PoolStatus(
name=pool_name,
state=status_data.get("state"),
health=health,
scan=status_data.get("scan"),
errors=status_data.get("errors"),
vdevs=status_data.get("vdevs", [])
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{pool_name}/scrub")
async def scrub_pool(pool_name: str, current_user: str = Depends(get_current_user)):
"""
Start or resume scrub on pool
"""
try:
result = zfs_runner.scrub_pool(pool_name)
if result.get("status") == "error":
raise HTTPException(status_code=400, detail=result.get("message"))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/clear-cache")
async def clear_cache(current_user: str = Depends(get_current_user)):
"""
Clear ZFS command cache (for testing/debugging)
"""
zfs_runner.clear_cache()
return {"status": "success", "message": "Cache cleared"}
+209
View File
@@ -0,0 +1,209 @@
"""
File Sharing endpoints (Samba/NFS) like cockpit-file-sharing
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from typing import Optional
from services.shares import share_manager
from services.auth import auth_service
router = APIRouter(prefix="/api/shares", tags=["shares"])
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify JWT token and return username"""
username = auth_service.verify_token(credentials.credentials)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return username
class CreateSambaShareRequest(BaseModel):
name: str
path: str
comment: Optional[str] = None
class CreateNFSShareRequest(BaseModel):
path: str
clients: str
options: Optional[str] = None
class SambaConfigRequest(BaseModel):
config: str
class SambaImportRequest(BaseModel):
config_file: str
# ============== SAMBA ==============
@router.get("/samba")
async def list_samba_shares(current_user: str = Depends(get_current_user)):
"""List all Samba shares"""
try:
shares = share_manager.list_samba_shares()
return {"shares": shares}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/samba")
async def create_samba_share(
request: CreateSambaShareRequest,
current_user: str = Depends(get_current_user)
):
"""Create new Samba share"""
if not request.name.strip() or not request.path.strip():
raise HTTPException(status_code=400, detail="Name and path are required")
try:
success = share_manager.create_samba_share(
request.name,
request.path,
request.comment
)
if not success:
raise HTTPException(status_code=400, detail="Failed to create Samba share")
return {"status": "created", "name": request.name}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/samba/{name}")
async def delete_samba_share(
name: str,
current_user: str = Depends(get_current_user)
):
"""Delete Samba share"""
try:
success = share_manager.delete_samba_share(name)
if not success:
raise HTTPException(status_code=404, detail=f"Samba share '{name}' not found")
return {"status": "deleted", "name": name}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/samba/config")
async def get_samba_config(current_user: str = Depends(get_current_user)):
"""Get Samba global configuration"""
try:
config = share_manager.get_samba_global_config()
return config
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/samba/config")
async def set_samba_config(
request: SambaConfigRequest,
current_user: str = Depends(get_current_user)
):
"""Update Samba global configuration"""
try:
success = share_manager.set_samba_global_config(request.config)
if not success:
raise HTTPException(status_code=400, detail="Failed to update Samba configuration")
return {"status": "updated"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/samba/config/import")
async def import_samba_config(
request: SambaImportRequest,
current_user: str = Depends(get_current_user)
):
"""Import Samba configuration using net conf import"""
try:
success = share_manager.import_samba_config(request.config_file)
if not success:
raise HTTPException(status_code=400, detail="Failed to import Samba configuration")
return {"status": "imported", "config_file": request.config_file}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============== NFS ==============
@router.get("/nfs")
async def list_nfs_shares(current_user: str = Depends(get_current_user)):
"""List all NFS shares"""
try:
shares = share_manager.list_nfs_shares()
return {"shares": shares}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/nfs")
async def create_nfs_share(
request: CreateNFSShareRequest,
current_user: str = Depends(get_current_user)
):
"""Create new NFS share"""
if not request.path.strip() or not request.clients.strip():
raise HTTPException(status_code=400, detail="Path and clients are required")
try:
success = share_manager.create_nfs_share(
request.path,
request.clients,
request.options
)
if not success:
raise HTTPException(status_code=400, detail="Failed to create NFS share")
return {"status": "created", "path": request.path}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/nfs")
async def delete_nfs_share(
path: str = None,
current_user: str = Depends(get_current_user)
):
"""Delete NFS share"""
try:
if not path:
raise HTTPException(status_code=400, detail="path parameter required")
success = share_manager.delete_nfs_share(path)
if not success:
raise HTTPException(status_code=404, detail=f"NFS share '{path}' not found")
return {"status": "deleted", "path": path}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/nfs/config")
async def get_nfs_config(current_user: str = Depends(get_current_user)):
"""Get NFS global configuration (/etc/exports)"""
try:
config = share_manager.get_nfs_config()
return config
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/nfs/config")
async def set_nfs_config(
request: SambaConfigRequest,
current_user: str = Depends(get_current_user)
):
"""Update NFS global configuration (/etc/exports)"""
try:
success = share_manager.set_nfs_config(request.config)
if not success:
raise HTTPException(status_code=400, detail="Failed to update NFS configuration")
return {"status": "updated"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+113
View File
@@ -0,0 +1,113 @@
"""
Snapshot management endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
from services.zfs_runner import zfs_runner
from services.auth import auth_service
from models import Snapshot
router = APIRouter(prefix="/api/snapshots", tags=["snapshots"])
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify JWT token and return username"""
username = auth_service.verify_token(credentials.credentials)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return username
class CreateSnapshotRequest(BaseModel):
dataset: str
name: Optional[str] = None # Auto-generate if not provided
class RollbackSnapshotRequest(BaseModel):
snapshot: str
@router.get("/", response_model=List[Snapshot])
async def list_snapshots(
dataset: Optional[str] = None,
limit: int = 50,
current_user: str = Depends(get_current_user)
):
"""
List snapshots (optionally filtered by dataset)
"""
try:
snapshots = zfs_runner.list_snapshots(dataset, limit=limit)
return [
Snapshot(
name=s["name"],
dataset=s["name"].split("@")[0], # Extract dataset part
created=s["creation"],
used=s["used"],
referenced=s["referenced"],
creation_datetime=datetime.fromtimestamp(s["creation"]).isoformat() + "Z"
)
for s in snapshots
]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/", response_model=dict)
async def create_snapshot(
request: CreateSnapshotRequest,
current_user: str = Depends(get_current_user)
):
"""
Create new snapshot
"""
try:
result = zfs_runner.create_snapshot(request.dataset, request.name)
if result.get("status") == "error":
raise HTTPException(status_code=400, detail=result.get("message"))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{snapshot_name:path}")
async def delete_snapshot(
snapshot_name: str,
current_user: str = Depends(get_current_user)
):
"""
Delete snapshot
"""
try:
result = zfs_runner.destroy_snapshot(snapshot_name)
if result.get("status") == "error":
raise HTTPException(status_code=400, detail=result.get("message"))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/rollback")
async def rollback_snapshot(
request: RollbackSnapshotRequest,
current_user: str = Depends(get_current_user)
):
"""
Rollback dataset to snapshot (WARNING: Destroys data after snapshot!)
"""
try:
result = zfs_runner.rollback_snapshot(request.snapshot)
if result.get("status") == "error":
raise HTTPException(status_code=400, detail=result.get("message"))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+210
View File
@@ -0,0 +1,210 @@
"""
System Management endpoints like cockpit-system
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from services.system_info import system_info
from services.auth import auth_service
router = APIRouter(prefix="/api/system", tags=["system"])
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Verify JWT token and return username"""
username = auth_service.verify_token(credentials.credentials)
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
return username
class SetHostnameRequest(BaseModel):
hostname: str
class SetTimeRequest(BaseModel):
iso: str
# ============== SYSTEM INFO ==============
@router.get("/info")
async def get_info():
"""Get general system information (public)"""
return system_info.get_system_info()
@router.get("/hostname")
async def get_hostname():
"""Get system hostname (public)"""
result = system_info.get_hostname()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/hostname")
async def set_hostname(
request: SetHostnameRequest,
current_user: str = Depends(get_current_user)
):
"""Set system hostname"""
result = system_info.set_hostname(request.hostname)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ============== UPTIME ==============
@router.get("/uptime")
async def get_uptime():
"""Get system uptime (public)"""
result = system_info.get_uptime()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ============== MEMORY ==============
@router.get("/memory")
async def get_memory():
"""Get memory usage (public)"""
result = system_info.get_memory()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ============== CPU ==============
@router.get("/cpu")
async def get_cpu_info():
"""Get CPU information (public)"""
result = system_info.get_cpu_info()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ============== TIME ==============
@router.get("/time")
async def get_time():
"""Get system time (public)"""
result = system_info.get_time()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/time")
async def set_time(
request: SetTimeRequest,
current_user: str = Depends(get_current_user)
):
"""Set system time (ISO format)"""
result = system_info.set_time(request.iso)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ============== UPDATES ==============
@router.get("/updates")
async def check_updates(current_user: str = Depends(get_current_user)):
"""Check available updates"""
result = system_info.get_updates()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ============== REBOOT/SHUTDOWN ==============
@router.post("/reboot")
async def reboot(current_user: str = Depends(get_current_user)):
"""Reboot system"""
result = system_info.reboot()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/shutdown")
async def shutdown(current_user: str = Depends(get_current_user)):
"""Shutdown system"""
result = system_info.shutdown()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ============== NETWORK ==============
@router.get("/network")
async def get_network():
"""Get network interface information (public)"""
result = system_info.get_network_info()
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
@router.get("/network/traffic")
async def get_network_traffic():
"""Get network interface traffic (RX/TX bytes) (public)"""
result = system_info.get_network_traffic()
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
# ============== DISK I/O ==============
@router.get("/diskio")
async def get_diskio():
"""Get disk I/O statistics (read/write operations and bytes) (public)"""
result = system_info.get_disk_io()
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
# ============== SERVICES ==============
@router.get("/services")
async def get_services():
"""Get running systemd services (public)"""
result = system_info.get_services()
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
@router.get("/units")
async def get_units():
"""Get all systemd units (services, targets, sockets, timers, paths) (public)"""
result = system_info.get_all_units()
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
# ============== LOGS ==============
@router.get("/logs")
async def get_logs(limit: int = 20):
"""Get recent system logs (public)"""
result = system_info.get_journal_logs(limit)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result