se agregaron nuevas caracteristicas
This commit is contained in:
@@ -10,7 +10,7 @@ Plataforma empresarial centralizada de backup y sincronización para entornos Wi
|
|||||||
┌────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────┐
|
||||||
│ CLIENTES WINDOWS (10/11/Server) │
|
│ CLIENTES WINDOWS (10/11/Server) │
|
||||||
│ │
|
│ │
|
||||||
│ [ Agente PyQt6 / System Tray (.EXE) ] │
|
│ [ Agente PyQt6 / System Tray (.EXE) ] │
|
||||||
│ ├── Selector visual de carpetas (QFileDialog) │
|
│ ├── Selector visual de carpetas (QFileDialog) │
|
||||||
│ ├── Detección de bloqueos SQL Server (Locks/Growth) │
|
│ ├── Detección de bloqueos SQL Server (Locks/Growth) │
|
||||||
│ ├── Motor de Chunks (Bloques de 4MB con reanudación) │
|
│ ├── Motor de Chunks (Bloques de 4MB con reanudación) │
|
||||||
@@ -44,6 +44,39 @@ Plataforma empresarial centralizada de backup y sincronización para entornos Wi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🧪 Ejecución Local para Desarrollo y Pruebas
|
||||||
|
|
||||||
|
Para depurar y probar la aplicación en tu máquina local:
|
||||||
|
|
||||||
|
### Backend (FastAPI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
# 1. Crear y activar entorno virtual
|
||||||
|
python -m venv venv
|
||||||
|
# Windows: venv\Scripts\activate
|
||||||
|
# Linux/Mac: source venv/bin/activate
|
||||||
|
|
||||||
|
# 2. Instalar dependencias
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 3. Ejecutar servidor en modo desarrollo
|
||||||
|
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (React + Vite)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
# 1. Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. Iniciar servidor de desarrollo
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🛠️ Requisitos de Despliegue en Proxmox VE
|
## 🛠️ Requisitos de Despliegue en Proxmox VE
|
||||||
|
|
||||||
El backend y frontend se despliegan en un **Contenedor LXC Debian 12/13** sin dependencias de Docker:
|
El backend y frontend se despliegan en un **Contenedor LXC Debian 12/13** sin dependencias de Docker:
|
||||||
|
|||||||
+133
-20
@@ -10,7 +10,8 @@ from app.core.security import generate_registration_code, generate_device_token,
|
|||||||
from app.models.models import User, Client, ClientCredential, RegistrationCode
|
from app.models.models import User, Client, ClientCredential, RegistrationCode
|
||||||
from app.schemas.schemas import (
|
from app.schemas.schemas import (
|
||||||
ClientResponse, ClientRegisterRequest, ClientRegisterResponse,
|
ClientResponse, ClientRegisterRequest, ClientRegisterResponse,
|
||||||
RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest
|
RegistrationCodeCreate, RegistrationCodeResponse, ClientHeartbeatRequest,
|
||||||
|
ClientUpdateRequest
|
||||||
)
|
)
|
||||||
from app.api.deps import get_current_user, require_admin, get_current_client
|
from app.api.deps import get_current_user, require_admin, get_current_client
|
||||||
from app.services.event_service import log_event
|
from app.services.event_service import log_event
|
||||||
@@ -78,27 +79,55 @@ async def register_client(
|
|||||||
detail="Invalid, expired, or already used registration code."
|
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
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
# Create Client
|
if reg_code.client_id:
|
||||||
client = Client(
|
# Re-registering an existing client!
|
||||||
client_code=client_code,
|
client_res = await db.execute(select(Client).where(Client.id == reg_code.client_id))
|
||||||
name=payload.name or reg_code.client_name_hint or payload.hostname,
|
client = client_res.scalar_one_or_none()
|
||||||
hostname=payload.hostname,
|
if not client:
|
||||||
os_info=payload.os_info,
|
raise HTTPException(
|
||||||
ip_address=client_ip,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
agent_version=payload.agent_version,
|
detail="Client associated with this registration code not found."
|
||||||
status="ONLINE",
|
)
|
||||||
last_seen_at=now,
|
# Update connection info and mark as active
|
||||||
is_active=True
|
client.hostname = payload.hostname
|
||||||
)
|
client.os_info = payload.os_info
|
||||||
db.add(client)
|
client.ip_address = client_ip
|
||||||
await db.flush()
|
client.agent_version = payload.agent_version
|
||||||
|
client.status = "ONLINE"
|
||||||
|
client.last_seen_at = now
|
||||||
|
client.is_active = True
|
||||||
|
else:
|
||||||
|
# Create a new Client
|
||||||
|
max_id_res = await db.execute(select(func.max(Client.id)))
|
||||||
|
max_id = max_id_res.scalar() or 0
|
||||||
|
client_code = f"CLIENT-{max_id + 1:04d}"
|
||||||
|
|
||||||
|
# Load default client quota from settings
|
||||||
|
from app.services.settings_service import get_setting
|
||||||
|
default_quota_gb_str = await get_setting(db, "default_client_quota_gb")
|
||||||
|
default_quota_bytes = 100 * 1024 * 1024 * 1024
|
||||||
|
if default_quota_gb_str:
|
||||||
|
try:
|
||||||
|
default_quota_bytes = int(default_quota_gb_str) * 1024 * 1024 * 1024
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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,
|
||||||
|
storage_quota_bytes=default_quota_bytes
|
||||||
|
)
|
||||||
|
db.add(client)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
# Generate device unique ID and secret token
|
# Generate device unique ID and secret token
|
||||||
device_id = str(uuid.uuid4())
|
device_id = str(uuid.uuid4())
|
||||||
@@ -220,6 +249,90 @@ async def revoke_client_credentials(
|
|||||||
|
|
||||||
return {"message": "Client credentials successfully revoked"}
|
return {"message": "Client credentials successfully revoked"}
|
||||||
|
|
||||||
|
@router.post("/{client_id}/re-register", response_model=RegistrationCodeResponse)
|
||||||
|
async def generate_client_re_registration_code(
|
||||||
|
client_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Generates a registration code to re-link an existing client's agent."""
|
||||||
|
client_res = await db.execute(select(Client).where(Client.id == 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revoke old credentials to prepare for the new agent connection
|
||||||
|
cred_res = await db.execute(select(ClientCredential).where(ClientCredential.client_id == client_id))
|
||||||
|
creds = cred_res.scalars().all()
|
||||||
|
for cred in creds:
|
||||||
|
cred.is_revoked = True
|
||||||
|
|
||||||
|
code_str = generate_registration_code()
|
||||||
|
expires = datetime.now(timezone.utc) + timedelta(hours=24) # 24 hours to re-register
|
||||||
|
|
||||||
|
reg_code = RegistrationCode(
|
||||||
|
code=code_str,
|
||||||
|
client_id=client.id,
|
||||||
|
client_name_hint=client.name,
|
||||||
|
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 re-registration code {code_str} for client {client.name} ({client.client_code}).",
|
||||||
|
severity="INFO",
|
||||||
|
client_id=client_id,
|
||||||
|
user_email=admin_user.email
|
||||||
|
)
|
||||||
|
|
||||||
|
return reg_code
|
||||||
|
|
||||||
|
@router.patch("/{client_id}", response_model=ClientResponse)
|
||||||
|
async def update_client(
|
||||||
|
client_id: int,
|
||||||
|
payload: ClientUpdateRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Updates client details such as alias."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
if payload.alias is not None:
|
||||||
|
client.alias = payload.alias
|
||||||
|
if payload.storage_quota_bytes is not None:
|
||||||
|
client.storage_quota_bytes = payload.storage_quota_bytes
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(client)
|
||||||
|
|
||||||
|
message_parts = []
|
||||||
|
if payload.alias is not None:
|
||||||
|
message_parts.append(f"alias to '{client.alias}'")
|
||||||
|
if payload.storage_quota_bytes is not None:
|
||||||
|
message_parts.append(f"quota to {client.storage_quota_bytes} bytes")
|
||||||
|
|
||||||
|
await log_event(
|
||||||
|
db=db,
|
||||||
|
event_type="CLIENT_UPDATED",
|
||||||
|
message=f"Updated client {client.client_code}: {', '.join(message_parts)}.",
|
||||||
|
severity="INFO",
|
||||||
|
client_id=client.id,
|
||||||
|
user_email=admin_user.email
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
@router.delete("/{client_id}")
|
@router.delete("/{client_id}")
|
||||||
async def delete_client(
|
async def delete_client(
|
||||||
client_id: int,
|
client_id: int,
|
||||||
|
|||||||
+69
-1
@@ -6,7 +6,7 @@ from sqlalchemy import select, func, desc
|
|||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.models.models import User, Client, BackupJob
|
from app.models.models import User, Client, BackupJob
|
||||||
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse
|
from app.schemas.schemas import JobCreate, JobUpdate, JobResponse, AgentJobRegister
|
||||||
from app.api.deps import get_current_user, require_admin, get_current_client
|
from app.api.deps import get_current_user, require_admin, get_current_client
|
||||||
from app.services.event_service import log_event
|
from app.services.event_service import log_event
|
||||||
from app.ws.manager import ws_manager
|
from app.ws.manager import ws_manager
|
||||||
@@ -36,6 +36,74 @@ async def get_agent_jobs(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
@router.post("/agent/register", response_model=JobResponse)
|
||||||
|
async def agent_register_job(
|
||||||
|
payload: AgentJobRegister,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_client: Client = Depends(get_current_client)
|
||||||
|
):
|
||||||
|
"""Called by the Windows Agent to register a new local job/folder on the server."""
|
||||||
|
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=current_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"Agent registered backup job '{job.name}' ({job.job_code}) from device.",
|
||||||
|
severity="INFO",
|
||||||
|
client_id=current_client.id,
|
||||||
|
job_id=job.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return job
|
||||||
|
|
||||||
|
@router.delete("/agent/{job_id}")
|
||||||
|
async def agent_delete_job(
|
||||||
|
job_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_client: Client = Depends(get_current_client)
|
||||||
|
):
|
||||||
|
"""Called by the Windows Agent to delete a backup job it owns."""
|
||||||
|
res = await db.execute(select(BackupJob).where(BackupJob.id == job_id, BackupJob.client_id == current_client.id))
|
||||||
|
job = res.scalar_one_or_none()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Job not found or not owned by this client"
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.delete(job)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await log_event(
|
||||||
|
db=db,
|
||||||
|
event_type="JOB_DELETED",
|
||||||
|
message=f"Agent deleted backup job {job.job_code} from device.",
|
||||||
|
severity="WARNING",
|
||||||
|
client_id=current_client.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Job successfully deleted by agent"}
|
||||||
|
|
||||||
@router.post("", response_model=JobResponse)
|
@router.post("", response_model=JobResponse)
|
||||||
async def create_job(
|
async def create_job(
|
||||||
payload: JobCreate,
|
payload: JobCreate,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.api.deps import require_admin
|
||||||
|
from app.models.models import User
|
||||||
|
from app.schemas.schemas import SystemSettingsResponse, SystemSettingsUpdate
|
||||||
|
from app.services.settings_service import get_all_settings, update_settings_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/settings", tags=["System Settings"])
|
||||||
|
|
||||||
|
@router.get("", response_model=SystemSettingsResponse)
|
||||||
|
async def get_settings(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Retrieve global system settings."""
|
||||||
|
settings_dict = await get_all_settings(db)
|
||||||
|
return {
|
||||||
|
"storage_root": settings_dict.get("storage_root", ""),
|
||||||
|
"global_quota_gb": int(settings_dict.get("global_quota_gb", 1000)),
|
||||||
|
"default_client_quota_gb": int(settings_dict.get("default_client_quota_gb", 100)),
|
||||||
|
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
|
||||||
|
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
||||||
|
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.put("", response_model=SystemSettingsResponse)
|
||||||
|
async def update_settings(
|
||||||
|
payload: SystemSettingsUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin_user: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Update global system settings."""
|
||||||
|
# Convert Pydantic model to a dict of values (filtering out None)
|
||||||
|
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings_dict = await update_settings_service(db, updates)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"storage_root": settings_dict.get("storage_root", ""),
|
||||||
|
"global_quota_gb": int(settings_dict.get("global_quota_gb", 1000)),
|
||||||
|
"default_client_quota_gb": int(settings_dict.get("default_client_quota_gb", 100)),
|
||||||
|
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
|
||||||
|
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
||||||
|
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
|
||||||
|
}
|
||||||
@@ -67,6 +67,23 @@ async def get_dashboard_stats(
|
|||||||
|
|
||||||
# Storage metrics
|
# Storage metrics
|
||||||
storage_stats = await storage_provider.get_storage_stats()
|
storage_stats = await storage_provider.get_storage_stats()
|
||||||
|
|
||||||
|
# Override with global virtual quota if set
|
||||||
|
from app.services.settings_service import get_setting
|
||||||
|
global_quota_gb_str = await get_setting(db, "global_quota_gb")
|
||||||
|
if global_quota_gb_str:
|
||||||
|
try:
|
||||||
|
global_quota_bytes = int(global_quota_gb_str) * 1024 * 1024 * 1024
|
||||||
|
# Sum up storage used by all clients
|
||||||
|
used_res = await db.execute(select(func.sum(Client.storage_used_bytes)))
|
||||||
|
total_used_bytes = used_res.scalar() or 0
|
||||||
|
|
||||||
|
storage_stats["total_bytes"] = global_quota_bytes
|
||||||
|
storage_stats["used_bytes"] = total_used_bytes
|
||||||
|
storage_stats["free_bytes"] = max(0, global_quota_bytes - total_used_bytes)
|
||||||
|
storage_stats["usage_percent"] = round((total_used_bytes / global_quota_bytes) * 100, 2) if global_quota_bytes > 0 else 0
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_clients": total_clients,
|
"total_clients": total_clients,
|
||||||
|
|||||||
+17
-1
@@ -14,6 +14,7 @@ from app.api.upload import router as upload_router
|
|||||||
from app.api.backups import router as backups_router
|
from app.api.backups import router as backups_router
|
||||||
from app.api.events import router as events_router
|
from app.api.events import router as events_router
|
||||||
from app.api.stats import router as stats_router
|
from app.api.stats import router as stats_router
|
||||||
|
from app.api.settings import router as settings_router
|
||||||
from app.ws.manager import ws_manager
|
from app.ws.manager import ws_manager
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -21,8 +22,22 @@ async def lifespan(app: FastAPI):
|
|||||||
# Initialize database tables
|
# Initialize database tables
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
# Seed default administrator if not present
|
# Seed default administrator and initialize settings
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
|
# Load and apply settings to storage_provider
|
||||||
|
from app.services.settings_service import get_all_settings
|
||||||
|
settings_dict = await get_all_settings(session)
|
||||||
|
|
||||||
|
# Apply storage_root
|
||||||
|
from app.storage.local import storage_provider
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
storage_root = settings_dict.get("storage_root")
|
||||||
|
if storage_root:
|
||||||
|
path = Path(storage_root).resolve()
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
storage_provider.root_dir = path
|
||||||
|
|
||||||
result = await session.execute(select(User))
|
result = await session.execute(select(User))
|
||||||
admin = result.scalar_one_or_none()
|
admin = result.scalar_one_or_none()
|
||||||
if not admin:
|
if not admin:
|
||||||
@@ -63,6 +78,7 @@ app.include_router(upload_router, prefix=settings.API_V1_PREFIX)
|
|||||||
app.include_router(backups_router, prefix=settings.API_V1_PREFIX)
|
app.include_router(backups_router, prefix=settings.API_V1_PREFIX)
|
||||||
app.include_router(events_router, prefix=settings.API_V1_PREFIX)
|
app.include_router(events_router, prefix=settings.API_V1_PREFIX)
|
||||||
app.include_router(stats_router, prefix=settings.API_V1_PREFIX)
|
app.include_router(stats_router, prefix=settings.API_V1_PREFIX)
|
||||||
|
app.include_router(settings_router, prefix=settings.API_V1_PREFIX)
|
||||||
|
|
||||||
@app.websocket("/ws/telemetry")
|
@app.websocket("/ws/telemetry")
|
||||||
async def websocket_telemetry(websocket: WebSocket):
|
async def websocket_telemetry(websocket: WebSocket):
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class Client(Base):
|
|||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
client_code = Column(String(50), unique=True, index=True, nullable=False) # e.g., CLIENT-0001
|
client_code = Column(String(50), unique=True, index=True, nullable=False) # e.g., CLIENT-0001
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
|
alias = Column(String(255), nullable=True)
|
||||||
hostname = Column(String(255), nullable=True)
|
hostname = Column(String(255), nullable=True)
|
||||||
os_info = Column(String(255), nullable=True)
|
os_info = Column(String(255), nullable=True)
|
||||||
ip_address = Column(String(100), nullable=True)
|
ip_address = Column(String(100), nullable=True)
|
||||||
@@ -65,6 +66,7 @@ class RegistrationCode(Base):
|
|||||||
code = Column(String(50), unique=True, index=True, nullable=False) # e.g., OED-A1B2-C3D4
|
code = Column(String(50), unique=True, index=True, nullable=False) # e.g., OED-A1B2-C3D4
|
||||||
is_used = Column(Boolean, default=False, nullable=False)
|
is_used = Column(Boolean, default=False, nullable=False)
|
||||||
client_name_hint = Column(String(255), nullable=True)
|
client_name_hint = Column(String(255), nullable=True)
|
||||||
|
client_id = Column(Integer, ForeignKey("clients.id"), nullable=True)
|
||||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
|
created_at = Column(DateTime(timezone=True), default=utc_now, nullable=False)
|
||||||
|
|
||||||
@@ -172,3 +174,9 @@ class EventLog(Base):
|
|||||||
ip_address = Column(String(100), nullable=True)
|
ip_address = Column(String(100), nullable=True)
|
||||||
message = Column(Text, nullable=False)
|
message = Column(Text, nullable=False)
|
||||||
details_json = Column(Text, nullable=True)
|
details_json = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
class SystemSetting(Base):
|
||||||
|
__tablename__ = "system_settings"
|
||||||
|
|
||||||
|
key = Column(String(100), primary_key=True, index=True)
|
||||||
|
value = Column(String(1024), nullable=False)
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ class RegistrationCodeCreate(BaseModel):
|
|||||||
expires_in_hours: int = 48
|
expires_in_hours: int = 48
|
||||||
|
|
||||||
class RegistrationCodeResponse(BaseModel):
|
class RegistrationCodeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
code: str
|
code: str
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
client_name_hint: Optional[str]
|
client_name_hint: Optional[str]
|
||||||
|
client_id: Optional[int] = None
|
||||||
|
|
||||||
class ClientRegisterRequest(BaseModel):
|
class ClientRegisterRequest(BaseModel):
|
||||||
registration_code: str
|
registration_code: str
|
||||||
@@ -51,10 +53,15 @@ class ClientHeartbeatRequest(BaseModel):
|
|||||||
agent_version: Optional[str] = None
|
agent_version: Optional[str] = None
|
||||||
ip_address: Optional[str] = None
|
ip_address: Optional[str] = None
|
||||||
|
|
||||||
|
class ClientUpdateRequest(BaseModel):
|
||||||
|
alias: Optional[str] = None
|
||||||
|
storage_quota_bytes: Optional[int] = None
|
||||||
|
|
||||||
class ClientResponse(BaseModel):
|
class ClientResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
client_code: str
|
client_code: str
|
||||||
name: str
|
name: str
|
||||||
|
alias: Optional[str]
|
||||||
hostname: Optional[str]
|
hostname: Optional[str]
|
||||||
os_info: Optional[str]
|
os_info: Optional[str]
|
||||||
ip_address: Optional[str]
|
ip_address: Optional[str]
|
||||||
@@ -205,3 +212,30 @@ class DashboardStatsResponse(BaseModel):
|
|||||||
backups_today_failed: int
|
backups_today_failed: int
|
||||||
active_uploads_count: int
|
active_uploads_count: int
|
||||||
storage: StorageStatsResponse
|
storage: StorageStatsResponse
|
||||||
|
|
||||||
|
# --- System Settings Schemas ---
|
||||||
|
class SystemSettingsResponse(BaseModel):
|
||||||
|
storage_root: str
|
||||||
|
global_quota_gb: int
|
||||||
|
default_client_quota_gb: int
|
||||||
|
default_keep_daily: int
|
||||||
|
default_keep_weekly: int
|
||||||
|
default_keep_monthly: int
|
||||||
|
|
||||||
|
class SystemSettingsUpdate(BaseModel):
|
||||||
|
storage_root: Optional[str] = None
|
||||||
|
global_quota_gb: Optional[int] = None
|
||||||
|
default_client_quota_gb: Optional[int] = None
|
||||||
|
default_keep_daily: Optional[int] = None
|
||||||
|
default_keep_weekly: Optional[int] = None
|
||||||
|
default_keep_monthly: Optional[int] = None
|
||||||
|
|
||||||
|
class AgentJobRegister(BaseModel):
|
||||||
|
name: str
|
||||||
|
source_path: str
|
||||||
|
file_patterns: str = "*.bak,*.mdf"
|
||||||
|
schedule_cron: str = "daily"
|
||||||
|
keep_daily: int = 7
|
||||||
|
keep_weekly: int = 4
|
||||||
|
keep_monthly: int = 12
|
||||||
|
min_stable_time_seconds: int = 60
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.models import SystemSetting
|
||||||
|
from app.core.config import settings
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"storage_root": settings.STORAGE_ROOT,
|
||||||
|
"global_quota_gb": "1000",
|
||||||
|
"default_client_quota_gb": "100",
|
||||||
|
"default_keep_daily": str(settings.DEFAULT_RETENTION_DAILY),
|
||||||
|
"default_keep_weekly": str(settings.DEFAULT_RETENTION_WEEKLY),
|
||||||
|
"default_keep_monthly": str(settings.DEFAULT_RETENTION_MONTHLY),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_setting(db: AsyncSession, key: str) -> str:
|
||||||
|
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
if setting:
|
||||||
|
return setting.value
|
||||||
|
return DEFAULT_SETTINGS.get(key, "")
|
||||||
|
|
||||||
|
async def get_all_settings(db: AsyncSession) -> dict:
|
||||||
|
result = await db.execute(select(SystemSetting))
|
||||||
|
db_settings = {s.key: s.value for s in result.scalars().all()}
|
||||||
|
|
||||||
|
# Merge defaults and add them if not present in db
|
||||||
|
updated = False
|
||||||
|
for k, v in DEFAULT_SETTINGS.items():
|
||||||
|
if k not in db_settings:
|
||||||
|
db_settings[k] = v
|
||||||
|
db.add(SystemSetting(key=k, value=v))
|
||||||
|
updated = True
|
||||||
|
if updated:
|
||||||
|
await db.commit()
|
||||||
|
return db_settings
|
||||||
|
|
||||||
|
async def update_settings_service(db: AsyncSession, new_settings: dict) -> dict:
|
||||||
|
from app.storage.local import storage_provider
|
||||||
|
|
||||||
|
for k, v in new_settings.items():
|
||||||
|
if k in DEFAULT_SETTINGS and v is not None:
|
||||||
|
# If changing storage root, validate and apply
|
||||||
|
if k == "storage_root":
|
||||||
|
path = Path(v).resolve()
|
||||||
|
try:
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
# Test write access
|
||||||
|
test_file = path / ".write_test"
|
||||||
|
test_file.touch()
|
||||||
|
test_file.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
|
||||||
|
|
||||||
|
# Apply to in-memory storage provider
|
||||||
|
storage_provider.root_dir = path
|
||||||
|
|
||||||
|
result = await db.execute(select(SystemSetting).where(SystemSetting.key == k))
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
if setting:
|
||||||
|
setting.value = str(v)
|
||||||
|
else:
|
||||||
|
db.add(SystemSetting(key=k, value=str(v)))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return await get_all_settings(db)
|
||||||
@@ -24,6 +24,28 @@ async def create_or_resume_session(
|
|||||||
"""
|
"""
|
||||||
total_chunks = max(1, math.ceil(file_size / chunk_size))
|
total_chunks = max(1, math.ceil(file_size / chunk_size))
|
||||||
|
|
||||||
|
# 1. Check client quota
|
||||||
|
if client.storage_used_bytes + file_size > client.storage_quota_bytes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Client storage quota exceeded. Limit: {client.storage_quota_bytes} bytes. Attempted to upload: {file_size} bytes."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Check global quota
|
||||||
|
from app.services.settings_service import get_setting
|
||||||
|
from sqlalchemy import func
|
||||||
|
global_quota_gb_str = await get_setting(db, "global_quota_gb")
|
||||||
|
if global_quota_gb_str:
|
||||||
|
try:
|
||||||
|
global_quota_bytes = int(global_quota_gb_str) * 1024 * 1024 * 1024
|
||||||
|
used_res = await db.execute(select(func.sum(Client.storage_used_bytes)))
|
||||||
|
total_used_bytes = used_res.scalar() or 0
|
||||||
|
if total_used_bytes + file_size > global_quota_bytes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Global storage quota exceeded. Limit: {global_quota_bytes} bytes. Current used: {total_used_bytes} bytes."
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Check for existing incomplete session for this client and file hash
|
# Check for existing incomplete session for this client and file hash
|
||||||
query = (
|
query = (
|
||||||
select(BackupSession)
|
select(BackupSession)
|
||||||
@@ -193,6 +215,8 @@ async def complete_session(
|
|||||||
job = job_res.scalar_one_or_none()
|
job = job_res.scalar_one_or_none()
|
||||||
if job:
|
if job:
|
||||||
job_code = job.job_code
|
job_code = job.job_code
|
||||||
|
job.status = "RUNNING"
|
||||||
|
job.last_run_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
session.status = "ASSEMBLING"
|
session.status = "ASSEMBLING"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -230,6 +254,12 @@ async def complete_session(
|
|||||||
client.storage_used_bytes += total_bytes
|
client.storage_used_bytes += total_bytes
|
||||||
client.last_backup_at = datetime.now(timezone.utc)
|
client.last_backup_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if session.job_id:
|
||||||
|
job_res = await db.execute(select(BackupJob).where(BackupJob.id == session.job_id))
|
||||||
|
job = job_res.scalar_one_or_none()
|
||||||
|
if job:
|
||||||
|
job.status = "SUCCESS"
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(backup_file)
|
await db.refresh(backup_file)
|
||||||
|
|
||||||
@@ -263,6 +293,16 @@ async def complete_session(
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
session.status = "FAILED"
|
session.status = "FAILED"
|
||||||
session.error_message = str(ex)
|
session.error_message = str(ex)
|
||||||
|
|
||||||
|
if session.job_id:
|
||||||
|
try:
|
||||||
|
job_res = await db.execute(select(BackupJob).where(BackupJob.id == session.job_id))
|
||||||
|
job = job_res.scalar_one_or_none()
|
||||||
|
if job:
|
||||||
|
job.status = "FAILED"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
await log_event(
|
await log_event(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db")
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print(f"Connecting to {DB_PATH}...")
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE clients ADD COLUMN alias VARCHAR(255)")
|
||||||
|
print("Column 'alias' added successfully.")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"Error (maybe column already exists?): {e}")
|
||||||
|
finally:
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "onever_drive.db")
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print(f"Connecting to {DB_PATH}...")
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE registration_codes ADD COLUMN client_id INTEGER REFERENCES clients(id)")
|
||||||
|
print("Column 'client_id' added successfully to registration_codes.")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f"Error (maybe column already exists?): {e}")
|
||||||
|
finally:
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
Generated
+7
@@ -52,6 +52,7 @@
|
|||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -1227,6 +1228,7 @@
|
|||||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -1237,6 +1239,7 @@
|
|||||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1305,6 +1308,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.11.12",
|
"baseline-browser-mapping": "^2.11.12",
|
||||||
"caniuse-lite": "^1.0.30001809",
|
"caniuse-lite": "^1.0.30001809",
|
||||||
@@ -1575,6 +1579,7 @@
|
|||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1616,6 +1621,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -1789,6 +1795,7 @@
|
|||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
+16
-2
@@ -7,6 +7,7 @@ import { JobsView } from './pages/JobsView';
|
|||||||
import { RestoreView } from './pages/RestoreView';
|
import { RestoreView } from './pages/RestoreView';
|
||||||
import { EventsView } from './pages/EventsView';
|
import { EventsView } from './pages/EventsView';
|
||||||
import { LoginView } from './pages/LoginView';
|
import { LoginView } from './pages/LoginView';
|
||||||
|
import { SettingsView } from './pages/SettingsView';
|
||||||
import { ActiveUpload } from './components/LiveTransferMeter';
|
import { ActiveUpload } from './components/LiveTransferMeter';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -16,7 +17,8 @@ import {
|
|||||||
EventLogItem,
|
EventLogItem,
|
||||||
getAuthToken,
|
getAuthToken,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
setAuthToken
|
setAuthToken,
|
||||||
|
SystemSettingsResponse
|
||||||
} from './services/api';
|
} from './services/api';
|
||||||
import { wsClient } from './services/websocket';
|
import { wsClient } from './services/websocket';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export const App: React.FC = () => {
|
|||||||
const [clients, setClients] = useState<ClientItem[]>([]);
|
const [clients, setClients] = useState<ClientItem[]>([]);
|
||||||
const [jobs, setJobs] = useState<BackupJobItem[]>([]);
|
const [jobs, setJobs] = useState<BackupJobItem[]>([]);
|
||||||
const [events, setEvents] = useState<EventLogItem[]>([]);
|
const [events, setEvents] = useState<EventLogItem[]>([]);
|
||||||
|
const [settings, setSettings] = useState<SystemSettingsResponse | null>(null);
|
||||||
const [activeUploads, setActiveUploads] = useState<ActiveUpload[]>([]);
|
const [activeUploads, setActiveUploads] = useState<ActiveUpload[]>([]);
|
||||||
|
|
||||||
const handleLoginSuccess = (user: any) => {
|
const handleLoginSuccess = (user: any) => {
|
||||||
@@ -54,17 +57,19 @@ export const App: React.FC = () => {
|
|||||||
if (!getAuthToken()) return;
|
if (!getAuthToken()) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statsRes, clientsRes, jobsRes, eventsRes] = await Promise.all([
|
const [statsRes, clientsRes, jobsRes, eventsRes, settingsRes] = await Promise.all([
|
||||||
api.getStats().catch(() => null),
|
api.getStats().catch(() => null),
|
||||||
api.getClients().catch(() => []),
|
api.getClients().catch(() => []),
|
||||||
api.getJobs().catch(() => []),
|
api.getJobs().catch(() => []),
|
||||||
api.getEvents(50).catch(() => []),
|
api.getEvents(50).catch(() => []),
|
||||||
|
api.getSettings().catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (statsRes) setStats(statsRes);
|
if (statsRes) setStats(statsRes);
|
||||||
setClients(clientsRes);
|
setClients(clientsRes);
|
||||||
setJobs(jobsRes);
|
setJobs(jobsRes);
|
||||||
setEvents(eventsRes);
|
setEvents(eventsRes);
|
||||||
|
if (settingsRes) setSettings(settingsRes);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading dashboard data:', err);
|
console.error('Error loading dashboard data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -147,6 +152,8 @@ export const App: React.FC = () => {
|
|||||||
return 'Explorador & Restore';
|
return 'Explorador & Restore';
|
||||||
case 'events':
|
case 'events':
|
||||||
return 'Auditoría & Logs';
|
return 'Auditoría & Logs';
|
||||||
|
case 'settings':
|
||||||
|
return 'Configuración Global';
|
||||||
default:
|
default:
|
||||||
return 'OnEver Drive';
|
return 'OnEver Drive';
|
||||||
}
|
}
|
||||||
@@ -191,6 +198,7 @@ export const App: React.FC = () => {
|
|||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
clients={clients}
|
clients={clients}
|
||||||
onRefresh={loadData}
|
onRefresh={loadData}
|
||||||
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -205,6 +213,12 @@ export const App: React.FC = () => {
|
|||||||
events={events}
|
events={events}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentTab === 'settings' && (
|
||||||
|
<SettingsView
|
||||||
|
onRefresh={loadData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
FileText,
|
FileText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Server
|
Server,
|
||||||
|
Settings
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -22,6 +23,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentTab, setCurrentTab, isW
|
|||||||
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
|
{ id: 'jobs', label: 'Trabajos de Backup', icon: Layers },
|
||||||
{ id: 'restore', label: 'Explorador & Restore', icon: RotateCcw },
|
{ id: 'restore', label: 'Explorador & Restore', icon: RotateCcw },
|
||||||
{ id: 'events', label: 'Auditoría & Logs', icon: FileText },
|
{ id: 'events', label: 'Auditoría & Logs', icon: FileText },
|
||||||
|
{ id: 'settings', label: 'Configuración Global', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Laptop,
|
Laptop,
|
||||||
Server as ServerIcon,
|
Server as ServerIcon,
|
||||||
X
|
X,
|
||||||
|
Edit2,
|
||||||
|
RefreshCw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ClientItem, api } from '../services/api';
|
import { ClientItem, api } from '../services/api';
|
||||||
|
|
||||||
@@ -19,6 +21,10 @@ interface ClientsViewProps {
|
|||||||
|
|
||||||
export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh }) => {
|
export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh }) => {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [editingClient, setEditingClient] = useState<ClientItem | null>(null);
|
||||||
|
const [editAlias, setEditAlias] = useState('');
|
||||||
|
const [editQuotaGb, setEditQuotaGb] = useState<number>(100);
|
||||||
const [clientHint, setClientHint] = useState('');
|
const [clientHint, setClientHint] = useState('');
|
||||||
const [generatedCode, setGeneratedCode] = useState<{ code: string; expires_at: string } | null>(null);
|
const [generatedCode, setGeneratedCode] = useState<{ code: string; expires_at: string } | null>(null);
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
@@ -67,6 +73,47 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReRegister = async (client: ClientItem) => {
|
||||||
|
if (confirm(`¿Estás seguro de que deseas volver a generar el código de vinculación para el cliente "${client.name}"?\n\nEsto revocará de inmediato los tokens de acceso actuales del equipo.`)) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const codeData = await api.generateReRegisterCode(client.id);
|
||||||
|
setGeneratedCode(codeData);
|
||||||
|
setShowModal(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Error al generar código de re-vinculación: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (client: ClientItem) => {
|
||||||
|
setEditingClient(client);
|
||||||
|
setEditAlias(client.alias || '');
|
||||||
|
setEditQuotaGb(Math.round(client.storage_quota_bytes / (1024 * 1024 * 1024)));
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingClient) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const quotaBytes = editQuotaGb * 1024 * 1024 * 1024;
|
||||||
|
await api.updateClient(editingClient.id, {
|
||||||
|
alias: editAlias.trim(),
|
||||||
|
storage_quota_bytes: quotaBytes
|
||||||
|
});
|
||||||
|
setShowEditModal(false);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Error al actualizar cliente: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const serverUrl = window.location.origin;
|
const serverUrl = window.location.origin;
|
||||||
const psCommand = generatedCode
|
const psCommand = generatedCode
|
||||||
? `python agent_cli.py register --server "${serverUrl}" --code "${generatedCode.code}"`
|
? `python agent_cli.py register --server "${serverUrl}" --code "${generatedCode.code}"`
|
||||||
@@ -100,6 +147,7 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Cliente ID</th>
|
<th>Cliente ID</th>
|
||||||
<th>Nombre / Hostname</th>
|
<th>Nombre / Hostname</th>
|
||||||
|
<th>Ubicación / Alias</th>
|
||||||
<th>Sistema Operativo</th>
|
<th>Sistema Operativo</th>
|
||||||
<th>Dirección IP</th>
|
<th>Dirección IP</th>
|
||||||
<th>Estado</th>
|
<th>Estado</th>
|
||||||
@@ -133,6 +181,9 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ fontSize: '0.84rem', color: 'var(--accent-cyan)', fontWeight: 600 }}>
|
||||||
|
{client.alias || '—'}
|
||||||
|
</td>
|
||||||
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
|
<td style={{ fontSize: '0.84rem', color: 'var(--text-muted)' }}>
|
||||||
{client.os_info || 'Windows'}
|
{client.os_info || 'Windows'}
|
||||||
</td>
|
</td>
|
||||||
@@ -145,7 +196,12 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.84rem' }}>
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.84rem' }}>
|
||||||
{formatBytes(client.storage_used_bytes)}
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div>{formatBytes(client.storage_used_bytes)}</div>
|
||||||
|
<div style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
|
||||||
|
de {formatBytes(client.storage_quota_bytes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
|
<td style={{ fontSize: '0.82rem', color: 'var(--text-dim)' }}>
|
||||||
{client.last_seen_at
|
{client.last_seen_at
|
||||||
@@ -154,6 +210,14 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right' }}>
|
<td style={{ textAlign: 'right' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||||
|
onClick={() => handleOpenEditModal(client)}
|
||||||
|
title="Editar Cliente (Alias / Cuota)"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||||
@@ -163,6 +227,15 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
<ShieldAlert size={14} color="var(--accent-amber)" />
|
<ShieldAlert size={14} color="var(--accent-amber)" />
|
||||||
Revocar
|
Revocar
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||||
|
onClick={() => handleReRegister(client)}
|
||||||
|
title="Re-vincular Agente (Generar nuevo código)"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} color="var(--accent-cyan)" />
|
||||||
|
Re-vincular
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
style={{ padding: '6px 10px', fontSize: '0.78rem' }}
|
||||||
@@ -178,7 +251,7 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
})}
|
})}
|
||||||
{clients.length === 0 && (
|
{clients.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
|
<td colSpan={9} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-dim)' }}>
|
||||||
No hay clientes Windows registrados. Haz clic en "Registrar Nuevo Cliente" para comenzar.
|
No hay clientes Windows registrados. Haz clic en "Registrar Nuevo Cliente" para comenzar.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -283,6 +356,65 @@ export const ClientsView: React.FC<ClientsViewProps> = ({ clients, onRefresh })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Edit Client Modal */}
|
||||||
|
{showEditModal && editingClient && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className="modal-card">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700 }}>Editar Cliente — {editingClient.client_code}</h3>
|
||||||
|
<button
|
||||||
|
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
|
||||||
|
onClick={() => setShowEditModal(false)}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveEdit}>
|
||||||
|
<div className="form-group" style={{ marginBottom: '12px' }}>
|
||||||
|
<label>Nombre del Equipo (Solo lectura):</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
disabled
|
||||||
|
value={editingClient.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group" style={{ marginBottom: '12px' }}>
|
||||||
|
<label>Alias / Ubicación (ej. "CLUB REGATAS"):</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Sin ubicación"
|
||||||
|
value={editAlias}
|
||||||
|
onChange={(e) => setEditAlias(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group" style={{ marginBottom: '12px' }}>
|
||||||
|
<label>Cuota de Almacenamiento Asignada (GB):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="form-input"
|
||||||
|
value={editQuotaGb}
|
||||||
|
onChange={(e) => setEditQuotaGb(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '24px' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setShowEditModal(false)}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
|
{loading ? 'Guardando...' : 'Guardar Cambios'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
X
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BackupJobItem, ClientItem, api } from '../services/api';
|
import { BackupJobItem, ClientItem, api, SystemSettingsResponse } from '../services/api';
|
||||||
|
|
||||||
interface JobsViewProps {
|
interface JobsViewProps {
|
||||||
jobs: BackupJobItem[];
|
jobs: BackupJobItem[];
|
||||||
clients: ClientItem[];
|
clients: ClientItem[];
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
settings?: SystemSettingsResponse | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh }) => {
|
export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh, settings }) => {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -92,7 +93,17 @@ export const JobsView: React.FC<JobsViewProps> = ({ jobs, clients, onRefresh })
|
|||||||
alert('Primero debes registrar al menos un cliente Windows.');
|
alert('Primero debes registrar al menos un cliente Windows.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFormData((prev) => ({ ...prev, client_id: clients[0].id }));
|
setFormData({
|
||||||
|
client_id: clients[0].id,
|
||||||
|
name: '',
|
||||||
|
source_path: 'C:\\SQLBackups',
|
||||||
|
file_patterns: '*.bak,*.mdf',
|
||||||
|
schedule_cron: '0 2 * * *',
|
||||||
|
keep_daily: settings?.default_keep_daily ?? 7,
|
||||||
|
keep_weekly: settings?.default_keep_weekly ?? 4,
|
||||||
|
keep_monthly: settings?.default_keep_monthly ?? 12,
|
||||||
|
min_stable_time_seconds: 60,
|
||||||
|
});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,9 +17,20 @@ interface RestoreViewProps {
|
|||||||
export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
|
export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
|
||||||
const [backups, setBackups] = useState<BackupFileItem[]>([]);
|
const [backups, setBackups] = useState<BackupFileItem[]>([]);
|
||||||
const [selectedClientId, setSelectedClientId] = useState<number | undefined>(undefined);
|
const [selectedClientId, setSelectedClientId] = useState<number | undefined>(undefined);
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<string>('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Get unique sorted group names (aliases) from clients
|
||||||
|
const groups = Array.from(
|
||||||
|
new Set(clients.map((c) => c.alias).filter((alias): alias is string => !!alias))
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
// Filter clients shown in dropdown based on selected group
|
||||||
|
const filteredClientsForDropdown = selectedGroup
|
||||||
|
? clients.filter((c) => c.alias === selectedGroup)
|
||||||
|
: clients;
|
||||||
|
|
||||||
const fetchBackups = async () => {
|
const fetchBackups = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -44,8 +55,12 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = (backup: BackupFileItem) => {
|
const handleDownload = async (backup: BackupFileItem) => {
|
||||||
window.open(`/api/backups/${backup.id}/download`, '_blank');
|
try {
|
||||||
|
await api.downloadBackup(backup);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Error descargando archivo: ${err.message}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (backup: BackupFileItem) => {
|
const handleDelete = async (backup: BackupFileItem) => {
|
||||||
@@ -59,14 +74,40 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredBackups = backups.filter((b) =>
|
const filteredBackups = backups.filter((b) => {
|
||||||
b.filename.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
const query = searchQuery.toLowerCase();
|
||||||
b.sha256.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
// Check filename and SHA-256
|
||||||
|
const matchesFile = b.filename.toLowerCase().includes(query) ||
|
||||||
|
b.sha256.toLowerCase().includes(query);
|
||||||
|
|
||||||
|
// Find client for this backup and check Name, Hostname, Client ID and Alias
|
||||||
|
const client = clients.find((c) => c.id === b.client_id);
|
||||||
|
|
||||||
|
// Filter by selected group (alias) if specified
|
||||||
|
if (selectedGroup && (!client || client.alias !== selectedGroup)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by selected client if specified
|
||||||
|
if (selectedClientId && b.client_id !== selectedClientId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesClient = client
|
||||||
|
? client.name.toLowerCase().includes(query) ||
|
||||||
|
(client.hostname && client.hostname.toLowerCase().includes(query)) ||
|
||||||
|
client.client_code.toLowerCase().includes(query) ||
|
||||||
|
(client.alias && client.alias.toLowerCase().includes(query))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return matchesFile || matchesClient;
|
||||||
|
});
|
||||||
|
|
||||||
const getClientName = (clientId: number) => {
|
const getClientName = (clientId: number) => {
|
||||||
const c = clients.find((client) => client.id === clientId);
|
const c = clients.find((client) => client.id === clientId);
|
||||||
return c ? `${c.name} (${c.client_code})` : `Cliente #${clientId}`;
|
if (!c) return `Cliente #${clientId}`;
|
||||||
|
return `${c.hostname || c.name} [${c.alias || c.name}] (${c.client_code})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -88,13 +129,33 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
|
|||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ paddingLeft: '36px' }}
|
style={{ paddingLeft: '36px' }}
|
||||||
placeholder="Buscar por nombre de archivo o hash SHA-256..."
|
placeholder="Buscar por archivo, hash, alias, equipo o cliente ID..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Search size={16} color="var(--text-dim)" style={{ position: 'absolute', left: '12px', top: '14px' }} />
|
<Search size={16} color="var(--text-dim)" style={{ position: 'absolute', left: '12px', top: '14px' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Filter size={16} color="var(--text-muted)" />
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
value={selectedGroup}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedGroup(e.target.value);
|
||||||
|
setSelectedClientId(undefined); // Reset client when group changes
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Todos los Grupos</option>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<option key={g} value={g}>
|
||||||
|
Grupo: {g}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<Filter size={16} color="var(--text-muted)" />
|
<Filter size={16} color="var(--text-muted)" />
|
||||||
<select
|
<select
|
||||||
@@ -104,9 +165,9 @@ export const RestoreView: React.FC<RestoreViewProps> = ({ clients }) => {
|
|||||||
onChange={(e) => setSelectedClientId(e.target.value ? Number(e.target.value) : undefined)}
|
onChange={(e) => setSelectedClientId(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
>
|
>
|
||||||
<option value="">Todos los Clientes</option>
|
<option value="">Todos los Clientes</option>
|
||||||
{clients.map((c) => (
|
{filteredClientsForDropdown.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name} ({c.client_code})
|
{c.hostname || c.name} [{c.alias || c.name}] ({c.client_code})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Folder,
|
||||||
|
HardDrive,
|
||||||
|
Calendar,
|
||||||
|
Loader2,
|
||||||
|
ShieldCheck,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { api, SystemSettingsResponse } from '../services/api';
|
||||||
|
|
||||||
|
interface SettingsViewProps {
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<SystemSettingsResponse>({
|
||||||
|
storage_root: '',
|
||||||
|
global_quota_gb: 1000,
|
||||||
|
default_client_quota_gb: 100,
|
||||||
|
default_keep_daily: 7,
|
||||||
|
default_keep_weekly: 4,
|
||||||
|
default_keep_monthly: 12
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await api.getSettings();
|
||||||
|
setFormData(res);
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(`Error al cargar configuraciones: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setErrorMsg(null);
|
||||||
|
setSuccessMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await api.updateSettings(formData);
|
||||||
|
setFormData(res);
|
||||||
|
setSuccessMsg('✓ Configuraciones globales guardadas y aplicadas con éxito.');
|
||||||
|
onRefresh(); // Refresh stats in App.tsx
|
||||||
|
setTimeout(() => setSuccessMsg(null), 5000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(`Error al guardar configuraciones: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '300px', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
<Loader2 className="animate-spin" size={32} color="var(--accent-cyan)" />
|
||||||
|
<span style={{ color: 'var(--text-muted)' }}>Cargando configuraciones globales...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h3 style={{ fontSize: '1.2rem', fontWeight: 700 }}>Configuración Global del Sistema</h3>
|
||||||
|
<p style={{ fontSize: '0.84rem', color: 'var(--text-muted)', marginTop: '2px' }}>
|
||||||
|
Personaliza los parámetros del servidor, límites de almacenamiento y políticas de retención
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-rose)', backgroundColor: 'rgba(244, 63, 94, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<AlertCircle size={18} color="var(--accent-rose)" />
|
||||||
|
<span style={{ fontSize: '0.86rem', color: '#FDA4AF' }}>{errorMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successMsg && (
|
||||||
|
<div className="glass-card" style={{ borderLeft: '4px solid var(--accent-emerald)', backgroundColor: 'rgba(16, 185, 129, 0.05)', padding: '12px 16px', display: 'flex', gap: '10px', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<ShieldCheck size={18} color="var(--accent-emerald)" />
|
||||||
|
<span style={{ fontSize: '0.86rem', color: '#A7F3D0' }}>{successMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
{/* Section 1: Storage Location */}
|
||||||
|
<div className="glass-card" style={{ padding: '24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||||
|
<Folder size={20} color="var(--accent-cyan)" />
|
||||||
|
<h4 style={{ fontSize: '1rem', fontWeight: 600 }}>1. Ubicación de Almacenamiento</h4>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
placeholder="Ej: C:\backups o /mnt/nas/backups"
|
||||||
|
value={formData.storage_root}
|
||||||
|
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--text-dim)', marginTop: '6px', lineHeight: '1.4' }}>
|
||||||
|
Define la ruta donde el motor de streaming ensamblará y almacenará de forma aislada los archivos de cada cliente.
|
||||||
|
Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: Quotas */}
|
||||||
|
<div className="glass-card" style={{ padding: '24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||||
|
<HardDrive size={20} color="var(--accent-cyan)" />
|
||||||
|
<h4 style={{ fontSize: '1rem', fontWeight: 600 }}>2. Límites de Capacidad y Cuotas</h4>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Cuota de Disco Global del Sistema (GB):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={formData.global_quota_gb}
|
||||||
|
onChange={(e) => setFormData({ ...formData, global_quota_gb: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
|
||||||
|
Equivale a: {formatBytes(formData.global_quota_gb * 1024 * 1024 * 1024)}. Límite máximo del sistema.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Cuota Inicial por Defecto para Clientes (GB):</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={formData.default_client_quota_gb}
|
||||||
|
onChange={(e) => setFormData({ ...formData, default_client_quota_gb: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.74rem', color: 'var(--text-dim)' }}>
|
||||||
|
Se asigna automáticamente a los nuevos agentes al registrarse.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 3: Retention Defaults */}
|
||||||
|
<div className="glass-card" style={{ padding: '24px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||||
|
<Calendar size={20} color="var(--accent-cyan)" />
|
||||||
|
<h4 style={{ fontSize: '1rem', fontWeight: 600 }}>3. Políticas de Retención por Defecto</h4>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '16px', lineHeight: '1.4' }}>
|
||||||
|
Establece los tiempos de retención predeterminados que se cargarán al crear nuevos trabajos de backup en el dashboard.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px' }}>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Copias Diarias a Mantener:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={formData.default_keep_daily}
|
||||||
|
onChange={(e) => setFormData({ ...formData, default_keep_daily: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Copias Semanales a Mantener:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={formData.default_keep_weekly}
|
||||||
|
onChange={(e) => setFormData({ ...formData, default_keep_weekly: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
|
<label>Copias Mensuales a Mantener:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={formData.default_keep_monthly}
|
||||||
|
onChange={(e) => setFormData({ ...formData, default_keep_monthly: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '10px' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ padding: '10px 24px' }} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
Guardando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={16} />
|
||||||
|
Guardar Configuraciones
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ export interface ClientItem {
|
|||||||
id: number;
|
id: number;
|
||||||
client_code: string;
|
client_code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
alias?: string;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
os_info?: string;
|
os_info?: string;
|
||||||
ip_address?: string;
|
ip_address?: string;
|
||||||
@@ -80,6 +81,15 @@ export interface EventLogItem {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemSettingsResponse {
|
||||||
|
storage_root: string;
|
||||||
|
global_quota_gb: number;
|
||||||
|
default_client_quota_gb: number;
|
||||||
|
default_keep_daily: number;
|
||||||
|
default_keep_weekly: number;
|
||||||
|
default_keep_monthly: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const getAuthToken = (): string | null => {
|
export const getAuthToken = (): string | null => {
|
||||||
return localStorage.getItem('oed_token');
|
return localStorage.getItem('oed_token');
|
||||||
};
|
};
|
||||||
@@ -156,8 +166,21 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
revokeClient: (clientId: number) =>
|
revokeClient: (clientId: number) =>
|
||||||
request<{ message: string }>(`/clients/${clientId}/revoke`, { method: 'POST' }),
|
request<{ message: string }>(`/clients/${clientId}/revoke`, { method: 'POST' }),
|
||||||
|
generateReRegisterCode: (clientId: number) =>
|
||||||
|
request<{ code: string; expires_at: string; client_id: number }>(`/clients/${clientId}/re-register`, { method: 'POST' }),
|
||||||
deleteClient: (clientId: number) =>
|
deleteClient: (clientId: number) =>
|
||||||
request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }),
|
request<{ message: string }>(`/clients/${clientId}`, { method: 'DELETE' }),
|
||||||
|
updateClient: (clientId: number, data: { alias?: string; storage_quota_bytes?: number }) =>
|
||||||
|
request<ClientItem>(`/clients/${clientId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
getSettings: () => request<SystemSettingsResponse>('/settings'),
|
||||||
|
updateSettings: (data: Partial<SystemSettingsResponse>) =>
|
||||||
|
request<SystemSettingsResponse>('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
|
||||||
// Jobs
|
// Jobs
|
||||||
getJobs: (clientId?: number) =>
|
getJobs: (clientId?: number) =>
|
||||||
@@ -189,6 +212,25 @@ export const api = {
|
|||||||
if (jobId) params.append('job_id', jobId.toString());
|
if (jobId) params.append('job_id', jobId.toString());
|
||||||
return request<BackupFileItem[]>(`/backups?${params.toString()}`);
|
return request<BackupFileItem[]>(`/backups?${params.toString()}`);
|
||||||
},
|
},
|
||||||
|
downloadBackup: async (backup: BackupFileItem) => {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`${API_BASE}/backups/${backup.id}/download`, {
|
||||||
|
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: 'Network error' }));
|
||||||
|
throw new Error(errorData.detail || `Download failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = backup.filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
},
|
||||||
deleteBackup: (backupId: number) =>
|
deleteBackup: (backupId: number) =>
|
||||||
request<{ message: string }>(`/backups/${backupId}`, { method: 'DELETE' }),
|
request<{ message: string }>(`/backups/${backupId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ CONFIG_FILE = AGENT_HOME / "config.json"
|
|||||||
|
|
||||||
class LocalFolderJob(BaseModel):
|
class LocalFolderJob(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||||
|
job_id: Optional[int] = None
|
||||||
name: str
|
name: str
|
||||||
source_path: str
|
source_path: str
|
||||||
file_patterns: str = "*.bak,*.mdf"
|
file_patterns: str = "*.bak,*.mdf"
|
||||||
|
schedule_cron: str = "daily"
|
||||||
schedule_interval_minutes: int = 60
|
schedule_interval_minutes: int = 60
|
||||||
min_stable_seconds: int = 60
|
min_stable_seconds: int = 60
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|||||||
+139
-42
@@ -20,6 +20,36 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("OnEverAgent")
|
logger = logging.getLogger("OnEverAgent")
|
||||||
|
|
||||||
|
def is_job_due(schedule_str: str, last_run_str: Optional[str]) -> bool:
|
||||||
|
if not schedule_str:
|
||||||
|
return True
|
||||||
|
if not last_run_str:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try parsing ISO (from server) or standard YYYY-MM-DD HH:MM:SS (local)
|
||||||
|
if "T" in last_run_str:
|
||||||
|
last_run = datetime.fromisoformat(last_run_str.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
last_run = datetime.strptime(last_run_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
delta = now - last_run
|
||||||
|
|
||||||
|
sched = schedule_str.lower().strip()
|
||||||
|
if sched == "hourly":
|
||||||
|
return delta.total_seconds() >= 3600
|
||||||
|
elif sched == "daily":
|
||||||
|
return delta.total_seconds() >= 86400
|
||||||
|
elif sched == "weekly":
|
||||||
|
return delta.total_seconds() >= 86400 * 7
|
||||||
|
elif sched == "monthly":
|
||||||
|
return delta.total_seconds() >= 86400 * 30
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
class AgentDaemon:
|
class AgentDaemon:
|
||||||
"""Background service worker for Windows: handles heartbeats, job polling and scheduled backups."""
|
"""Background service worker for Windows: handles heartbeats, job polling and scheduled backups."""
|
||||||
|
|
||||||
@@ -35,6 +65,7 @@ class AgentDaemon:
|
|||||||
self.config = config or load_config()
|
self.config = config or load_config()
|
||||||
self.running = False
|
self.running = False
|
||||||
self.uploader = ChunkUploader(self.config)
|
self.uploader = ChunkUploader(self.config)
|
||||||
|
self.last_server_jobs = []
|
||||||
self._heartbeat_thread: Optional[threading.Thread] = None
|
self._heartbeat_thread: Optional[threading.Thread] = None
|
||||||
self._worker_thread: Optional[threading.Thread] = None
|
self._worker_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
@@ -100,53 +131,122 @@ class AgentDaemon:
|
|||||||
|
|
||||||
time.sleep(30)
|
time.sleep(30)
|
||||||
|
|
||||||
def _run_backup_cycle(self):
|
def _run_backup_cycle(self, force: bool = False):
|
||||||
self.config = load_config()
|
self.config = load_config()
|
||||||
self.uploader.config = self.config
|
self.uploader.config = self.config
|
||||||
|
|
||||||
# 1. Fetch server-assigned jobs
|
# 1. Fetch server-assigned jobs and run bidirectional sync
|
||||||
server_jobs = []
|
server_jobs = []
|
||||||
|
sync_success = False
|
||||||
try:
|
try:
|
||||||
base_url = self.config.server_url.rstrip("/")
|
base_url = self.config.server_url.rstrip("/")
|
||||||
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client:
|
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client:
|
||||||
resp = client.get("/api/jobs/agent/assigned")
|
resp = client.get("/api/jobs/agent/assigned")
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
server_jobs = resp.json()
|
server_jobs = resp.json()
|
||||||
except Exception:
|
self.last_server_jobs = server_jobs
|
||||||
pass
|
sync_success = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch server jobs: {e}")
|
||||||
|
|
||||||
# 2. Combine server jobs + user local folders
|
config_changed = False
|
||||||
all_jobs = []
|
|
||||||
for sj in server_jobs:
|
|
||||||
all_jobs.append({
|
|
||||||
"job_id": sj.get("id"),
|
|
||||||
"name": sj.get("name"),
|
|
||||||
"source_path": sj.get("source_path"),
|
|
||||||
"file_patterns": sj.get("file_patterns", "*.*"),
|
|
||||||
"min_stable_seconds": sj.get("min_stable_time_seconds", 60)
|
|
||||||
})
|
|
||||||
|
|
||||||
for lj in self.config.local_folders:
|
if sync_success:
|
||||||
if lj.is_active:
|
# A. Sync Server -> Local
|
||||||
all_jobs.append({
|
server_job_ids = {sj["id"] for sj in server_jobs}
|
||||||
"job_id": None,
|
|
||||||
"local_job_id": lj.id,
|
# Remove local jobs that have a job_id but are not on the server anymore (deleted on server)
|
||||||
"name": lj.name,
|
local_jobs_to_keep = []
|
||||||
"source_path": lj.source_path,
|
for lj in self.config.local_folders:
|
||||||
"file_patterns": lj.file_patterns,
|
if lj.job_id is None:
|
||||||
"min_stable_seconds": lj.min_stable_seconds
|
# New local job, keep it so we register it next
|
||||||
})
|
local_jobs_to_keep.append(lj)
|
||||||
|
elif lj.job_id in server_job_ids:
|
||||||
|
# Keep it and update local properties from server
|
||||||
|
sj = next(x for x in server_jobs if x["id"] == lj.job_id)
|
||||||
|
lj.name = sj.get("name", lj.name)
|
||||||
|
lj.source_path = sj.get("source_path", lj.source_path)
|
||||||
|
lj.file_patterns = sj.get("file_patterns", lj.file_patterns)
|
||||||
|
lj.schedule_cron = sj.get("schedule_cron", lj.schedule_cron)
|
||||||
|
lj.min_stable_seconds = sj.get("min_stable_time_seconds", lj.min_stable_seconds)
|
||||||
|
|
||||||
|
# Also sync last_run_at from server if available and newer
|
||||||
|
if sj.get("last_run_at"):
|
||||||
|
lj.last_backup_at = sj["last_run_at"].replace("T", " ")[:19]
|
||||||
|
lj.last_status = sj.get("status", lj.last_status)
|
||||||
|
|
||||||
|
local_jobs_to_keep.append(lj)
|
||||||
|
else:
|
||||||
|
# Deleted on server, don't keep it
|
||||||
|
config_changed = True
|
||||||
|
|
||||||
|
self.config.local_folders = local_jobs_to_keep
|
||||||
|
|
||||||
# 3. Process jobs
|
# Add server jobs that are missing locally
|
||||||
for job in all_jobs:
|
local_job_ids = {lj.job_id for lj in self.config.local_folders if lj.job_id is not None}
|
||||||
source_path = job["source_path"]
|
for sj in server_jobs:
|
||||||
file_patterns = job["file_patterns"]
|
if sj["id"] not in local_job_ids:
|
||||||
min_stable = job["min_stable_seconds"]
|
new_job = LocalFolderJob(
|
||||||
job_name = job["name"]
|
job_id=sj["id"],
|
||||||
|
name=sj["name"],
|
||||||
|
source_path=sj["source_path"],
|
||||||
|
file_patterns=sj["file_patterns"],
|
||||||
|
schedule_cron=sj["schedule_cron"],
|
||||||
|
min_stable_seconds=sj["min_stable_time_seconds"],
|
||||||
|
last_status=sj.get("status", "En espera")
|
||||||
|
)
|
||||||
|
if sj.get("last_run_at"):
|
||||||
|
new_job.last_backup_at = sj["last_run_at"].replace("T", " ")[:19]
|
||||||
|
self.config.local_folders.append(new_job)
|
||||||
|
config_changed = True
|
||||||
|
|
||||||
|
# B. Sync Local -> Server (Register new local folders on the server)
|
||||||
|
for lj in self.config.local_folders:
|
||||||
|
if lj.job_id is None:
|
||||||
|
try:
|
||||||
|
base_url = self.config.server_url.rstrip("/")
|
||||||
|
payload = {
|
||||||
|
"name": lj.name,
|
||||||
|
"source_path": lj.source_path,
|
||||||
|
"file_patterns": lj.file_patterns,
|
||||||
|
"schedule_cron": lj.schedule_cron,
|
||||||
|
"min_stable_time_seconds": lj.min_stable_seconds
|
||||||
|
}
|
||||||
|
resp = httpx.post(f"{base_url}/api/jobs/agent/register", headers=self._get_headers(), json=payload, timeout=10.0)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
lj.job_id = data["id"]
|
||||||
|
config_changed = True
|
||||||
|
logger.info(f"Registered local job '{lj.name}' on server with ID {lj.job_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not register local job '{lj.name}' on server: {e}")
|
||||||
|
|
||||||
|
if config_changed:
|
||||||
|
save_config(self.config)
|
||||||
|
|
||||||
|
# 2. Process active jobs
|
||||||
|
for job in self.config.local_folders:
|
||||||
|
if not job.is_active:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if job is due or forced
|
||||||
|
if not force and not is_job_due(job.schedule_cron, job.last_backup_at):
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_path = job.source_path
|
||||||
|
file_patterns = job.file_patterns
|
||||||
|
min_stable = job.min_stable_seconds
|
||||||
|
job_name = job.name
|
||||||
|
|
||||||
|
# Skip if path does not exist
|
||||||
|
if not Path(source_path).exists():
|
||||||
|
logger.warning(f"Source path {source_path} for job {job_name} does not exist. Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
|
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
|
||||||
files = scanner.scan()
|
files = scanner.scan()
|
||||||
|
|
||||||
|
# Track files successfully backed up in this run
|
||||||
for filepath in files:
|
for filepath in files:
|
||||||
if not is_file_stable(filepath, min_stable_seconds=min_stable):
|
if not is_file_stable(filepath, min_stable_seconds=min_stable):
|
||||||
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
|
||||||
@@ -159,7 +259,6 @@ class AgentDaemon:
|
|||||||
file_size_bytes = filepath.stat().st_size
|
file_size_bytes = filepath.stat().st_size
|
||||||
logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)")
|
logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)")
|
||||||
|
|
||||||
# Single notification when the process starts
|
|
||||||
if self.on_started:
|
if self.on_started:
|
||||||
self.on_started(filepath.name, file_size_bytes)
|
self.on_started(filepath.name, file_size_bytes)
|
||||||
|
|
||||||
@@ -170,24 +269,22 @@ class AgentDaemon:
|
|||||||
try:
|
try:
|
||||||
res = self.uploader.upload_file(
|
res = self.uploader.upload_file(
|
||||||
filepath,
|
filepath,
|
||||||
job_id=job.get("job_id"),
|
job_id=job.job_id,
|
||||||
progress_callback=on_chunk_progress
|
progress_callback=on_chunk_progress
|
||||||
)
|
)
|
||||||
logger.info(f"Successfully backed up {filepath.name}!")
|
logger.info(f"Successfully backed up {filepath.name}!")
|
||||||
|
|
||||||
# Update local folder last backup status
|
|
||||||
if "local_job_id" in job:
|
|
||||||
for folder in self.config.local_folders:
|
|
||||||
if folder.id == job["local_job_id"]:
|
|
||||||
folder.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
folder.last_status = "Backup Exitoso"
|
|
||||||
save_config(self.config)
|
|
||||||
|
|
||||||
# Single notification when the process finishes
|
|
||||||
if self.on_completed:
|
if self.on_completed:
|
||||||
self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes)
|
self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes)
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.error(f"Failed to backup {filepath.name}: {str(ex)}")
|
logger.error(f"Failed to backup {filepath.name}: {str(ex)}")
|
||||||
|
job.last_status = "Error"
|
||||||
|
save_config(self.config)
|
||||||
if self.on_error:
|
if self.on_error:
|
||||||
self.on_error(filepath.name, str(ex))
|
self.on_error(filepath.name, str(ex))
|
||||||
|
|
||||||
|
# Update job state in config after checking directory
|
||||||
|
job.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
job.last_status = "Backup Exitoso" if job.last_status != "Error" else "Error"
|
||||||
|
save_config(self.config)
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import time
|
|||||||
import socket
|
import socket
|
||||||
import platform
|
import platform
|
||||||
import threading
|
import threading
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize
|
logger = logging.getLogger("OnEverAgentGUI")
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
|
||||||
from PyQt6.QtGui import QIcon, QFont, QColor, QAction
|
from PyQt6.QtGui import QIcon, QFont, QColor, QAction
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
@@ -233,11 +236,10 @@ class AddFolderDialog(QDialog):
|
|||||||
self.txt_patterns.setText("*.bak,*.mdf")
|
self.txt_patterns.setText("*.bak,*.mdf")
|
||||||
form.addRow("Filtros de archivo:", self.txt_patterns)
|
form.addRow("Filtros de archivo:", self.txt_patterns)
|
||||||
|
|
||||||
self.spin_interval = QSpinBox()
|
self.cmb_schedule = QComboBox()
|
||||||
self.spin_interval.setRange(5, 1440)
|
self.cmb_schedule.addItems(["hourly", "daily", "weekly", "monthly"])
|
||||||
self.spin_interval.setValue(60)
|
self.cmb_schedule.setCurrentText("daily")
|
||||||
self.spin_interval.setSuffix(" min")
|
form.addRow("Planificación (Schedule):", self.cmb_schedule)
|
||||||
form.addRow("Frecuencia de sondeo:", self.spin_interval)
|
|
||||||
|
|
||||||
self.spin_stable = QSpinBox()
|
self.spin_stable = QSpinBox()
|
||||||
self.spin_stable.setRange(10, 600)
|
self.spin_stable.setRange(10, 600)
|
||||||
@@ -279,7 +281,7 @@ class AddFolderDialog(QDialog):
|
|||||||
name=self.txt_name.text().strip(),
|
name=self.txt_name.text().strip(),
|
||||||
source_path=self.txt_path.text().strip(),
|
source_path=self.txt_path.text().strip(),
|
||||||
file_patterns=self.txt_patterns.text().strip() or "*.*",
|
file_patterns=self.txt_patterns.text().strip() or "*.*",
|
||||||
schedule_interval_minutes=self.spin_interval.value(),
|
schedule_cron=self.cmb_schedule.currentText(),
|
||||||
min_stable_seconds=self.spin_stable.value()
|
min_stable_seconds=self.spin_stable.value()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -311,6 +313,12 @@ class OnEverDriveMainWindow(QMainWindow):
|
|||||||
self._init_ui()
|
self._init_ui()
|
||||||
self._init_tray()
|
self._init_tray()
|
||||||
|
|
||||||
|
# Timer to refresh folders list in GUI with server-assigned jobs periodically
|
||||||
|
self.timer_refresh = QTimer(self)
|
||||||
|
self.timer_refresh.setInterval(10000) # every 10 seconds
|
||||||
|
self.timer_refresh.timeout.connect(self._refresh_folders_table)
|
||||||
|
self.timer_refresh.start()
|
||||||
|
|
||||||
if self.config.device_id:
|
if self.config.device_id:
|
||||||
self.daemon.start()
|
self.daemon.start()
|
||||||
|
|
||||||
@@ -611,7 +619,13 @@ class OnEverDriveMainWindow(QMainWindow):
|
|||||||
self._build_tray_menu()
|
self._build_tray_menu()
|
||||||
|
|
||||||
def _on_tray_activated(self, reason):
|
def _on_tray_activated(self, reason):
|
||||||
if reason == QSystemTrayIcon.ActivationReason.DoubleClick or reason == QSystemTrayIcon.ActivationReason.Trigger:
|
# Safely convert to int to bypass PyQt6 enum comparison bugs
|
||||||
|
try:
|
||||||
|
val = int(reason)
|
||||||
|
except Exception:
|
||||||
|
val = reason.value if hasattr(reason, 'value') else reason
|
||||||
|
|
||||||
|
if val in (2, 3): # 2: DoubleClick, 3: Trigger
|
||||||
self.showNormal()
|
self.showNormal()
|
||||||
self.activateWindow()
|
self.activateWindow()
|
||||||
|
|
||||||
@@ -650,13 +664,34 @@ class OnEverDriveMainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _refresh_folders_table(self):
|
def _refresh_folders_table(self):
|
||||||
self.config = load_config()
|
self.config = load_config()
|
||||||
self.tbl_folders.setRowCount(len(self.config.local_folders))
|
server_jobs = getattr(self.daemon, "last_server_jobs", [])
|
||||||
for row, job in enumerate(self.config.local_folders):
|
|
||||||
|
# Combine local folders and server-assigned jobs
|
||||||
|
total_rows = len(server_jobs) + len(self.config.local_folders)
|
||||||
|
self.tbl_folders.setRowCount(total_rows)
|
||||||
|
|
||||||
|
# Show server jobs first
|
||||||
|
row = 0
|
||||||
|
for job in server_jobs:
|
||||||
|
self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.get("name", "")))
|
||||||
|
self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.get("source_path", "")))
|
||||||
|
self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.get("file_patterns", "")))
|
||||||
|
self.tbl_folders.setItem(row, 3, QTableWidgetItem(job.get("schedule_cron", "")))
|
||||||
|
|
||||||
|
status_item = QTableWidgetItem("Sincronizado (Web)")
|
||||||
|
status_item.setForeground(QColor("#34D399")) # Light green color in dark mode
|
||||||
|
self.tbl_folders.setItem(row, 4, status_item)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Then show local folders
|
||||||
|
for job in self.config.local_folders:
|
||||||
self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.name))
|
self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.name))
|
||||||
self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.source_path))
|
self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.source_path))
|
||||||
self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.file_patterns))
|
self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.file_patterns))
|
||||||
self.tbl_folders.setItem(row, 3, QTableWidgetItem(f"{job.schedule_interval_minutes} min"))
|
self.tbl_folders.setItem(row, 3, QTableWidgetItem(getattr(job, "schedule_cron", "daily")))
|
||||||
self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera"))
|
|
||||||
|
self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera (Local)"))
|
||||||
|
row += 1
|
||||||
|
|
||||||
def _delete_selected_folder(self):
|
def _delete_selected_folder(self):
|
||||||
row = self.tbl_folders.currentRow()
|
row = self.tbl_folders.currentRow()
|
||||||
@@ -665,11 +700,29 @@ class OnEverDriveMainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
job_name = self.tbl_folders.item(row, 0).text()
|
job_name = self.tbl_folders.item(row, 0).text()
|
||||||
reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el monitoreo de la carpeta '{job_name}'?")
|
reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el trabajo de backup '{job_name}' del Agente y del Servidor?")
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
self.config = load_config()
|
self.config = load_config()
|
||||||
if row < len(self.config.local_folders):
|
path_val = self.tbl_folders.item(row, 1).text()
|
||||||
self.config.local_folders.pop(row)
|
matched_jobs = [f for f in self.config.local_folders if f.source_path == path_val]
|
||||||
|
|
||||||
|
if matched_jobs:
|
||||||
|
job = matched_jobs[0]
|
||||||
|
if job.job_id:
|
||||||
|
try:
|
||||||
|
base_url = self.config.server_url.rstrip("/")
|
||||||
|
headers = {
|
||||||
|
"X-Device-Id": self.config.device_id,
|
||||||
|
"X-Device-Token": self.config.device_token
|
||||||
|
}
|
||||||
|
resp = httpx.delete(f"{base_url}/api/jobs/agent/{job.job_id}", headers=headers, timeout=5.0)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
QMessageBox.warning(self, "Advertencia", f"No se pudo eliminar en el servidor: {resp.text}.\nSe eliminará localmente.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to delete job on server: {e}")
|
||||||
|
QMessageBox.warning(self, "Advertencia", f"No se pudo contactar al servidor: {e}.\nSe eliminará localmente.")
|
||||||
|
|
||||||
|
self.config.local_folders = [f for f in self.config.local_folders if f.source_path != path_val]
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
self._refresh_folders_table()
|
self._refresh_folders_table()
|
||||||
self._update_header_status()
|
self._update_header_status()
|
||||||
@@ -735,7 +788,7 @@ class OnEverDriveMainWindow(QMainWindow):
|
|||||||
QMessageBox.warning(self, "Sin Registro", "Primero vincula el dispositivo en la pestaña Servidor & Config.")
|
QMessageBox.warning(self, "Sin Registro", "Primero vincula el dispositivo en la pestaña Servidor & Config.")
|
||||||
return
|
return
|
||||||
|
|
||||||
threading.Thread(target=self.daemon._run_backup_cycle, daemon=True).start()
|
threading.Thread(target=lambda: self.daemon._run_backup_cycle(force=True), daemon=True).start()
|
||||||
self.lbl_transfer_info.setText("Iniciando escaneo de carpetas y comprobación de locks...")
|
self.lbl_transfer_info.setText("Iniciando escaneo de carpetas y comprobación de locks...")
|
||||||
|
|
||||||
# --- DISCRETE NOTIFICATION HANDLERS (START & FINISH ONLY) ---
|
# --- DISCRETE NOTIFICATION HANDLERS (START & FINISH ONLY) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user