Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f59540686 | |||
| cc694e71b4 | |||
| abeb64637e | |||
| 1978602da6 | |||
| 55ec0687a5 | |||
| a4a16655b9 | |||
| 4d2c21a42e | |||
| b4a22a1c44 | |||
| b2d196498b | |||
| 66f9de17c5 | |||
| 99eeffc810 | |||
| d3b92593d5 | |||
| 7676438466 | |||
| ed64b5ff62 | |||
| 19987d68a0 | |||
| 58c4bc7f30 | |||
| 55ba9b62d6 | |||
| 878e303cc6 | |||
| 955ed9db49 | |||
| 1f1ed72494 | |||
| 810aae1b6e | |||
| 0d0f41d616 | |||
| b69c098cab | |||
| f925ae094c | |||
| 83c3075de0 | |||
| 76cf5054be | |||
| caf5bcb172 | |||
| 13488f7b21 | |||
| 3deab5fdf3 | |||
| eff41bd801 | |||
| 4989652c5d | |||
| 56b68d7e57 |
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = sqlite+aiosqlite:///./backup_tool.db
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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.
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.
@@ -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"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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**
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user