feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database import get_db
|
||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||
from app.models.models import User
|
||||
from app.schemas.schemas import LoginRequest, TokenResponse, UserResponse
|
||||
from app.api.deps import get_current_user
|
||||
from app.services.event_service import log_event
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(credentials: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == credentials.email.strip().lower()))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(credentials.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is deactivated"
|
||||
)
|
||||
|
||||
access_token = create_access_token(subject=user.id, role=user.role)
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="LOGIN",
|
||||
message=f"User {user.email} logged in successfully.",
|
||||
severity="INFO",
|
||||
user_email=user.email
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"role": user.role
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
@router.post("/seed-admin")
|
||||
async def seed_admin(db: AsyncSession = Depends(get_db)):
|
||||
"""Creates default admin if no users exist in the database."""
|
||||
result = await db.execute(select(User))
|
||||
first_user = result.scalar_one_or_none()
|
||||
if first_user:
|
||||
return {"message": "Admin already exists"}
|
||||
|
||||
admin = User(
|
||||
email="admin@oneverdrive.local",
|
||||
hashed_password=get_password_hash("Admin1234!"),
|
||||
full_name="System Administrator",
|
||||
role="ADMIN",
|
||||
is_active=True
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
return {"message": "Default admin created: admin@oneverdrive.local / Admin1234!"}
|
||||
@@ -0,0 +1,116 @@
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import User, BackupFile, Client
|
||||
from app.schemas.schemas import BackupFileResponse
|
||||
from app.api.deps import get_current_user, require_admin
|
||||
from app.storage.local import storage_provider
|
||||
from app.services.event_service import log_event
|
||||
|
||||
router = APIRouter(prefix="/backups", tags=["Backups & Restore"])
|
||||
|
||||
@router.get("", response_model=List[BackupFileResponse])
|
||||
async def list_backups(
|
||||
client_id: Optional[int] = None,
|
||||
job_id: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
query = select(BackupFile).where(BackupFile.is_active == True)
|
||||
if client_id:
|
||||
query = query.where(BackupFile.client_id == client_id)
|
||||
if job_id:
|
||||
query = query.where(BackupFile.job_id == job_id)
|
||||
query = query.order_by(desc(BackupFile.created_at)).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.get("/{backup_id}", response_model=BackupFileResponse)
|
||||
async def get_backup(
|
||||
backup_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id))
|
||||
bf = res.scalar_one_or_none()
|
||||
if not bf:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found")
|
||||
return bf
|
||||
|
||||
@router.get("/{backup_id}/download")
|
||||
async def download_backup(
|
||||
backup_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Allows downloading a backup file directly from the Web interface."""
|
||||
res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id))
|
||||
bf = res.scalar_one_or_none()
|
||||
if not bf or not bf.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found")
|
||||
|
||||
try:
|
||||
abs_path = await storage_provider.get_file_path(bf.relative_path)
|
||||
except Exception as ex:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ex))
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="RESTORE_STARTED",
|
||||
message=f"Backup download initiated for '{bf.filename}' ({bf.file_size / (1024*1024):.2f} MB).",
|
||||
severity="INFO",
|
||||
client_id=bf.client_id,
|
||||
job_id=bf.job_id,
|
||||
user_email=current_user.email
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=abs_path,
|
||||
filename=bf.filename,
|
||||
media_type="application/octet-stream"
|
||||
)
|
||||
|
||||
@router.delete("/{backup_id}")
|
||||
async def delete_backup(
|
||||
backup_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
res = await db.execute(select(BackupFile).where(BackupFile.id == backup_id))
|
||||
bf = res.scalar_one_or_none()
|
||||
if not bf:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Backup file not found")
|
||||
|
||||
try:
|
||||
await storage_provider.delete_backup_file(bf.relative_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bf.is_active = False
|
||||
|
||||
# Update client storage counter
|
||||
client_res = await db.execute(select(Client).where(Client.id == bf.client_id))
|
||||
client = client_res.scalar_one_or_none()
|
||||
if client:
|
||||
client.storage_used_bytes = max(0, client.storage_used_bytes - bf.file_size)
|
||||
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="FILE_DELETED",
|
||||
message=f"Manual deletion of backup '{bf.filename}'.",
|
||||
severity="WARNING",
|
||||
client_id=bf.client_id,
|
||||
job_id=bf.job_id,
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return {"message": "Backup file deleted"}
|
||||
@@ -0,0 +1,245 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import generate_registration_code, generate_device_token, hash_token
|
||||
from app.models.models import User, Client, ClientCredential, RegistrationCode
|
||||
from app.schemas.schemas import (
|
||||
ClientResponse, ClientRegisterRequest, ClientRegisterResponse,
|
||||
RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest
|
||||
)
|
||||
from app.api.deps import get_current_user, require_admin, get_current_client
|
||||
from app.services.event_service import log_event
|
||||
from app.ws.manager import ws_manager
|
||||
|
||||
router = APIRouter(prefix="/clients", tags=["Clients"])
|
||||
|
||||
@router.get("", response_model=List[ClientResponse])
|
||||
async def list_clients(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
result = await db.execute(select(Client).order_by(desc(Client.created_at)))
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/registration-code", response_model=RegistrationCodeResponse)
|
||||
async def create_registration_code(
|
||||
payload: RegistrationCodeCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
code_str = generate_registration_code()
|
||||
expires = datetime.now(timezone.utc) + timedelta(hours=payload.expires_in_hours)
|
||||
|
||||
reg_code = RegistrationCode(
|
||||
code=code_str,
|
||||
client_name_hint=payload.client_name_hint,
|
||||
expires_at=expires,
|
||||
is_used=False
|
||||
)
|
||||
db.add(reg_code)
|
||||
await db.commit()
|
||||
await db.refresh(reg_code)
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="REGISTRATION_CODE_GENERATED",
|
||||
message=f"Generated registration code {code_str} (hint: {payload.client_name_hint or 'None'}).",
|
||||
severity="INFO",
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return reg_code
|
||||
|
||||
@router.post("/register", response_model=ClientRegisterResponse)
|
||||
async def register_client(
|
||||
payload: ClientRegisterRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Called by the Windows Agent during initial setup to register against the server."""
|
||||
# Find valid registration code
|
||||
now = datetime.now(timezone.utc)
|
||||
res = await db.execute(
|
||||
select(RegistrationCode).where(
|
||||
RegistrationCode.code == payload.registration_code.strip().upper(),
|
||||
RegistrationCode.is_used == False,
|
||||
RegistrationCode.expires_at > now
|
||||
)
|
||||
)
|
||||
reg_code = res.scalar_one_or_none()
|
||||
if not reg_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid, expired, or already used registration code."
|
||||
)
|
||||
|
||||
# Determine next client code (e.g., CLIENT-0001)
|
||||
count_res = await db.execute(select(func.count(Client.id)))
|
||||
client_count = count_res.scalar() or 0
|
||||
client_code = f"CLIENT-{client_count + 1:04d}"
|
||||
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
# Create Client
|
||||
client = Client(
|
||||
client_code=client_code,
|
||||
name=payload.name or reg_code.client_name_hint or payload.hostname,
|
||||
hostname=payload.hostname,
|
||||
os_info=payload.os_info,
|
||||
ip_address=client_ip,
|
||||
agent_version=payload.agent_version,
|
||||
status="ONLINE",
|
||||
last_seen_at=now,
|
||||
is_active=True
|
||||
)
|
||||
db.add(client)
|
||||
await db.flush()
|
||||
|
||||
# Generate device unique ID and secret token
|
||||
device_id = str(uuid.uuid4())
|
||||
device_token = generate_device_token()
|
||||
token_hash = hash_token(device_token)
|
||||
|
||||
credential = ClientCredential(
|
||||
client_id=client.id,
|
||||
device_id=device_id,
|
||||
token_hash=token_hash,
|
||||
name=f"{payload.hostname} Agent",
|
||||
is_revoked=False,
|
||||
created_at=now,
|
||||
last_used_at=now
|
||||
)
|
||||
db.add(credential)
|
||||
|
||||
# Mark registration code as used
|
||||
reg_code.is_used = True
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="CLIENT_REGISTERED",
|
||||
message=f"Windows client registered: {client.name} ({client.client_code}, Hostname: {client.hostname}, IP: {client_ip})",
|
||||
severity="INFO",
|
||||
client_id=client.id,
|
||||
ip_address=client_ip,
|
||||
details={"device_id": device_id, "os_info": payload.os_info}
|
||||
)
|
||||
|
||||
await ws_manager.broadcast("CLIENT_REGISTERED", {
|
||||
"id": client.id,
|
||||
"client_code": client.client_code,
|
||||
"name": client.name,
|
||||
"hostname": client.hostname,
|
||||
"status": client.status
|
||||
})
|
||||
|
||||
return {
|
||||
"client_code": client.client_code,
|
||||
"device_id": device_id,
|
||||
"device_token": device_token,
|
||||
"name": client.name,
|
||||
"server_time": now
|
||||
}
|
||||
|
||||
@router.post("/{client_id}/heartbeat")
|
||||
async def client_heartbeat(
|
||||
client_id: int,
|
||||
payload: ClientHeartbeatRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""Heartbeat endpoint invoked periodically by the Windows Agent."""
|
||||
if current_client.id != client_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client ID mismatch")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
current_client.last_seen_at = now
|
||||
current_client.status = payload.status
|
||||
if payload.agent_version:
|
||||
current_client.agent_version = payload.agent_version
|
||||
|
||||
client_ip = payload.ip_address or (request.client.host if request.client else None)
|
||||
if client_ip:
|
||||
current_client.ip_address = client_ip
|
||||
|
||||
await db.commit()
|
||||
|
||||
await ws_manager.broadcast("CLIENT_HEARTBEAT", {
|
||||
"client_id": current_client.id,
|
||||
"status": current_client.status,
|
||||
"last_seen_at": now.isoformat()
|
||||
})
|
||||
|
||||
return {"status": "ok", "server_time": now}
|
||||
|
||||
@router.get("/{client_id}", response_model=ClientResponse)
|
||||
async def get_client(
|
||||
client_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
res = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = res.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
|
||||
return client
|
||||
|
||||
@router.post("/{client_id}/revoke")
|
||||
async def revoke_client_credentials(
|
||||
client_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Revokes all active authentication tokens for a client."""
|
||||
res = await db.execute(select(ClientCredential).where(ClientCredential.client_id == client_id))
|
||||
creds = res.scalars().all()
|
||||
for cred in creds:
|
||||
cred.is_revoked = True
|
||||
|
||||
client_res = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = client_res.scalar_one_or_none()
|
||||
if client:
|
||||
client.status = "OFFLINE"
|
||||
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="CREDENTIALS_REVOKED",
|
||||
message=f"Revoked credentials for client ID {client_id}.",
|
||||
severity="WARNING",
|
||||
client_id=client_id,
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return {"message": "Client credentials successfully revoked"}
|
||||
|
||||
@router.delete("/{client_id}")
|
||||
async def delete_client(
|
||||
client_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
res = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = res.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
|
||||
|
||||
await db.delete(client)
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="CLIENT_DELETED",
|
||||
message=f"Deleted client {client.name} ({client.client_code}).",
|
||||
severity="WARNING",
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return {"message": f"Client {client.client_code} deleted"}
|
||||
@@ -0,0 +1,87 @@
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_access_token, hash_token
|
||||
from app.models.models import User, Client, ClientCredential
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
async def get_current_user(
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""Authenticates web UI users via JWT Bearer token."""
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
payload = decode_access_token(token)
|
||||
if not payload or "sub" not in payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired authentication token",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
user_id = int(payload["sub"])
|
||||
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or deactivated"
|
||||
)
|
||||
return user
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
"""Ensures current user has ADMIN role."""
|
||||
if user.role != "ADMIN":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin privilege required"
|
||||
)
|
||||
return user
|
||||
|
||||
async def get_current_client(
|
||||
x_device_id: Optional[str] = Header(None, alias="X-Device-Id"),
|
||||
x_device_token: Optional[str] = Header(None, alias="X-Device-Token"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Client:
|
||||
"""Authenticates Windows Agent devices via individual device ID and secret token."""
|
||||
if not x_device_id or not x_device_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Device authentication headers (X-Device-Id, X-Device-Token) required"
|
||||
)
|
||||
|
||||
token_hash = hash_token(x_device_token)
|
||||
result = await db.execute(
|
||||
select(ClientCredential)
|
||||
.where(
|
||||
ClientCredential.device_id == x_device_id,
|
||||
ClientCredential.token_hash == token_hash,
|
||||
ClientCredential.is_revoked == False
|
||||
)
|
||||
)
|
||||
credential = result.scalar_one_or_none()
|
||||
if not credential:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or revoked device credentials"
|
||||
)
|
||||
|
||||
client_res = await db.execute(select(Client).where(Client.id == credential.client_id, Client.is_active == True))
|
||||
client = client_res.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Client device not found or inactive"
|
||||
)
|
||||
|
||||
return client
|
||||
@@ -0,0 +1,32 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import EventLog, User
|
||||
from app.schemas.schemas import EventLogResponse
|
||||
from app.api.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["Audit & Events"])
|
||||
|
||||
@router.get("", response_model=List[EventLogResponse])
|
||||
async def list_events(
|
||||
client_id: Optional[int] = None,
|
||||
job_id: Optional[int] = None,
|
||||
event_type: Optional[str] = None,
|
||||
limit: int = Query(50, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
query = select(EventLog)
|
||||
if client_id:
|
||||
query = query.where(EventLog.client_id == client_id)
|
||||
if job_id:
|
||||
query = query.where(EventLog.job_id == job_id)
|
||||
if event_type:
|
||||
query = query.where(EventLog.event_type == event_type)
|
||||
|
||||
query = query.order_by(desc(EventLog.timestamp)).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,175 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import User, Client, BackupJob
|
||||
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse
|
||||
from app.api.deps import get_current_user, require_admin, get_current_client
|
||||
from app.services.event_service import log_event
|
||||
from app.ws.manager import ws_manager
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["Backup Jobs"])
|
||||
|
||||
@router.get("", response_model=List[JobResponse])
|
||||
async def list_jobs(
|
||||
client_id: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
query = select(BackupJob)
|
||||
if client_id:
|
||||
query = query.where(BackupJob.client_id == client_id)
|
||||
query = query.order_by(desc(BackupJob.created_at))
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.get("/agent/assigned", response_model=List[JobResponse])
|
||||
async def get_agent_jobs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""Called by the Windows Agent to query its active backup jobs."""
|
||||
query = select(BackupJob).where(BackupJob.client_id == current_client.id, BackupJob.is_active == True)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("", response_model=JobResponse)
|
||||
async def create_job(
|
||||
payload: JobCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
# Verify client exists
|
||||
client_res = await db.execute(select(Client).where(Client.id == payload.client_id))
|
||||
client = client_res.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found")
|
||||
|
||||
count_res = await db.execute(select(func.count(BackupJob.id)))
|
||||
job_count = count_res.scalar() or 0
|
||||
job_code = f"JOB-{job_count + 1:03d}"
|
||||
|
||||
job = BackupJob(
|
||||
job_code=job_code,
|
||||
client_id=payload.client_id,
|
||||
name=payload.name,
|
||||
source_path=payload.source_path,
|
||||
file_patterns=payload.file_patterns,
|
||||
schedule_cron=payload.schedule_cron,
|
||||
keep_daily=payload.keep_daily,
|
||||
keep_weekly=payload.keep_weekly,
|
||||
keep_monthly=payload.keep_monthly,
|
||||
min_stable_time_seconds=payload.min_stable_time_seconds,
|
||||
status="IDLE",
|
||||
is_active=True
|
||||
)
|
||||
db.add(job)
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="JOB_CREATED",
|
||||
message=f"Created backup job '{job.name}' ({job.job_code}) for client {client.name}.",
|
||||
severity="INFO",
|
||||
client_id=client.id,
|
||||
job_id=job.id,
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return job
|
||||
|
||||
@router.get("/{job_id}", response_model=JobResponse)
|
||||
async def get_job(
|
||||
job_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
||||
job = res.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
||||
return job
|
||||
|
||||
@router.put("/{job_id}", response_model=JobResponse)
|
||||
async def update_job(
|
||||
job_id: int,
|
||||
payload: JobUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
||||
job = res.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(job, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(job)
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="CONFIG_CHANGED",
|
||||
message=f"Updated backup job '{job.name}' ({job.job_code}).",
|
||||
severity="INFO",
|
||||
client_id=job.client_id,
|
||||
job_id=job.id,
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return job
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def delete_job(
|
||||
job_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(require_admin)
|
||||
):
|
||||
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
||||
job = res.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
||||
|
||||
await db.delete(job)
|
||||
await db.commit()
|
||||
|
||||
await log_event(
|
||||
db=db,
|
||||
event_type="JOB_DELETED",
|
||||
message=f"Deleted backup job {job.job_code}.",
|
||||
severity="WARNING",
|
||||
client_id=job.client_id,
|
||||
user_email=admin_user.email
|
||||
)
|
||||
|
||||
return {"message": f"Job {job.job_code} deleted"}
|
||||
|
||||
@router.post("/{job_id}/trigger")
|
||||
async def trigger_job(
|
||||
job_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Notifies the connected agent to start executing this backup job immediately."""
|
||||
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id))
|
||||
job = res.scalar_one_or_none()
|
||||
if not job:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
||||
|
||||
job.status = "QUEUED"
|
||||
await db.commit()
|
||||
|
||||
# Broadcast event so the agent / web UI knows the job was triggered
|
||||
await ws_manager.broadcast("JOB_TRIGGERED", {
|
||||
"job_id": job.id,
|
||||
"job_code": job.job_code,
|
||||
"client_id": job.client_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
return {"message": f"Job {job.job_code} triggered"}
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import Client, BackupJob, BackupSession, User
|
||||
from app.schemas.schemas import DashboardStatsResponse
|
||||
from app.api.deps import get_current_user
|
||||
from app.storage.local import storage_provider
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["Dashboard Statistics"])
|
||||
|
||||
@router.get("", response_model=DashboardStatsResponse)
|
||||
async def get_dashboard_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
# Clients aggregation
|
||||
total_clients_res = await db.execute(select(func.count(Client.id)).where(Client.is_active == True))
|
||||
total_clients = total_clients_res.scalar() or 0
|
||||
|
||||
# Consider online if last_seen_at was in the last 5 minutes
|
||||
cutoff_online = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
online_clients_res = await db.execute(
|
||||
select(func.count(Client.id)).where(
|
||||
Client.is_active == True,
|
||||
Client.status == "ONLINE",
|
||||
Client.last_seen_at >= cutoff_online
|
||||
)
|
||||
)
|
||||
online_clients = online_clients_res.scalar() or 0
|
||||
offline_clients = max(0, total_clients - online_clients)
|
||||
|
||||
# Jobs count
|
||||
total_jobs_res = await db.execute(select(func.count(BackupJob.id)).where(BackupJob.is_active == True))
|
||||
total_jobs = total_jobs_res.scalar() or 0
|
||||
|
||||
# Backups today
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
backups_today_res = await db.execute(
|
||||
select(func.count(BackupSession.id)).where(BackupSession.started_at >= today_start)
|
||||
)
|
||||
backups_today = backups_today_res.scalar() or 0
|
||||
|
||||
backups_success_res = await db.execute(
|
||||
select(func.count(BackupSession.id)).where(
|
||||
BackupSession.started_at >= today_start,
|
||||
BackupSession.status == "SUCCESS"
|
||||
)
|
||||
)
|
||||
backups_success = backups_success_res.scalar() or 0
|
||||
|
||||
backups_failed_res = await db.execute(
|
||||
select(func.count(BackupSession.id)).where(
|
||||
BackupSession.started_at >= today_start,
|
||||
BackupSession.status == "FAILED"
|
||||
)
|
||||
)
|
||||
backups_failed = backups_failed_res.scalar() or 0
|
||||
|
||||
# Active uploads
|
||||
active_uploads_res = await db.execute(
|
||||
select(func.count(BackupSession.id)).where(BackupSession.status.in_(["PENDING", "UPLOADING", "ASSEMBLING"]))
|
||||
)
|
||||
active_uploads = active_uploads_res.scalar() or 0
|
||||
|
||||
# Storage metrics
|
||||
storage_stats = await storage_provider.get_storage_stats()
|
||||
|
||||
return {
|
||||
"total_clients": total_clients,
|
||||
"online_clients": online_clients,
|
||||
"offline_clients": offline_clients,
|
||||
"total_jobs": total_jobs,
|
||||
"backups_today_count": backups_today,
|
||||
"backups_today_success": backups_success,
|
||||
"backups_today_failed": backups_failed,
|
||||
"active_uploads_count": active_uploads,
|
||||
"storage": storage_stats
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Header, Request, Query, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.models import Client, BackupSession
|
||||
from app.schemas.schemas import (
|
||||
UploadSessionInitRequest, UploadSessionInitResponse,
|
||||
UploadSessionStatusResponse, ChunkUploadResponse,
|
||||
UploadSessionCompleteResponse
|
||||
)
|
||||
from app.api.deps import get_current_client
|
||||
from app.services.upload_service import (
|
||||
create_or_resume_session, process_chunk_upload,
|
||||
get_session_status_info, complete_session
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["Chunk Upload Engine"])
|
||||
|
||||
@router.post("/session", response_model=UploadSessionInitResponse)
|
||||
async def init_session(
|
||||
payload: UploadSessionInitRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""
|
||||
Initializes a new upload session or resumes an existing incomplete session
|
||||
for the specified file. Returns list of previously received chunks so the agent
|
||||
only transmits the remaining blocks.
|
||||
"""
|
||||
session, received_chunks = await create_or_resume_session(
|
||||
db=db,
|
||||
client=current_client,
|
||||
filename=payload.filename,
|
||||
file_size=payload.file_size,
|
||||
sha256_full=payload.sha256,
|
||||
chunk_size=payload.chunk_size,
|
||||
job_id=payload.job_id
|
||||
)
|
||||
|
||||
return {
|
||||
"session_code": session.session_code,
|
||||
"filename": session.filename,
|
||||
"file_size": session.file_size,
|
||||
"chunk_size": session.chunk_size,
|
||||
"total_chunks": session.total_chunks,
|
||||
"received_chunks": received_chunks,
|
||||
"status": session.status
|
||||
}
|
||||
|
||||
@router.post("/{session_code}/chunk", response_model=ChunkUploadResponse)
|
||||
async def upload_chunk(
|
||||
session_code: str,
|
||||
request: Request,
|
||||
chunk_index: int = Query(..., description="0-indexed chunk number"),
|
||||
chunk_sha256: Optional[str] = Query(None, description="SHA-256 hash of this specific chunk"),
|
||||
x_chunk_index: Optional[int] = Header(None, alias="X-Chunk-Index"),
|
||||
x_chunk_sha256: Optional[str] = Header(None, alias="X-Chunk-SHA256"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""
|
||||
Receives and stores a single chunk of data for an active upload session.
|
||||
Accepts raw binary body directly via streaming.
|
||||
"""
|
||||
idx = x_chunk_index if x_chunk_index is not None else chunk_index
|
||||
sha = x_chunk_sha256 or chunk_sha256
|
||||
|
||||
res = await db.execute(
|
||||
select(BackupSession).where(
|
||||
BackupSession.session_code == session_code,
|
||||
BackupSession.client_id == current_client.id
|
||||
)
|
||||
)
|
||||
session = res.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found")
|
||||
|
||||
chunk_data = await request.body()
|
||||
if not chunk_data:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty chunk payload")
|
||||
|
||||
try:
|
||||
result = await process_chunk_upload(
|
||||
db=db,
|
||||
session=session,
|
||||
chunk_index=idx,
|
||||
chunk_data=chunk_data,
|
||||
chunk_sha256=sha
|
||||
)
|
||||
return result
|
||||
except ValueError as ex:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex))
|
||||
|
||||
@router.get("/{session_code}/status", response_model=UploadSessionStatusResponse)
|
||||
async def get_session_status(
|
||||
session_code: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""
|
||||
Returns the current status of an upload session, including the list of received
|
||||
chunks and missing chunks for easy re-connection and resumption.
|
||||
"""
|
||||
res = await db.execute(
|
||||
select(BackupSession).where(
|
||||
BackupSession.session_code == session_code,
|
||||
BackupSession.client_id == current_client.id
|
||||
)
|
||||
)
|
||||
session = res.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found")
|
||||
|
||||
return await get_session_status_info(db, session)
|
||||
|
||||
@router.post("/{session_code}/complete", response_model=UploadSessionCompleteResponse)
|
||||
async def complete_upload(
|
||||
session_code: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_client: Client = Depends(get_current_client)
|
||||
):
|
||||
"""
|
||||
Triggers sequential file assembly and final SHA-256 integrity verification.
|
||||
If integrity passes, the backup file is registered and retention policies are applied.
|
||||
"""
|
||||
res = await db.execute(
|
||||
select(BackupSession).where(
|
||||
BackupSession.session_code == session_code,
|
||||
BackupSession.client_id == current_client.id
|
||||
)
|
||||
)
|
||||
session = res.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Upload session not found")
|
||||
|
||||
try:
|
||||
backup_file = await complete_session(db, session)
|
||||
return {
|
||||
"session_code": session.session_code,
|
||||
"filename": backup_file.filename,
|
||||
"relative_path": backup_file.relative_path,
|
||||
"file_size": backup_file.file_size,
|
||||
"sha256": backup_file.sha256,
|
||||
"status": "SUCCESS",
|
||||
"completed_at": backup_file.created_at
|
||||
}
|
||||
except Exception as ex:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Integrity check or assembly failed: {str(ex)}"
|
||||
)
|
||||
Reference in New Issue
Block a user