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)}
+
+ {@html iconMap[toast.type]}
+ {toast.message}
+
+
+ {/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
+ }
+ }
+ }
+});