se agregaron nuevas caracteristicas

This commit is contained in:
Carlos Tello
2026-08-13 23:07:01 -03:00
parent 1bfb808c79
commit d736db982e
23 changed files with 1244 additions and 100 deletions
+34 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+52
View File
@@ -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)),
}
+17
View File
@@ -68,6 +68,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,
"online_clients": online_clients, "online_clients": online_clients,
+17 -1
View File
@@ -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):
+8
View File
@@ -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)
+34
View File
@@ -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
+67
View File
@@ -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)
+40
View File
@@ -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(
+20
View File
@@ -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()
+20
View File
@@ -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()
+7
View File
@@ -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
View File
@@ -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>
+3 -1
View File
@@ -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 (
+135 -3
View File
@@ -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>
); );
}; };
+14 -3
View File
@@ -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);
}} }}
> >
+71 -10
View File
@@ -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>
+235
View File
@@ -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>
);
};
+42
View File
@@ -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' }),
+2
View File
@@ -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
View File
@@ -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,
"name": lj.name,
"source_path": lj.source_path,
"file_patterns": lj.file_patterns,
"min_stable_seconds": lj.min_stable_seconds
})
# 3. Process jobs # Remove local jobs that have a job_id but are not on the server anymore (deleted on server)
for job in all_jobs: local_jobs_to_keep = []
source_path = job["source_path"] for lj in self.config.local_folders:
file_patterns = job["file_patterns"] if lj.job_id is None:
min_stable = job["min_stable_seconds"] # New local job, keep it so we register it next
job_name = job["name"] 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
# Add server jobs that are missing locally
local_job_ids = {lj.job_id for lj in self.config.local_folders if lj.job_id is not None}
for sj in server_jobs:
if sj["id"] not in local_job_ids:
new_job = LocalFolderJob(
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)
+69 -16
View File
@@ -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) ---