Compare commits

...

32 Commits

Author SHA1 Message Date
alex 9f59540686 cleaned up 2026-06-11 14:55:34 +02:00
alex cc694e71b4 feat: dockerize app and convert to pyproject setup
- Add pyproject.toml with proper metadata and dependency groups
- Create multi-stage Dockerfile for backend
- Add docker-compose.yml with dev/prod profiles
- Create frontend Dockerfile with nginx
- Add .dockerignore for optimized builds
- Update README with Docker instructions and troubleshooting
- Remove requirements.txt in favor of pyproject.toml
- Ensure data persistence with Docker volumes
2026-05-11 22:50:11 +02:00
alex abeb64637e docs: add comprehensive README and update .gitignore 2026-05-11 22:05:03 +02:00
alex 1978602da6 feat(frontend): add static file serving for production build 2026-05-11 22:04:26 +02:00
alex 55ec0687a5 feat(retention): add count-based and days-based retention policies 2026-05-11 22:03:50 +02:00
alex a4a16655b9 feat(scheduler): add APScheduler integration with cron support 2026-05-11 22:03:03 +02:00
alex 4d2c21a42e feat(adapters): add SSH and database adapters 2026-05-11 22:02:25 +02:00
alex b4a22a1c44 feat(routers): add executions, backups, and settings endpoints 2026-05-11 22:01:31 +02:00
alex b2d196498b feat(dashboard): add GET /api/dashboard/ stats endpoint
- Active jobs count, total backups, storage used
- Recent failures (24h) and last 10 executions
- Uses DashboardStats schema
2026-05-11 21:56:02 +02:00
alex 66f9de17c5 feat(frontend): add Dashboard, Backups, and Settings pages
- Create Dashboard page with stats cards and recent activity feed
- Create Backups page with job listing table, create modal, and actions
- Create Settings page with tabs for General, Notifications, Security, Logs
- Update App.tsx to use new page components
- Add delete method to jobsApi in client.ts
- TypeScript compiles cleanly
2026-05-11 21:54:36 +02:00
alex 99eeffc810 chore: add .gitignore and remove node_modules from tracking 2026-05-11 21:48:38 +02:00
alex d3b92593d5 feat(frontend): add API client, layout, and routing
- Create Axios-based API client with sources, jobs, and dashboard endpoints
- Add responsive Layout component with navigation sidebar
- Set up React Router with Dashboard, Backups, and Settings routes
- Configure TanStack Query provider with default options
- Use lucide-react for navigation icons
2026-05-11 21:47:20 +02:00
alex 7676438466 fix(tests): resolve lazy loading errors in test_engine.py
Add await db.refresh() calls with relationship attributes before accessing
test_job.executions and execution.backups in async test context.

Fixes lazy loading errors in:
- test_execute_full_backup
- test_execute_incremental_without_full
2026-05-11 21:40:52 +02:00
alex ed64b5ff62 fix: use @pytest_asyncio.fixture for async fixtures in test_engine.py 2026-05-11 21:39:12 +02:00
alex 19987d68a0 refactor(tests): use client fixture for test isolation
Replace all AsyncClient instantiations in test_sources.py and test_jobs.py
with the shared  fixture from conftest.py. This ensures proper test
database isolation via dependency overrides.

- Remove imports of httpx AsyncClient, ASGITransport, and app
- Add client fixture parameter to all test functions
- Remove async with blocks around HTTP requests
- test_engine.py already uses db fixture correctly, no changes needed
2026-05-11 21:38:15 +02:00
alex 58c4bc7f30 fix: use ASGITransport for httpx AsyncClient in tests
- httpx.AsyncClient requires ASGITransport for FastAPI app testing
- Updated all test files to use correct API
2026-05-11 21:28:07 +02:00
alex 55ba9b62d6 fix: improve test isolation and configuration
- Use in-memory SQLite for tests to prevent conflicts
- Add try/finally for robust test cleanup
- Make database URL configurable via env var
- Make CORS origins configurable via env var
- Make SQL echo configurable via env var
2026-05-11 21:25:35 +02:00
alex 878e303cc6 feat: setup FastAPI main app with routers and test fixtures
- Main FastAPI app with all routers included
- CORS middleware for frontend
- Test fixtures with in-memory SQLite database
- Placeholder routers for future tasks
2026-05-11 21:22:55 +02:00
alex 955ed9db49 fix: jobs router session handling, schedule creation, and tests
- Use new session in background task for thread safety
- Fix schedule creation to associate with job
- Fix schedule endpoint return type
- Update tests to use AsyncClient
- Add missing tests for list, get, delete, schedule
- Add updated_at field to Schedule model
2026-05-11 21:20:31 +02:00
alex 1f1ed72494 feat: add jobs CRUD API with manual execution
- Full CRUD for backup jobs
- Manual job trigger endpoint with background execution
- Schedule creation endpoint
- Tests for job creation, update, execution, and 404 cases
2026-05-11 21:12:01 +02:00
alex 810aae1b6e test: add update and 404 tests for sources API 2026-05-11 21:01:41 +02:00
alex 0d0f41d616 feat: add sources CRUD API with tests
- Full CRUD endpoints for backup sources
- Pydantic validation for source types
- pytest tests for create, list, get, delete
2026-05-11 20:59:11 +02:00
alex b69c098cab feat: add backup engine with full/incremental support
- BackupEngine class executes jobs and creates backups
- Automatic fallback from incremental to full if no parent exists
- SHA-256 checksum calculation for integrity
- Tests for full backup and incremental fallback
2026-05-11 20:56:06 +02:00
alex f925ae094c feat: add source adapter base class and local filesystem adapter
- Abstract SourceAdapter with FileInfo dataclass
- LocalAdapter for filesystem backups
- Adapter factory for extensibility
2026-05-11 20:52:31 +02:00
alex 83c3075de0 fix: update pydantic schemas to v2 syntax
- Replace Config class with ConfigDict
- Add cron expression validation
- Add missing updated_at to Schedule
- Add JobExecutionUpdate schema
2026-05-11 20:50:35 +02:00
alex 76cf5054be feat: add pydantic schemas for API validation 2026-05-11 20:47:48 +02:00
alex caf5bcb172 fix: address database model quality issues
- Fix mutable default arguments with lambda
- Add indexes on foreign keys
- Replace deprecated datetime.utcnow
- Add __repr__ methods for debugging
- Remove redundant primary key indexes
2026-05-11 20:46:03 +02:00
alex 13488f7b21 feat: add database models and alembic setup
- SQLAlchemy async models for sources, jobs, schedules, executions, backups, settings
- Alembic migration configuration
2026-05-11 20:43:23 +02:00
alex 3deab5fdf3 fix: resolve frontend code quality issues
- Create missing tsconfig.node.json for Vite config compilation
- Create postcss.config.js for Tailwind CSS processing
- Add test dependencies (vitest, testing-library, jsdom)
- Remove missing /vite.svg favicon reference from index.html
2026-05-11 20:39:05 +02:00
alex eff41bd801 chore: setup project structure with dependencies 2026-05-11 20:28:22 +02:00
alex 4989652c5d docs: add comprehensive implementation plan
- 20 tasks covering backend, frontend, adapters, scheduler
- File structure with exact paths
- Complete code in every step
- Self-review with spec coverage verification
2026-05-11 20:23:54 +02:00
alex 56b68d7e57 docs: add backup tool design specification
- Architecture: Python FastAPI backend + React frontend
- Data model: 6 core tables (sources, jobs, schedules, executions, backups, settings)
- Backup flow: 7-step execution with incremental support
- Error handling: Retry logic, partial backup handling, retention policies
- Extensibility: Adapter pattern for sources and storage backends
2026-05-11 20:10:13 +02:00
87 changed files with 8619 additions and 1 deletions
+73
View File
@@ -0,0 +1,73 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
env/
ENV/
# Database
*.db
*.sqlite
*.sqlite3
# Frontend
node_modules/
frontend/dist/
frontend/build/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Environment
.env
.env.local
# Git
.git/
.gitignore
# Documentation
*.md
!README.md
# Tests
tests/
.pytest_cache/
.coverage
# Backup tool data
backups/
test.db
backup_tool.db
+67
View File
@@ -0,0 +1,67 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
.venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment files
.env
.env.local
.env.*.local
# Database
*.db
*.sqlite
*.sqlite3
# Frontend
frontend/node_modules/
frontend/dist/
frontend/package-lock.json
frontend/yarn.lock
frontend/pnpm-lock.yaml
# Logs
*.log
logs/
# Testing
.pytest_cache/
.coverage
htmlcov/
# Backup tool specific
backups/
+149
View File
@@ -0,0 +1,149 @@
---
description: Implement tasks from an OpenSpec change (Experimental)
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! You can archive this change with `/opsx-archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
+154
View File
@@ -0,0 +1,154 @@
---
description: Archive a completed change in the experimental workflow
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Prompt user for confirmation to continue
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Spec sync status (synced / sync skipped / no delta specs)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+170
View File
@@ -0,0 +1,170 @@
---
description: Enter explore mode - think through ideas, investigate problems, clarify requirements
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
If the user mentioned a specific change name, read its artifacts for context.
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+103
View File
@@ -0,0 +1,103 @@
---
description: Propose a new change - create it and generate all artifacts in one step
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` to start implementing."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
+288
View File
@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
┌───────────┐
│ Session │
└─────┬─────┘
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx-explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own
+110
View File
@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx-apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next
@@ -0,0 +1,113 @@
<h2>Architecture Overview</h2>
<p class="subtitle">High-level system design for the backup tool</p>
<div class="section">
<div class="mockup">
<div class="mockup-header">System Architecture</div>
<div class="mockup-body">
<div style="display: flex; flex-direction: column; gap: 20px;">
<!-- Frontend Layer -->
<div style="border: 2px solid #4CAF50; border-radius: 8px; padding: 15px; background: #f8fff8;">
<div class="label" style="color: #4CAF50;">Frontend Layer</div>
<div style="display: flex; gap: 15px; margin-top: 10px;">
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Dashboard View</strong><br>
<small>Overview, stats, recent backups</small>
</div>
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Backup View</strong><br>
<small>Jobs, sources, schedules</small>
</div>
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Settings View</strong><br>
<small>Config, storage, notifications</small>
</div>
</div>
</div>
<!-- API Layer -->
<div style="text-align: center; color: #666; font-size: 24px;">↓ REST API ↓</div>
<!-- Backend Layer -->
<div style="border: 2px solid #2196F3; border-radius: 8px; padding: 15px; background: #f0f8ff;">
<div class="label" style="color: #2196F3;">Backend Layer (Python)</div>
<div style="display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap;">
<div style="flex: 1; min-width: 120px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>API Server</strong><br>
<small>FastAPI/Flask</small>
</div>
<div style="flex: 1; min-width: 120px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Backup Engine</strong><br>
<small>Core logic</small>
</div>
<div style="flex: 1; min-width: 120px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Scheduler</strong><br>
<small>APScheduler</small>
</div>
<div style="flex: 1; min-width: 120px; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>Source Adapters</strong><br>
<small>SSH, DB, Local</small>
</div>
</div>
</div>
<!-- Data Layer -->
<div style="text-align: center; color: #666; font-size: 24px;">↓ Persistence ↓</div>
<div style="border: 2px solid #FF9800; border-radius: 8px; padding: 15px; background: #fff8f0;">
<div class="label" style="color: #FF9800;">Data Layer</div>
<div style="display: flex; gap: 15px; margin-top: 10px;">
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>SQLite Database</strong><br>
<small>Jobs, sources, history, config</small>
</div>
<div style="flex: 1; border: 1px solid #ddd; padding: 10px; border-radius: 4px; text-align: center;">
<strong>File Storage</strong><br>
<small>Backup archives</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Key Design Principles</h3>
<ul>
<li><strong>Source-agnostic</strong>: Adapter pattern for different backup sources (local, SSH, future cloud)</li>
<li><strong>Job-driven</strong>: Everything is a job - manual or scheduled, full or incremental</li>
<li><strong>SQLite for simplicity</strong>: Single file database, no external DB server needed</li>
<li><strong>REST API</strong>: Clean separation between frontend and backend</li>
<li><strong>Extensible</strong>: Plugin architecture for new source types and storage backends</li>
</ul>
</div>
<div class="section">
<h3>Technology Stack</h3>
<div style="display: flex; gap: 20px;">
<div style="flex: 1;">
<h4>Backend</h4>
<ul>
<li>Python 3.11+</li>
<li>FastAPI (async, auto-docs)</li>
<li>SQLAlchemy + Alembic</li>
<li>APScheduler (job scheduling)</li>
<li>Paramiko (SSH)</li>
<li>rsync/libsync (incremental backups)</li>
</ul>
</div>
<div style="flex: 1;">
<h4>Frontend</h4>
<ul>
<li>React 18+ with TypeScript</li>
<li>TanStack Query (data fetching)</li>
<li>React Router (navigation)</li>
<li>Tailwind CSS (styling)</li>
<li>Recharts (dashboard charts)</li>
</ul>
</div>
</div>
</div>
@@ -0,0 +1,110 @@
<h2>Backup Flow</h2>
<p class="subtitle">How a backup job executes from trigger to completion</p>
<div class="section">
<div class="mockup">
<div class="mockup-header">Backup Execution Flow</div>
<div class="mockup-body">
<div style="display: flex; flex-direction: column; gap: 0;">
<!-- Step 1 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #f0f8ff; border-radius: 8px; border-left: 4px solid #2196F3;">
<div style="background: #2196F3; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">1</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Trigger</div>
<div style="color: #666; font-size: 14px;">Manual click or scheduled cron trigger fires</div>
</div>
</div>
<div style="text-align: center; color: #2196F3; font-size: 20px;"></div>
<!-- Step 2 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #fff8f0; border-radius: 8px; border-left: 4px solid #FF9800;">
<div style="background: #FF9800; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">2</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Create Execution Record</div>
<div style="color: #666; font-size: 14px;">Insert row into job_executions with status "pending"</div>
</div>
</div>
<div style="text-align: center; color: #FF9800; font-size: 20px;"></div>
<!-- Step 3 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #f8fff8; border-radius: 8px; border-left: 4px solid #4CAF50;">
<div style="background: #4CAF50; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">3</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Connect to Source</div>
<div style="color: #666; font-size: 14px;">Establish connection (local filesystem, SSH, or database)</div>
</div>
</div>
<div style="text-align: center; color: #4CAF50; font-size: 20px;"></div>
<!-- Step 4 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #faf0ff; border-radius: 8px; border-left: 4px solid #9C27B0;">
<div style="background: #9C27B0; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">4</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Determine Backup Strategy</div>
<div style="color: #666; font-size: 14px;">Full backup OR incremental based on last successful backup</div>
</div>
</div>
<div style="text-align: center; color: #9C27B0; font-size: 20px;"></div>
<!-- Step 5 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #f0f8ff; border-radius: 8px; border-left: 4px solid #2196F3;">
<div style="background: #2196F3; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">5</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Execute Backup</div>
<div style="color: #666; font-size: 14px;">Transfer data, compress, encrypt (if enabled), calculate checksums</div>
</div>
</div>
<div style="text-align: center; color: #2196F3; font-size: 20px;"></div>
<!-- Step 6 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #f8fff8; border-radius: 8px; border-left: 4px solid #4CAF50;">
<div style="background: #4CAF50; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">6</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Verify & Store</div>
<div style="color: #666; font-size: 14px;">Verify checksum, store metadata in backups table, update execution status</div>
</div>
</div>
<div style="text-align: center; color: #4CAF50; font-size: 20px;"></div>
<!-- Step 7 -->
<div style="display: flex; align-items: center; gap: 15px; padding: 15px; background: #fff8f0; border-radius: 8px; border-left: 4px solid #FF9800;">
<div style="background: #FF9800; color: white; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold;">7</div>
<div style="flex: 1;">
<div style="font-weight: bold;">Cleanup & Retention</div>
<div style="color: #666; font-size: 14px;">Apply retention policy, remove old backups, update storage stats</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Error Handling</h3>
<ul>
<li><strong>Connection failures</strong>: Retry 3x with exponential backoff, then mark as failed</li>
<li><strong>Partial backups</strong>: Mark as failed if any source file fails; keep partial for inspection</li>
<li><strong>Storage full</strong>: Check before starting, alert if < 10% free space</li>
<li><strong>Checksum mismatch</strong>: Delete corrupted backup, retry once, alert admin</li>
<li><strong>Concurrent jobs</strong>: Queue or cancel based on resource availability</li>
</ul>
</div>
<div class="section">
<h3>Extensibility Design</h3>
<p>The system is designed for easy extension:</p>
<ul>
<li><strong>Source adapters</strong>: Abstract base class for new source types (S3, Azure, etc.)</li>
<li><strong>Storage backends</strong>: Pluggable storage interface for cloud destinations</li>
<li><strong>Notification channels</strong>: Webhook, email, Slack plugins for alerts</li>
<li><strong>Compression algorithms</strong>: Configurable (gzip, bzip2, zstd, none)</li>
</ul>
</div>
@@ -0,0 +1,128 @@
<h2>Data Model</h2>
<p class="subtitle">SQLite database schema for the backup tool</p>
<div class="section">
<div class="mockup">
<div class="mockup-header">Entity Relationship Diagram</div>
<div class="mockup-body">
<div style="display: flex; flex-direction: column; gap: 15px; font-family: monospace; font-size: 14px;">
<!-- Sources -->
<div style="border: 2px solid #4CAF50; border-radius: 6px; padding: 12px; background: #f8fff8;">
<div style="font-weight: bold; color: #4CAF50; margin-bottom: 8px;">📁 sources</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>name</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>type</span> <span style="color: #666;">TEXT (local|ssh|database)</span>
<span> </span> <span>config</span> <span style="color: #666;">JSON</span>
<span> </span> <span>created_at</span> <span style="color: #666;">DATETIME</span>
<span> </span> <span>updated_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
<!-- Jobs -->
<div style="border: 2px solid #2196F3; border-radius: 6px; padding: 12px; background: #f0f8ff;">
<div style="font-weight: bold; color: #2196F3; margin-bottom: 8px;">⚙️ jobs</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>name</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>source_id</span> <span style="color: #666;">INTEGER → sources</span>
<span> </span> <span>strategy</span> <span style="color: #666;">TEXT (full|incremental)</span>
<span> </span> <span>destination_path</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>exclude_patterns</span> <span style="color: #666;">JSON</span>
<span> </span> <span>enabled</span> <span style="color: #666;">BOOLEAN</span>
<span> </span> <span>created_at</span> <span style="color: #666;">DATETIME</span>
<span> </span> <span>updated_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
<!-- Schedules -->
<div style="border: 2px solid #FF9800; border-radius: 6px; padding: 12px; background: #fff8f0;">
<div style="font-weight: bold; color: #FF9800; margin-bottom: 8px;">📅 schedules</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>job_id</span> <span style="color: #666;">INTEGER → jobs</span>
<span> </span> <span>cron_expression</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>enabled</span> <span style="color: #666;">BOOLEAN</span>
<span> </span> <span>created_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
<!-- Job Executions -->
<div style="border: 2px solid #9C27B0; border-radius: 6px; padding: 12px; background: #faf0ff;">
<div style="font-weight: bold; color: #9C27B0; margin-bottom: 8px;">▶️ job_executions</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>job_id</span> <span style="color: #666;">INTEGER → jobs</span>
<span> </span> <span>status</span> <span style="color: #666;">TEXT (pending|running|success|failed|cancelled)</span>
<span> </span> <span>started_at</span> <span style="color: #666;">DATETIME</span>
<span> </span> <span>completed_at</span> <span style="color: #666;">DATETIME</span>
<span> </span> <span>bytes_processed</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>bytes_backed_up</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>error_message</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>triggered_by</span> <span style="color: #666;">TEXT (manual|schedule)</span>
</div>
</div>
<!-- Backups -->
<div style="border: 2px solid #607D8B; border-radius: 6px; padding: 12px; background: #f5f5f5;">
<div style="font-weight: bold; color: #607D8B; margin-bottom: 8px;">💾 backups</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>id</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>execution_id</span> <span style="color: #666;">INTEGER → job_executions</span>
<span> </span> <span>storage_path</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>size_bytes</span> <span style="color: #666;">INTEGER</span>
<span> </span> <span>checksum</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>type</span> <span style="color: #666;">TEXT (full|incremental)</span>
<span> </span> <span>parent_backup_id</span> <span style="color: #666;">INTEGER → backups (for incremental)</span>
<span> </span> <span>created_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
<!-- Settings -->
<div style="border: 2px solid #795548; border-radius: 6px; padding: 12px; background: #faf5f0;">
<div style="font-weight: bold; color: #795548; margin-bottom: 8px;">⚙️ settings</div>
<div style="display: grid; grid-template-columns: auto 1fr auto; gap: 4px 12px;">
<span style="color: #d32f2f;">PK</span> <span>key</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>value</span> <span style="color: #666;">TEXT</span>
<span> </span> <span>updated_at</span> <span style="color: #666;">DATETIME</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Key Relationships</h3>
<ul>
<li><strong>sources → jobs</strong>: One source can have many jobs (different backup strategies for the same data)</li>
<li><strong>jobs → schedules</strong>: One job can have one schedule (1:1 for simplicity)</li>
<li><strong>jobs → job_executions</strong>: One job has many executions over time</li>
<li><strong>job_executions → backups</strong>: Each execution produces one or more backup archives</li>
<li><strong>backups → backups</strong>: Self-referencing for incremental chain (parent_backup_id)</li>
</ul>
</div>
<div class="section">
<h3>Design Decisions</h3>
<div class="pros-cons">
<div class="pros">
<h4>Pros</h4>
<ul>
<li>Flexible source config via JSON (extensible for new source types)</li>
<li>Execution history tracking for audit and debugging</li>
<li>Incremental backup chain support</li>
<li>Simple key-value settings (easy to extend)</li>
</ul>
</div>
<div class="cons">
<h4>Cons>/h4>
<ul>
<li>JSON config loses some type safety (mitigated by validation)</li>
<li>No built-in backup retention policy table (can be added later)</li>
</ul>
</div>
</div>
</div>
@@ -0,0 +1,174 @@
<h2>Frontend Views</h2>
<p class="subtitle">UI mockups for the three main views</p>
<div class="section">
<h3>Dashboard View</h3>
<div class="mockup">
<div class="mockup-header">Dashboard</div>
<div class="mockup-body">
<div class="mock-nav">
<span style="font-weight: bold;">🛡️ Backup Tool</span> | Dashboard | Backups | Settings
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin: 20px 0;">
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #4CAF50;">12</div>
<div style="color: #666;">Active Jobs</div>
</div>
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #2196F3;">847</div>
<div style="color: #666;">Total Backups</div>
</div>
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #FF9800;">2.4 TB</div>
<div style="color: #666;">Storage Used</div>
</div>
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px; text-align: center;">
<div style="font-size: 32px; font-weight: bold; color: #f44336;">1</div>
<div style="color: #666;">Failed (24h)</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px;">
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; margin-bottom: 10px;">Recent Backup Activity</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; justify-content: space-between; padding: 8px; background: #f5f5f5; border-radius: 4px;">
<span>📁 Home Server Files</span>
<span style="color: #4CAF50;">✓ Success</span>
<span style="color: #666;">2 hours ago</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 8px; background: #f5f5f5; border-radius: 4px;">
<span>🗄️ Production DB</span>
<span style="color: #4CAF50;">✓ Success</span>
<span style="color: #666;">5 hours ago</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 8px; background: #f5f5f5; border-radius: 4px;">
<span>📁 Cloud Storage Sync</span>
<span style="color: #f44336;">✗ Failed</span>
<span style="color: #666;">1 day ago</span>
</div>
</div>
</div>
<div style="border: 1px solid #ddd; padding: 15px; border-radius: 6px;">
<div style="font-weight: bold; margin-bottom: 10px;">Storage Overview</div>
<div style="height: 150px; background: linear-gradient(to bottom, #e3f2fd 0%, #bbdefb 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center;">
[Pie Chart: Storage by Job]
</div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Backup View</h3>
<div class="mockup">
<div class="mockup-header">Backups</div>
<div class="mockup-body">
<div class="mock-nav">
<span>🛡️ Backup Tool</span> | Dashboard | <span style="font-weight: bold;">Backups</span> | Settings
</div>
<div style="display: flex; justify-content: space-between; margin: 20px 0;">
<div>
<button class="mock-button">+ New Job</button>
<button class="mock-button">+ New Source</button>
</div>
<div>
<input class="mock-input" placeholder="Search jobs..." style="width: 200px;">
</div>
</div>
<div style="border: 1px solid #ddd; border-radius: 6px; overflow: hidden;">
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; background: #f5f5f5; padding: 10px; font-weight: bold;">
<div>Job Name</div>
<div>Source</div>
<div>Strategy</div>
<div>Schedule</div>
<div>Last Run</div>
<div>Status</div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; padding: 10px; border-top: 1px solid #eee;">
<div>🏠 Home Server Files</div>
<div>Local Path</div>
<div><span style="background: #e3f2fd; padding: 2px 8px; border-radius: 12px; font-size: 12px;">Incremental</span></div>
<div>Daily at 2 AM</div>
<div>2 hours ago</div>
<div><span style="color: #4CAF50;">● Active</span></div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; padding: 10px; border-top: 1px solid #eee;">
<div>🗄️ Production DB</div>
<div>PostgreSQL</div>
<div><span style="background: #fff3e0; padding: 2px 8px; border-radius: 12px; font-size: 12px;">Full</span></div>
<div>Hourly</div>
<div>5 hours ago</div>
<div><span style="color: #4CAF50;">● Active</span></div>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; padding: 10px; border-top: 1px solid #eee;">
<div>☁️ Cloud Storage Sync</div>
<div>SSH (AWS)</div>
<div><span style="background: #e3f2fd; padding: 2px 8px; border-radius: 12px; font-size: 12px;">Incremental</span></div>
<div>Weekly (Sun)</div>
<div>1 day ago</div>
<div><span style="color: #f44336;">● Failed</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3>Settings View</h3>
<div class="mockup">
<div class="mockup-header">Settings</div>
<div class="mockup-body">
<div class="mock-nav">
<span>🛡️ Backup Tool</span> | Dashboard | Backups | <span style="font-weight: bold;">Settings</span>
</div>
<div style="display: grid; grid-template-columns: 200px 1fr; gap: 20px; margin-top: 20px;">
<div style="border-right: 1px solid #ddd; padding-right: 20px;">
<div style="padding: 10px; background: #e3f2fd; border-radius: 4px; font-weight: bold;">General</div>
<div style="padding: 10px;">Storage</div>
<div style="padding: 10px;">Notifications</div>
<div style="padding: 10px;">Security</div>
<div style="padding: 10px;">Logs</div>
</div>
<div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px;">Default Backup Location</div>
<input class="mock-input" value="/var/backups/backup-tool" style="width: 300px;">
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px;">Retention Policy</div>
<select class="mock-input" style="width: 200px;">
<option>Keep all backups</option>
<option>Keep last 30 days</option>
<option selected>Keep last 10 versions</option>
</select>
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px;">Compression</div>
<label><input type="checkbox" checked> Enable compression (gzip)</label>
</div>
<div style="margin-bottom: 20px;">
<div style="font-weight: bold; margin-bottom: 8px;">Encryption</div>
<label><input type="checkbox"> Enable AES-256 encryption</label>
</div>
<div>
<button class="mock-button">Save Changes</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1778524615786}
@@ -0,0 +1,6 @@
{"type":"server-started","port":63865,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63865","screen_dir":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content","state_dir":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/state"}
{"type":"screen-added","file":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content/architecture-overview.html"}
{"type":"screen-added","file":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content/data-model.html"}
{"type":"screen-added","file":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content/frontend-views.html"}
{"type":"screen-added","file":"/home/alex/projects/backup-tool/.superpowers/brainstorm/269331-1778521434/content/backup-flow.html"}
{"type":"server-stopped","reason":"idle timeout"}
@@ -0,0 +1 @@
269339
+220 -1
View File
@@ -1,2 +1,221 @@
# backup-tool
# Backup Tool
A modern backup management application with a FastAPI backend and React frontend.
## Features
- **Multiple Source Types**: Local filesystem, SSH/SFTP, and database (PostgreSQL, MySQL)
- **Backup Strategies**: Full and incremental backups
- **Scheduled Backups**: Cron-based scheduling with APScheduler
- **Retention Policies**: Count-based and days-based backup retention
- **Web Dashboard**: React-based UI for managing backups
- **REST API**: Full REST API for programmatic access
## Architecture
```
backup-tool/
├── backend/ # FastAPI backend
│ ├── app/ # FastAPI application
│ │ ├── routers/ # API endpoints
│ │ ├── models.py # SQLAlchemy models
│ │ ├── schemas.py # Pydantic schemas
│ │ └── main.py # Application entry point
│ ├── backup/ # Backup engine
│ │ ├── adapters/ # Source adapters (local, SSH, database)
│ │ ├── engine.py # Backup execution engine
│ │ ├── scheduler.py # Job scheduler
│ │ └── retention.py # Retention policies
│ └── requirements.txt # Python dependencies
├── frontend/ # React frontend
│ ├── src/ # Source code
│ └── package.json # Node dependencies
└── docs/ # Documentation
```
## Quick Start
### Option 1: Docker (Recommended)
The easiest way to run the backup tool is using Docker Compose:
```bash
# Start the backend
docker compose up -d
# Start with frontend (production)
docker compose --profile prod up -d
# Start with frontend (development with hot reload)
docker compose --profile dev up -d
```
Access the application:
- Backend API: http://localhost:8000
- Frontend: http://localhost:3000
- API Docs: http://localhost:8000/docs
### Option 2: Manual Setup
#### Prerequisites
- Python 3.11+
- Node.js 18+
- PostgreSQL or MySQL (for database backups)
#### Backend Setup
```bash
cd backend
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -e ".[dev]"
# Run the server
uvicorn app.main:app --reload --port 8000
```
#### Frontend Setup
```bash
cd frontend
npm install
npm run dev
```
#### Production Build
```bash
cd frontend
npm run build
cd ../backend
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
## API Documentation
Once the backend is running, visit:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///./backup_tool.db` |
| `CORS_ORIGINS` | Comma-separated allowed CORS origins | `http://localhost:3000` |
| `SQL_ECHO` | Enable SQL query logging | `false` |
| `BACKUP_STORAGE_PATH` | Path for storing backups | `/app/backups` |
### Docker-Specific Configuration
When running with Docker Compose, the following volumes are mounted:
- `backup-data`: Persisted SQLite database at `/app/data`
- `backup-storage`: Backup files at `/app/backups`
### Development vs Production
**Development Mode** (`docker compose --profile dev up -d`):
- Backend hot reload enabled
- Frontend Vite dev server with HMR
- Source code mounted as volumes
**Production Mode** (`docker compose --profile prod up -d`):
- Optimized frontend build served via nginx
- Backend without reload
- Static assets compiled
## Troubleshooting
### Docker Issues
**Port already in use**
```bash
# Check what's using port 8000
lsof -i :8000
# Or use different ports in docker-compose.yml
```
**Container fails to start**
```bash
# Check logs
docker logs backup-tool-backend
# Rebuild with no cache
docker compose build --no-cache
```
**Permission denied on data directory**
```bash
# Fix permissions
docker compose exec backend chown -R backup-tool:backup-tool /app/data
```
**Tests fail in Docker**
Tests require development dependencies. Install with:
```bash
docker compose exec backend pip install -e ".[dev]"
```
### Manual Setup Issues
**Python version incompatibility**
Ensure Python 3.11+ is installed:
```bash
python --version
```
**Node modules conflicts**
```bash
cd frontend
rm -rf node_modules package-lock.json
npm install
```
## Deployment
### Docker Deployment
1. Clone the repository
2. Run `docker compose --profile prod up -d`
3. Access at http://localhost:3000
### Manual Deployment
1. Install Python 3.11+ and Node.js 18+
2. Install backend: `cd backend && pip install -e ".[prod]"`
3. Build frontend: `cd frontend && npm run build`
4. Start backend: `cd backend && uvicorn app.main:app --host 0.0.0.0`
### Production Considerations
- Use a reverse proxy (nginx, traefik) for SSL termination
- Set strong credentials for database sources
- Configure backup retention policies
- Monitor disk usage for backup storage
- Use `docker compose -f docker-compose.yml up -d` for production without dev tools
## Development
### Running Tests
```bash
cd backend
pytest
```
### Database Migrations
```bash
cd backend
alembic revision --autogenerate -m "Description"
alembic upgrade head
```
## License
MIT
+50
View File
@@ -0,0 +1,50 @@
# Stage 1: Builder
FROM python:3.14 AS builder
WORKDIR /app
# Install build dependencies
RUN pip install --no-cache-dir setuptools wheel
# Copy pyproject.toml first for better layer caching
COPY pyproject.toml ./
# Copy source code
COPY app/ ./app/
COPY backup/ ./backup/
COPY alembic/ ./alembic/
COPY alembic.ini ./
# Build the package with dev dependencies
RUN pip install --no-cache-dir -e ".[dev]"
# Stage 2: Runtime
FROM python:3.14-slim
WORKDIR /app
# Create non-root user
RUN groupadd -r backup-tool && useradd -r -g backup-tool backup-tool
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.14/site-packages/ /usr/local/lib/python3.14/site-packages/
COPY --from=builder /usr/local/bin/ /usr/local/bin/
# Copy application code
COPY --from=builder /app/ ./
# Create data directory for SQLite and backups
RUN mkdir -p /app/data /app/backups && \
chown -R backup-tool:backup-tool /app
USER backup-tool
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" || exit 1
# Default command
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+5
View File
@@ -0,0 +1,5 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+47
View File
@@ -0,0 +1,47 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./backup_tool.db")
engine = create_async_engine(DATABASE_URL, echo=os.environ.get("SQL_ECHO", "false").lower() == "true")
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
+60
View File
@@ -0,0 +1,60 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
from app.database import engine, Base
from app import models # noqa: F401 - registers models with Base.metadata
from app.routers import sources, jobs, executions, backups, settings, dashboard
from backup.scheduler import backup_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
backup_scheduler.start()
await backup_scheduler.sync_schedules()
yield
backup_scheduler.shutdown()
app = FastAPI(
title="Backup Tool API",
version="0.1.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=os.environ.get("CORS_ORIGINS", "http://localhost:3000").split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(sources.router)
app.include_router(jobs.router)
app.include_router(executions.router)
app.include_router(backups.router)
app.include_router(settings.router)
app.include_router(dashboard.router)
@app.get("/api/health")
async def health_check():
return {"status": "healthy"}
def main():
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
# Static files for production
frontend_dist = os.path.join(os.path.dirname(__file__), "../../frontend/dist")
if os.path.exists(frontend_dist):
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets")
@app.get("/{path:path}")
async def serve_frontend(path: str):
index_file = os.path.join(frontend_dist, "index.html")
if os.path.exists(index_file):
return FileResponse(index_file)
return {"detail": "Frontend not built"}
+137
View File
@@ -0,0 +1,137 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from .database import Base
class Source(Base):
__tablename__ = "sources"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
type = Column(String, nullable=False) # local, ssh, database
config = Column(JSON, default=lambda: {})
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
jobs = relationship("Job", back_populates="source", cascade="all, delete-orphan")
def __repr__(self):
return f"<Source(id={self.id}, name='{self.name}', type='{self.type}')>"
class Job(Base):
__tablename__ = "jobs"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
source_id = Column(Integer, ForeignKey("sources.id"), nullable=False, index=True)
strategy = Column(String, nullable=False, default="full") # full, incremental
destination_path = Column(String, nullable=False)
exclude_patterns = Column(JSON, default=lambda: [])
enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
source = relationship("Source", back_populates="jobs")
schedule = relationship(
"Schedule", back_populates="job", uselist=False, cascade="all, delete-orphan"
)
executions = relationship(
"JobExecution", back_populates="job", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<Job(id={self.id}, name='{self.name}', source_id={self.source_id})>"
class Schedule(Base):
__tablename__ = "schedules"
id = Column(Integer, primary_key=True)
job_id = Column(
Integer, ForeignKey("jobs.id"), unique=True, nullable=False, index=True
)
cron_expression = Column(String, nullable=False)
enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
job = relationship("Job", back_populates="schedule")
def __repr__(self):
return f"<Schedule(id={self.id}, job_id={self.job_id}, cron='{self.cron_expression}')>"
class JobExecution(Base):
__tablename__ = "job_executions"
id = Column(Integer, primary_key=True)
job_id = Column(Integer, ForeignKey("jobs.id"), nullable=False, index=True)
status = Column(
String, nullable=False, default="pending"
) # pending, running, success, failed, cancelled
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
bytes_processed = Column(Integer, default=0)
bytes_backed_up = Column(Integer, default=0)
error_message = Column(Text, nullable=True)
triggered_by = Column(String, nullable=False) # manual, schedule
job = relationship("Job", back_populates="executions")
backups = relationship(
"Backup", back_populates="execution", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<JobExecution(id={self.id}, job_id={self.job_id}, status='{self.status}')>"
class Backup(Base):
__tablename__ = "backups"
id = Column(Integer, primary_key=True)
execution_id = Column(
Integer, ForeignKey("job_executions.id"), nullable=False, index=True
)
storage_path = Column(String, nullable=False)
size_bytes = Column(Integer, default=0)
checksum = Column(String, nullable=True)
type = Column(String, nullable=False) # full, incremental
parent_backup_id = Column(
Integer, ForeignKey("backups.id"), nullable=True, index=True
)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
execution = relationship("JobExecution", back_populates="backups")
parent_backup = relationship("Backup", remote_side=[id])
def __repr__(self):
return f"<Backup(id={self.id}, execution_id={self.execution_id}, type='{self.type}')>"
class Setting(Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(Text, nullable=True)
updated_at = Column(
DateTime,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def __repr__(self):
return f"<Setting(key='{self.key}', value='{self.value}')>"
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models import Backup
from app.schemas import Backup as BackupSchema
router = APIRouter(prefix="/api/backups", tags=["backups"])
@router.get("/", response_model=List[BackupSchema])
async def list_backups(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Backup).order_by(Backup.created_at.desc()))
backups = result.scalars().all()
return backups
@router.get("/{backup_id}", response_model=BackupSchema)
async def get_backup(backup_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Backup).where(Backup.id == backup_id))
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(status_code=404, detail="Backup not found")
return backup
@router.delete("/{backup_id}")
async def delete_backup(backup_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Backup).where(Backup.id == backup_id))
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(status_code=404, detail="Backup not found")
await db.delete(backup)
await db.commit()
return {"message": "Backup deleted"}
+54
View File
@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from datetime import datetime, timezone, timedelta
from typing import List
from app.database import get_db
from app.models import Job, JobExecution, Backup
from app.schemas import DashboardStats, JobExecution as JobExecutionSchema
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/", response_model=DashboardStats)
async def get_dashboard_stats(db: AsyncSession = Depends(get_db)):
# Active jobs count (enabled jobs)
active_jobs_result = await db.execute(
select(func.count(Job.id)).where(Job.enabled == True)
)
active_jobs = active_jobs_result.scalar() or 0
# Total backups count
total_backups_result = await db.execute(select(func.count(Backup.id)))
total_backups = total_backups_result.scalar() or 0
# Storage used bytes (sum of all backup sizes)
storage_used_result = await db.execute(select(func.sum(Backup.size_bytes)))
storage_used_bytes = storage_used_result.scalar() or 0
# Recent failures count (last 24 hours)
twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24)
recent_failures_result = await db.execute(
select(func.count(JobExecution.id)).where(
and_(
JobExecution.status == "failed",
JobExecution.completed_at >= twenty_four_hours_ago,
)
)
)
recent_failures = recent_failures_result.scalar() or 0
# Recent executions (last 10, ordered by started_at desc)
recent_executions_result = await db.execute(
select(JobExecution).order_by(JobExecution.started_at.desc()).limit(10)
)
recent_executions = recent_executions_result.scalars().all()
return DashboardStats(
active_jobs=active_jobs,
total_backups=total_backups,
storage_used_bytes=storage_used_bytes,
recent_failures=recent_failures,
recent_executions=list(recent_executions),
)
+23
View File
@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models import JobExecution
from app.schemas import JobExecution as JobExecutionSchema
router = APIRouter(prefix="/api/executions", tags=["executions"])
@router.get("/", response_model=List[JobExecutionSchema])
async def list_executions(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JobExecution).order_by(JobExecution.started_at.desc()))
executions = result.scalars().all()
return executions
@router.get("/{execution_id}", response_model=JobExecutionSchema)
async def get_execution(execution_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(JobExecution).where(JobExecution.id == execution_id))
execution = result.scalar_one_or_none()
if not execution:
raise HTTPException(status_code=404, detail="Execution not found")
return execution
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db, AsyncSessionLocal
from app.models import Job, Schedule
from app.schemas import JobCreate, JobUpdate, Job as JobSchema, ScheduleCreate, Schedule as ScheduleSchema
from backup.engine import BackupEngine
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@router.get("/", response_model=List[JobSchema])
async def list_jobs(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Job))
jobs = result.scalars().all()
return jobs
@router.post("/", response_model=JobSchema)
async def create_job(job: JobCreate, db: AsyncSession = Depends(get_db)):
db_job = Job(**job.model_dump())
db.add(db_job)
await db.commit()
await db.refresh(db_job)
return db_job
@router.get("/{job_id}", response_model=JobSchema)
async def get_job(job_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job
@router.put("/{job_id}", response_model=JobSchema)
async def update_job(
job_id: int,
job_update: JobUpdate,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
update_data = job_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(job, field, value)
await db.commit()
await db.refresh(job)
return job
@router.delete("/{job_id}")
async def delete_job(job_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
await db.delete(job)
await db.commit()
return {"message": "Job deleted"}
@router.post("/{job_id}/run")
async def run_job(
job_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# Run in background
async def execute():
async with AsyncSessionLocal() as session:
engine = BackupEngine(session)
await engine.execute_job(job_id, triggered_by="manual")
background_tasks.add_task(execute)
return {"message": "Job execution started"}
@router.post("/{job_id}/schedule", response_model=ScheduleSchema)
async def create_schedule(
job_id: int,
schedule: ScheduleCreate,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Job).where(Job.id == job_id))
job = result.scalar_one_or_none()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
schedule_data = schedule.model_dump()
schedule_data["job_id"] = job_id
db_schedule = Schedule(**schedule_data)
db.add(db_schedule)
await db.commit()
await db.refresh(db_schedule)
return ScheduleSchema.model_validate(db_schedule)
+43
View File
@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models import Setting
from app.schemas import Setting as SettingSchema, SettingUpdate
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("/", response_model=List[SettingSchema])
async def list_settings(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Setting))
settings = result.scalars().all()
return settings
@router.get("/{key}", response_model=SettingSchema)
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if not setting:
raise HTTPException(status_code=404, detail="Setting not found")
return setting
@router.put("/{key}", response_model=SettingSchema)
async def update_setting(
key: str,
setting_update: SettingUpdate,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if not setting:
# Create if not exists
setting = Setting(key=key, value=setting_update.value)
db.add(setting)
else:
setting.value = setting_update.value
await db.commit()
await db.refresh(setting)
return setting
+61
View File
@@ -0,0 +1,61 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models import Source
from app.schemas import SourceCreate, SourceUpdate, Source as SourceSchema
router = APIRouter(prefix="/api/sources", tags=["sources"])
@router.get("/", response_model=List[SourceSchema])
async def list_sources(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Source))
sources = result.scalars().all()
return sources
@router.post("/", response_model=SourceSchema)
async def create_source(source: SourceCreate, db: AsyncSession = Depends(get_db)):
db_source = Source(**source.model_dump())
db.add(db_source)
await db.commit()
await db.refresh(db_source)
return db_source
@router.get("/{source_id}", response_model=SourceSchema)
async def get_source(source_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Source).where(Source.id == source_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source not found")
return source
@router.put("/{source_id}", response_model=SourceSchema)
async def update_source(
source_id: int,
source_update: SourceUpdate,
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Source).where(Source.id == source_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source not found")
update_data = source_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(source, field, value)
await db.commit()
await db.refresh(source)
return source
@router.delete("/{source_id}")
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Source).where(Source.id == source_id))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Source not found")
await db.delete(source)
await db.commit()
return {"message": "Source deleted"}
+143
View File
@@ -0,0 +1,143 @@
from pydantic import BaseModel, Field, ConfigDict, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
import re
# Source schemas
class SourceBase(BaseModel):
name: str
type: str = Field(..., pattern="^(local|ssh|database)$")
config: Dict[str, Any] = Field(default_factory=dict)
class SourceCreate(SourceBase):
pass
class SourceUpdate(BaseModel):
name: Optional[str] = None
config: Optional[Dict[str, Any]] = None
class Source(SourceBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Job schemas
class JobBase(BaseModel):
name: str
source_id: int
strategy: str = Field(default="full", pattern="^(full|incremental)$")
destination_path: str
exclude_patterns: List[str] = Field(default_factory=list)
enabled: bool = True
class JobCreate(JobBase):
pass
class JobUpdate(BaseModel):
name: Optional[str] = None
strategy: Optional[str] = None
destination_path: Optional[str] = None
exclude_patterns: Optional[List[str]] = None
enabled: Optional[bool] = None
class Job(JobBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Schedule schemas
class ScheduleBase(BaseModel):
job_id: int
cron_expression: str
enabled: bool = True
@field_validator('cron_expression')
@classmethod
def validate_cron(cls, v: str) -> str:
pattern = r'^([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)\s+([0-9*,/-]+)$'
if not re.match(pattern, v):
raise ValueError('Invalid cron expression format')
return v
class ScheduleCreate(ScheduleBase):
pass
class ScheduleUpdate(BaseModel):
cron_expression: Optional[str] = None
enabled: Optional[bool] = None
class Schedule(ScheduleBase):
id: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Execution schemas
class JobExecutionBase(BaseModel):
job_id: int
status: str = Field(default="pending", pattern="^(pending|running|success|failed|cancelled)$")
triggered_by: str = Field(..., pattern="^(manual|schedule)$")
class JobExecutionCreate(JobExecutionBase):
pass
class JobExecution(JobExecutionBase):
id: int
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
bytes_processed: int = 0
bytes_backed_up: int = 0
error_message: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class JobExecutionUpdate(BaseModel):
status: Optional[str] = Field(None, pattern="^(pending|running|success|failed|cancelled)$")
error_message: Optional[str] = None
# Backup schemas
class BackupBase(BaseModel):
execution_id: int
storage_path: str
size_bytes: int = 0
checksum: Optional[str] = None
type: str = Field(..., pattern="^(full|incremental)$")
parent_backup_id: Optional[int] = None
class BackupCreate(BackupBase):
pass
class Backup(BackupBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# Settings schemas
class SettingBase(BaseModel):
key: str
value: Optional[str] = None
class SettingCreate(SettingBase):
pass
class SettingUpdate(BaseModel):
value: Optional[str] = None
class Setting(SettingBase):
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Dashboard schemas
class DashboardStats(BaseModel):
active_jobs: int
total_backups: int
storage_used_bytes: int
recent_failures: int
recent_executions: List[JobExecution]
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
from typing import Dict, Any
from .base import SourceAdapter
from .local import LocalAdapter
from .ssh import SSHAdapter
from .database import DatabaseAdapter
ADAPTER_MAP = {
"local": LocalAdapter,
"ssh": SSHAdapter,
"database": DatabaseAdapter,
}
def get_adapter(source_type: str, config: Dict[str, Any]) -> SourceAdapter:
adapter_class = ADAPTER_MAP.get(source_type)
if not adapter_class:
raise ValueError(f"Unknown source type: {source_type}")
return adapter_class(config)
+39
View File
@@ -0,0 +1,39 @@
from abc import ABC, abstractmethod
from typing import List, Dict, Any, AsyncIterator
from dataclasses import dataclass
@dataclass
class FileInfo:
path: str
size: int
modified_time: float
is_directory: bool
class SourceAdapter(ABC):
def __init__(self, config: Dict[str, Any]):
self.config = config
@abstractmethod
async def connect(self) -> None:
"""Establish connection to source."""
pass
@abstractmethod
async def disconnect(self) -> None:
"""Close connection to source."""
pass
@abstractmethod
async def list_files(self, path: str = "") -> List[FileInfo]:
"""List files at given path."""
pass
@abstractmethod
async def read_file(self, path: str) -> AsyncIterator[bytes]:
"""Read file in chunks."""
pass
@abstractmethod
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
"""Get database dump. Only implemented for database adapters."""
pass
+74
View File
@@ -0,0 +1,74 @@
import os
import subprocess
import tempfile
from typing import List, AsyncIterator, Dict, Any
from .base import SourceAdapter, FileInfo
class DatabaseAdapter(SourceAdapter):
async def connect(self) -> None:
pass
async def disconnect(self) -> None:
pass
async def list_files(self, path: str = "") -> List[FileInfo]:
return []
async def read_file(self, path: str) -> AsyncIterator[bytes]:
raise NotImplementedError("Database adapter does not support file reading")
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
db_type = config.get("db_type", "postgresql")
host = config.get("host", "localhost")
port = config.get("port", 5432 if db_type == "postgresql" else 3306)
database = config.get("database")
username = config.get("username")
password = config.get("password")
if not database:
raise ValueError("Database name is required")
with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
tmp_path = tmp.name
try:
if db_type == "postgresql":
env = os.environ.copy()
if password:
env["PGPASSWORD"] = password
cmd = [
"pg_dump",
"-h", host,
"-p", str(port),
"-U", username or "postgres",
"-f", tmp_path,
database
]
elif db_type == "mysql":
env = os.environ.copy()
if password:
env["MYSQL_PWD"] = password
cmd = [
"mysqldump",
"-h", host,
"-P", str(port),
"-u", username or "root",
"--result-file", tmp_path,
database
]
else:
raise ValueError(f"Unsupported database type: {db_type}")
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Database dump failed: {result.stderr}")
with open(tmp_path, "rb") as f:
while chunk := f.read(8192):
yield chunk
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
+47
View File
@@ -0,0 +1,47 @@
import os
import aiofiles
from pathlib import Path
from typing import List, AsyncIterator, Dict, Any
from .base import SourceAdapter, FileInfo
class LocalAdapter(SourceAdapter):
async def connect(self) -> None:
base_path = self.config.get("path", ".")
if not os.path.exists(base_path):
raise FileNotFoundError(f"Path not found: {base_path}")
async def disconnect(self) -> None:
pass
async def list_files(self, path: str = "") -> List[FileInfo]:
base_path = Path(self.config.get("path", "."))
target_path = base_path / path if path else base_path
files = []
exclude_patterns = self.config.get("exclude", [])
for item in target_path.iterdir():
# Check exclude patterns
if any(item.match(pattern) for pattern in exclude_patterns):
continue
stat = item.stat()
files.append(FileInfo(
path=str(item.relative_to(base_path)),
size=stat.st_size,
modified_time=stat.st_mtime,
is_directory=item.is_dir()
))
return files
async def read_file(self, path: str) -> AsyncIterator[bytes]:
base_path = Path(self.config.get("path", "."))
file_path = base_path / path
async with aiofiles.open(file_path, "rb") as f:
while chunk := await f.read(8192):
yield chunk
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
raise NotImplementedError("Local adapter does not support database dumps")
+92
View File
@@ -0,0 +1,92 @@
import os
import tempfile
from pathlib import Path
from typing import List, AsyncIterator, Dict, Any
import paramiko
from .base import SourceAdapter, FileInfo
class SSHAdapter(SourceAdapter):
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.client = None
self.sftp = None
async def connect(self) -> None:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
host = self.config.get("host", "localhost")
port = self.config.get("port", 22)
username = self.config.get("username")
password = self.config.get("password")
key_path = self.config.get("key_path")
connect_kwargs = {
"hostname": host,
"port": port,
"username": username,
}
if password:
connect_kwargs["password"] = password
elif key_path and os.path.exists(key_path):
connect_kwargs["key_filename"] = key_path
self.client.connect(**connect_kwargs)
self.sftp = self.client.open_sftp()
async def disconnect(self) -> None:
if self.sftp:
self.sftp.close()
self.sftp = None
if self.client:
self.client.close()
self.client = None
async def list_files(self, path: str = "") -> List[FileInfo]:
remote_path = self.config.get("path", ".")
target_path = f"{remote_path}/{path}" if path else remote_path
files = []
exclude_patterns = self.config.get("exclude", [])
try:
for entry in self.sftp.listdir_attr(target_path):
entry_path = f"{target_path}/{entry.filename}"
rel_path = entry_path.replace(remote_path + "/", "", 1) if remote_path != "." else entry_path
if any(pattern in rel_path for pattern in exclude_patterns):
continue
is_dir = entry.st_mode & 0o40000 == 0o40000 if hasattr(entry, 'st_mode') else False
files.append(FileInfo(
path=rel_path,
size=entry.st_size,
modified_time=entry.st_mtime,
is_directory=is_dir
))
except IOError:
pass
return files
async def read_file(self, path: str) -> AsyncIterator[bytes]:
remote_path = self.config.get("path", ".")
file_path = f"{remote_path}/{path}" if not path.startswith("/") else path
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
try:
self.sftp.get(file_path, tmp_path)
with open(tmp_path, "rb") as f:
while chunk := f.read(8192):
yield chunk
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
async def get_database_dump(self, config: Dict[str, Any]) -> AsyncIterator[bytes]:
raise NotImplementedError("SSH adapter does not support database dumps directly")
+138
View File
@@ -0,0 +1,138 @@
import os
import hashlib
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models import Job, JobExecution, Backup
from backup.adapters import get_adapter
from backup.retention import RetentionPolicy
class BackupEngine:
def __init__(self, db: AsyncSession):
self.db = db
async def execute_job(self, job_id: int, triggered_by: str = "manual") -> JobExecution:
# Create execution record
execution = JobExecution(
job_id=job_id,
status="pending",
triggered_by=triggered_by
)
self.db.add(execution)
await self.db.commit()
await self.db.refresh(execution)
try:
# Load job with source
result = await self.db.execute(
select(Job).where(Job.id == job_id)
)
job = result.scalar_one()
# Update status to running
execution.status = "running"
execution.started_at = datetime.now(timezone.utc)
await self.db.commit()
# Determine strategy
strategy = job.strategy
parent_backup_id = None
if strategy == "incremental":
# Find last successful full backup
result = await self.db.execute(
select(Backup)
.join(JobExecution)
.where(
JobExecution.job_id == job_id,
JobExecution.status == "success",
Backup.type == "full"
)
.order_by(Backup.created_at.desc())
)
last_full = result.scalar_one_or_none()
if last_full:
parent_backup_id = last_full.id
else:
# No full backup exists, do full instead
strategy = "full"
# Create backup directory
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%S")
backup_dir = Path(job.destination_path) / str(job_id) / f"{timestamp}_{strategy}"
backup_dir.mkdir(parents=True, exist_ok=True)
# Get adapter and connect
adapter = get_adapter(job.source.type, job.source.config)
await adapter.connect()
try:
# Copy files
total_processed = 0
total_backed_up = 0
source_path = Path(job.source.config.get("path", "."))
for item in source_path.rglob("*"):
if item.is_file():
rel_path = item.relative_to(source_path)
dest_path = backup_dir / "data" / rel_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
# Copy file
shutil.copy2(item, dest_path)
size = item.stat().st_size
total_processed += size
total_backed_up += size
# Calculate checksum
checksum = await self._calculate_checksum(backup_dir)
# Create backup record
backup = Backup(
execution_id=execution.id,
storage_path=str(backup_dir),
size_bytes=total_backed_up,
checksum=checksum,
type=strategy,
parent_backup_id=parent_backup_id
)
self.db.add(backup)
# Update execution
execution.status = "success"
execution.completed_at = datetime.now(timezone.utc)
execution.bytes_processed = total_processed
execution.bytes_backed_up = total_backed_up
# Apply retention policy
retention = RetentionPolicy(self.db)
keep_count = getattr(job, 'retention_count', None)
keep_days = getattr(job, 'retention_days', None)
if keep_count or keep_days:
await retention.apply_retention_for_job(job_id, keep_count, keep_days)
finally:
await adapter.disconnect()
except Exception as e:
execution.status = "failed"
execution.completed_at = datetime.now(timezone.utc)
execution.error_message = str(e)
await self.db.commit()
return execution
async def _calculate_checksum(self, path: Path) -> str:
hasher = hashlib.sha256()
for item in sorted(path.rglob("*")):
if item.is_file():
with open(item, "rb") as f:
while chunk := f.read(8192):
hasher.update(chunk)
return hasher.hexdigest()
+90
View File
@@ -0,0 +1,90 @@
import shutil
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import List, Optional
from sqlalchemy import select
from app.models import Backup
class RetentionPolicy:
def __init__(self, db):
self.db = db
async def apply_retention_for_job(
self,
job_id: int,
keep_count: Optional[int] = None,
keep_days: Optional[int] = None
) -> List[Backup]:
"""
Apply retention policy for a job's backups.
Args:
job_id: The job ID to apply retention for
keep_count: Maximum number of backups to keep (oldest deleted first)
keep_days: Delete backups older than this many days
Returns:
List of deleted backups
"""
deleted_backups = []
result = await self.db.execute(
select(Backup)
.where(Backup.execution.has(job_id=job_id))
.order_by(Backup.created_at.asc())
)
backups = result.scalars().all()
if not backups:
return deleted_backups
backups_to_delete = set()
if keep_count is not None and len(backups) > keep_count:
backups_to_delete.update(backups[:-keep_count])
if keep_days is not None:
cutoff_date = datetime.now(timezone.utc) - timedelta(days=keep_days)
for backup in backups:
if backup.created_at < cutoff_date:
backups_to_delete.add(backup)
for backup in list(backups_to_delete):
await self._delete_backup(backup)
deleted_backups.append(backup)
await self.db.commit()
return deleted_backups
async def _delete_backup(self, backup: Backup):
"""Delete a backup and its storage."""
try:
storage_path = Path(backup.storage_path)
if storage_path.exists():
shutil.rmtree(storage_path)
except Exception:
pass
await self.db.delete(backup)
async def cleanup_orphaned_backups(self) -> int:
"""
Remove backup records whose storage no longer exists.
Returns:
Number of orphaned backups removed
"""
result = await self.db.execute(select(Backup))
backups = result.scalars().all()
removed_count = 0
for backup in backups:
storage_path = Path(backup.storage_path)
if not storage_path.exists():
await self.db.delete(backup)
removed_count += 1
await self.db.commit()
return removed_count
+81
View File
@@ -0,0 +1,81 @@
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.
+68
View File
@@ -0,0 +1,68 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
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",
]
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",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.26.0",
]
prod = [
"gunicorn>=23.0.0",
]
[project.scripts]
backup-tool = "app.main: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.packages.find]
where = ["."]
include = ["app*", "backup*", "alembic*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = [".", "app", "backup"]
[tool.setuptools.package-data]
alembic = ["*.ini", "*.py", "*.mako"]
+33
View File
@@ -0,0 +1,33 @@
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.database import Base, get_db
from app.main import app
from httpx import AsyncClient, ASGITransport
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture
async def db():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
finally:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def client(db):
async def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
+82
View File
@@ -0,0 +1,82 @@
import pytest
import pytest_asyncio
import tempfile
import os
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Source, Job, JobExecution
from backup.engine import BackupEngine
@pytest_asyncio.fixture
async def test_source(db: AsyncSession):
with tempfile.TemporaryDirectory() as tmpdir:
# Create test files
(Path(tmpdir) / "test.txt").write_text("Hello, World!")
(Path(tmpdir) / "subdir").mkdir()
(Path(tmpdir) / "subdir" / "nested.txt").write_text("Nested content")
source = Source(
name="Test Source",
type="local",
config={"path": tmpdir}
)
db.add(source)
await db.commit()
await db.refresh(source)
yield source
@pytest_asyncio.fixture
async def test_job(db: AsyncSession, test_source):
with tempfile.TemporaryDirectory() as tmpdir:
job = Job(
name="Test Job",
source_id=test_source.id,
strategy="full",
destination_path=tmpdir
)
db.add(job)
await db.commit()
await db.refresh(job)
yield job
@pytest.mark.asyncio
async def test_execute_full_backup(db: AsyncSession, test_job):
engine = BackupEngine(db)
execution = await engine.execute_job(test_job.id, triggered_by="manual")
assert execution.status == "success"
assert execution.bytes_processed > 0
assert execution.bytes_backed_up > 0
assert execution.triggered_by == "manual"
# Refresh test_job to load executions relationship
await db.refresh(test_job, ["executions"])
# Verify backup was created
assert len(test_job.executions) == 1
# Refresh execution to load backups relationship
await db.refresh(test_job.executions[0], ["backups"])
backup = test_job.executions[0].backups[0]
assert backup.type == "full"
assert backup.checksum is not None
assert os.path.exists(backup.storage_path)
@pytest.mark.asyncio
async def test_execute_incremental_without_full(db: AsyncSession, test_job):
# Set job to incremental but no full backup exists
test_job.strategy = "incremental"
await db.commit()
engine = BackupEngine(db)
execution = await engine.execute_job(test_job.id)
# Should fall back to full backup
assert execution.status == "success"
# Refresh execution to load backups relationship
await db.refresh(execution, ["backups"])
backup = execution.backups[0]
assert backup.type == "full"
assert backup.parent_backup_id is None
+205
View File
@@ -0,0 +1,205 @@
import pytest
@pytest.mark.asyncio
async def test_create_job(client):
# Create a source first (job requires source_id)
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
assert source_resp.status_code == 200
source_id = source_resp.json()["id"]
response = await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Job"
assert data["source_id"] == source_id
assert data["strategy"] == "full"
assert "id" in data
@pytest.mark.asyncio
async def test_list_jobs(client):
# Create source and job
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
response = await client.get("/api/jobs/")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
@pytest.mark.asyncio
async def test_get_job(client):
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
job_resp = await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.get(f"/api/jobs/{job_id}")
assert response.status_code == 200
assert response.json()["id"] == job_id
@pytest.mark.asyncio
async def test_delete_job(client):
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
job_resp = await client.post("/api/jobs/", json={
"name": "Delete Me",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.delete(f"/api/jobs/{job_id}")
assert response.status_code == 200
# Verify deletion
get_resp = await client.get(f"/api/jobs/{job_id}")
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_run_job(client):
# Create source
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.post(f"/api/jobs/{job_id}/run")
assert response.status_code == 200
assert response.json()["message"] == "Job execution started"
@pytest.mark.asyncio
async def test_run_job_not_found(client):
response = await client.post("/api/jobs/999/run")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_job(client):
# Create source
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await client.post("/api/jobs/", json={
"name": "Original Name",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.put(f"/api/jobs/{job_id}", json={
"name": "Updated Name"
})
assert response.status_code == 200
assert response.json()["name"] == "Updated Name"
assert response.json()["strategy"] == "full" # Unchanged
@pytest.mark.asyncio
async def test_create_schedule(client):
# Create source
source_resp = await client.post("/api/sources/", json={
"name": "Test Source",
"type": "local",
"config": {"path": "/tmp/test"}
})
source_id = source_resp.json()["id"]
# Create job
job_resp = await client.post("/api/jobs/", json={
"name": "Test Job",
"source_id": source_id,
"strategy": "full",
"destination_path": "/tmp/backups",
"exclude_patterns": [],
"enabled": True
})
job_id = job_resp.json()["id"]
response = await client.post(f"/api/jobs/{job_id}/schedule", json={
"job_id": job_id,
"cron_expression": "0 0 * * *",
"enabled": True
})
assert response.status_code == 200
data = response.json()
assert data["job_id"] == job_id
assert data["cron_expression"] == "0 0 * * *"
assert "id" in data
@pytest.mark.asyncio
async def test_get_job_not_found(client):
response = await client.get("/api/jobs/99999")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_job_not_found(client):
response = await client.put("/api/jobs/99999", json={"name": "Test"})
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_job_not_found(client):
response = await client.delete("/api/jobs/99999")
assert response.status_code == 404
+88
View File
@@ -0,0 +1,88 @@
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
View File
+60
View File
@@ -0,0 +1,60 @@
version: "3.8"
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: backup-tool-backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=sqlite+aiosqlite:///data/backup_tool.db
- CORS_ORIGINS=http://localhost:3000
- BACKUP_STORAGE_PATH=/app/backups
volumes:
- backup-data:/app/data
- backup-storage:/app/backups
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: backup-tool-frontend
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
profiles:
- prod
frontend-dev:
image: node:20-alpine
container_name: backup-tool-frontend-dev
working_dir: /app
ports:
- "3000:3000"
volumes:
- ./frontend:/app
- /app/node_modules
command: sh -c "npm install && npm run dev"
environment:
- VITE_API_URL=http://localhost:8000
depends_on:
- backend
profiles:
- dev
volumes:
backup-data:
driver: local
backup-storage:
driver: local
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,533 @@
# Backup Tool Design Specification
**Date:** 2026-05-11
**Status:** Approved
**Target:** Small team / SMB backup management
---
## 1. Overview
A web-based backup management tool for small teams and SMBs. The tool pulls data from various sources (local filesystem, remote via SSH, databases) to a central backup server, providing a dashboard for monitoring and management.
### Goals
- Centralized backup management with web UI
- Support for local, SSH, and database sources
- Full and incremental backup strategies
- Manual and scheduled job execution
- SQLite-based persistence for simplicity
- Extensible architecture for future source types and storage backends
### Non-Goals
- Enterprise-scale distributed backup (1000+ nodes)
- Real-time continuous backup (near-CDP)
- Built-in cloud storage (S3, Azure Blob) in v1
- Multi-tenancy or RBAC beyond basic auth
---
## 2. Architecture
### 2.1 High-Level Design
```
┌─────────────────────────────────────────────┐
│ Frontend Layer (React) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Dashboard│ │ Backups │ │ Settings │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────┬──────────────────────────┘
│ REST API
┌──────────────────▼──────────────────────────┐
│ Backend Layer (Python) │
│ ┌────────┐ ┌──────────┐ ┌──────────┐ │
│ │ API │ │ Backup │ │ Scheduler│ │
│ │ Server │ │ Engine │ │ (APSched)│ │
│ └────────┘ └──────────┘ └──────────┘ │
│ ┌──────────────────────────────────┐ │
│ │ Source Adapters │ │
│ │ (Local | SSH | Database | ...) │ │
│ └──────────────────────────────────┘ │
└──────────────────┬──────────────────────────┘
┌──────────────────▼──────────────────────────┐
│ Data Layer │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ SQLite DB │ │ File Storage │ │
│ │ (Jobs, History)│ │ (Backup Archives)│ │
│ └────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────┘
```
### 2.2 Key Design Principles
1. **Source-agnostic**: Adapter pattern enables new source types without core changes
2. **Job-driven**: Everything is a job - manual or scheduled, full or incremental
3. **SQLite simplicity**: Single-file database, zero external dependencies
4. **REST API separation**: Clean frontend/backend boundary
5. **Extensible**: Plugin architecture for sources, storage, notifications
---
## 3. Technology Stack
### Backend
- **Python 3.11+**
- **FastAPI** - Async web framework with auto-generated OpenAPI docs
- **SQLAlchemy 2.0+** - ORM with async support
- **Alembic** - Database migrations
- **APScheduler** - Job scheduling (cron expressions)
- **Paramiko** - SSH client for remote sources
- **rsync/libsync** - Incremental file synchronization
- **Pydantic** - Data validation and settings management
### Frontend
- **React 18+** with TypeScript
- **TanStack Query** - Server state management and caching
- **React Router 6+** - Client-side routing
- **Tailwind CSS** - Utility-first styling
- **Recharts** - Dashboard charts and visualizations
- **React Hook Form** - Form management
### Data Storage
- **SQLite** - Single-file relational database
- **Local filesystem** - Backup archive storage
---
## 4. Data Model
### 4.1 Entities
#### sources
Stores backup source configurations.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment primary key |
| name | TEXT | Human-readable source name |
| type | TEXT | Source type: `local`, `ssh`, `database` |
| config | JSON | Type-specific configuration (path, host, credentials, etc.) |
| created_at | DATETIME | Creation timestamp |
| updated_at | DATETIME | Last update timestamp |
**Config examples by type:**
- `local`: `{"path": "/var/data", "exclude": ["*.tmp", "*.log"]}``
- `ssh`: `{"host": "server1", "port": 22, "username": "backup", "path": "/data", "key_path": "/keys/id_rsa"}`
- `database`: `{"db_type": "postgresql", "host": "db1", "port": 5432, "database": "app", "username": "backup"}`
#### jobs
Defines backup jobs with strategy and scheduling.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment primary key |
| name | TEXT | Human-readable job name |
| source_id | INTEGER FK → sources | Source to backup |
| strategy | TEXT | `full` or `incremental` |
| destination_path | TEXT | Local path for backup storage |
| exclude_patterns | JSON | Additional exclude patterns (merged with source config) |
| enabled | BOOLEAN | Whether job is active |
| created_at | DATETIME | Creation timestamp |
| updated_at | DATETIME | Last update timestamp |
#### schedules
Cron-based scheduling for jobs (1:1 with jobs for simplicity).
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment primary key |
| job_id | INTEGER FK → jobs | Associated job |
| cron_expression | TEXT | Cron expression (e.g., "0 2 * * *" for daily 2 AM) |
| enabled | BOOLEAN | Whether schedule is active |
| created_at | DATETIME | Creation timestamp |
#### job_executions
Tracks each job run with status and metrics.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment primary key |
| job_id | INTEGER FK → jobs | Executed job |
| status | TEXT | `pending`, `running`, `success`, `failed`, `cancelled` |
| started_at | DATETIME | Execution start time |
| completed_at | DATETIME | Execution end time (NULL if running) |
| bytes_processed | INTEGER | Total bytes read from source |
| bytes_backed_up | INTEGER | Total bytes written to destination |
| error_message | TEXT | Error details if failed |
| triggered_by | TEXT | `manual` or `schedule` |
#### backups
Individual backup archives created by executions.
| Column | Type | Description |
|--------|------|-------------|
| id | INTEGER PK | Auto-increment primary key |
| execution_id | INTEGER FK → job_executions | Parent execution |
| storage_path | TEXT | Path to backup archive on disk |
| size_bytes | INTEGER | Archive size in bytes |
| checksum | TEXT | SHA-256 checksum for integrity verification |
| type | TEXT | `full` or `incremental` |
| parent_backup_id | INTEGER FK → backups | Previous backup in incremental chain (NULL for full) |
| created_at | DATETIME | Creation timestamp |
#### settings
Key-value application configuration.
| Column | Type | Description |
|--------|------|-------------|
| key | TEXT PK | Setting identifier |
| value | TEXT | Setting value (JSON-encoded if complex) |
| updated_at | DATETIME | Last update timestamp |
### 4.2 Relationships
- **sources → jobs**: One-to-many (one source can have multiple jobs)
- **jobs → schedules**: One-to-one (each job has one schedule)
- **jobs → job_executions**: One-to-many (job run history)
- **job_executions → backups**: One-to-many (execution produces archives)
- **backups → backups**: Self-referencing (incremental chain via parent_backup_id)
---
## 5. Backend Components
### 5.1 API Server (FastAPI)
**Responsibilities:**
- Expose REST endpoints for frontend
- Validate requests with Pydantic schemas
- Handle authentication (basic auth or API keys in v1)
- Serve static frontend files in production
**Key Endpoints:**
- `GET /api/dashboard` - Dashboard stats and recent activity
- `GET /api/sources` - List all sources
- `POST /api/sources` - Create new source
- `PUT /api/sources/{id}` - Update source
- `DELETE /api/sources/{id}` - Delete source
- `GET /api/jobs` - List all jobs
- `POST /api/jobs` - Create new job
- `PUT /api/jobs/{id}` - Update job
- `DELETE /api/jobs/{id}` - Delete job
- `POST /api/jobs/{id}/run` - Trigger manual execution
- `GET /api/jobs/{id}/executions` - Get execution history
- `GET /api/executions/{id}` - Get execution details
- `GET /api/executions/{id}/logs` - Get execution logs
- `GET /api/backups` - List all backups
- `GET /api/backups/{id}/download` - Download backup archive
- `DELETE /api/backups/{id}` - Delete backup
- `GET /api/settings` - Get all settings
- `PUT /api/settings` - Update settings
### 5.2 Backup Engine
**Responsibilities:**
- Execute backup jobs (full and incremental)
- Coordinate source adapters for data retrieval
- Handle compression and encryption
- Calculate and verify checksums
- Update execution status in real-time
**Flow:**
1. Receive job execution request
2. Load source configuration
3. Instantiate appropriate source adapter
4. Connect to source
5. Determine strategy (full vs incremental based on history)
6. Transfer data using adapter
7. Compress and encrypt (if configured)
8. Calculate checksums
9. Store metadata in database
10. Apply retention policy
### 5.3 Scheduler (APScheduler)
**Responsibilities:**
- Parse and evaluate cron expressions
- Trigger job executions at scheduled times
- Handle timezone support
- Provide next-run predictions for UI
**Configuration:**
- Uses SQLite backend for job persistence (survives restarts)
- AsyncIO executor for non-blocking operation
- Misfire grace period: 15 minutes
### 5.4 Source Adapters
**Abstract Base Class Interface:**
```python
class SourceAdapter(ABC):
@abstractmethod
async def connect(self) -> None: ...
@abstractmethod
async def disconnect(self) -> None: ...
@abstractmethod
async def list_files(self, path: str) -> List[FileInfo]: ...
@abstractmethod
async def read_file(self, path: str) -> AsyncIterator[bytes]: ...
@abstractmethod
async def get_database_dump(self, config: dict) -> AsyncIterator[bytes]: ...
```
**Implementations:**
- **LocalAdapter**: Direct filesystem access
- **SSHAdapter**: Paramiko-based SSH/SFTP connection
- **DatabaseAdapter**: Uses native CLI tools (pg_dump, mysqldump) for consistency
---
## 6. Frontend Views
### 6.1 Dashboard
**Purpose:** At-a-glance system health and recent activity
**Components:**
- Stats cards: Active jobs, total backups, storage used, recent failures
- Recent activity feed: Last 10 executions with status
- Storage overview chart: Pie chart showing storage by job
- Quick actions: Run job, view failed executions
### 6.2 Backups View
**Purpose:** Job and source management
**Components:**
- Job listing table: Name, source, strategy, schedule, last run, status
- Source listing: Name, type, connection status
- Create/Edit Job modal: Form with source selection, strategy, destination, schedule
- Create/Edit Source modal: Type-specific configuration forms
- Execution history per job: Expandable rows showing past runs
- Manual run button: Trigger immediate execution with confirmation
### 6.3 Settings View
**Purpose:** Application configuration
**Sections:**
- **General**: Default backup location, retention policy, compression
- **Storage**: Storage path, disk usage warnings
- **Notifications**: Webhook URLs, email settings
- **Security**: Encryption toggle, key management
- **Logs**: Log level, retention, download
---
## 7. Backup Execution Flow
### 7.1 Normal Flow
```
Trigger (Manual/Schedule)
Create Execution Record (status: pending)
Connect to Source (via adapter)
Determine Strategy:
- If no previous full backup → Full
- If strategy = full → Full
- If strategy = incremental → Incremental (link to parent)
Execute Backup:
- Stream data from source
- Compress (if enabled)
- Encrypt (if enabled)
- Calculate checksums
Verify & Store:
- Verify checksum
- Write metadata to backups table
- Update execution status → success
Cleanup & Retention:
- Apply retention policy
- Delete old backups
- Update storage stats
```
### 7.2 Incremental Backup Strategy
For incremental backups, the system uses file-level deduplication:
1. Compare file metadata (mtime, size) against last backup
2. Only transfer changed files
3. Create hard links or copy-on-write references for unchanged files
4. Store incremental manifest referencing parent backup
**Storage format:**
```
backups/
├── 2026-05-11_020000_full/
│ ├── data/
│ └── manifest.json
└── 2026-05-11_140000_incr/
├── data/ (only changed files)
└── manifest.json (references parent)
```
---
## 8. Error Handling
### 8.1 Error Scenarios
| Scenario | Handling |
|----------|----------|
| **Connection failure** | Retry 3x with exponential backoff (1s, 2s, 4s), then mark failed |
| **Partial backup** | Mark as failed if any source file fails; keep partial for inspection |
| **Storage full** | Check before starting (>10% free required); alert if threshold reached |
| **Checksum mismatch** | Delete corrupted backup, retry once, alert admin |
| **Concurrent jobs** | Queue if resource limit reached; configurable max concurrent |
| **Source unavailable** | Mark failed, schedule retry based on policy |
| **Network interruption** | Resume capability for large transfers (SSH/SCP) |
### 8.2 Logging
- Structured JSON logging for machine parsing
- Separate logs per execution: `/var/log/backup-tool/executions/{id}.log`
- Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
- Frontend can stream logs via WebSocket (future) or polling
---
## 9. Extensibility
### 9.1 Source Adapter Extension
To add a new source type (e.g., S3):
1. Create class extending `SourceAdapter`
2. Implement required methods
3. Register in adapter factory
4. Add UI form components for configuration
### 9.2 Storage Backend Extension
Storage backends follow similar pattern:
1. Implement `StorageBackend` interface
2. Support `store()`, `retrieve()`, `delete()`, `list()` operations
3. Register in backend factory
### 9.3 Notification Channels
Notification system supports pluggable channels:
- Webhook (generic HTTP POST)
- Email (SMTP)
- Slack/Discord (webhook URLs)
---
## 10. Security Considerations
- **Credential storage**: Source credentials stored encrypted in SQLite (Fernet encryption)
- **API authentication**: JWT tokens or API keys
- **Backup encryption**: Optional AES-256 encryption of archives
- **Transport security**: SSH for remote sources, HTTPS for web UI
- **File permissions**: Backup archives readable only by backup service user
---
## 11. Performance Considerations
- **Streaming**: Large files streamed rather than loaded into memory
- **Async I/O**: All source adapters use async operations
- **Pagination**: API endpoints paginated (50 items default)
- **Database indexing**: Indexed on frequently queried columns (job_id, status, created_at)
- **Background tasks**: Long-running backups execute in background workers
---
## 12. Deployment
### 12.1 Development
```bash
# Backend
pip install -r requirements.txt
uvicorn main:app --reload
# Frontend
cd frontend
npm install
npm run dev
```
### 12.2 Production
- Backend: Uvicorn with Gunicorn (workers=4)
- Frontend: Built static files served by FastAPI
- Systemd service for automatic startup
- SQLite: Single file, backup with `.backup` command
---
## 13. Testing Strategy
### 13.1 Backend Tests
- **Unit tests**: Adapter mocking, engine logic
- **Integration tests**: Database operations, API endpoints
- **End-to-end tests**: Full backup flows with test fixtures
### 13.2 Frontend Tests
- **Component tests**: React Testing Library
- **Integration tests**: API mocking with MSW
- **E2E tests**: Playwright for critical flows
---
## 14. Future Roadmap
### v1.1
- Cloud storage backends (S3, Azure Blob)
- Backup verification (automated restore testing)
- Email notifications
### v1.2
- Multi-node backup (agent-based architecture)
- Backup encryption at rest
- WebSocket live log streaming
### v2.0
- REST API for external integrations
- Backup reporting and analytics
- Role-based access control
---
## 15. Appendix
### 15.1 Cron Expression Examples
| Expression | Schedule |
|------------|----------|
| `0 2 * * *` | Daily at 2:00 AM |
| `0 */6 * * *` | Every 6 hours |
| `0 0 * * 0` | Weekly on Sunday |
| `0 0 1 * *` | Monthly on 1st |
### 15.2 Database Migration Strategy
- Alembic for schema migrations
- One migration per release
- Backward compatibility for rolling updates
- Migration tests in CI pipeline
### 15.3 Backup Archive Format
```
{destination_path}/{job_id}/
├── 2026-05-11_020000/
│ ├── manifest.json # Metadata and file list
│ ├── data.tar.gz # Compressed archive (or directory tree)
│ └── checksum.sha256 # Integrity verification
└── latest -> 2026-05-11_020000/ # Symlink to latest backup
```
---
**End of Specification**
+20
View File
@@ -0,0 +1,20 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Backup Tool</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "backup-tool-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"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"
},
"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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+33
View File
@@ -0,0 +1,33 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Layout } from './components/Layout';
import { Dashboard } from './pages/Dashboard';
import { Backups } from './pages/Backups';
import { Settings } from './pages/Settings';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/backups" element={<Backups />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;
+62
View File
@@ -0,0 +1,62 @@
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}`),
};
+80
View File
@@ -0,0 +1,80 @@
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>
);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+238
View File
@@ -0,0 +1,238 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Play, Trash2, Plus } from 'lucide-react';
import { jobsApi, sourcesApi } from '../api/client';
import type { BackupJob, BackupSource } from '../api/client';
function StatusBadge({ status }: { status: string }) {
const styles = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-800'
}`}
>
{status}
</span>
);
}
function CreateJobModal({
onClose,
}: {
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [sourceId, setSourceId] = useState('');
const { data: sources } = useQuery({
queryKey: ['sources'],
queryFn: () => sourcesApi.getAll().then((res) => res.data),
});
const createMutation = useMutation({
mutationFn: (sid: string) => jobsApi.create(sid),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (sourceId) {
createMutation.mutate(sourceId);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Create Backup Job
</h3>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Source
</label>
<select
value={sourceId}
onChange={(e) => setSourceId(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select a source...</option>
{sources?.map((source: BackupSource) => (
<option key={source.id} value={source.id}>
{source.name} ({source.type})
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={!sourceId || createMutation.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{createMutation.isPending ? 'Creating...' : 'Create Job'}
</button>
</div>
</form>
</div>
</div>
);
}
export function Backups() {
const [showModal, setShowModal] = useState(false);
const queryClient = useQueryClient();
const { data: jobs, isLoading } = useQuery({
queryKey: ['jobs'],
queryFn: () => jobsApi.getAll().then((res) => res.data),
});
const { data: sources } = useQuery({
queryKey: ['sources'],
queryFn: () => sourcesApi.getAll().then((res) => res.data),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => jobsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
},
});
const runMutation = useMutation({
mutationFn: (id: string) => jobsApi.create(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobs'] });
},
});
const getSourceName = (sourceId: string) => {
const source = sources?.find((s: BackupSource) => s.id === sourceId);
return source?.name || sourceId.slice(0, 8);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900">Backups</h2>
<button
onClick={() => setShowModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
New Job
</button>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Job ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Source
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Started
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{isLoading && (
<tr>
<td
colSpan={5}
className="px-6 py-8 text-center text-gray-500"
>
Loading...
</td>
</tr>
)}
{jobs?.length === 0 && !isLoading && (
<tr>
<td
colSpan={5}
className="px-6 py-8 text-center text-gray-500"
>
No backup jobs yet. Create one to get started.
</td>
</tr>
)}
{jobs?.map((job: BackupJob) => (
<tr key={job.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{job.id.slice(0, 8)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{getSourceName(job.source_id)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{job.started_at
? new Date(job.started_at).toLocaleString()
: 'Not started'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => runMutation.mutate(job.source_id)}
disabled={job.status === 'running'}
className="p-1 text-gray-600 hover:text-blue-600 disabled:opacity-50"
title="Run job"
>
<Play className="w-4 h-4" />
</button>
<button
onClick={() => {
if (confirm('Delete this job?')) {
deleteMutation.mutate(job.id);
}
}}
className="p-1 text-gray-600 hover:text-red-600"
title="Delete job"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showModal && <CreateJobModal onClose={() => setShowModal(false)} />}
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { useQuery } from '@tanstack/react-query';
import { Activity, Archive, AlertTriangle, CheckCircle } from 'lucide-react';
import { dashboardApi } from '../api/client';
function StatCard({
title,
value,
icon: Icon,
color,
}: {
title: string;
value: number;
icon: React.ElementType;
color: string;
}) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
<p className="mt-2 text-3xl font-bold text-gray-900">{value}</p>
</div>
<div className={`p-3 rounded-lg ${color}`}>
<Icon className="w-6 h-6 text-white" />
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles = {
pending: 'bg-yellow-100 text-yellow-800',
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
};
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-800'
}`}
>
{status}
</span>
);
}
export function Dashboard() {
const { data: stats } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: () => dashboardApi.getStats().then((res) => res.data),
});
const { data: recentJobs } = useQuery({
queryKey: ['recent-jobs'],
queryFn: () => dashboardApi.getRecentJobs(5).then((res) => res.data),
});
const activeJobs = stats?.pending_jobs || 0;
const recentFailures = stats?.failed_jobs || 0;
return (
<div className="space-y-6">
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Active Jobs"
value={activeJobs}
icon={Activity}
color="bg-blue-500"
/>
<StatCard
title="Total Backups"
value={stats?.total_jobs || 0}
icon={Archive}
color="bg-green-500"
/>
<StatCard
title="Completed"
value={stats?.completed_jobs || 0}
icon={CheckCircle}
color="bg-indigo-500"
/>
<StatCard
title="Recent Failures"
value={recentFailures}
icon={AlertTriangle}
color="bg-red-500"
/>
</div>
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Recent Activity
</h3>
</div>
<div className="divide-y divide-gray-200">
{recentJobs?.length === 0 && (
<div className="px-6 py-8 text-center text-gray-500">
No recent activity
</div>
)}
{recentJobs?.map((job) => (
<div
key={job.id}
className="px-6 py-4 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div
className={`w-2 h-2 rounded-full ${
job.status === 'completed'
? 'bg-green-500'
: job.status === 'failed'
? 'bg-red-500'
: job.status === 'running'
? 'bg-blue-500'
: 'bg-yellow-500'
}`}
/>
<div>
<p className="text-sm font-medium text-gray-900">
Job {job.id.slice(0, 8)}
</p>
<p className="text-xs text-gray-500">
{job.created_at
? new Date(job.created_at).toLocaleString()
: 'Unknown'}
</p>
</div>
</div>
<StatusBadge status={job.status} />
</div>
))}
</div>
</div>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
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>
);
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:8000'
}
}
})
+20
View File
@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours