chore(v2): establish protocol and test foundation

This commit is contained in:
2026-07-27 18:44:47 +02:00
parent 33b0c21292
commit 791f4526f9
86 changed files with 4060 additions and 2582 deletions
+3 -1
View File
@@ -50,7 +50,6 @@ Thumbs.db
# Frontend # Frontend
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/package-lock.json
frontend/yarn.lock frontend/yarn.lock
frontend/pnpm-lock.yaml frontend/pnpm-lock.yaml
@@ -60,8 +59,11 @@ logs/
# Testing # Testing
.pytest_cache/ .pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage .coverage
htmlcov/ htmlcov/
# Backup tool specific # Backup tool specific
backups/ backups/
.pi-subagents/
+1
View File
@@ -0,0 +1 @@
22.17.1
+1
View File
@@ -0,0 +1 @@
3.12.11
+28
View File
@@ -0,0 +1,28 @@
PYTHON ?= .venv/bin/python
PIP ?= .venv/bin/pip
NPM ?= npm
.PHONY: install test-fast lint typecheck frontend-build check-v1-absent check
install:
$(PIP) install -e 'backend[dev]'
$(NPM) --prefix frontend ci
test-fast:
$(PYTHON) -m pytest tests/unit tests/contract -q
lint:
$(PYTHON) -m ruff check --config backend/pyproject.toml backend/src tests tools
$(PYTHON) -m ruff format --check --config backend/pyproject.toml backend/src tests tools
typecheck:
$(PYTHON) -m mypy --config-file backend/pyproject.toml
$(NPM) --prefix frontend run typecheck
frontend-build:
$(NPM) --prefix frontend run build
check-v1-absent:
$(PYTHON) tools/forbidden_v1_scan.py .
check: check-v1-absent test-fast lint typecheck frontend-build
-5
View File
@@ -1,5 +0,0 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
-47
View File
@@ -1,47 +0,0 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-28
View File
@@ -1,28 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-17
View File
@@ -1,17 +0,0 @@
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./backup_tool.db")
engine = create_async_engine(DATABASE_URL, echo=os.environ.get("SQL_ECHO", "false").lower() == "true")
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
-60
View File
@@ -1,60 +0,0 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
from app.database import engine, Base
from app import models # noqa: F401 - registers models with Base.metadata
from app.routers import sources, jobs, executions, backups, settings, dashboard
from backup.scheduler import backup_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
backup_scheduler.start()
await backup_scheduler.sync_schedules()
yield
backup_scheduler.shutdown()
app = FastAPI(
title="Backup Tool API",
version="0.1.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(sources.router)
app.include_router(jobs.router)
app.include_router(executions.router)
app.include_router(backups.router)
app.include_router(settings.router)
app.include_router(dashboard.router)
@app.get("/api/health")
async def health_check():
return {"status": "healthy"}
def main():
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
# Static files for production
frontend_dist = os.path.join(os.path.dirname(__file__), "../../frontend/dist")
if os.path.exists(frontend_dist):
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets")
@app.get("/{path:path}")
async def serve_frontend(path: str):
index_file = os.path.join(frontend_dist, "index.html")
if os.path.exists(index_file):
return FileResponse(index_file)
return {"detail": "Frontend not built"}
-137
View File
@@ -1,137 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from .database import Base
class Source(Base):
__tablename__ = "sources"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
type = Column(String, nullable=False) # local, ssh, database
config = Column(JSON, default=lambda: {})
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
jobs = relationship("Job", back_populates="source", cascade="all, delete-orphan")
def __repr__(self):
return f"<Source(id={self.id}, name='{self.name}', type='{self.type}')>"
class Job(Base):
__tablename__ = "jobs"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
source_id = Column(Integer, ForeignKey("sources.id"), nullable=False, index=True)
strategy = Column(String, nullable=False, default="full") # full, incremental
destination_path = Column(String, nullable=False)
exclude_patterns = Column(JSON, default=lambda: [])
enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
source = relationship("Source", back_populates="jobs")
schedule = relationship(
"Schedule", back_populates="job", uselist=False, cascade="all, delete-orphan"
)
executions = relationship(
"JobExecution", back_populates="job", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<Job(id={self.id}, name='{self.name}', source_id={self.source_id})>"
class Schedule(Base):
__tablename__ = "schedules"
id = Column(Integer, primary_key=True)
job_id = Column(
Integer, ForeignKey("jobs.id"), unique=True, nullable=False, index=True
)
cron_expression = Column(String, nullable=False)
enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
job = relationship("Job", back_populates="schedule")
def __repr__(self):
return f"<Schedule(id={self.id}, job_id={self.job_id}, cron='{self.cron_expression}')>"
class JobExecution(Base):
__tablename__ = "job_executions"
id = Column(Integer, primary_key=True)
job_id = Column(Integer, ForeignKey("jobs.id"), nullable=False, index=True)
status = Column(
String, nullable=False, default="pending"
) # pending, running, success, failed, cancelled
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
bytes_processed = Column(Integer, default=0)
bytes_backed_up = Column(Integer, default=0)
error_message = Column(Text, nullable=True)
triggered_by = Column(String, nullable=False) # manual, schedule
job = relationship("Job", back_populates="executions")
backups = relationship(
"Backup", back_populates="execution", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<JobExecution(id={self.id}, job_id={self.job_id}, status='{self.status}')>"
class Backup(Base):
__tablename__ = "backups"
id = Column(Integer, primary_key=True)
execution_id = Column(
Integer, ForeignKey("job_executions.id"), nullable=False, index=True
)
storage_path = Column(String, nullable=False)
size_bytes = Column(Integer, default=0)
checksum = Column(String, nullable=True)
type = Column(String, nullable=False) # full, incremental
parent_backup_id = Column(
Integer, ForeignKey("backups.id"), nullable=True, index=True
)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
execution = relationship("JobExecution", back_populates="backups")
parent_backup = relationship("Backup", remote_side=[id])
def __repr__(self):
return f"<Backup(id={self.id}, execution_id={self.execution_id}, type='{self.type}')>"
class Setting(Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(Text, nullable=True)
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def __repr__(self):
return f"<Setting(key='{self.key}', value='{self.value}')>"
Binary file not shown.
-34
View File
@@ -1,34 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models import Backup
from app.schemas import Backup as BackupSchema
router = APIRouter(prefix="/api/backups", tags=["backups"])
@router.get("/", response_model=List[BackupSchema])
async def list_backups(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Backup).order_by(Backup.created_at.desc()))
backups = result.scalars().all()
return backups
@router.get("/{backup_id}", response_model=BackupSchema)
async def get_backup(backup_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Backup).where(Backup.id == backup_id))
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(status_code=404, detail="Backup not found")
return backup
@router.delete("/{backup_id}")
async def delete_backup(backup_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Backup).where(Backup.id == backup_id))
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(status_code=404, detail="Backup not found")
await db.delete(backup)
await db.commit()
return {"message": "Backup deleted"}
-54
View File
@@ -1,54 +0,0 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from datetime import datetime, timezone, timedelta
from typing import List
from app.database import get_db
from app.models import Job, JobExecution, Backup
from app.schemas import DashboardStats, JobExecution as JobExecutionSchema
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/", response_model=DashboardStats)
async def get_dashboard_stats(db: AsyncSession = Depends(get_db)):
# Active jobs count (enabled jobs)
active_jobs_result = await db.execute(
select(func.count(Job.id)).where(Job.enabled == True)
)
active_jobs = active_jobs_result.scalar() or 0
# Total backups count
total_backups_result = await db.execute(select(func.count(Backup.id)))
total_backups = total_backups_result.scalar() or 0
# Storage used bytes (sum of all backup sizes)
storage_used_result = await db.execute(select(func.sum(Backup.size_bytes)))
storage_used_bytes = storage_used_result.scalar() or 0
# Recent failures count (last 24 hours)
twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
recent_failures_result = await db.execute(
select(func.count(JobExecution.id)).where(
and_(
JobExecution.status == "failed",
JobExecution.completed_at >= twenty_four_hours_ago,
)
)
)
recent_failures = recent_failures_result.scalar() or 0
# Recent executions (last 10, ordered by started_at desc)
recent_executions_result = await db.execute(
select(JobExecution).order_by(JobExecution.started_at.desc()).limit(10)
)
recent_executions = recent_executions_result.scalars().all()
return DashboardStats(
active_jobs=active_jobs,
total_backups=total_backups,
storage_used_bytes=storage_used_bytes,
recent_failures=recent_failures,
recent_executions=list(recent_executions),
)
-23
View File
@@ -1,23 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models import JobExecution
from app.schemas import JobExecution as JobExecutionSchema
router = APIRouter(prefix="/api/executions", tags=["executions"])
@router.get("/", response_model=List[JobExecutionSchema])
async def list_executions(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JobExecution).order_by(JobExecution.started_at.desc()))
executions = result.scalars().all()
return executions
@router.get("/{execution_id}", response_model=JobExecutionSchema)
async def get_execution(execution_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JobExecution).where(JobExecution.id == execution_id))
execution = result.scalar_one_or_none()
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
return execution
-101
View File
@@ -1,101 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db, AsyncSessionLocal
from app.models import Job, Schedule
from app.schemas import JobCreate, JobUpdate, Job as JobSchema, ScheduleCreate, Schedule as ScheduleSchema
from backup.engine import BackupEngine
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@router.get("/", response_model=List[JobSchema])
async def list_jobs(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Job))
jobs = result.scalars().all()
return jobs
@router.post("/", response_model=JobSchema)
async def create_job(job: JobCreate, db: AsyncSession = Depends(get_db)):
db_job = Job(**job.model_dump())
db.add(db_job)
await db.commit()
await db.refresh(db_job)
return db_job
@router.get("/{job_id}", response_model=JobSchema)
async def get_job(job_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@router.put("/{job_id}", response_model=JobSchema)
async def update_job(
job_id: int,
job_update: JobUpdate,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
update_data = job_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(job, field, value)
await db.commit()
await db.refresh(job)
return job
@router.delete("/{job_id}")
async def delete_job(job_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
await db.delete(job)
await db.commit()
return {"message": "Job deleted"}
@router.post("/{job_id}/run")
async def run_job(
job_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# Run in background
async def execute():
async with AsyncSessionLocal() as session:
engine = BackupEngine(session)
await engine.execute_job(job_id, triggered_by="manual")
background_tasks.add_task(execute)
return {"message": "Job execution started"}
@router.post("/{job_id}/schedule", response_model=ScheduleSchema)
async def create_schedule(
job_id: int,
schedule: ScheduleCreate,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
schedule_data = schedule.model_dump()
schedule_data["job_id"] = job_id
db_schedule = Schedule(**schedule_data)
db.add(db_schedule)
await db.commit()
await db.refresh(db_schedule)
return ScheduleSchema.model_validate(db_schedule)
-43
View File
@@ -1,43 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models import Setting
from app.schemas import Setting as SettingSchema, SettingUpdate
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("/", response_model=List[SettingSchema])
async def list_settings(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Setting))
settings = result.scalars().all()
return settings
@router.get("/{key}", response_model=SettingSchema)
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if not setting:
raise HTTPException(status_code=404, detail="Setting not found")
return setting
@router.put("/{key}", response_model=SettingSchema)
async def update_setting(
key: str,
setting_update: SettingUpdate,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if not setting:
# Create if not exists
setting = Setting(key=key, value=setting_update.value)
db.add(setting)
else:
setting.value = setting_update.value
await db.commit()
await db.refresh(setting)
return setting
-61
View File
@@ -1,61 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models import Source
from app.schemas import SourceCreate, SourceUpdate, Source as SourceSchema
router = APIRouter(prefix="/api/sources", tags=["sources"])
@router.get("/", response_model=List[SourceSchema])
async def list_sources(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Source))
sources = result.scalars().all()
return sources
@router.post("/", response_model=SourceSchema)
async def create_source(source: SourceCreate, db: AsyncSession = Depends(get_db)):
db_source = Source(**source.model_dump())
db.add(db_source)
await db.commit()
await db.refresh(db_source)
return db_source
@router.get("/{source_id}", response_model=SourceSchema)
async def get_source(source_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Source).where(Source.id == source_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source not found")
return source
@router.put("/{source_id}", response_model=SourceSchema)
async def update_source(
source_id: int,
source_update: SourceUpdate,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Source).where(Source.id == source_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source not found")
update_data = source_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(source, field, value)
await db.commit()
await db.refresh(source)
return source
@router.delete("/{source_id}")
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Source).where(Source.id == source_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source not found")
await db.delete(source)
await db.commit()
return {"message": "Source deleted"}
-143
View File
@@ -1,143 +0,0 @@
from pydantic import BaseModel, Field, ConfigDict, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
import re
# Source schemas
class SourceBase(BaseModel):
name: str
type: str = Field(..., pattern="^(local|ssh|database)$")
config: Dict[str, Any] = Field(default_factory=dict)
class SourceCreate(SourceBase):
pass
class SourceUpdate(BaseModel):
name: Optional[str] = None
config: Optional[Dict[str, Any]] = None
class Source(SourceBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Job schemas
class JobBase(BaseModel):
name: str
source_id: int
strategy: str = Field(default="full", pattern="^(full|incremental)$")
destination_path: str
exclude_patterns: List[str] = Field(default_factory=list)
enabled: bool = True
class JobCreate(JobBase):
pass
class JobUpdate(BaseModel):
name: Optional[str] = None
strategy: Optional[str] = None
destination_path: Optional[str] = None
exclude_patterns: Optional[List[str]] = None
enabled: Optional[bool] = None
class Job(JobBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Schedule schemas
class ScheduleBase(BaseModel):
job_id: int
cron_expression: str
enabled: bool = True
@field_validator('cron_expression')
@classmethod
def validate_cron(cls, v: str) -> str:
pattern = r'^([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)$'
if not re.match(pattern, v):
raise ValueError('Invalid cron expression format')
return v
class ScheduleCreate(ScheduleBase):
pass
class ScheduleUpdate(BaseModel):
cron_expression: Optional[str] = None
enabled: Optional[bool] = None
class Schedule(ScheduleBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Execution schemas
class JobExecutionBase(BaseModel):
job_id: int
status: str = Field(default="pending", pattern="^(pending|running|success|failed|cancelled)$")
triggered_by: str = Field(..., pattern="^(manual|schedule)$")
class JobExecutionCreate(JobExecutionBase):
pass
class JobExecution(JobExecutionBase):
id: int
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
bytes_processed: int = 0
bytes_backed_up: int = 0
error_message: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class JobExecutionUpdate(BaseModel):
status: Optional[str] = Field(None, pattern="^(pending|running|success|failed|cancelled)$")
error_message: Optional[str] = None
# Backup schemas
class BackupBase(BaseModel):
execution_id: int
storage_path: str
size_bytes: int = 0
checksum: Optional[str] = None
type: str = Field(..., pattern="^(full|incremental)$")
parent_backup_id: Optional[int] = None
class BackupCreate(BackupBase):
pass
class Backup(BackupBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# Settings schemas
class SettingBase(BaseModel):
key: str
value: Optional[str] = None
class SettingCreate(SettingBase):
pass
class SettingUpdate(BaseModel):
value: Optional[str] = None
class Setting(SettingBase):
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Dashboard schemas
class DashboardStats(BaseModel):
active_jobs: int
total_backups: int
storage_used_bytes: int
recent_failures: int
recent_executions: List[JobExecution]
Binary file not shown.
-17
View File
@@ -1,17 +0,0 @@
from typing import Dict, Any
from .base import SourceAdapter
from .local import LocalAdapter
from .ssh import SSHAdapter
from .database import DatabaseAdapter
ADAPTER_MAP = {
"local": LocalAdapter,
"ssh": SSHAdapter,
"database": DatabaseAdapter,
}
def get_adapter(source_type: str, config: Dict[str, Any]) -> SourceAdapter:
adapter_class = ADAPTER_MAP.get(source_type)
if not adapter_class:
raise ValueError(f"Unknown source type: {source_type}")
return adapter_class(config)
-39
View File
@@ -1,39 +0,0 @@
from abc import ABC, abstractmethod
from typing import List, Dict, Any, AsyncIterator
from dataclasses import dataclass
@dataclass
class FileInfo:
path: str
size: int
modified_time: float
is_directory: bool
class SourceAdapter(ABC):
def __init__(self, config: Dict[str, Any]):
self.config = config
@abstractmethod
async def connect(self) -> None:
"""Establish connection to source."""
pass
@abstractmethod
async def disconnect(self) -> None:
"""Close connection to source."""
pass
@abstractmethod
async def list_files(self, path: str = "") -> List[FileInfo]:
"""List files at given path."""
pass
@abstractmethod
async def read_file(self, path: str) -> AsyncIterator[bytes]:
"""Read file in chunks."""
pass
@abstractmethod
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
"""Get database dump. Only implemented for database adapters."""
pass
-74
View File
@@ -1,74 +0,0 @@
import os
import subprocess
import tempfile
from typing import List, AsyncIterator, Dict, Any
from .base import SourceAdapter, FileInfo
class DatabaseAdapter(SourceAdapter):
async def connect(self) -> None:
pass
async def disconnect(self) -> None:
pass
async def list_files(self, path: str = "") -> List[FileInfo]:
return []
async def read_file(self, path: str) -> AsyncIterator[bytes]:
raise NotImplementedError("Database adapter does not support file reading")
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
db_type = config.get("db_type", "postgresql")
host = config.get("host", "localhost")
port = config.get("port", 5432 if db_type == "postgresql" else 3306)
database = config.get("database")
username = config.get("username")
password = config.get("password")
if not database:
raise ValueError("Database name is required")
with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
tmp_path = tmp.name
try:
if db_type == "postgresql":
env = os.environ.copy()
if password:
env["PGPASSWORD"] = password
cmd = [
"pg_dump",
"-h", host,
"-p", str(port),
"-U", username or "postgres",
"-f", tmp_path,
database
]
elif db_type == "mysql":
env = os.environ.copy()
if password:
env["MYSQL_PWD"] = password
cmd = [
"mysqldump",
"-h", host,
"-P", str(port),
"-u", username or "root",
"--result-file", tmp_path,
database
]
else:
raise ValueError(f"Unsupported database type: {db_type}")
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Database dump failed: {result.stderr}")
with open(tmp_path, "rb") as f:
while chunk := f.read(8192):
yield chunk
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
-47
View File
@@ -1,47 +0,0 @@
import os
import aiofiles
from pathlib import Path
from typing import List, AsyncIterator, Dict, Any
from .base import SourceAdapter, FileInfo
class LocalAdapter(SourceAdapter):
async def connect(self) -> None:
base_path = self.config.get("path", ".")
if not os.path.exists(base_path):
raise FileNotFoundError(f"Path not found: {base_path}")
async def disconnect(self) -> None:
pass
async def list_files(self, path: str = "") -> List[FileInfo]:
base_path = Path(self.config.get("path", "."))
target_path = base_path / path if path else base_path
files = []
exclude_patterns = self.config.get("exclude", [])
for item in target_path.iterdir():
# Check exclude patterns
if any(item.match(pattern) for pattern in exclude_patterns):
continue
stat = item.stat()
files.append(FileInfo(
path=str(item.relative_to(base_path)),
size=stat.st_size,
modified_time=stat.st_mtime,
is_directory=item.is_dir()
))
return files
async def read_file(self, path: str) -> AsyncIterator[bytes]:
base_path = Path(self.config.get("path", "."))
file_path = base_path / path
async with aiofiles.open(file_path, "rb") as f:
while chunk := await f.read(8192):
yield chunk
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
raise NotImplementedError("Local adapter does not support database dumps")
-92
View File
@@ -1,92 +0,0 @@
import os
import tempfile
from pathlib import Path
from typing import List, AsyncIterator, Dict, Any
import paramiko
from .base import SourceAdapter, FileInfo
class SSHAdapter(SourceAdapter):
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.client = None
self.sftp = None
async def connect(self) -> None:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
host = self.config.get("host", "localhost")
port = self.config.get("port", 22)
username = self.config.get("username")
password = self.config.get("password")
key_path = self.config.get("key_path")
connect_kwargs = {
"hostname": host,
"port": port,
"username": username,
}
if password:
connect_kwargs["password"] = password
elif key_path and os.path.exists(key_path):
connect_kwargs["key_filename"] = key_path
self.client.connect(**connect_kwargs)
self.sftp = self.client.open_sftp()
async def disconnect(self) -> None:
if self.sftp:
self.sftp.close()
self.sftp = None
if self.client:
self.client.close()
self.client = None
async def list_files(self, path: str = "") -> List[FileInfo]:
remote_path = self.config.get("path", ".")
target_path = f"{remote_path}/{path}" if path else remote_path
files = []
exclude_patterns = self.config.get("exclude", [])
try:
for entry in self.sftp.listdir_attr(target_path):
entry_path = f"{target_path}/{entry.filename}"
rel_path = entry_path.replace(remote_path + "/", "", 1) if remote_path != "." else entry_path
if any(pattern in rel_path for pattern in exclude_patterns):
continue
is_dir = entry.st_mode & 0o40000 == 0o40000 if hasattr(entry, 'st_mode') else False
files.append(FileInfo(
path=rel_path,
size=entry.st_size,
modified_time=entry.st_mtime,
is_directory=is_dir
))
except IOError:
pass
return files
async def read_file(self, path: str) -> AsyncIterator[bytes]:
remote_path = self.config.get("path", ".")
file_path = f"{remote_path}/{path}" if not path.startswith("/") else path
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
self.sftp.get(file_path, tmp_path)
with open(tmp_path, "rb") as f:
while chunk := f.read(8192):
yield chunk
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
raise NotImplementedError("SSH adapter does not support database dumps directly")
-138
View File
@@ -1,138 +0,0 @@
import os
import hashlib
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models import Job, JobExecution, Backup
from backup.adapters import get_adapter
from backup.retention import RetentionPolicy
class BackupEngine:
def __init__(self, db: AsyncSession):
self.db = db
async def execute_job(self, job_id: int, triggered_by: str = "manual") -> JobExecution:
# Create execution record
execution = JobExecution(
job_id=job_id,
status="pending",
triggered_by=triggered_by
)
self.db.add(execution)
await self.db.commit()
await self.db.refresh(execution)
try:
# Load job with source
result = await self.db.execute(
select(Job).where(Job.id == job_id)
)
job = result.scalar_one()
# Update status to running
execution.status = "running"
execution.started_at = datetime.now(timezone.utc)
await self.db.commit()
# Determine strategy
strategy = job.strategy
parent_backup_id = None
if strategy == "incremental":
# Find last successful full backup
result = await self.db.execute(
select(Backup)
.join(JobExecution)
.where(
JobExecution.job_id == job_id,
JobExecution.status == "success",
Backup.type == "full"
)
.order_by(Backup.created_at.desc())
)
last_full = result.scalar_one_or_none()
if last_full:
parent_backup_id = last_full.id
else:
# No full backup exists, do full instead
strategy = "full"
# Create backup directory
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S")
backup_dir = Path(job.destination_path) / str(job_id) / f"{timestamp}_{strategy}"
backup_dir.mkdir(parents=True, exist_ok=True)
# Get adapter and connect
adapter = get_adapter(job.source.type, job.source.config)
await adapter.connect()
try:
# Copy files
total_processed = 0
total_backed_up = 0
source_path = Path(job.source.config.get("path", "."))
for item in source_path.rglob("*"):
if item.is_file():
rel_path = item.relative_to(source_path)
dest_path = backup_dir / "data" / rel_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
# Copy file
shutil.copy2(item, dest_path)
size = item.stat().st_size
total_processed += size
total_backed_up += size
# Calculate checksum
checksum = await self._calculate_checksum(backup_dir)
# Create backup record
backup = Backup(
execution_id=execution.id,
storage_path=str(backup_dir),
size_bytes=total_backed_up,
checksum=checksum,
type=strategy,
parent_backup_id=parent_backup_id
)
self.db.add(backup)
# Update execution
execution.status = "success"
execution.completed_at = datetime.now(timezone.utc)
execution.bytes_processed = total_processed
execution.bytes_backed_up = total_backed_up
# Apply retention policy
retention = RetentionPolicy(self.db)
keep_count = getattr(job, 'retention_count', None)
keep_days = getattr(job, 'retention_days', None)
if keep_count or keep_days:
await retention.apply_retention_for_job(job_id, keep_count, keep_days)
finally:
await adapter.disconnect()
except Exception as e:
execution.status = "failed"
execution.completed_at = datetime.now(timezone.utc)
execution.error_message = str(e)
await self.db.commit()
return execution
async def _calculate_checksum(self, path: Path) -> str:
hasher = hashlib.sha256()
for item in sorted(path.rglob("*")):
if item.is_file():
with open(item, "rb") as f:
while chunk := f.read(8192):
hasher.update(chunk)
return hasher.hexdigest()
-90
View File
@@ -1,90 +0,0 @@
import shutil
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import List, Optional
from sqlalchemy import select
from app.models import Backup
class RetentionPolicy:
def __init__(self, db):
self.db = db
async def apply_retention_for_job(
self,
job_id: int,
keep_count: Optional[int] = None,
keep_days: Optional[int] = None
) -> List[Backup]:
"""
Apply retention policy for a job's backups.
Args:
job_id: The job ID to apply retention for
keep_count: Maximum number of backups to keep (oldest deleted first)
keep_days: Delete backups older than this many days
Returns:
List of deleted backups
"""
deleted_backups = []
result = await self.db.execute(
select(Backup)
.where(Backup.execution.has(job_id=job_id))
.order_by(Backup.created_at.asc())
)
backups = result.scalars().all()
if not backups:
return deleted_backups
backups_to_delete = set()
if keep_count is not None and len(backups) > keep_count:
backups_to_delete.update(backups[:-keep_count])
if keep_days is not None:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=keep_days)
for backup in backups:
if backup.created_at < cutoff_date:
backups_to_delete.add(backup)
for backup in list(backups_to_delete):
await self._delete_backup(backup)
deleted_backups.append(backup)
await self.db.commit()
return deleted_backups
async def _delete_backup(self, backup: Backup):
"""Delete a backup and its storage."""
try:
storage_path = Path(backup.storage_path)
if storage_path.exists():
shutil.rmtree(storage_path)
except Exception:
pass
await self.db.delete(backup)
async def cleanup_orphaned_backups(self) -> int:
"""
Remove backup records whose storage no longer exists.
Returns:
Number of orphaned backups removed
"""
result = await self.db.execute(select(Backup))
backups = result.scalars().all()
removed_count = 0
for backup in backups:
storage_path = Path(backup.storage_path)
if not storage_path.exists():
await self.db.delete(backup)
removed_count += 1
await self.db.commit()
return removed_count
-81
View File
@@ -1,81 +0,0 @@
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from typing import Optional
import logging
from app.database import AsyncSessionLocal
from app.models import Schedule
from backup.engine import BackupEngine
logger = logging.getLogger(__name__)
class BackupScheduler:
def __init__(self):
self.scheduler = AsyncIOScheduler()
self._job_map = {}
def start(self):
"""Start the scheduler."""
self.scheduler.start()
logger.info("Backup scheduler started")
def shutdown(self):
"""Shutdown the scheduler."""
self.scheduler.shutdown()
logger.info("Backup scheduler shutdown")
async def sync_schedules(self):
"""Sync all enabled schedules from database."""
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Schedule).where(Schedule.enabled == True)
)
schedules = result.scalars().all()
# Clear existing jobs
for schedule_id, job_id in list(self._job_map.items()):
self.scheduler.remove_job(job_id)
del self._job_map[schedule_id]
# Add new jobs
for schedule in schedules:
await self._add_schedule_job(schedule)
async def _add_schedule_job(self, schedule: Schedule):
"""Add a single schedule job to the scheduler."""
try:
trigger = CronTrigger.from_crontab(schedule.cron_expression)
job = self.scheduler.add_job(
self._run_backup_job,
trigger=trigger,
args=[schedule.job_id],
id=f"backup_job_{schedule.job_id}",
replace_existing=True
)
self._job_map[schedule.id] = job.id
logger.info(f"Scheduled backup job {schedule.job_id} with cron: {schedule.cron_expression}")
except Exception as e:
logger.error(f"Failed to schedule job {schedule.job_id}: {e}")
async def _run_backup_job(self, job_id: int):
"""Execute a backup job."""
logger.info(f"Running scheduled backup job {job_id}")
async with AsyncSessionLocal() as db:
engine = BackupEngine(db)
await engine.execute_job(job_id, triggered_by="schedule")
async def add_schedule(self, schedule: Schedule):
"""Add a new schedule to the scheduler."""
await self._add_schedule_job(schedule)
def remove_schedule(self, schedule_id: int):
"""Remove a schedule from the scheduler."""
if schedule_id in self._job_map:
self.scheduler.remove_job(self._job_map[schedule_id])
del self._job_map[schedule_id]
# Global scheduler instance
backup_scheduler = BackupScheduler()
Binary file not shown.
+43 -48
View File
@@ -1,68 +1,63 @@
[build-system] [build-system]
requires = ["setuptools>=61.0", "wheel"] requires = ["setuptools==80.9.0", "wheel==0.46.3"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "backup-tool" name = "backup-tool"
version = "0.1.0" version = "2.0.0.dev0"
description = "Web-based backup management tool for small teams and SMBs" description = "Self-hosted, integrity-first backup appliance"
readme = "README.md" license = "MIT"
license = {text = "MIT"} requires-python = ">=3.12,<3.15"
requires-python = ">=3.11" authors = [{ name = "Backup Tool Team" }]
authors = [
{name = "Backup Tool Team"}
]
keywords = ["backup", "restore", "scheduler", "web"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: System :: Archiving :: Backup",
]
dependencies = [ dependencies = [
"fastapi>=0.115.0", "aiofiles==25.1.0",
"uvicorn[standard]>=0.34.0", "aiosqlite==0.22.1",
"sqlalchemy[asyncio]>=2.0.0", "alembic==1.18.5",
"aiosqlite>=0.21.0", "apscheduler==3.11.3",
"alembic>=1.15.0", "fastapi==0.136.1",
"pydantic>=2.13.0", "httpx==0.28.1",
"pydantic-settings>=2.9.0", "paramiko==5.0.0",
"apscheduler>=3.11.0", "pydantic==2.13.4",
"paramiko>=3.5.0", "pydantic-settings==2.14.2",
"aiofiles>=23.2.0", "sqlalchemy[asyncio]==2.0.49",
"httpx>=0.28.0", "uvicorn[standard]==0.51.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.3.0", "jsonschema==4.26.0",
"pytest-asyncio>=0.26.0", "mypy==2.3.0",
] "pytest==9.0.3",
prod = [ "pytest-asyncio==1.3.0",
"gunicorn>=23.0.0", "ruff==0.16.0",
] ]
prod = ["gunicorn==23.0.0"]
[project.scripts] [project.scripts]
backup-tool = "app.main:main" backup-tool = "backup_tool.cli:main"
[project.urls] [tool.setuptools]
Homepage = "https://github.com/backup-tool/backup-tool" package-dir = { "" = "src" }
Documentation = "https://github.com/backup-tool/backup-tool#readme"
Repository = "https://github.com/backup-tool/backup-tool.git"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["src"]
include = ["app*", "backup*", "alembic*"] include = ["backup_tool*"]
[tool.setuptools.package-data]
backup_tool = ["py.typed"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = [".", "app", "backup"]
[tool.setuptools.package-data] [tool.ruff]
alembic = ["*.ini", "*.py", "*.mako"] target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.mypy]
python_version = "3.12"
strict = true
packages = ["backup_tool"]
mypy_path = "src"
+3
View File
@@ -0,0 +1,3 @@
"""Backup Tool v2 package."""
__version__ = "2.0.0.dev0"
+23
View File
@@ -0,0 +1,23 @@
"""Minimal v2 command entry point; runtime roles arrive in M1."""
from __future__ import annotations
import argparse
from collections.abc import Sequence
from backup_tool import __version__
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="backup-tool")
parser.add_argument("--version", action="version", version=__version__)
return parser
def main(argv: Sequence[str] | None = None) -> int:
build_parser().parse_args(argv)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-33
View File
@@ -1,33 +0,0 @@
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.database import Base, get_db
from app.main import app
from httpx import AsyncClient, ASGITransport
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture
async def db():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
finally:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def client(db):
async def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
-82
View File
@@ -1,82 +0,0 @@
import pytest
import pytest_asyncio
import tempfile
import os
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Source, Job, JobExecution
from backup.engine import BackupEngine
@pytest_asyncio.fixture
async def test_source(db: AsyncSession):
with tempfile.TemporaryDirectory() as tmpdir:
# Create test files
(Path(tmpdir) / "test.txt").write_text("Hello, World!")
(Path(tmpdir) / "subdir").mkdir()
(Path(tmpdir) / "subdir" / "nested.txt").write_text("Nested content")
source = Source(
name="Test Source",
type="local",
config={"path": tmpdir}
)
db.add(source)
await db.commit()
await db.refresh(source)
yield source
@pytest_asyncio.fixture
async def test_job(db: AsyncSession, test_source):
with tempfile.TemporaryDirectory() as tmpdir:
job = Job(
name="Test Job",
source_id=test_source.id,
strategy="full",
destination_path=tmpdir
)
db.add(job)
await db.commit()
await db.refresh(job)
yield job
@pytest.mark.asyncio
async def test_execute_full_backup(db: AsyncSession, test_job):
engine = BackupEngine(db)
execution = await engine.execute_job(test_job.id, triggered_by="manual")
assert execution.status == "success"
assert execution.bytes_processed > 0
assert execution.bytes_backed_up > 0
assert execution.triggered_by == "manual"
# Refresh test_job to load executions relationship
await db.refresh(test_job, ["executions"])
# Verify backup was created
assert len(test_job.executions) == 1
# Refresh execution to load backups relationship
await db.refresh(test_job.executions[0], ["backups"])
backup = test_job.executions[0].backups[0]
assert backup.type == "full"
assert backup.checksum is not None
assert os.path.exists(backup.storage_path)
@pytest.mark.asyncio
async def test_execute_incremental_without_full(db: AsyncSession, test_job):
# Set job to incremental but no full backup exists
test_job.strategy = "incremental"
await db.commit()
engine = BackupEngine(db)
execution = await engine.execute_job(test_job.id)
# Should fall back to full backup
assert execution.status == "success"
# Refresh execution to load backups relationship
await db.refresh(execution, ["backups"])
backup = execution.backups[0]
assert backup.type == "full"
assert backup.parent_backup_id is None
-205
View File
@@ -1,205 +0,0 @@
import pytest
@pytest.mark.asyncio
async def test_create_job(client):
# Create a source first (job requires source_id)
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
assert source_resp.status_code == 200
source_id = source_resp.json()["id"]
response = await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Job"
assert data["source_id"] == source_id
assert data["strategy"] == "full"
assert "id" in data
@pytest.mark.asyncio
async def test_list_jobs(client):
# Create source and job
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
response = await client.get("/api/jobs/")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
@pytest.mark.asyncio
async def test_get_job(client):
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
job_resp = await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.get(f"/api/jobs/{job_id}")
assert response.status_code == 200
assert response.json()["id"] == job_id
@pytest.mark.asyncio
async def test_delete_job(client):
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
job_resp = await client.post("/api/jobs/", json={
"name": "Delete Me",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.delete(f"/api/jobs/{job_id}")
assert response.status_code == 200
# Verify deletion
get_resp = await client.get(f"/api/jobs/{job_id}")
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_run_job(client):
# Create source
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.post(f"/api/jobs/{job_id}/run")
assert response.status_code == 200
assert response.json()["message"] == "Job execution started"
@pytest.mark.asyncio
async def test_run_job_not_found(client):
response = await client.post("/api/jobs/999/run")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_job(client):
# Create source
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await client.post("/api/jobs/", json={
"name": "Original Name",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.put(f"/api/jobs/{job_id}", json={
"name": "Updated Name"
})
assert response.status_code == 200
assert response.json()["name"] == "Updated Name"
assert response.json()["strategy"] == "full" # Unchanged
@pytest.mark.asyncio
async def test_create_schedule(client):
# Create source
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.post(f"/api/jobs/{job_id}/schedule", json={
"job_id": job_id,
"cron_expression": "0 0 * * *",
"enabled": True
})
assert response.status_code == 200
data = response.json()
assert data["job_id"] == job_id
assert data["cron_expression"] == "0 0 * * *"
assert "id" in data
@pytest.mark.asyncio
async def test_get_job_not_found(client):
response = await client.get("/api/jobs/99999")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_job_not_found(client):
response = await client.put("/api/jobs/99999", json={"name": "Test"})
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_job_not_found(client):
response = await client.delete("/api/jobs/99999")
assert response.status_code == 404
-88
View File
@@ -1,88 +0,0 @@
import pytest
@pytest.mark.asyncio
async def test_create_source(client):
response = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Source"
assert data["type"] == "local"
assert "id" in data
@pytest.mark.asyncio
async def test_list_sources(client):
# Create source first
await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
response = await client.get("/api/sources/")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
@pytest.mark.asyncio
async def test_get_source(client):
create_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = create_resp.json()["id"]
response = await client.get(f"/api/sources/{source_id}")
assert response.status_code == 200
assert response.json()["id"] == source_id
@pytest.mark.asyncio
async def test_delete_source(client):
create_resp = await client.post("/api/sources/", json={
"name": "Delete Me",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = create_resp.json()["id"]
response = await client.delete(f"/api/sources/{source_id}")
assert response.status_code == 200
# Verify deletion
get_resp = await client.get(f"/api/sources/{source_id}")
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_update_source(client):
create_resp = await client.post("/api/sources/", json={
"name": "Original Name",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = create_resp.json()["id"]
response = await client.put(f"/api/sources/{source_id}", json={
"name": "Updated Name"
})
assert response.status_code == 200
assert response.json()["name"] == "Updated Name"
assert response.json()["type"] == "local" # Unchanged
@pytest.mark.asyncio
async def test_get_source_not_found(client):
response = await client.get("/api/sources/99999")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_source_not_found(client):
response = await client.put("/api/sources/99999", json={"name": "Test"})
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_source_not_found(client):
response = await client.delete("/api/sources/99999")
assert response.status_code == 404
@@ -0,0 +1,14 @@
{
"api_version": "v2",
"sources": ["local", "ssh"],
"repositories": ["local"],
"features": {
"email": true,
"encryption": true,
"mysql": false,
"postgresql": false,
"restore": true,
"tar_download": false,
"webhook": true
}
}
+22
View File
@@ -0,0 +1,22 @@
[
"authentication_failed",
"baseline_missing",
"cancelled_by_operator",
"capacity_exhausted",
"conflict_active_execution",
"corrupt_blob",
"corrupt_manifest",
"deletion_failed",
"forbidden",
"host_key_changed",
"host_key_unknown",
"invalid_configuration",
"not_found",
"permission_denied",
"source_changed",
"source_empty",
"timeout",
"transient_io",
"unsupported_entry",
"worker_lost"
]
@@ -0,0 +1,10 @@
{
"queued": ["cancelled", "preparing"],
"preparing": ["cancelling", "failed", "running"],
"running": ["cancelling", "failed", "verifying"],
"verifying": ["committed", "failed"],
"cancelling": ["cancelled", "failed"],
"committed": [],
"failed": [],
"cancelled": []
}
+14
View File
@@ -0,0 +1,14 @@
[
"blob.before_write",
"blob.after_write",
"blob.after_fsync",
"blob.before_rename",
"manifest.before_write",
"manifest.after_fsync",
"manifest.before_publish",
"metadata.before_commit",
"metadata.after_commit",
"restore.before_write",
"restore.after_fsync",
"restore.before_replace"
]
@@ -0,0 +1 @@
{"aggregates":{"entry_count":-1,"logical_bytes":-5,"stored_bytes":0},"backup_id":"bad","created_at":"not-a-time","effective_mode":"full","encryption_key_id":null,"entries":[],"exclusion_policy":{"matcher":"glob","patterns":[],"version":0},"execution_id":"bad","format_version":2,"job_id":"bad","manifest_digest":"short","repository_id":"bad","requested_mode":"full","source_consistency":{},"source_id":"bad"}
@@ -0,0 +1 @@
{"compression":"none","created_at":"2026-07-27T00:00:00Z","digest_algorithm":"md5","encryption":{"key_id":null,"mode":"none"},"format_version":2,"repository_id":"not-a-uuid"}
@@ -0,0 +1 @@
{"aggregates":{"entry_count":2,"logical_bytes":5,"stored_bytes":5},"backup_id":"0198c57f-0000-7000-8000-000000000006","created_at":"2026-07-27T00:00:00Z","effective_mode":"full","encryption_key_id":null,"entries":[{"blob_digest":null,"link_target":null,"metadata_support":["mode","mtime_ns"],"mode":493,"mtime_ns":0,"path":"data","size":0,"type":"directory"},{"blob_digest":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","link_target":null,"metadata_support":["mode","mtime_ns"],"mode":420,"mtime_ns":0,"path":"data/hello.txt","size":5,"type":"file"}],"exclusion_policy":{"matcher":"gitignore","patterns":[],"version":1},"execution_id":"0198c57f-0000-7000-8000-000000000005","format_version":1,"job_id":"0198c57f-0000-7000-8000-000000000004","manifest_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","repository_id":"0198c57f-0000-7000-8000-000000000001","requested_mode":"full","source_consistency":{"adapter":"local","captured_at":"2026-07-27T00:00:00Z"},"source_id":"0198c57f-0000-7000-8000-000000000003"}
@@ -0,0 +1 @@
{"compression":"none","created_at":"2026-07-27T00:00:00Z","digest_algorithm":"sha256","encryption":{"key_id":null,"mode":"none"},"format_version":1,"repository_id":"0198c57f-0000-7000-8000-000000000001"}
@@ -0,0 +1,67 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://backup-tool.invalid/contracts/repository/v1/manifest.schema.json",
"title": "Backup Tool Manifest v1",
"type": "object",
"additionalProperties": false,
"required": [
"format_version", "backup_id", "repository_id", "source_id", "job_id", "execution_id",
"requested_mode", "effective_mode", "created_at", "source_consistency", "exclusion_policy",
"entries", "aggregates", "encryption_key_id", "manifest_digest"
],
"properties": {
"format_version": {"const": 1},
"backup_id": {"type": "string", "format": "uuid"},
"repository_id": {"type": "string", "format": "uuid"},
"source_id": {"type": "string", "format": "uuid"},
"job_id": {"type": "string", "format": "uuid"},
"execution_id": {"type": "string", "format": "uuid"},
"requested_mode": {"enum": ["full", "incremental"]},
"effective_mode": {"enum": ["full", "incremental"]},
"created_at": {"type": "string", "format": "date-time"},
"source_consistency": {"type": "object"},
"exclusion_policy": {
"type": "object",
"additionalProperties": false,
"required": ["matcher", "version", "patterns"],
"properties": {
"matcher": {"const": "gitignore"},
"version": {"type": "integer", "minimum": 1},
"patterns": {"type": "array", "items": {"type": "string"}}
}
},
"entries": {
"type": "array",
"items": {"$ref": "#/$defs/entry"}
},
"aggregates": {
"type": "object",
"additionalProperties": false,
"required": ["entry_count", "logical_bytes", "stored_bytes"],
"properties": {
"entry_count": {"type": "integer", "minimum": 0},
"logical_bytes": {"type": "integer", "minimum": 0},
"stored_bytes": {"type": "integer", "minimum": 0}
}
},
"encryption_key_id": {"type": ["string", "null"]},
"manifest_digest": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
},
"$defs": {
"entry": {
"type": "object",
"additionalProperties": false,
"required": ["path", "type", "size", "blob_digest", "mode", "mtime_ns", "link_target", "metadata_support"],
"properties": {
"path": {"type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\).+$"},
"type": {"enum": ["file", "directory", "symlink"]},
"size": {"type": "integer", "minimum": 0},
"blob_digest": {"type": ["string", "null"], "pattern": "^[0-9a-f]{64}$"},
"mode": {"type": ["integer", "null"], "minimum": 0},
"mtime_ns": {"type": ["integer", "null"], "minimum": 0},
"link_target": {"type": ["string", "null"]},
"metadata_support": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}
}
}
}
}
@@ -0,0 +1,9 @@
[
{"raw": "file.txt", "normalized": "file.txt", "valid": true},
{"raw": "dir/./file.txt", "normalized": "dir/file.txt", "valid": true},
{"raw": "unicodé/文件.txt", "normalized": "unicodé/文件.txt", "valid": true},
{"raw": "/absolute", "normalized": null, "valid": false},
{"raw": "../escape", "normalized": null, "valid": false},
{"raw": "a\\b", "normalized": null, "valid": false},
{"raw": "", "normalized": null, "valid": false}
]
@@ -0,0 +1,24 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://backup-tool.invalid/contracts/repository/v1/repository.schema.json",
"title": "Backup Tool Repository v1",
"type": "object",
"additionalProperties": false,
"required": ["repository_id", "format_version", "digest_algorithm", "compression", "encryption", "created_at"],
"properties": {
"repository_id": {"type": "string", "format": "uuid"},
"format_version": {"const": 1},
"digest_algorithm": {"const": "sha256"},
"compression": {"enum": ["none", "zstd"]},
"encryption": {
"type": "object",
"additionalProperties": false,
"required": ["mode", "key_id"],
"properties": {
"mode": {"enum": ["none", "aes-256-gcm"]},
"key_id": {"type": ["string", "null"], "minLength": 1}
}
},
"created_at": {"type": "string", "format": "date-time"}
}
}
+3670
View File
File diff suppressed because it is too large Load Diff
+21 -22
View File
@@ -1,36 +1,35 @@
{ {
"name": "backup-tool-frontend", "name": "backup-tool-frontend",
"private": true, "private": true,
"version": "0.0.1", "version": "2.0.0-dev.0",
"type": "module", "type": "module",
"packageManager": "npm@10.9.2",
"engines": {
"node": "22.17.1"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"react": "^18.2.0", "react": "19.2.8",
"react-dom": "^18.2.0", "react-dom": "19.2.8"
"react-router-dom": "^6.21.0",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"react-hook-form": "^7.49.0",
"recharts": "^2.10.0",
"lucide-react": "^0.303.0",
"clsx": "^2.1.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@testing-library/jest-dom": "7.0.0",
"@types/react-dom": "^18.2.17", "@testing-library/react": "16.3.2",
"@vitejs/plugin-react": "^4.2.1", "@types/react": "19.2.17",
"autoprefixer": "^10.4.16", "@types/react-dom": "19.2.3",
"postcss": "^8.4.32", "@vitejs/plugin-react": "6.0.4",
"tailwindcss": "^3.4.0", "autoprefixer": "10.5.4",
"typescript": "^5.2.2", "jsdom": "30.0.0",
"vite": "^5.0.8", "postcss": "8.5.23",
"vitest": "^1.1.0", "tailwindcss": "3.4.19",
"@testing-library/react": "^14.1.0", "typescript": "7.0.2",
"@testing-library/jest-dom": "^6.2.0", "vite": "8.1.5",
"jsdom": "^23.0.0" "vitest": "4.1.10"
} }
} }
-33
View File
@@ -1,33 +0,0 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Layout } from './components/Layout';
import { Dashboard } from './pages/Dashboard';
import { Backups } from './pages/Backups';
import { Settings } from './pages/Settings';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/backups" element={<Backups />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;
-62
View File
@@ -1,62 +0,0 @@
import axios from 'axios';
const API_BASE_URL = '/api';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
export interface BackupSource {
id: string;
name: string;
type: string;
config: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface BackupJob {
id: string;
source_id: string;
status: 'pending' | 'running' | 'completed' | 'failed';
started_at: string | null;
completed_at: string | null;
error_message: string | null;
created_at: string;
}
export interface DashboardStats {
total_sources: number;
total_jobs: number;
completed_jobs: number;
failed_jobs: number;
pending_jobs: number;
}
export const sourcesApi = {
getAll: () => apiClient.get<BackupSource[]>('/sources'),
getById: (id: string) => apiClient.get<BackupSource>(`/sources/${id}`),
create: (data: Omit<BackupSource, 'id' | 'created_at' | 'updated_at'>) =>
apiClient.post<BackupSource>('/sources', data),
update: (id: string, data: Partial<BackupSource>) =>
apiClient.put<BackupSource>(`/sources/${id}`, data),
delete: (id: string) => apiClient.delete(`/sources/${id}`),
};
export const jobsApi = {
getAll: () => apiClient.get<BackupJob[]>('/jobs'),
getById: (id: string) => apiClient.get<BackupJob>(`/jobs/${id}`),
create: (sourceId: string) =>
apiClient.post<BackupJob>('/jobs', { source_id: sourceId }),
delete: (id: string) => apiClient.delete(`/jobs/${id}`),
getLogs: (id: string) => apiClient.get<string>(`/jobs/${id}/logs`),
};
export const dashboardApi = {
getStats: () => apiClient.get<DashboardStats>('/dashboard/stats'),
getRecentJobs: (limit: number = 10) =>
apiClient.get<BackupJob[]>(`/dashboard/recent-jobs?limit=${limit}`),
};
View File
+16
View File
@@ -0,0 +1,16 @@
export function App() {
return (
<main className="min-h-screen bg-slate-950 p-8 text-slate-100">
<section className="mx-auto max-w-3xl rounded-xl border border-slate-800 bg-slate-900 p-8">
<p className="text-sm font-semibold uppercase tracking-widest text-emerald-400">
Backup Tool v2
</p>
<h1 className="mt-3 text-3xl font-bold">Protocol foundation ready</h1>
<p className="mt-4 text-slate-300">
Operator workflows are added as their versioned API contracts become
executable.
</p>
</section>
</main>
);
}
-80
View File
@@ -1,80 +0,0 @@
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Archive, Settings, Menu } from 'lucide-react';
import { useState } from 'react';
interface LayoutProps {
children: React.ReactNode;
}
const navItems = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/backups', label: 'Backups', icon: Archive },
{ path: '/settings', label: 'Settings', icon: Settings },
];
export function Layout({ children }: LayoutProps) {
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex h-screen bg-gray-50">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed lg:static inset-y-0 left-0 z-50 w-64 bg-white border-r border-gray-200 transform transition-transform duration-200 ease-in-out lg:transform-none ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center h-16 px-6 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">Backup Tool</h1>
</div>
<nav className="p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
return (
<Link
key={item.path}
to={item.path}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
);
})}
</nav>
</aside>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Mobile header */}
<header className="lg:hidden flex items-center h-16 px-4 bg-white border-b border-gray-200">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 -ml-2 text-gray-600 hover:bg-gray-100 rounded-lg"
>
<Menu className="w-6 h-6" />
</button>
<span className="ml-3 text-lg font-semibold text-gray-900">
Backup Tool
</span>
</header>
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}
+16 -9
View File
@@ -1,10 +1,17 @@
import React from 'react' import React from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from "react-dom/client";
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render( import { App } from "./app/App";
<React.StrictMode> import "./index.css";
<App />
</React.StrictMode>, const root = document.getElementById("root");
)
if (!root) {
throw new Error("Missing #root element");
}
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-238
View File
@@ -1,238 +0,0 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react';
import { jobsApi, sourcesApi } from '../api/client';
import type { BackupJob, BackupSource } from '../api/client';
function StatusBadge({ status }: { status: string }) {
const styles = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-800'
}`}
>
{status}
</span>
);
}
function CreateJobModal({
onClose,
}: {
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [sourceId, setSourceId] = useState('');
const { data: sources } = useQuery({
queryKey: ['sources'],
queryFn: () => sourcesApi.getAll().then((res) => res.data),
});
const createMutation = useMutation({
mutationFn: (sid: string) => jobsApi.create(sid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (sourceId) {
createMutation.mutate(sourceId);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Create Backup Job
</h3>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Source
</label>
<select
value={sourceId}
onChange={(e) => setSourceId(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select a source...</option>
{sources?.map((source: BackupSource) => (
<option key={source.id} value={source.id}>
{source.name} ({source.type})
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={!sourceId || createMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Creating...' : 'Create Job'}
</button>
</div>
</form>
</div>
</div>
);
}
export function Backups() {
const [showModal, setShowModal] = useState(false);
const queryClient = useQueryClient();
const { data: jobs, isLoading } = useQuery({
queryKey: ['jobs'],
queryFn: () => jobsApi.getAll().then((res) => res.data),
});
const { data: sources } = useQuery({
queryKey: ['sources'],
queryFn: () => sourcesApi.getAll().then((res) => res.data),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => jobsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
},
});
const runMutation = useMutation({
mutationFn: (id: string) => jobsApi.create(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
},
});
const getSourceName = (sourceId: string) => {
const source = sources?.find((s: BackupSource) => s.id === sourceId);
return source?.name || sourceId.slice(0, 8);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Backups</h2>
<button
onClick={() => setShowModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
New Job
</button>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Job ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Source
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading && (
<tr>
<td
colSpan={5}
className="px-6 py-8 text-center text-gray-500"
>
Loading...
</td>
</tr>
)}
{jobs?.length === 0 && !isLoading && (
<tr>
<td
colSpan={5}
className="px-6 py-8 text-center text-gray-500"
>
No backup jobs yet. Create one to get started.
</td>
</tr>
)}
{jobs?.map((job: BackupJob) => (
<tr key={job.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{job.id.slice(0, 8)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{getSourceName(job.source_id)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{job.started_at
? new Date(job.started_at).toLocaleString()
: 'Not started'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => runMutation.mutate(job.source_id)}
disabled={job.status === 'running'}
className="p-1 text-gray-600 hover:text-blue-600 disabled:opacity-50"
title="Run job"
>
<Play className="w-4 h-4" />
</button>
<button
onClick={() => {
if (confirm('Delete this job?')) {
deleteMutation.mutate(job.id);
}
}}
className="p-1 text-gray-600 hover:text-red-600"
title="Delete job"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && <CreateJobModal onClose={() => setShowModal(false)} />}
</div>
);
}
-141
View File
@@ -1,141 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Activity, Archive, AlertTriangle, CheckCircle } from 'lucide-react';
import { dashboardApi } from '../api/client';
function StatCard({
title,
value,
icon: Icon,
color,
}: {
title: string;
value: number;
icon: React.ElementType;
color: string;
}) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{value}</p>
</div>
<div className={`p-3 rounded-lg ${color}`}>
<Icon className="w-6 h-6 text-white" />
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-800'
}`}
>
{status}
</span>
);
}
export function Dashboard() {
const { data: stats } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: () => dashboardApi.getStats().then((res) => res.data),
});
const { data: recentJobs } = useQuery({
queryKey: ['recent-jobs'],
queryFn: () => dashboardApi.getRecentJobs(5).then((res) => res.data),
});
const activeJobs = stats?.pending_jobs || 0;
const recentFailures = stats?.failed_jobs || 0;
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Active Jobs"
value={activeJobs}
icon={Activity}
color="bg-blue-500"
/>
<StatCard
title="Total Backups"
value={stats?.total_jobs || 0}
icon={Archive}
color="bg-green-500"
/>
<StatCard
title="Completed"
value={stats?.completed_jobs || 0}
icon={CheckCircle}
color="bg-indigo-500"
/>
<StatCard
title="Recent Failures"
value={recentFailures}
icon={AlertTriangle}
color="bg-red-500"
/>
</div>
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Recent Activity
</h3>
</div>
<div className="divide-y divide-gray-200">
{recentJobs?.length === 0 && (
<div className="px-6 py-8 text-center text-gray-500">
No recent activity
</div>
)}
{recentJobs?.map((job) => (
<div
key={job.id}
className="px-6 py-4 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div
className={`w-2 h-2 rounded-full ${
job.status === 'completed'
? 'bg-green-500'
: job.status === 'failed'
? 'bg-red-500'
: job.status === 'running'
? 'bg-blue-500'
: 'bg-yellow-500'
}`}
/>
<div>
<p className="text-sm font-medium text-gray-900">
Job {job.id.slice(0, 8)}
</p>
<p className="text-xs text-gray-500">
{job.created_at
? new Date(job.created_at).toLocaleString()
: 'Unknown'}
</p>
</div>
</div>
<StatusBadge status={job.status} />
</div>
))}
</div>
</div>
</div>
);
}
-205
View File
@@ -1,205 +0,0 @@
import { useState } from 'react';
const tabs = [
{ id: 'general', label: 'General' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'security', label: 'Security' },
{ id: 'logs', label: 'Logs' },
];
function GeneralSettings() {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Backup Retention (days)
</label>
<input
type="number"
defaultValue={30}
className="w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Default Backup Strategy
</label>
<select
defaultValue="incremental"
className="w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="full">Full</option>
<option value="incremental">Incremental</option>
<option value="differential">Differential</option>
</select>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="auto-cleanup"
defaultChecked
className="w-4 h-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="auto-cleanup" className="text-sm text-gray-700">
Enable automatic cleanup of old backups
</label>
</div>
</div>
);
}
function NotificationSettings() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="email-notify"
defaultChecked
className="w-4 h-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="email-notify" className="text-sm text-gray-700">
Enable email notifications
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
placeholder="admin@example.com"
className="w-full max-w-md rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="notify-failures"
defaultChecked
className="w-4 h-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="notify-failures" className="text-sm text-gray-700">
Notify on backup failures only
</label>
</div>
</div>
);
}
function SecuritySettings() {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Encryption Key
</label>
<input
type="password"
placeholder="Enter encryption key"
className="w-full max-w-md rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="encrypt-backups"
className="w-4 h-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="encrypt-backups" className="text-sm text-gray-700">
Encrypt all backups
</label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="require-auth"
defaultChecked
className="w-4 h-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="require-auth" className="text-sm text-gray-700">
Require authentication for API access
</label>
</div>
</div>
);
}
function LogSettings() {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Log Level
</label>
<select
defaultValue="info"
className="w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Log Retention (days)
</label>
<input
type="number"
defaultValue={7}
className="w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="verbose-logs"
className="w-4 h-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="verbose-logs" className="text-sm text-gray-700">
Enable verbose logging
</label>
</div>
</div>
);
}
export function Settings() {
const [activeTab, setActiveTab] = useState('general');
const tabContent = {
general: <GeneralSettings />,
notifications: <NotificationSettings />,
security: <SecuritySettings />,
logs: <LogSettings />,
};
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900">Settings</h2>
<div className="bg-white rounded-lg border border-gray-200">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
<div className="p-6">{tabContent[activeTab as keyof typeof tabContent]}</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+1 -2
View File
@@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import subprocess import subprocess
import sys import sys
from pathlib import Path
ROOT = Path(__file__).parents[2] ROOT = Path(__file__).parents[2]
-1
View File
@@ -7,7 +7,6 @@ from typing import Any
import jsonschema import jsonschema
import pytest import pytest
ROOT = Path(__file__).parents[2] ROOT = Path(__file__).parents[2]
CONTRACT = ROOT / "contracts" / "repository" / "v1" CONTRACT = ROOT / "contracts" / "repository" / "v1"
FIXTURES = CONTRACT / "fixtures" FIXTURES = CONTRACT / "fixtures"
View File
View File
View File
View File
View File
View File
View File
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Reject executable v1 compatibility paths and symbols from the v2 tree."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
LEGACY_PATHS = (
Path("backend/app"),
Path("backend/backup"),
Path("backend/tests"),
Path("frontend/src/api/client.ts"),
)
SCAN_ROOTS = (Path("backend/src"), Path("frontend/src"), Path("openapi"))
TEXT_SUFFIXES = {".json", ".py", ".ts", ".tsx", ".yaml", ".yml"}
FORBIDDEN = (
re.compile(r"/api/v1(?:/|\b)"),
re.compile(r"\blegacy_(?:reader|importer?|converter?)\b", re.IGNORECASE),
re.compile(r"\btimestamp_directory\b", re.IGNORECASE),
re.compile(r"\b(?:app\.main|backup\.engine)\b"),
)
def scan(root: Path) -> list[str]:
findings: list[str] = []
for relative in LEGACY_PATHS:
if (root / relative).exists():
findings.append(f"legacy path exists: {relative}")
for relative_root in SCAN_ROOTS:
scan_root = root / relative_root
if not scan_root.exists():
continue
for path in sorted(scan_root.rglob("*")):
if not path.is_file() or path.suffix not in TEXT_SUFFIXES:
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
findings.append(f"cannot read {path.relative_to(root)}: {error}")
continue
for pattern in FORBIDDEN:
if pattern.search(text):
findings.append(
f"forbidden symbol {pattern.pattern!r}: {path.relative_to(root)}"
)
return findings
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("root", nargs="?", default=".", type=Path)
args = parser.parse_args()
root = args.root.resolve()
findings = scan(root)
if findings:
print("v1 compatibility scan failed:", file=sys.stderr)
for finding in findings:
print(f"- {finding}", file=sys.stderr)
return 1
print("v1 compatibility scan: OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())