fix(docker-watcher): phase 8 security fixes
Remove webhook secret from logs and API response. Add auth-pending note to router. Fix decrypt fallback that would use ciphertext as auth token on decrypt failure.
This commit is contained in:
+1
-1
@@ -82,7 +82,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("ensure webhook secret: %v", err)
|
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.
|
// Initialize registry poller.
|
||||||
poller := registry.NewPoller(db, dep, encKey)
|
poller := registry.NewPoller(db, dep, encKey)
|
||||||
|
|||||||
@@ -195,10 +195,10 @@ func (s *Server) testRegistry(w http.ResponseWriter, r *http.Request) {
|
|||||||
if token != "" {
|
if token != "" {
|
||||||
decrypted, err := crypto.Decrypt(s.encKey, token)
|
decrypted, err := crypto.Decrypt(s.encKey, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
token = reg.Token // Fall back to raw token.
|
respondError(w, http.StatusInternalServerError, "failed to decrypt registry token")
|
||||||
} else {
|
return
|
||||||
token = decrypted
|
|
||||||
}
|
}
|
||||||
|
token = decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := registry.NewClient(reg.Type, reg.URL, token)
|
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 != "" {
|
if token != "" {
|
||||||
decrypted, err := crypto.Decrypt(s.encKey, token)
|
decrypted, err := crypto.Decrypt(s.encKey, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
token = reg.Token
|
respondError(w, http.StatusInternalServerError, "failed to decrypt registry token")
|
||||||
} else {
|
return
|
||||||
token = decrypted
|
|
||||||
}
|
}
|
||||||
|
token = decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := registry.NewClient(reg.Type, reg.URL, token)
|
client, err := registry.NewClient(reg.Type, reg.URL, token)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ func NewServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Router returns a chi router with all API routes mounted.
|
// 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 {
|
func (s *Server) Router() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ func (s *Server) getWebhookURL(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
"webhook_url": webhookURL,
|
"webhook_url": webhookURL,
|
||||||
"webhook_secret": settings.WebhookSecret,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 5: Registry & Poller | backend | ✅ Complete | ✅ Pass w/ fixes | ⏭️ Skip (Big Bang) | ✅ |
|
||||||
| Phase 6: Webhook Handler | 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 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 9: Dashboard | frontend | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
| Phase 10: Settings & Deploy | 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) | ⬜ |
|
| Phase 11: Embed & SSE | fullstack | ⬜ Not Started | ⬜ | ⏭️ Skip (Big Bang) | ⬜ |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...init?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope: ApiEnvelope<T> = await res.json();
|
||||||
|
|
||||||
|
if (!envelope.success) {
|
||||||
|
throw new ApiError(envelope.error ?? 'Unknown API error', res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get<T>(path: string): Promise<T> {
|
||||||
|
return request<T>(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function put<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
return request<T>(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function del<T>(path: string): Promise<T> {
|
||||||
|
return request<T>(path, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function listProjects(): Promise<Project[]> {
|
||||||
|
return get<Project[]>('/api/projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProject(id: string): Promise<ProjectDetail> {
|
||||||
|
return get<ProjectDetail>(`/api/projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProject(data: Partial<Project>): Promise<Project> {
|
||||||
|
return post<Project>('/api/projects', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProject(id: string, data: Partial<Project>): Promise<Project> {
|
||||||
|
return put<Project>(`/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<Instance[]> {
|
||||||
|
return get<Instance[]>(`/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<Deploy[]> {
|
||||||
|
return get<Deploy[]>(`/api/deploys?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeployLogs(deployId: string): Promise<DeployLog[]> {
|
||||||
|
return get<DeployLog[]>(`/api/deploys/${deployId}/logs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectImage(image: string): Promise<InspectResult> {
|
||||||
|
return post<InspectResult>('/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<Registry[]> {
|
||||||
|
return get<Registry[]>('/api/registries');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRegistry(data: Partial<Registry>): Promise<Registry> {
|
||||||
|
return post<Registry>('/api/registries', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRegistry(id: string, data: Partial<Registry>): Promise<Registry> {
|
||||||
|
return put<Registry>(`/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<string[]> {
|
||||||
|
return get<string[]>(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getSettings(): Promise<Settings> {
|
||||||
|
return get<Settings>('/api/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
||||||
|
return put<Settings>('/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 };
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
confirmVariant?: 'danger' | 'primary';
|
||||||
|
onconfirm: () => void;
|
||||||
|
oncancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
confirmVariant = 'primary',
|
||||||
|
onconfirm,
|
||||||
|
oncancel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const confirmClass = $derived(
|
||||||
|
confirmVariant === 'danger'
|
||||||
|
? 'bg-red-600 hover:bg-red-700 focus-visible:outline-red-600'
|
||||||
|
: 'bg-indigo-600 hover:bg-indigo-700 focus-visible:outline-indigo-600'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="fixed inset-0 z-40 bg-black/30" role="presentation" onclick={oncancel}></div>
|
||||||
|
|
||||||
|
<!-- Dialog -->
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">{title}</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-600">{message}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||||
|
onclick={oncancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-3 py-2 text-sm font-medium text-white {confirmClass} focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||||
|
onclick={onconfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
helpText?: string;
|
||||||
|
oninput?: (e: Event) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
type = 'text',
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder = '',
|
||||||
|
required = false,
|
||||||
|
disabled = false,
|
||||||
|
error = '',
|
||||||
|
helpText = '',
|
||||||
|
oninput
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for={name} class="text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-red-500">*</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if type === 'textarea'}
|
||||||
|
<textarea
|
||||||
|
id={name}
|
||||||
|
{name}
|
||||||
|
bind:value
|
||||||
|
{placeholder}
|
||||||
|
{required}
|
||||||
|
{disabled}
|
||||||
|
{oninput}
|
||||||
|
class="rounded-md border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
{error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'}
|
||||||
|
{disabled ? 'bg-gray-100 text-gray-500' : 'bg-white'}"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
{name}
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
{placeholder}
|
||||||
|
{required}
|
||||||
|
{disabled}
|
||||||
|
{oninput}
|
||||||
|
class="rounded-md border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
{error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'}
|
||||||
|
{disabled ? 'bg-gray-100 text-gray-500' : 'bg-white'}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-xs text-red-600">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if helpText && !error}
|
||||||
|
<p class="text-xs text-gray-500">{helpText}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Instance } from '$lib/types';
|
||||||
|
import StatusBadge from './StatusBadge.svelte';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||||
|
import * as api from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instance: Instance;
|
||||||
|
projectId: string;
|
||||||
|
onchange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { instance, projectId, onchange }: Props = $props();
|
||||||
|
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let confirmAction = $state<'stop' | 'restart' | 'remove' | null>(null);
|
||||||
|
|
||||||
|
const subdomainUrl = $derived(
|
||||||
|
instance.subdomain ? `https://${instance.subdomain}` : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeSinceCreated = $derived(() => {
|
||||||
|
const created = new Date(instance.created_at);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - created.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60_000);
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleAction(action: 'stop' | 'start' | 'restart' | 'remove') {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
confirmAction = null;
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case 'stop':
|
||||||
|
await api.stopInstance(projectId, instance.stage_id, instance.id);
|
||||||
|
break;
|
||||||
|
case 'start':
|
||||||
|
await api.startInstance(projectId, instance.stage_id, instance.id);
|
||||||
|
break;
|
||||||
|
case 'restart':
|
||||||
|
await api.restartInstance(projectId, instance.stage_id, instance.id);
|
||||||
|
break;
|
||||||
|
case 'remove':
|
||||||
|
await api.removeInstance(projectId, instance.stage_id, instance.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
onchange?.();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Action failed';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestConfirm(action: 'stop' | 'restart' | 'remove') {
|
||||||
|
confirmAction = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMessages: Record<string, string> = {
|
||||||
|
stop: 'This will stop the running container. The instance can be started again later.',
|
||||||
|
restart: 'This will restart the container, causing brief downtime.',
|
||||||
|
remove: 'This will permanently remove the container and its proxy configuration. This cannot be undone.'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="truncate font-mono text-sm font-medium text-gray-900">
|
||||||
|
{instance.image_tag}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={instance.status} size="sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if subdomainUrl}
|
||||||
|
<a
|
||||||
|
href={subdomainUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="mt-1 block truncate text-xs text-indigo-600 hover:text-indigo-800"
|
||||||
|
>
|
||||||
|
{instance.subdomain}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-1 flex items-center gap-3 text-xs text-gray-500">
|
||||||
|
<span>Port {instance.port}</span>
|
||||||
|
<span>{timeSinceCreated()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-3 flex items-center gap-1">
|
||||||
|
{#if instance.status === 'running'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-yellow-600 disabled:opacity-50"
|
||||||
|
title="Stop"
|
||||||
|
disabled={loading}
|
||||||
|
onclick={() => requestConfirm('stop')}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-blue-600 disabled:opacity-50"
|
||||||
|
title="Restart"
|
||||||
|
disabled={loading}
|
||||||
|
onclick={() => requestConfirm('restart')}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{:else if instance.status === 'stopped'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-green-600 disabled:opacity-50"
|
||||||
|
title="Start"
|
||||||
|
disabled={loading}
|
||||||
|
onclick={() => handleAction('start')}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600 disabled:opacity-50"
|
||||||
|
title="Remove"
|
||||||
|
disabled={loading}
|
||||||
|
onclick={() => requestConfirm('remove')}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="mt-2 text-xs text-red-600">{error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmAction !== null}
|
||||||
|
title="{confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''} Instance"
|
||||||
|
message={confirmAction ? confirmMessages[confirmAction] ?? '' : ''}
|
||||||
|
confirmLabel={confirmAction ? confirmAction.charAt(0).toUpperCase() + confirmAction.slice(1) : ''}
|
||||||
|
confirmVariant={confirmAction === 'remove' ? 'danger' : 'primary'}
|
||||||
|
onconfirm={() => { if (confirmAction) handleAction(confirmAction); }}
|
||||||
|
oncancel={() => { confirmAction = null; }}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Project, Instance } from '$lib/types';
|
||||||
|
import StatusBadge from './StatusBadge.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
instances?: Instance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { project, instances = [] }: Props = $props();
|
||||||
|
|
||||||
|
const runningCount = $derived(instances.filter((i) => i.status === 'running').length);
|
||||||
|
const stoppedCount = $derived(instances.filter((i) => i.status === 'stopped').length);
|
||||||
|
const failedCount = $derived(instances.filter((i) => i.status === 'failed').length);
|
||||||
|
const totalCount = $derived(instances.length);
|
||||||
|
|
||||||
|
const overallStatus = $derived<string>(() => {
|
||||||
|
if (failedCount > 0) return 'failed';
|
||||||
|
if (runningCount > 0) return 'running';
|
||||||
|
if (stoppedCount > 0) return 'stopped';
|
||||||
|
return 'stopped';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/projects/{project.id}"
|
||||||
|
class="block rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition hover:border-indigo-300 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="truncate text-base font-semibold text-gray-900">{project.name}</h3>
|
||||||
|
<p class="mt-1 truncate text-sm text-gray-500">{project.image}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={overallStatus()} size="sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-4 text-sm text-gray-600">
|
||||||
|
{#if totalCount > 0}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-green-500"></span>
|
||||||
|
{runningCount} running
|
||||||
|
</span>
|
||||||
|
{#if stoppedCount > 0}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||||
|
{stoppedCount} stopped
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if failedCount > 0}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||||
|
{failedCount} failed
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400">No instances</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
{#if project.port}
|
||||||
|
<span>Port {project.port}</span>
|
||||||
|
{/if}
|
||||||
|
{#if project.healthcheck}
|
||||||
|
<span>HC: {project.healthcheck}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InstanceStatus, DeployStatus } from '$lib/types';
|
||||||
|
|
||||||
|
type Status = InstanceStatus | DeployStatus | string;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
status: Status;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, size = 'md' }: Props = $props();
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
running: 'bg-green-100 text-green-800',
|
||||||
|
success: 'bg-green-100 text-green-800',
|
||||||
|
stopped: 'bg-gray-100 text-gray-800',
|
||||||
|
failed: 'bg-red-100 text-red-800',
|
||||||
|
rolled_back: 'bg-red-100 text-red-800',
|
||||||
|
removing: 'bg-yellow-100 text-yellow-800',
|
||||||
|
pending: 'bg-blue-100 text-blue-800',
|
||||||
|
pulling: 'bg-blue-100 text-blue-800',
|
||||||
|
starting: 'bg-yellow-100 text-yellow-800',
|
||||||
|
configuring_proxy: 'bg-yellow-100 text-yellow-800',
|
||||||
|
health_checking: 'bg-yellow-100 text-yellow-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotColorMap: Record<string, string> = {
|
||||||
|
running: 'bg-green-500',
|
||||||
|
success: 'bg-green-500',
|
||||||
|
stopped: 'bg-gray-400',
|
||||||
|
failed: 'bg-red-500',
|
||||||
|
rolled_back: 'bg-red-500',
|
||||||
|
removing: 'bg-yellow-500',
|
||||||
|
pending: 'bg-blue-500',
|
||||||
|
pulling: 'bg-blue-500',
|
||||||
|
starting: 'bg-yellow-500',
|
||||||
|
configuring_proxy: 'bg-yellow-500',
|
||||||
|
health_checking: 'bg-yellow-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorClass = $derived(colorMap[status] ?? 'bg-gray-100 text-gray-800');
|
||||||
|
const dotClass = $derived(dotColorMap[status] ?? 'bg-gray-400');
|
||||||
|
const sizeClass = $derived(size === 'sm' ? 'text-xs px-2 py-0.5' : 'text-sm px-2.5 py-0.5');
|
||||||
|
const dotSize = $derived(size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2');
|
||||||
|
const label = $derived(status.replace(/_/g, ' '));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full font-medium {colorClass} {sizeClass}">
|
||||||
|
<span class="rounded-full {dotClass} {dotSize}"></span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toasts, type ToastType } from '$lib/stores/toast';
|
||||||
|
|
||||||
|
const colorMap: Record<ToastType, string> = {
|
||||||
|
success: 'bg-green-600',
|
||||||
|
error: 'bg-red-600',
|
||||||
|
warning: 'bg-yellow-500',
|
||||||
|
info: 'bg-blue-600'
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconMap: Record<ToastType, string> = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✗',
|
||||||
|
warning: '⚠',
|
||||||
|
info: 'ℹ'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||||
|
{#each $toasts as toast (toast.id)}
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto flex items-center gap-3 rounded-lg px-4 py-3 text-white shadow-lg transition-all duration-300 {colorMap[toast.type]}"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span class="text-lg" aria-hidden="true">{@html iconMap[toast.type]}</span>
|
||||||
|
<span class="flex-1 text-sm font-medium">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
class="ml-2 text-white/80 hover:text-white transition-colors"
|
||||||
|
onclick={() => toasts.remove(toast.id)}
|
||||||
|
aria-label="Dismiss notification"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -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<Toast[]>([]);
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -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<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { InspectResult, QuickDeployRequest } from '$lib/types';
|
||||||
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
|
import { toasts } from '$lib/stores/toast';
|
||||||
|
|
||||||
|
let imageUrl = $state('');
|
||||||
|
let inspecting = $state(false);
|
||||||
|
let deploying = $state(false);
|
||||||
|
let inspected = $state(false);
|
||||||
|
|
||||||
|
let inspectResult: InspectResult | null = $state(null);
|
||||||
|
|
||||||
|
// Form fields populated after inspect
|
||||||
|
let projectName = $state('');
|
||||||
|
let port = $state('');
|
||||||
|
let healthcheck = $state('');
|
||||||
|
let stage = $state('dev');
|
||||||
|
let subdomain = $state('');
|
||||||
|
let envVars = $state('');
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
function validateImageUrl(url: string): string {
|
||||||
|
if (!url.trim()) return 'Image URL is required';
|
||||||
|
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
|
||||||
|
return 'Invalid image URL format (e.g., registry.example.com/org/app:tag)';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePort(value: string): string {
|
||||||
|
if (!value.trim()) return 'Port is required';
|
||||||
|
const num = parseInt(value, 10);
|
||||||
|
if (isNaN(num) || num < 1 || num > 65535) return 'Port must be between 1 and 65535';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return 'Must be lowercase alphanumeric with hyphens (e.g., my-app)';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
const nameErr = validateProjectName(projectName);
|
||||||
|
if (nameErr) newErrors.projectName = nameErr;
|
||||||
|
const portErr = validatePort(port);
|
||||||
|
if (portErr) newErrors.port = portErr;
|
||||||
|
errors = newErrors;
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInspect() {
|
||||||
|
const urlError = validateImageUrl(imageUrl);
|
||||||
|
if (urlError) {
|
||||||
|
errors = { imageUrl: urlError };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errors = {};
|
||||||
|
|
||||||
|
inspecting = true;
|
||||||
|
try {
|
||||||
|
const result = await api.post<InspectResult>('/api/deploy/inspect', {
|
||||||
|
image: imageUrl.trim()
|
||||||
|
});
|
||||||
|
inspectResult = result;
|
||||||
|
|
||||||
|
// Auto-fill form with inspection results
|
||||||
|
projectName = result.project_name ?? '';
|
||||||
|
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') : '';
|
||||||
|
inspected = true;
|
||||||
|
toasts.success('Image inspected successfully');
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to inspect image';
|
||||||
|
toasts.error(message);
|
||||||
|
} finally {
|
||||||
|
inspecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeploy() {
|
||||||
|
if (!validateAll()) return;
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
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);
|
||||||
|
toasts.success(`Deployed ${projectName} successfully!`);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
imageUrl = '';
|
||||||
|
inspected = false;
|
||||||
|
inspectResult = null;
|
||||||
|
projectName = '';
|
||||||
|
port = '';
|
||||||
|
healthcheck = '';
|
||||||
|
stage = 'dev';
|
||||||
|
subdomain = '';
|
||||||
|
envVars = '';
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Deployment failed';
|
||||||
|
toasts.error(message);
|
||||||
|
} finally {
|
||||||
|
deploying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Quick Deploy - Docker Watcher</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Quick Deploy</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Deploy a container image with zero configuration. Paste an image URL, review the defaults,
|
||||||
|
and deploy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Image URL input -->
|
||||||
|
<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">1. Enter Image URL</h2>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<FormField
|
||||||
|
label="Image URL"
|
||||||
|
name="imageUrl"
|
||||||
|
bind:value={imageUrl}
|
||||||
|
placeholder="registry.example.com/org/app:tag"
|
||||||
|
required
|
||||||
|
error={errors.imageUrl ?? ''}
|
||||||
|
helpText="Full image URL including tag (e.g., git.example.com/user/app:dev-abc123)"
|
||||||
|
disabled={inspecting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button
|
||||||
|
onclick={handleInspect}
|
||||||
|
disabled={inspecting || !imageUrl.trim()}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{#if inspecting}
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 animate-spin" 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>
|
||||||
|
Inspecting...
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Inspect
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Review and configure (shown after inspect) -->
|
||||||
|
{#if inspected}
|
||||||
|
<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">2. Review Configuration</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
|
These defaults were detected from the image. Adjust as needed before deploying.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
label="Project Name"
|
||||||
|
name="projectName"
|
||||||
|
bind:value={projectName}
|
||||||
|
placeholder="my-app"
|
||||||
|
required
|
||||||
|
error={errors.projectName ?? ''}
|
||||||
|
helpText="Lowercase with hyphens"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Port"
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
bind:value={port}
|
||||||
|
placeholder="3000"
|
||||||
|
required
|
||||||
|
error={errors.port ?? ''}
|
||||||
|
helpText="Container port to expose (1-65535)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Health Check Path"
|
||||||
|
name="healthcheck"
|
||||||
|
bind:value={healthcheck}
|
||||||
|
placeholder="/api/health"
|
||||||
|
helpText="Optional HTTP path for health verification"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="stage" class="text-sm font-medium text-gray-700">Stage</label>
|
||||||
|
<select
|
||||||
|
id="stage"
|
||||||
|
bind:value={stage}
|
||||||
|
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="dev">Development</option>
|
||||||
|
<option value="rel">Release</option>
|
||||||
|
<option value="prod">Production</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500">Deployment stage for this image</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Subdomain Override"
|
||||||
|
name="subdomain"
|
||||||
|
bind:value={subdomain}
|
||||||
|
placeholder="auto-generated"
|
||||||
|
helpText="Leave empty to use the default subdomain pattern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<FormField
|
||||||
|
label="Environment Variables"
|
||||||
|
name="envVars"
|
||||||
|
type="textarea"
|
||||||
|
bind:value={envVars}
|
||||||
|
placeholder="KEY=value ANOTHER_KEY=another_value"
|
||||||
|
helpText="One per line, KEY=VALUE format"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Deploy -->
|
||||||
|
<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">3. Deploy</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-500">
|
||||||
|
A new project will be created and the container will be deployed immediately.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={handleDeploy}
|
||||||
|
disabled={deploying}
|
||||||
|
class="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if deploying}
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 animate-spin" 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>
|
||||||
|
Deploying...
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Deploy
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
inspected = false;
|
||||||
|
inspectResult = null;
|
||||||
|
}}
|
||||||
|
disabled={deploying}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/settings', label: 'General' },
|
||||||
|
{ href: '/settings/registries', label: 'Registries' },
|
||||||
|
{ href: '/settings/credentials', label: 'Credentials' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentPath = $derived($page.url.pathname);
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
if (href === '/settings') {
|
||||||
|
return currentPath === '/settings';
|
||||||
|
}
|
||||||
|
return currentPath.startsWith(href);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<h1 class="mb-6 text-2xl font-bold text-gray-900">Settings</h1>
|
||||||
|
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<!-- Sub-navigation -->
|
||||||
|
<nav class="w-48 flex-shrink-0">
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each navItems as item}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class="block rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||||
|
{isActive(item.href)
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Settings content -->
|
||||||
|
<div class="flex-1">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user