chore(v2): establish protocol and test foundation
This commit is contained in:
+3
-1
@@ -50,7 +50,6 @@ Thumbs.db
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/package-lock.json
|
||||
frontend/yarn.lock
|
||||
frontend/pnpm-lock.yaml
|
||||
|
||||
@@ -60,8 +59,11 @@ logs/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Backup tool specific
|
||||
backups/
|
||||
.pi-subagents/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12.11
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db
|
||||
@@ -1 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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.
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -1,68 +1,63 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
requires = ["setuptools==80.9.0", "wheel==0.46.3"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "backup-tool"
|
||||
version = "0.1.0"
|
||||
description = "Web-based backup management tool for small teams and SMBs"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.11"
|
||||
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",
|
||||
]
|
||||
version = "2.0.0.dev0"
|
||||
description = "Self-hosted, integrity-first backup appliance"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.12,<3.15"
|
||||
authors = [{ name = "Backup Tool Team" }]
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
"sqlalchemy[asyncio]>=2.0.0",
|
||||
"aiosqlite>=0.21.0",
|
||||
"alembic>=1.15.0",
|
||||
"pydantic>=2.13.0",
|
||||
"pydantic-settings>=2.9.0",
|
||||
"apscheduler>=3.11.0",
|
||||
"paramiko>=3.5.0",
|
||||
"aiofiles>=23.2.0",
|
||||
"httpx>=0.28.0",
|
||||
"aiofiles==25.1.0",
|
||||
"aiosqlite==0.22.1",
|
||||
"alembic==1.18.5",
|
||||
"apscheduler==3.11.3",
|
||||
"fastapi==0.136.1",
|
||||
"httpx==0.28.1",
|
||||
"paramiko==5.0.0",
|
||||
"pydantic==2.13.4",
|
||||
"pydantic-settings==2.14.2",
|
||||
"sqlalchemy[asyncio]==2.0.49",
|
||||
"uvicorn[standard]==0.51.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.0",
|
||||
"pytest-asyncio>=0.26.0",
|
||||
]
|
||||
prod = [
|
||||
"gunicorn>=23.0.0",
|
||||
"jsonschema==4.26.0",
|
||||
"mypy==2.3.0",
|
||||
"pytest==9.0.3",
|
||||
"pytest-asyncio==1.3.0",
|
||||
"ruff==0.16.0",
|
||||
]
|
||||
prod = ["gunicorn==23.0.0"]
|
||||
|
||||
[project.scripts]
|
||||
backup-tool = "app.main:main"
|
||||
backup-tool = "backup_tool.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/backup-tool/backup-tool"
|
||||
Documentation = "https://github.com/backup-tool/backup-tool#readme"
|
||||
Repository = "https://github.com/backup-tool/backup-tool.git"
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*", "backup*", "alembic*"]
|
||||
where = ["src"]
|
||||
include = ["backup_tool*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
backup_tool = ["py.typed"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = [".", "app", "backup"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
alembic = ["*.ini", "*.py", "*.mako"]
|
||||
[tool.ruff]
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Backup Tool v2 package."""
|
||||
|
||||
__version__ = "2.0.0.dev0"
|
||||
@@ -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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
Generated
+3670
File diff suppressed because it is too large
Load Diff
+21
-22
@@ -1,36 +1,35 @@
|
||||
{
|
||||
"name": "backup-tool-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "2.0.0-dev.0",
|
||||
"type": "module",
|
||||
"packageManager": "npm@10.9.2",
|
||||
"engines": {
|
||||
"node": "22.17.1"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"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"
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^1.1.0",
|
||||
"@testing-library/react": "^14.1.0",
|
||||
"@testing-library/jest-dom": "^6.2.0",
|
||||
"jsdom": "^23.0.0"
|
||||
"@testing-library/jest-dom": "7.0.0",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.4",
|
||||
"autoprefixer": "10.5.4",
|
||||
"jsdom": "30.0.0",
|
||||
"postcss": "8.5.23",
|
||||
"tailwindcss": "3.4.19",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.1.5",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+13
-6
@@ -1,10 +1,17 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
import { App } from "./app/App";
|
||||
import "./index.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Missing #root element");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,9 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
CONTRACT = ROOT / "contracts" / "repository" / "v1"
|
||||
FIXTURES = CONTRACT / "fixtures"
|
||||
|
||||
Vendored
@@ -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())
|
||||
Reference in New Issue
Block a user