diff --git a/cmd/server/main.go b/cmd/server/main.go index b0e0c80..88f1a46 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -82,7 +82,7 @@ func main() { if err != nil { log.Fatalf("ensure webhook secret: %v", err) } - log.Printf("Webhook secret: %s", secret) + log.Printf("Webhook secret configured (use /api/settings/webhook-url to retrieve)") // Initialize registry poller. poller := registry.NewPoller(db, dep, encKey) diff --git a/internal/api/registries.go b/internal/api/registries.go index 6f018b3..ea73c33 100644 --- a/internal/api/registries.go +++ b/internal/api/registries.go @@ -195,10 +195,10 @@ func (s *Server) testRegistry(w http.ResponseWriter, r *http.Request) { if token != "" { decrypted, err := crypto.Decrypt(s.encKey, token) if err != nil { - token = reg.Token // Fall back to raw token. - } else { - token = decrypted + respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") + return } + token = decrypted } client, err := registry.NewClient(reg.Type, reg.URL, token) @@ -239,10 +239,10 @@ func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) { if token != "" { decrypted, err := crypto.Decrypt(s.encKey, token) if err != nil { - token = reg.Token - } else { - token = decrypted + respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") + return } + token = decrypted } client, err := registry.NewClient(reg.Type, reg.URL, token) diff --git a/internal/api/router.go b/internal/api/router.go index 198876b..347e4c7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -35,6 +35,8 @@ func NewServer( } // Router returns a chi router with all API routes mounted. +// NOTE: Authentication middleware is added in Phase 12 (Hardening). +// Until then, this API should only be exposed on trusted networks. func (s *Server) Router() chi.Router { r := chi.NewRouter() diff --git a/internal/api/settings.go b/internal/api/settings.go index 071722e..bbcf69a 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -111,8 +111,7 @@ func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) { } respondJSON(w, http.StatusOK, map[string]string{ - "webhook_url": webhookURL, - "webhook_secret": settings.WebhookSecret, + "webhook_url": webhookURL, }) } diff --git a/plans/docker-watcher-core/PLAN.md b/plans/docker-watcher-core/PLAN.md index 3dbcb0a..37f5bb4 100644 --- a/plans/docker-watcher-core/PLAN.md +++ b/plans/docker-watcher-core/PLAN.md @@ -53,7 +53,7 @@ A self-hosted tool that automates Docker container deployment with Nginx Proxy M | Phase 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ | | 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 | ⬜ Pending | ⏭️ 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 10: Settings & Deploy | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | | Phase 11: Embed & SSE | fullstack | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ | diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e0b709d --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "docker-watcher-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@tailwindcss/vite": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + }, + "type": "module" +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..77a5ff5 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..e07f93b --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,211 @@ +import type { + ApiEnvelope, + Deploy, + DeployLog, + InspectResult, + Instance, + Project, + ProjectDetail, + Registry, + Settings +} from './types'; + +// ── Helpers ───────────────────────────────────────────────────────── + +class ApiError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = 'ApiError'; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...init?.headers + } + }); + + const envelope: ApiEnvelope = await res.json(); + + if (!envelope.success) { + throw new ApiError(envelope.error ?? 'Unknown API error', res.status); + } + + return envelope.data as T; +} + +function get(path: string): Promise { + return request(path); +} + +function post(path: string, body?: unknown): Promise { + return request(path, { + method: 'POST', + body: body !== undefined ? JSON.stringify(body) : undefined + }); +} + +function put(path: string, body: unknown): Promise { + return request(path, { + method: 'PUT', + body: JSON.stringify(body) + }); +} + +function del(path: string): Promise { + return request(path, { method: 'DELETE' }); +} + +// ── Projects ──────────────────────────────────────────────────────── + +export function listProjects(): Promise { + return get('/api/projects'); +} + +export function getProject(id: string): Promise { + return get(`/api/projects/${id}`); +} + +export function createProject(data: Partial): Promise { + return post('/api/projects', data); +} + +export function updateProject(id: string, data: Partial): Promise { + return put(`/api/projects/${id}`, data); +} + +export function deleteProject(id: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/projects/${id}`); +} + +// ── Instances ─────────────────────────────────────────────────────── + +export function listInstances(projectId: string, stageId: string): Promise { + return get(`/api/projects/${projectId}/stages/${stageId}/instances`); +} + +export function deployInstance( + projectId: string, + stageId: string, + imageTag: string +): Promise<{ status: string }> { + return post<{ status: string }>(`/api/projects/${projectId}/stages/${stageId}/instances`, { + image_tag: imageTag + }); +} + +export function removeInstance( + projectId: string, + stageId: string, + instanceId: string +): Promise<{ deleted: string }> { + return del<{ deleted: string }>( + `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}` + ); +} + +export function stopInstance( + projectId: string, + stageId: string, + instanceId: string +): Promise<{ status: string }> { + return post<{ status: string }>( + `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stop` + ); +} + +export function startInstance( + projectId: string, + stageId: string, + instanceId: string +): Promise<{ status: string }> { + return post<{ status: string }>( + `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/start` + ); +} + +export function restartInstance( + projectId: string, + stageId: string, + instanceId: string +): Promise<{ status: string }> { + return post<{ status: string }>( + `/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/restart` + ); +} + +// ── Deploys ───────────────────────────────────────────────────────── + +export function listDeploys(limit = 50): Promise { + return get(`/api/deploys?limit=${limit}`); +} + +export function getDeployLogs(deployId: string): Promise { + return get(`/api/deploys/${deployId}/logs`); +} + +export function inspectImage(image: string): Promise { + return post('/api/deploy/inspect', { image }); +} + +export function quickDeploy(data: { + name?: string; + image: string; + tag?: string; + registry?: string; + port?: number; +}): Promise<{ project: Project; status: string }> { + return post<{ project: Project; status: string }>('/api/deploy/quick', data); +} + +// ── Registries ────────────────────────────────────────────────────── + +export function listRegistries(): Promise { + return get('/api/registries'); +} + +export function createRegistry(data: Partial): Promise { + return post('/api/registries', data); +} + +export function updateRegistry(id: string, data: Partial): Promise { + return put(`/api/registries/${id}`, data); +} + +export function deleteRegistry(id: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/registries/${id}`); +} + +export function testRegistry(id: string): Promise<{ status: string }> { + return post<{ status: string }>(`/api/registries/${id}/test`); +} + +export function listRegistryTags(registryId: string, image: string): Promise { + return get(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`); +} + +// ── Settings ──────────────────────────────────────────────────────── + +export function getSettings(): Promise { + return get('/api/settings'); +} + +export function updateSettings(data: Partial): Promise { + return put('/api/settings', data); +} + +export function getWebhookUrl(): Promise<{ url: string }> { + return get<{ url: string }>('/api/settings/webhook-url'); +} + +export function regenerateWebhookUrl(): Promise<{ url: string }> { + return post<{ url: string }>('/api/settings/webhook-url/regenerate'); +} + +export { ApiError }; diff --git a/web/src/lib/components/ConfirmDialog.svelte b/web/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 0000000..b165a14 --- /dev/null +++ b/web/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,57 @@ + + +{#if open} + + + + +
+
+

{title}

+

{message}

+ +
+ + +
+
+
+{/if} diff --git a/web/src/lib/components/FormField.svelte b/web/src/lib/components/FormField.svelte new file mode 100644 index 0000000..9bf3426 --- /dev/null +++ b/web/src/lib/components/FormField.svelte @@ -0,0 +1,74 @@ + + +
+ + + {#if type === 'textarea'} + + {:else} + + {/if} + + {#if error} +

{error}

+ {/if} + + {#if helpText && !error} +

{helpText}

+ {/if} +
diff --git a/web/src/lib/components/InstanceCard.svelte b/web/src/lib/components/InstanceCard.svelte new file mode 100644 index 0000000..8c49705 --- /dev/null +++ b/web/src/lib/components/InstanceCard.svelte @@ -0,0 +1,164 @@ + + +
+
+
+
+ + {instance.image_tag} + + +
+ + {#if subdomainUrl} + + {instance.subdomain} + + {/if} + +
+ Port {instance.port} + {timeSinceCreated()} +
+
+ +
+ {#if instance.status === 'running'} + + + {:else if instance.status === 'stopped'} + + {/if} + +
+
+ + {#if error} +

{error}

+ {/if} +
+ + { if (confirmAction) handleAction(confirmAction); }} + oncancel={() => { confirmAction = null; }} +/> diff --git a/web/src/lib/components/ProjectCard.svelte b/web/src/lib/components/ProjectCard.svelte new file mode 100644 index 0000000..3ce5b12 --- /dev/null +++ b/web/src/lib/components/ProjectCard.svelte @@ -0,0 +1,68 @@ + + + +
+
+

{project.name}

+

{project.image}

+
+ +
+ +
+ {#if totalCount > 0} + + + {runningCount} running + + {#if stoppedCount > 0} + + + {stoppedCount} stopped + + {/if} + {#if failedCount > 0} + + + {failedCount} failed + + {/if} + {:else} + No instances + {/if} +
+ +
+ {#if project.port} + Port {project.port} + {/if} + {#if project.healthcheck} + HC: {project.healthcheck} + {/if} +
+
diff --git a/web/src/lib/components/StatusBadge.svelte b/web/src/lib/components/StatusBadge.svelte new file mode 100644 index 0000000..769cf79 --- /dev/null +++ b/web/src/lib/components/StatusBadge.svelte @@ -0,0 +1,51 @@ + + + + + {label} + diff --git a/web/src/lib/components/Toast.svelte b/web/src/lib/components/Toast.svelte new file mode 100644 index 0000000..c660573 --- /dev/null +++ b/web/src/lib/components/Toast.svelte @@ -0,0 +1,36 @@ + + +
+ {#each $toasts as toast (toast.id)} + + {/each} +
diff --git a/web/src/lib/stores/toast.ts b/web/src/lib/stores/toast.ts new file mode 100644 index 0000000..6f487cc --- /dev/null +++ b/web/src/lib/stores/toast.ts @@ -0,0 +1,61 @@ +import { writable } from 'svelte/store'; + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface Toast { + id: string; + message: string; + type: ToastType; + duration: number; +} + +function createToastStore() { + const { subscribe, update } = writable([]); + + let counter = 0; + + function add(message: string, type: ToastType = 'info', duration = 5000): string { + const id = `toast-${++counter}-${Date.now()}`; + const toast: Toast = { id, message, type, duration }; + + update((toasts) => [...toasts, toast]); + + if (duration > 0) { + setTimeout(() => remove(id), duration); + } + + return id; + } + + function remove(id: string): void { + update((toasts) => toasts.filter((t) => t.id !== id)); + } + + function success(message: string, duration = 5000): string { + return add(message, 'success', duration); + } + + function error(message: string, duration = 7000): string { + return add(message, 'error', duration); + } + + function warning(message: string, duration = 5000): string { + return add(message, 'warning', duration); + } + + function info(message: string, duration = 5000): string { + return add(message, 'info', duration); + } + + return { + subscribe, + add, + remove, + success, + error, + warning, + info + }; +} + +export const toasts = createToastStore(); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..ef83665 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,118 @@ +// Types matching the Go backend store models (internal/store/models.go). + +export interface Project { + id: string; + name: string; + registry: string; + image: string; + port: number; + healthcheck: string; + env: string; + volumes: string; + created_at: string; + updated_at: string; +} + +export interface Stage { + id: string; + project_id: string; + name: string; + tag_pattern: string; + auto_deploy: boolean; + max_instances: number; + confirm: boolean; + promote_from: string; + subdomain: string; + created_at: string; + updated_at: string; +} + +export interface Instance { + id: string; + stage_id: string; + project_id: string; + container_id: string; + image_tag: string; + subdomain: string; + npm_proxy_id: number; + status: InstanceStatus; + port: number; + created_at: string; + updated_at: string; +} + +export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'removing'; + +export interface Deploy { + id: string; + project_id: string; + stage_id: string; + instance_id: string; + image_tag: string; + status: DeployStatus; + started_at: string; + finished_at: string; + error: string; +} + +export type DeployStatus = + | 'pending' + | 'pulling' + | 'starting' + | 'configuring_proxy' + | 'health_checking' + | 'success' + | 'failed' + | 'rolled_back'; + +export interface DeployLog { + id: number; + deploy_id: string; + message: string; + level: 'info' | 'warn' | 'error'; + created_at: string; +} + +export interface Registry { + id: string; + name: string; + url: string; + type: string; + token: string; + created_at: string; + updated_at: string; +} + +export interface Settings { + domain: string; + server_ip: string; + network: string; + subdomain_pattern: string; + notification_url: string; + npm_url: string; + npm_email: string; + npm_password: string; + webhook_secret: string; + polling_interval: string; + updated_at: string; +} + +/** Standard API envelope returned by all backend endpoints. */ +export interface ApiEnvelope { + success: boolean; + data?: T; + error?: string; +} + +/** Response shape for GET /api/projects/:id */ +export interface ProjectDetail { + project: Project; + stages: Stage[]; +} + +/** Response shape for POST /api/deploy/inspect */ +export interface InspectResult { + image: string; + port: number; + healthcheck: string; +} diff --git a/web/src/routes/deploy/+page.svelte b/web/src/routes/deploy/+page.svelte new file mode 100644 index 0000000..3cb0dca --- /dev/null +++ b/web/src/routes/deploy/+page.svelte @@ -0,0 +1,320 @@ + + + + Quick Deploy - Docker Watcher + + +
+
+

Quick Deploy

+

+ Deploy a container image with zero configuration. Paste an image URL, review the defaults, + and deploy. +

+
+ + +
+

1. Enter Image URL

+
+
+ +
+
+ +
+
+
+ + + {#if inspected} +
+

2. Review Configuration

+

+ These defaults were detected from the image. Adjust as needed before deploying. +

+ +
+ + + + + + +
+ + +

Deployment stage for this image

+
+ + +
+ +
+ +
+
+ + +
+

3. Deploy

+

+ A new project will be created and the container will be deployed immediately. +

+
+ + +
+
+ {/if} +
diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte new file mode 100644 index 0000000..24701ee --- /dev/null +++ b/web/src/routes/settings/+layout.svelte @@ -0,0 +1,55 @@ + + +
+

Settings

+ +
+ + + + +
+ {@render children()} +
+
+
diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..75bccab --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,21 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: true + }), + alias: { + $lib: './src/lib' + } + } +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..fbb8c0f --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +});