feat(docker-watcher): phase 9 - SvelteKit dashboard & project views

SvelteKit project with Svelte 5, TypeScript, Tailwind CSS v4.
Dashboard with project cards, project detail with stage/instance
management, deploy history, instance controls. Shared API client
and reusable components (StatusBadge, InstanceCard, ProjectCard,
ConfirmDialog). Add Phase 14 (Volumes & Environment) to plan.
This commit is contained in:
2026-03-27 22:15:54 +03:00
parent 757c72eea1
commit 09d185d94e
13 changed files with 1787 additions and 53 deletions
+45 -7
View File
@@ -287,14 +287,52 @@ Full dashboard for visibility, manual control, and configuration.
22. **Embed in Go** — build SvelteKit to static, embed with `go:embed`, serve from Go
23. **Real-time updates** — SSE for deploy progress and instance status changes
### Phase 4: Hardening
### Phase 4: Volumes & Environment
24. **Blue-green deploys** — start new, health check, swap, stop old (zero downtime)
25. **Promote flow** — enforce `promote_from` for production deploys
26. **Auth on dashboard** — basic auth or token-based
27. **Graceful shutdown** — drain in-progress deploys on SIGTERM
28. **Structured logging** — JSON logs with deploy context
29. **Config export** — download current SQLite state as YAML
Persistent storage and app-specific configuration for deployed containers.
24. **Environment variables per project** — key/value pairs stored in SQLite, sensitive values encrypted
25. **Per-stage env overrides** — e.g., `NODE_ENV=development` for dev, `NODE_ENV=production` for prod
26. **Volume mounts per project** — configurable source/target paths with shared/isolated modes
27. **Shared volumes** — all instances of a project mount the same host path (for stateless apps or shared uploads)
28. **Isolated volumes** — each instance gets its own subdirectory: `{source}/{stage}-{tag}/``{target}` (for stateful apps with local DBs/files)
29. **UI for volumes & env** — project settings page with key/value editor, volume list, shared/isolated toggle, per-stage override support
Volume config per project:
```yaml
env:
NODE_ENV: production
DATABASE_URL: postgres://db:5432/myapp # shared external DB
SECRET_KEY: "..." # encrypted in SQLite
volumes:
- source: /data/my-app/uploads
target: /app/uploads
mode: shared # all instances share this path
- source: /data/my-app/data
target: /app/data
mode: isolated # auto-appends /{stage}-{tag}/ to source
```
Stage-level env overrides:
```yaml
stages:
dev:
env:
NODE_ENV: development # overrides project-level
DATABASE_URL: postgres://db:5432/myapp_dev
prod:
env:
NODE_ENV: production # uses project-level default
```
### Phase 5: Hardening
30. **Blue-green deploys** — start new, health check, swap, stop old (zero downtime)
31. **Promote flow** — enforce `promote_from` for production deploys
32. **Auth on dashboard** — basic auth or token-based
33. **Graceful shutdown** — drain in-progress deploys on SIGTERM
34. **Structured logging** — JSON logs with deploy context
35. **Config export** — download current SQLite state as YAML
## Key Dependencies (Go)
+12 -3
View File
@@ -31,11 +31,12 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M
- [x] Phase 6: Webhook Handler [domain: backend] → [subplan](./phase-6-webhook-handler.md)
- [x] Phase 7: Deployer & Health Checker [domain: backend] → [subplan](./phase-7-deployer.md)
- [x] Phase 8: REST API Layer [domain: backend] → [subplan](./phase-8-api-layer.md)
- [ ] Phase 9: SvelteKit Dashboard & Project Views [domain: frontend] → [subplan](./phase-9-dashboard.md)
- [x] Phase 9: SvelteKit Dashboard & Project Views [domain: frontend] → [subplan](./phase-9-dashboard.md)
- [ ] Phase 10: Quick Deploy & Settings Pages [domain: frontend] → [subplan](./phase-10-settings-deploy.md)
- [ ] Phase 11: Frontend Embed & Real-Time Updates [domain: fullstack] → [subplan](./phase-11-embed-sse.md)
- [ ] Phase 12: Hardening [domain: backend] → [subplan](./phase-12-hardening.md)
- [ ] Phase 13: Frontend Polish & Modern UI [domain: frontend] → [subplan](./phase-13-ui-polish.md)
- [ ] Phase 14: Volumes & Environment [domain: fullstack] → [subplan](./phase-14-volumes-env.md)
### Parallel Execution Notes
@@ -54,11 +55,12 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M
| Phase 6: Webhook Handler | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 7: Deployer & Health | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 8: API Layer | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
| Phase 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 9: Dashboard | frontend | ✅ Complete | ⬜ Pending | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 11: Embed & SSE | fullstack | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 12: Hardening | backend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 13: UI Polish | frontend | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ |
| Phase 13: UI Polish | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
| Phase 14: Volumes & Env | fullstack | ⬜ Not Started | ⬜ | ✅ Required (Final) | ⬜ |
## Amendment Log
@@ -76,6 +78,13 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M
**Why:** User wants bilingual support (English and Russian) in the dashboard
**Impact on existing phases:** None — contained within Phase 13
### Amendment 3 — 2026-03-27
**Type:** Added phase
**What changed:** Added Phase 14: Volumes & Environment — per-project env vars with per-stage overrides, volume mounts with shared/isolated modes, encryption for sensitive values, UI editor
**Why:** Missing from feature planner phases but present in root PLAN.md Phase 4
**Impact on existing phases:** Phase 14 becomes the final phase (build/tests required). Phase 13 (UI Polish) remains but no longer the final phase for build enforcement.
## Final Review
- [ ] Comprehensive code review
@@ -0,0 +1,58 @@
# Phase 14: Volumes & Environment
**Status:** ⬜ Not Started
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Implement per-project environment variables with per-stage overrides, volume mounts with shared/isolated modes, sensitive env value encryption, and UI for managing both.
## Tasks
- [ ] Task 1: Extend store schema — add `stage_env` table for per-stage env overrides (stage_id, key, value, encrypted bool)
- [ ] Task 2: Extend store schema — add `volumes` table for volume config (project_id, source, target, mode: shared|isolated)
- [ ] Task 3: Implement store CRUD for stage env overrides (Create, GetByStageID, Update, Delete)
- [ ] Task 4: Implement store CRUD for volumes (Create, GetByProjectID, Update, Delete)
- [ ] Task 5: Encrypt sensitive env values (values marked as secret) using crypto.Encrypt before storage
- [ ] Task 6: Merge env vars during deploy — project-level env + stage-level overrides, decrypt secrets
- [ ] Task 7: Compute volume mounts during deploy — shared mode uses path as-is, isolated mode appends `/{stage}-{tag}/` to source
- [ ] Task 8: Pass merged env vars and volume mounts to Docker container creation
- [ ] Task 9: API endpoints — CRUD for stage env vars and project volumes
- [ ] Task 10: Frontend — env var editor in project/stage settings (key/value pairs, secret toggle)
- [ ] Task 11: Frontend — volume editor in project settings (source/target/mode)
- [ ] Task 12: Frontend — per-stage env override UI showing inherited vs overridden values
## Files to Modify/Create
- `internal/store/stage_env.go` — stage env CRUD
- `internal/store/volumes.go` — volume CRUD
- `internal/store/store.go` — add new tables to schema
- `internal/deployer/deployer.go` — merge env vars and compute volume mounts during deploy
- `internal/docker/container.go` — accept volume mounts in ContainerConfig
- `internal/api/stages.go` — add env var endpoints
- `internal/api/projects.go` — add volume endpoints
- `web/src/routes/projects/[id]/env/+page.svelte` — env var editor
- `web/src/routes/projects/[id]/volumes/+page.svelte` — volume editor
## Acceptance Criteria
- Project-level env vars applied to all containers
- Stage-level overrides replace project-level values for matching keys
- Sensitive env values encrypted at rest, decrypted only during deploy
- Shared volumes: all instances mount same host path
- Isolated volumes: each instance gets `{source}/{stage}-{tag}/` subdirectory
- UI allows managing env vars and volumes per project and per stage
## Notes
- Project `env` field already exists as JSON blob in the store — this phase may migrate to a proper table or keep JSON and add stage overrides separately
- Volume `mode` is either "shared" or "isolated"
- Isolated volume subdirectory is created automatically by Docker (bind mount creates parent dirs)
- Sensitive env display: masked in UI, "Change" button pattern (same as credentials page)
## Review Checklist
- [ ] All tasks completed
- [ ] Env merge logic is correct (stage overrides project)
- [ ] Secret values never appear in plaintext in API responses
- [ ] Volume paths are validated (no path traversal)
- [ ] Isolated volume subdirectory naming is deterministic
## Handoff to Next Phase
<!-- Filled in by the implementation agent after completing this phase. -->
+49 -14
View File
@@ -1,6 +1,6 @@
# Phase 9: SvelteKit Dashboard & Project Views
**Status:** ⬜ Not Started
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
@@ -9,18 +9,18 @@ Build the SvelteKit frontend with the dashboard overview and project detail view
## Tasks
- [ ] Task 1: Initialize SvelteKit project in `web/` directory with TypeScript, static adapter
- [ ] Task 2: Set up Tailwind CSS (or project's preferred styling)
- [ ] Task 3: Create shared API client (`lib/api.ts`) — typed fetch wrapper for all backend endpoints
- [ ] Task 4: Define TypeScript types (`lib/types.ts`) — Project, Stage, Instance, Deploy, Registry, Settings
- [ ] Task 5: Create layout with navigation — sidebar or top nav with Dashboard, Projects, Deploy, Settings links
- [ ] Task 6: Dashboard page (`routes/+page.svelte`) — project overview cards with instance counts, status indicators, latest activity
- [ ] Task 7: Projects list page (`routes/projects/+page.svelte`) — all projects with quick stats, "Add Project" button
- [ ] Task 8: Project detail page (`routes/projects/[id]/+page.svelte`) — stages, instances per stage, controls
- [ ] Task 9: Instance controls — Stop, Start, Restart, Remove buttons with confirmation dialogs
- [ ] Task 10: Deploy history section in project detail — recent deploys with status, timestamp, tag
- [ ] Task 11: "Deploy new version" dropdown — list available tags from registry, trigger deploy
- [ ] Task 12: Create reusable components: StatusBadge, InstanceCard, ProjectCard, ConfirmDialog
- [x] Task 1: Initialize SvelteKit project in `web/` directory with TypeScript, static adapter
- [x] Task 2: Set up Tailwind CSS v4 with @tailwindcss/vite plugin
- [x] Task 3: Create shared API client (`lib/api.ts`) — typed fetch wrapper for all backend endpoints
- [x] Task 4: Define TypeScript types (`lib/types.ts`) — Project, Stage, Instance, Deploy, Registry, Settings
- [x] Task 5: Create layout with navigation — sidebar with Dashboard, Projects, Deploy, Settings links
- [x] Task 6: Dashboard page (`routes/+page.svelte`) — project overview cards with instance counts, status indicators
- [x] Task 7: Projects list page (`routes/projects/+page.svelte`) — all projects with quick stats, "Add Project" button
- [x] Task 8: Project detail page (`routes/projects/[id]/+page.svelte`) — stages, instances per stage, controls
- [x] Task 9: Instance controls — Stop, Start, Restart, Remove buttons with confirmation dialogs
- [x] Task 10: Deploy history section in project detail — recent deploys with status, timestamp, tag
- [x] Task 11: "Deploy new version" dropdown — list available tags from registry, trigger deploy
- [x] Task 12: Create reusable components: StatusBadge, InstanceCard, ProjectCard, ConfirmDialog
## Files to Modify/Create
- `web/package.json` — SvelteKit project config
@@ -61,4 +61,39 @@ Build the SvelteKit frontend with the dashboard overview and project detail view
- [ ] Components are reusable and well-structured
## Handoff to Next Phase
<!-- Filled in by the implementation agent after completing this phase. -->
Phase 9 is complete. All 14 files have been created in the `web/` directory:
**Configuration files:**
- `web/package.json` — Svelte 5, SvelteKit 2, Tailwind CSS v4, static adapter, TypeScript
- `web/svelte.config.js` — Static adapter with SPA fallback (`index.html`)
- `web/vite.config.ts` — Tailwind v4 vite plugin + `/api` proxy to `localhost:8080`
- `web/tsconfig.json` — Strict TypeScript, bundler module resolution
- `web/src/app.html` — Base HTML shell
- `web/src/app.css` — Tailwind v4 import
- `web/src/routes/+layout.ts` — Disables SSR, enables prerender for static adapter
**Core library:**
- `web/src/lib/types.ts` — All TypeScript types matching Go backend models exactly (Project, Stage, Instance, Deploy, DeployLog, Registry, Settings, ApiEnvelope)
- `web/src/lib/api.ts` — Full typed API client covering all endpoints (projects, instances, deploys, registries, settings). Unwraps envelope, throws `ApiError` on failure.
**Components (Svelte 5 runes):**
- `StatusBadge.svelte` — Color-coded status pill (green/yellow/red/gray/blue)
- `ConfirmDialog.svelte` — Modal with danger/primary variants
- `InstanceCard.svelte` — Instance display with stop/start/restart/remove controls
- `ProjectCard.svelte` — Project summary card for dashboard grid
**Pages:**
- `+layout.svelte` — Sidebar navigation (Dashboard, Projects, Deploy, Settings)
- `routes/+page.svelte` — Dashboard with stats cards and project grid
- `routes/projects/+page.svelte` — Project table with inline add-project form
- `routes/projects/[id]/+page.svelte` — Full project detail: stages, instances, deploy form, deploy history
**Key decisions:**
- Used Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`) throughout
- Tailwind CSS v4 with `@tailwindcss/vite` plugin (no PostCSS config needed)
- Client-side only rendering (SSR disabled) for static adapter compatibility
- API client uses relative `/api/` paths — works in both dev (vite proxy) and prod (embedded)
- All API calls include loading spinners and error states with retry buttons
**Ready for Phase 10:** Settings pages, Quick Deploy page, and remaining UI routes. The API client already includes all endpoint wrappers needed.
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import '../app.css';
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
interface Props {
children: Snippet;
}
const { children }: Props = $props();
const navItems = [
{ href: '/', label: 'Dashboard', icon: 'dashboard' },
{ href: '/projects', label: 'Projects', icon: 'projects' },
{ href: '/deploy', label: 'Deploy', icon: 'deploy' },
{ href: '/settings', label: 'Settings', icon: 'settings' }
] as const;
function isActive(href: string, pathname: string): boolean {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
}
</script>
<div class="flex h-screen bg-gray-50">
<!-- Sidebar -->
<aside class="flex w-64 flex-col border-r border-gray-200 bg-white">
<div class="flex h-16 items-center gap-2 border-b border-gray-200 px-6">
<svg class="h-7 w-7 text-indigo-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
<span class="text-lg font-bold text-gray-900">Docker Watcher</span>
</div>
<nav class="flex-1 space-y-1 px-3 py-4">
{#each navItems as item}
{@const active = isActive(item.href, $page.url.pathname)}
<a
href={item.href}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition {active
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'}"
>
{#if item.icon === 'dashboard'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" />
</svg>
{:else if item.icon === 'projects'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
</svg>
{:else if item.icon === 'deploy'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58m-.119-8.54a6 6 0 0 0-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 0 0-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 0 1-2.448-2.448 14.9 14.9 0 0 1 .06-.312m-2.24 2.39a4.493 4.493 0 0 0-1.757 4.306 4.493 4.493 0 0 0 4.306-1.758M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" />
</svg>
{:else if item.icon === 'settings'}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
{/if}
{item.label}
</a>
{/each}
</nav>
<div class="border-t border-gray-200 px-6 py-3">
<p class="text-xs text-gray-400">Docker Watcher v0.1</p>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-7xl px-6 py-8">
{@render children()}
</div>
</main>
</div>
+3
View File
@@ -0,0 +1,3 @@
// Disable SSR for static adapter — all rendering happens client-side.
export const ssr = false;
export const prerender = true;
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import type { Project, Instance } from '$lib/types';
import * as api from '$lib/api';
import ProjectCard from '$lib/components/ProjectCard.svelte';
let projects = $state<Project[]>([]);
let instancesByProject = $state<Record<string, Instance[]>>({});
let loading = $state(true);
let error = $state('');
async function loadDashboard() {
loading = true;
error = '';
try {
projects = await api.listProjects();
// Fetch instances for each project by loading the project detail.
const detailPromises = projects.map(async (p) => {
try {
const detail = await api.getProject(p.id);
// Fetch instances for each stage.
const stageInstances = await Promise.all(
detail.stages.map((s) => api.listInstances(p.id, s.id))
);
return { projectId: p.id, instances: stageInstances.flat() };
} catch {
return { projectId: p.id, instances: [] };
}
});
const results = await Promise.all(detailPromises);
const mapped: Record<string, Instance[]> = {};
for (const r of results) {
mapped[r.projectId] = r.instances;
}
instancesByProject = mapped;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load dashboard';
} finally {
loading = false;
}
}
$effect(() => {
loadDashboard();
});
const totalProjects = $derived(projects.length);
const totalRunning = $derived(
Object.values(instancesByProject)
.flat()
.filter((i) => i.status === 'running').length
);
const totalFailed = $derived(
Object.values(instancesByProject)
.flat()
.filter((i) => i.status === 'failed').length
);
</script>
<svelte:head>
<title>Dashboard - Docker Watcher</title>
</svelte:head>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<a
href="/deploy"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700"
>
Quick Deploy
</a>
</div>
<!-- Stats -->
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">Total Projects</p>
<p class="mt-1 text-3xl font-bold text-gray-900">{totalProjects}</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">Running Instances</p>
<p class="mt-1 text-3xl font-bold text-green-600">{totalRunning}</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">Failed Instances</p>
<p class="mt-1 text-3xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-gray-900'}">
{totalFailed}
</p>
</div>
</div>
<!-- Project cards -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">Projects</h2>
{#if loading}
<div class="mt-4 flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
</div>
{:else if error}
<div class="mt-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{error}</p>
<button
type="button"
class="mt-2 text-sm font-medium text-red-700 underline"
onclick={loadDashboard}
>
Retry
</button>
</div>
{:else if projects.length === 0}
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
<p class="text-sm text-gray-500">No projects yet.</p>
<a
href="/projects"
class="mt-2 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-500"
>
Add your first project
</a>
</div>
{:else}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each projects as project (project.id)}
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
{/each}
</div>
{/if}
</div>
+18 -29
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import type { InspectResult, QuickDeployRequest } from '$lib/types';
import { inspectImage, quickDeploy } from '$lib/api';
import type { InspectResult } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
@@ -39,7 +39,7 @@
function validateProjectName(value: string): string {
if (!value.trim()) return 'Project name is required';
if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim()) && value.trim().length > 1) {
if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) {
return 'Must be lowercase alphanumeric with hyphens (e.g., my-app)';
}
return '';
@@ -55,6 +55,13 @@
return Object.keys(newErrors).length === 0;
}
/** Derive a project name from the image URL (last path segment before the colon). */
function deriveProjectName(image: string): string {
const withoutTag = image.split(':')[0] ?? image;
const segments = withoutTag.split('/');
return (segments[segments.length - 1] ?? 'unknown').toLowerCase().replace(/[^a-z0-9\-]/g, '-');
}
async function handleInspect() {
const urlError = validateImageUrl(imageUrl);
if (urlError) {
@@ -65,18 +72,16 @@
inspecting = true;
try {
const result = await api.post<InspectResult>('/api/deploy/inspect', {
image: imageUrl.trim()
});
const result = await inspectImage(imageUrl.trim());
inspectResult = result;
// Auto-fill form with inspection results
projectName = result.project_name ?? '';
projectName = deriveProjectName(result.image);
port = result.port?.toString() ?? '';
healthcheck = result.healthcheck ?? '';
stage = 'dev';
subdomain = result.suggested_subdomain ?? '';
envVars = result.env_vars ? result.env_vars.map((e) => `${e.key}=${e.value}`).join('\n') : '';
subdomain = '';
envVars = '';
inspected = true;
toasts.success('Image inspected successfully');
} catch (err) {
@@ -92,27 +97,11 @@
deploying = true;
try {
const envMap: Record<string, string> = {};
if (envVars.trim()) {
for (const line of envVars.split('\n')) {
const eqIndex = line.indexOf('=');
if (eqIndex > 0) {
envMap[line.substring(0, eqIndex).trim()] = line.substring(eqIndex + 1).trim();
}
}
}
const payload: QuickDeployRequest = {
await quickDeploy({
image: imageUrl.trim(),
project_name: projectName.trim(),
port: parseInt(port, 10),
healthcheck: healthcheck.trim() || undefined,
stage: stage,
subdomain: subdomain.trim() || undefined,
env: Object.keys(envMap).length > 0 ? envMap : undefined
};
await api.post('/api/deploy/quick', payload);
name: projectName.trim(),
port: parseInt(port, 10)
});
toasts.success(`Deployed ${projectName} successfully!`);
// Reset form
+220
View File
@@ -0,0 +1,220 @@
<script lang="ts">
import type { Project } from '$lib/types';
import * as api from '$lib/api';
let projects = $state<Project[]>([]);
let loading = $state(true);
let error = $state('');
let showAddForm = $state(false);
// Add project form state.
let formName = $state('');
let formImage = $state('');
let formRegistry = $state('');
let formPort = $state(3000);
let formHealthcheck = $state('');
let formSubmitting = $state(false);
let formError = $state('');
async function loadProjects() {
loading = true;
error = '';
try {
projects = await api.listProjects();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load projects';
} finally {
loading = false;
}
}
async function handleAddProject() {
if (!formName.trim() || !formImage.trim()) {
formError = 'Name and image are required.';
return;
}
formSubmitting = true;
formError = '';
try {
await api.createProject({
name: formName.trim(),
image: formImage.trim(),
registry: formRegistry.trim(),
port: formPort,
healthcheck: formHealthcheck.trim()
});
// Reset form.
formName = '';
formImage = '';
formRegistry = '';
formPort = 3000;
formHealthcheck = '';
showAddForm = false;
await loadProjects();
} catch (e) {
formError = e instanceof Error ? e.message : 'Failed to create project';
} finally {
formSubmitting = false;
}
}
$effect(() => {
loadProjects();
});
</script>
<svelte:head>
<title>Projects - Docker Watcher</title>
</svelte:head>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Projects</h1>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700"
onclick={() => { showAddForm = !showAddForm; }}
>
{showAddForm ? 'Cancel' : 'Add Project'}
</button>
</div>
<!-- Add project form -->
{#if showAddForm}
<div class="mt-6 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-gray-900">New Project</h2>
{#if formError}
<div class="mt-3 rounded-md bg-red-50 p-3">
<p class="text-sm text-red-700">{formError}</p>
</div>
{/if}
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name *</label>
<input
id="name"
type="text"
bind:value={formName}
placeholder="my-web-app"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="image" class="block text-sm font-medium text-gray-700">Image *</label>
<input
id="image"
type="text"
bind:value={formImage}
placeholder="registry.example.com/org/app"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="registry" class="block text-sm font-medium text-gray-700">Registry</label>
<input
id="registry"
type="text"
bind:value={formRegistry}
placeholder="gitea"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="port" class="block text-sm font-medium text-gray-700">Port</label>
<input
id="port"
type="number"
bind:value={formPort}
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
<div class="sm:col-span-2">
<label for="healthcheck" class="block text-sm font-medium text-gray-700">Healthcheck Path</label>
<input
id="healthcheck"
type="text"
bind:value={formHealthcheck}
placeholder="/api/health"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
disabled={formSubmitting}
onclick={handleAddProject}
>
{formSubmitting ? 'Creating...' : 'Create Project'}
</button>
</div>
</div>
{/if}
<!-- Projects list -->
{#if loading}
<div class="mt-6 flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
</div>
{:else if error}
<div class="mt-6 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-red-700 underline" onclick={loadProjects}>
Retry
</button>
</div>
{:else if projects.length === 0}
<div class="mt-6 rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
<p class="text-sm text-gray-500">No projects configured yet.</p>
<p class="mt-1 text-sm text-gray-400">Click "Add Project" to get started.</p>
</div>
{:else}
<div class="mt-6 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Image</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Port</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Registry</th>
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Created</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each projects as project (project.id)}
<tr class="hover:bg-gray-50">
<td class="whitespace-nowrap px-6 py-4">
<a href="/projects/{project.id}" class="font-medium text-indigo-600 hover:text-indigo-800">
{project.name}
</a>
</td>
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-gray-500">
{project.image}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{project.port || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{project.registry || '-'}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{new Date(project.created_at).toLocaleDateString()}
</td>
<td class="whitespace-nowrap px-6 py-4 text-right text-sm">
<a href="/projects/{project.id}" class="text-indigo-600 hover:text-indigo-800">
View
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
+344
View File
@@ -0,0 +1,344 @@
<script lang="ts">
import { page } from '$app/stores';
import type { Project, Stage, Instance, Deploy } from '$lib/types';
import * as api from '$lib/api';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import InstanceCard from '$lib/components/InstanceCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let project = $state<Project | null>(null);
let stages = $state<Stage[]>([]);
let instancesByStage = $state<Record<string, Instance[]>>({});
let deploys = $state<Deploy[]>([]);
let loading = $state(true);
let error = $state('');
// Deploy form state.
let deployStageId = $state('');
let deployTag = $state('');
let deployLoading = $state(false);
let deployError = $state('');
// Available tags for deploy dropdown.
let availableTags = $state<string[]>([]);
let tagsLoading = $state(false);
// Delete project confirmation.
let showDeleteConfirm = $state(false);
const projectId = $derived($page.params.id);
async function loadProject() {
loading = true;
error = '';
try {
const detail = await api.getProject(projectId);
project = detail.project;
stages = detail.stages;
// Fetch instances for each stage in parallel.
const instanceResults = await Promise.all(
stages.map(async (s) => {
try {
const instances = await api.listInstances(projectId, s.id);
return { stageId: s.id, instances };
} catch {
return { stageId: s.id, instances: [] };
}
})
);
const mapped: Record<string, Instance[]> = {};
for (const r of instanceResults) {
mapped[r.stageId] = r.instances;
}
instancesByStage = mapped;
// Load recent deploys.
try {
const allDeploys = await api.listDeploys(20);
deploys = allDeploys.filter((d) => d.project_id === projectId);
} catch {
deploys = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load project';
} finally {
loading = false;
}
}
async function loadTags(stageId: string) {
deployStageId = stageId;
deployTag = '';
availableTags = [];
if (!project?.registry || !project?.image) return;
tagsLoading = true;
try {
availableTags = await api.listRegistryTags(project.registry, project.image);
} catch {
availableTags = [];
} finally {
tagsLoading = false;
}
}
async function handleDeploy() {
if (!deployTag.trim() || !deployStageId) return;
deployLoading = true;
deployError = '';
try {
await api.deployInstance(projectId, deployStageId, deployTag.trim());
deployTag = '';
deployStageId = '';
await loadProject();
} catch (e) {
deployError = e instanceof Error ? e.message : 'Deploy failed';
} finally {
deployLoading = false;
}
}
async function handleDeleteProject() {
showDeleteConfirm = false;
try {
await api.deleteProject(projectId);
window.location.href = '/projects';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete project';
}
}
$effect(() => {
// Re-run when projectId changes.
void projectId;
loadProject();
});
</script>
<svelte:head>
<title>{project?.name ?? 'Project'} - Docker Watcher</title>
</svelte:head>
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-indigo-600"></div>
</div>
{:else if error}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-700">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-red-700 underline" onclick={loadProject}>
Retry
</button>
</div>
{:else if project}
<div>
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<a href="/projects" class="text-sm text-gray-500 hover:text-gray-700">Projects</a>
<span class="text-sm text-gray-400">/</span>
</div>
<h1 class="mt-1 text-2xl font-bold text-gray-900">{project.name}</h1>
<p class="mt-1 font-mono text-sm text-gray-500">{project.image}</p>
</div>
<button
type="button"
class="rounded-md border border-red-300 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50"
onclick={() => { showDeleteConfirm = true; }}
>
Delete Project
</button>
</div>
<!-- Project info -->
<div class="mt-6 grid grid-cols-2 gap-4 rounded-lg border border-gray-200 bg-white p-5 sm:grid-cols-4">
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Port</p>
<p class="mt-1 text-sm text-gray-900">{project.port || '-'}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Registry</p>
<p class="mt-1 text-sm text-gray-900">{project.registry || '-'}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Healthcheck</p>
<p class="mt-1 text-sm text-gray-900">{project.healthcheck || '-'}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500 uppercase">Created</p>
<p class="mt-1 text-sm text-gray-900">{new Date(project.created_at).toLocaleDateString()}</p>
</div>
</div>
<!-- Stages & Instances -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">Stages</h2>
{#if stages.length === 0}
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
<p class="text-sm text-gray-500">No stages configured for this project.</p>
</div>
{:else}
<div class="mt-4 space-y-6">
{#each stages as stage (stage.id)}
{@const stageInstances = instancesByStage[stage.id] ?? []}
<div class="rounded-lg border border-gray-200 bg-white shadow-sm">
<!-- Stage header -->
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold text-gray-900">{stage.name}</h3>
<span class="text-xs text-gray-500">Pattern: {stage.tag_pattern}</span>
{#if stage.auto_deploy}
<span class="rounded bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700">
auto-deploy
</span>
{/if}
{#if stage.confirm}
<span class="rounded bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700">
requires confirm
</span>
{/if}
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500">
{stageInstances.length} / {stage.max_instances} instances
</span>
<button
type="button"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700"
onclick={() => loadTags(stage.id)}
>
Deploy new version
</button>
</div>
</div>
<!-- Deploy form (shown when this stage is selected) -->
{#if deployStageId === stage.id}
<div class="border-b border-gray-100 bg-gray-50 px-5 py-4">
<div class="flex items-end gap-3">
<div class="flex-1">
<label for="deploy-tag-{stage.id}" class="block text-xs font-medium text-gray-700">
Select tag to deploy
</label>
{#if tagsLoading}
<p class="mt-1 text-sm text-gray-500">Loading tags...</p>
{:else if availableTags.length > 0}
<select
id="deploy-tag-{stage.id}"
bind:value={deployTag}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
>
<option value="">Choose a tag...</option>
{#each availableTags as tag}
<option value={tag}>{tag}</option>
{/each}
</select>
{:else}
<input
id="deploy-tag-{stage.id}"
type="text"
bind:value={deployTag}
placeholder="Enter image tag (e.g., dev-abc123)"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none"
/>
{/if}
</div>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
disabled={!deployTag.trim() || deployLoading}
onclick={handleDeploy}
>
{deployLoading ? 'Deploying...' : 'Deploy'}
</button>
<button
type="button"
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-200"
onclick={() => { deployStageId = ''; }}
>
Cancel
</button>
</div>
{#if deployError}
<p class="mt-2 text-xs text-red-600">{deployError}</p>
{/if}
</div>
{/if}
<!-- Instances -->
<div class="p-5">
{#if stageInstances.length === 0}
<p class="text-center text-sm text-gray-400">No instances running</p>
{:else}
<div class="space-y-3">
{#each stageInstances as instance (instance.id)}
<InstanceCard
{instance}
{projectId}
onchange={loadProject}
/>
{/each}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<!-- Deploy History -->
<h2 class="mt-8 text-lg font-semibold text-gray-900">Recent Deploys</h2>
{#if deploys.length === 0}
<p class="mt-4 text-sm text-gray-500">No deploy history for this project.</p>
{:else}
<div class="mt-4 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Tag</th>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Status</th>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Started</th>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Finished</th>
<th class="px-5 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">Error</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{#each deploys as deploy (deploy.id)}
<tr>
<td class="whitespace-nowrap px-5 py-3 font-mono text-sm text-gray-900">{deploy.image_tag}</td>
<td class="whitespace-nowrap px-5 py-3">
<StatusBadge status={deploy.status} size="sm" />
</td>
<td class="whitespace-nowrap px-5 py-3 text-sm text-gray-500">
{deploy.started_at ? new Date(deploy.started_at).toLocaleString() : '-'}
</td>
<td class="whitespace-nowrap px-5 py-3 text-sm text-gray-500">
{deploy.finished_at ? new Date(deploy.finished_at).toLocaleString() : '-'}
</td>
<td class="max-w-xs truncate px-5 py-3 text-sm text-red-600">
{deploy.error || '-'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<ConfirmDialog
open={showDeleteConfirm}
title="Delete Project"
message="This will permanently delete the project '{project.name}' and all its stages, instances, and deploy history. This cannot be undone."
confirmLabel="Delete"
confirmVariant="danger"
onconfirm={handleDeleteProject}
oncancel={() => { showDeleteConfirm = false; }}
/>
{/if}
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts">
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl } from '$lib/api';
import type { Settings } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
let loading = $state(true);
let saving = $state(false);
let webhookUrl = $state('');
let regenerating = $state(false);
// Settings fields
let domain = $state('');
let serverIp = $state('');
let network = $state('');
let subdomainPattern = $state('');
let pollingInterval = $state('');
let notificationUrl = $state('');
let errors = $state<Record<string, string>>({});
function validateDomain(value: string): string {
if (!value.trim()) return 'Domain is required';
if (!/^[a-zA-Z0-9][a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(value.trim())) {
return 'Invalid domain format';
}
return '';
}
function validateIp(value: string): string {
if (!value.trim()) return '';
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(value.trim())) {
return 'Invalid IP address format';
}
return '';
}
function validatePollingInterval(value: string): string {
if (!value.trim()) return '';
const num = parseInt(value, 10);
if (isNaN(num) || num < 10 || num > 86400) {
return 'Polling interval must be between 10 and 86400 seconds';
}
return '';
}
function validateUrl(value: string): string {
if (!value.trim()) return '';
try {
new URL(value.trim());
return '';
} catch {
return 'Invalid URL format';
}
}
function validateAll(): boolean {
const newErrors: Record<string, string> = {};
const domainErr = validateDomain(domain);
if (domainErr) newErrors.domain = domainErr;
const ipErr = validateIp(serverIp);
if (ipErr) newErrors.serverIp = ipErr;
const intervalErr = validatePollingInterval(pollingInterval);
if (intervalErr) newErrors.pollingInterval = intervalErr;
const urlErr = validateUrl(notificationUrl);
if (urlErr) newErrors.notificationUrl = urlErr;
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
async function loadSettings() {
loading = true;
try {
const settings = await getSettings();
domain = settings.domain ?? '';
serverIp = settings.server_ip ?? '';
network = settings.network ?? '';
subdomainPattern = settings.subdomain_pattern ?? '';
pollingInterval = settings.polling_interval ?? '';
notificationUrl = settings.notification_url ?? '';
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load settings';
toasts.error(message);
} finally {
loading = false;
}
}
async function loadWebhookUrlValue() {
try {
const result = await getWebhookUrl();
webhookUrl = result.url;
} catch {
// Webhook URL may not be configured yet
}
}
async function handleSave() {
if (!validateAll()) return;
saving = true;
try {
const payload: Partial<Settings> = {
domain: domain.trim(),
server_ip: serverIp.trim(),
network: network.trim(),
subdomain_pattern: subdomainPattern.trim(),
polling_interval: pollingInterval.trim(),
notification_url: notificationUrl.trim()
};
await updateSettings(payload);
toasts.success('Settings saved successfully');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save settings';
toasts.error(message);
} finally {
saving = false;
}
}
async function handleRegenerateWebhook() {
regenerating = true;
try {
const result = await regenerateWebhookUrl();
webhookUrl = result.url;
toasts.success('Webhook URL regenerated');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to regenerate webhook URL';
toasts.error(message);
} finally {
regenerating = false;
}
}
$effect(() => {
loadSettings();
loadWebhookUrlValue();
});
</script>
<svelte:head>
<title>General Settings - Docker Watcher</title>
</svelte:head>
<div class="space-y-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
</div>
{:else}
<!-- Global settings form -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">Global Configuration</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
label="Domain"
name="domain"
bind:value={domain}
placeholder="example.com"
required
error={errors.domain ?? ''}
helpText="Base domain for subdomain routing"
/>
<FormField
label="Server IP"
name="serverIp"
bind:value={serverIp}
placeholder="93.84.96.191"
error={errors.serverIp ?? ''}
helpText="Public IP address of the server"
/>
<FormField
label="Docker Network"
name="network"
bind:value={network}
placeholder="staging-net"
helpText="Docker network for deployed containers"
/>
<FormField
label="Subdomain Pattern"
name="subdomainPattern"
bind:value={subdomainPattern}
placeholder="stage-{stage}-{project}"
helpText="Pattern for auto-generated subdomains"
/>
<FormField
label="Polling Interval (seconds)"
name="pollingInterval"
type="number"
bind:value={pollingInterval}
placeholder="60"
error={errors.pollingInterval ?? ''}
helpText="How often to check registries for new tags (10-86400)"
/>
<FormField
label="Notification URL"
name="notificationUrl"
bind:value={notificationUrl}
placeholder="https://notify.example.com/webhook"
error={errors.notificationUrl ?? ''}
helpText="Webhook URL for deploy notifications"
/>
</div>
<div class="mt-6">
<button
onclick={handleSave}
disabled={saving}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
<!-- Webhook URL section -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">Webhook URL</h2>
<p class="mb-3 text-sm text-gray-500">
This secret URL receives image push notifications from your CI pipeline.
</p>
{#if webhookUrl}
<div class="flex items-center gap-3">
<code
class="flex-1 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm font-mono text-gray-700 break-all"
>
{webhookUrl}
</code>
<button
onclick={() => {
navigator.clipboard.writeText(webhookUrl);
toasts.info('Webhook URL copied to clipboard');
}}
class="rounded-md border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Copy
</button>
</div>
{:else}
<p class="text-sm text-gray-400 italic">No webhook URL configured</p>
{/if}
<div class="mt-4">
<button
onclick={handleRegenerateWebhook}
disabled={regenerating}
class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50"
>
{regenerating ? 'Regenerating...' : 'Regenerate URL'}
</button>
<p class="mt-1 text-xs text-gray-500">
Warning: regenerating will invalidate the current URL. Update your CI pipelines.
</p>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,238 @@
<script lang="ts">
import { getSettings, updateSettings } from '$lib/api';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
let loading = $state(true);
let saving = $state(false);
// NPM credentials
let npmUrl = $state('');
let npmEmail = $state('');
let npmPassword = $state('');
let npmHasCredentials = $state(false);
let editingNpm = $state(false);
let errors = $state<Record<string, string>>({});
function validateNpmForm(): boolean {
const newErrors: Record<string, string> = {};
if (!npmUrl.trim()) {
newErrors.npmUrl = 'NPM URL is required';
} else {
try {
new URL(npmUrl.trim());
} catch {
newErrors.npmUrl = 'Invalid URL format';
}
}
if (!npmEmail.trim()) {
newErrors.npmEmail = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(npmEmail.trim())) {
newErrors.npmEmail = 'Invalid email format';
}
if (editingNpm && !npmPassword.trim()) {
newErrors.npmPassword = 'Password is required when updating credentials';
}
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
async function loadCredentials() {
loading = true;
try {
const settings = await getSettings();
npmUrl = settings.npm_url ?? '';
npmEmail = settings.npm_email ?? '';
// If npm_password is present (even masked), credentials exist
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
npmPassword = '';
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load credentials';
toasts.error(message);
} finally {
loading = false;
}
}
async function handleSaveNpm() {
if (!validateNpmForm()) return;
saving = true;
try {
const payload: Record<string, string> = {
npm_url: npmUrl.trim(),
npm_email: npmEmail.trim()
};
if (npmPassword.trim()) {
payload.npm_password = npmPassword.trim();
}
await updateSettings(payload);
npmHasCredentials = true;
editingNpm = false;
npmPassword = '';
toasts.success('NPM credentials saved');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save NPM credentials';
toasts.error(message);
} finally {
saving = false;
}
}
$effect(() => {
loadCredentials();
});
</script>
<svelte:head>
<title>Credentials - Docker Watcher</title>
</svelte:head>
<div class="space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-800">Credentials</h2>
<p class="text-sm text-gray-500">
Manage credentials for Nginx Proxy Manager and registry tokens. All values are encrypted at
rest.
</p>
</div>
{#if loading}
<div class="flex items-center justify-center py-12">
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
</div>
{:else}
<!-- NPM Credentials -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-semibold text-gray-800">Nginx Proxy Manager</h3>
<p class="text-xs text-gray-500">Credentials for managing proxy hosts via NPM API</p>
</div>
{#if npmHasCredentials && !editingNpm}
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
Configured
</span>
{/if}
</div>
{#if !editingNpm && npmHasCredentials}
<!-- Masked display -->
<div class="space-y-3">
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<div>
<p class="text-xs font-medium text-gray-500">URL</p>
<p class="text-sm text-gray-700">{npmUrl || 'Not set'}</p>
</div>
</div>
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<div>
<p class="text-xs font-medium text-gray-500">Email</p>
<p class="text-sm text-gray-700">{npmEmail || 'Not set'}</p>
</div>
</div>
<div class="flex items-center justify-between rounded-md bg-gray-50 px-3 py-2">
<div>
<p class="text-xs font-medium text-gray-500">Password</p>
<p class="text-sm font-mono text-gray-700">--------</p>
</div>
</div>
<button
onclick={() => {
editingNpm = true;
}}
class="mt-2 rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Change Credentials
</button>
</div>
{:else}
<!-- Edit form -->
<div class="space-y-4">
<FormField
label="NPM URL"
name="npmUrl"
bind:value={npmUrl}
placeholder="http://npm:81"
required
error={errors.npmUrl ?? ''}
helpText="Nginx Proxy Manager API URL"
/>
<FormField
label="Email"
name="npmEmail"
type="email"
bind:value={npmEmail}
placeholder="admin@example.com"
required
error={errors.npmEmail ?? ''}
helpText="NPM admin email"
/>
<FormField
label="Password"
name="npmPassword"
type="password"
bind:value={npmPassword}
placeholder={npmHasCredentials ? '(enter new password)' : 'npm-password'}
required={editingNpm}
error={errors.npmPassword ?? ''}
helpText={npmHasCredentials
? 'Enter the new password to replace the existing one'
: 'NPM admin password (will be encrypted)'}
/>
<div class="flex gap-3">
<button
onclick={handleSaveNpm}
disabled={saving}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
{#if npmHasCredentials}
<button
onclick={() => {
editingNpm = false;
npmPassword = '';
errors = {};
}}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
</button>
{/if}
</div>
</div>
{/if}
</div>
<!-- Registry Tokens info -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h3 class="text-sm font-semibold text-gray-800">Registry Tokens</h3>
<p class="mt-1 text-sm text-gray-500">
Registry authentication tokens are managed per-registry in the
<a href="/settings/registries" class="text-blue-600 hover:text-blue-700 underline"
>Registries</a
>
section. Each registry stores its token encrypted in the database.
</p>
</div>
{/if}
</div>
@@ -0,0 +1,315 @@
<script lang="ts">
import {
listRegistries,
createRegistry,
updateRegistry,
deleteRegistry,
testRegistry
} from '$lib/api';
import type { Registry } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
let registries = $state<Registry[]>([]);
let loading = $state(true);
// Form state
let showForm = $state(false);
let editingId = $state<string | null>(null);
let formName = $state('');
let formUrl = $state('');
let formType = $state('gitea');
let formToken = $state('');
let formSaving = $state(false);
let testingId = $state<string | null>(null);
let errors = $state<Record<string, string>>({});
function validateForm(): boolean {
const newErrors: Record<string, string> = {};
if (!formName.trim()) newErrors.name = 'Name is required';
if (!formUrl.trim()) {
newErrors.url = 'URL is required';
} else {
try {
new URL(formUrl.trim());
} catch {
newErrors.url = 'Invalid URL format';
}
}
if (!formToken.trim() && !editingId) {
newErrors.token = 'Token is required for new registries';
}
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
function resetForm() {
showForm = false;
editingId = null;
formName = '';
formUrl = '';
formType = 'gitea';
formToken = '';
errors = {};
}
function startEdit(registry: Registry) {
editingId = registry.id;
formName = registry.name;
formUrl = registry.url;
formType = registry.type;
formToken = '';
showForm = true;
errors = {};
}
async function loadRegistryList() {
loading = true;
try {
registries = await listRegistries();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load registries';
toasts.error(message);
} finally {
loading = false;
}
}
async function handleSave() {
if (!validateForm()) return;
formSaving = true;
try {
const payload: Partial<Registry> = {
name: formName.trim(),
url: formUrl.trim(),
type: formType
};
if (formToken.trim()) {
payload.token = formToken.trim();
}
if (editingId) {
await updateRegistry(editingId, payload);
toasts.success('Registry updated');
} else {
await createRegistry(payload);
toasts.success('Registry added');
}
resetForm();
await loadRegistryList();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save registry';
toasts.error(message);
} finally {
formSaving = false;
}
}
async function handleDelete(registry: Registry) {
if (!confirm(`Delete registry "${registry.name}"? This cannot be undone.`)) return;
try {
await deleteRegistry(registry.id);
toasts.success(`Registry "${registry.name}" deleted`);
await loadRegistryList();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete registry';
toasts.error(message);
}
}
async function handleTestConnection(registry: Registry) {
testingId = registry.id;
try {
await testRegistry(registry.id);
toasts.success(`Connection to "${registry.name}" successful`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Connection test failed';
toasts.error(message);
} finally {
testingId = null;
}
}
$effect(() => {
loadRegistryList();
});
</script>
<svelte:head>
<title>Registries - Docker Watcher</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-800">Container Registries</h2>
<p class="text-sm text-gray-500">Manage your container registries for image detection.</p>
</div>
{#if !showForm}
<button
onclick={() => {
resetForm();
showForm = true;
}}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
Add Registry
</button>
{/if}
</div>
<!-- Add/Edit Form -->
{#if showForm}
<div class="rounded-lg border border-blue-200 bg-blue-50/50 p-6">
<h3 class="mb-4 text-sm font-semibold text-gray-800">
{editingId ? 'Edit Registry' : 'Add New Registry'}
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
label="Name"
name="registryName"
bind:value={formName}
placeholder="gitea"
required
error={errors.name ?? ''}
helpText="A friendly name for this registry"
/>
<FormField
label="URL"
name="registryUrl"
bind:value={formUrl}
placeholder="https://git.example.com"
required
error={errors.url ?? ''}
helpText="Registry base URL"
/>
<div class="flex flex-col gap-1">
<label for="registryType" class="text-sm font-medium text-gray-700">Type</label>
<select
id="registryType"
bind:value={formType}
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
<option value="docker_hub">Docker Hub</option>
<option value="custom">Custom</option>
</select>
<p class="text-xs text-gray-500">Registry type for API compatibility</p>
</div>
<FormField
label="Token"
name="registryToken"
type="password"
bind:value={formToken}
placeholder={editingId ? '(leave empty to keep current)' : 'registry-access-token'}
required={!editingId}
error={errors.token ?? ''}
helpText={editingId
? 'Leave empty to keep the existing token'
: 'API token for authentication'}
/>
</div>
<div class="mt-4 flex gap-3">
<button
onclick={handleSave}
disabled={formSaving}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{formSaving ? 'Saving...' : editingId ? 'Update' : 'Add Registry'}
</button>
<button
onclick={resetForm}
disabled={formSaving}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
{/if}
<!-- Registry List -->
{#if loading}
<div class="flex items-center justify-center py-12">
<svg class="h-8 w-8 animate-spin text-blue-600" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
</div>
{:else if registries.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 p-8 text-center">
<p class="text-sm text-gray-500">No registries configured yet.</p>
{#if !showForm}
<button
onclick={() => {
showForm = true;
}}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Add your first registry
</button>
{/if}
</div>
{:else}
<div class="space-y-3">
{#each registries as registry (registry.id)}
<div
class="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
>
<div>
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-800">{registry.name}</h3>
<span
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
>
{registry.type}
</span>
</div>
<p class="mt-1 text-sm text-gray-500">{registry.url}</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => handleTestConnection(registry)}
disabled={testingId === registry.id}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
>
{testingId === registry.id ? 'Testing...' : 'Test'}
</button>
<button
onclick={() => startEdit(registry)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Edit
</button>
<button
onclick={() => handleDelete(registry)}
class="rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50"
>
Delete
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>