mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.18
This commit is contained in:
+8
-2
@@ -54,6 +54,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - postgresql-client" \
|
||||
" - git" \
|
||||
" - openssh-client" \
|
||||
" - openssh-keygen" \
|
||||
" - curl" \
|
||||
" - tini" \
|
||||
" - su-exec" \
|
||||
@@ -86,7 +87,9 @@ ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
# libnss-wrapper: needed for git SSH with arbitrary UIDs on read-only containers (getpwuid workaround)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates libnss-wrapper && rm -rf /var/lib/apt/lists/* \
|
||||
&& cp "$(dpkg -L libnss-wrapper | grep 'libnss_wrapper\.so$')" /usr/local/lib/libnss_wrapper.so
|
||||
|
||||
# Copy package files and install ALL dependencies (needed for build)
|
||||
COPY package.json bun.lock* bunfig.toml ./
|
||||
@@ -95,7 +98,7 @@ RUN bun install --frozen-lockfile
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
|
||||
# Build with parallelism - dedicated build VM has 16 CPUs and 32GB RAM
|
||||
# Build the application
|
||||
RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run build
|
||||
|
||||
# Prepare production node_modules (do this in builder where we have compilers)
|
||||
@@ -130,6 +133,9 @@ COPY --from=os-builder /work/rootfs/ /
|
||||
# For regular builds, this contains the standard oven/bun binary
|
||||
COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
# Copy libnss_wrapper for git SSH with arbitrary UIDs (same cross-copy pattern as Bun above)
|
||||
COPY --from=app-builder /usr/local/lib/libnss_wrapper.so /usr/lib/libnss_wrapper.so
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Set up environment variables
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.17",
|
||||
"version": "1.0.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite dev",
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
const progressText: Record<string, string> = {
|
||||
remove: 'removing',
|
||||
start: 'starting',
|
||||
@@ -30,6 +38,7 @@
|
||||
items: Array<{ id: string; name: string }>;
|
||||
envId?: number;
|
||||
options?: Record<string, any>;
|
||||
totalSize?: number;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
@@ -42,6 +51,7 @@
|
||||
items,
|
||||
envId,
|
||||
options = {},
|
||||
totalSize,
|
||||
onClose,
|
||||
onComplete
|
||||
}: Props = $props();
|
||||
@@ -233,7 +243,7 @@
|
||||
{#if isRunning}
|
||||
Processing {items.length} {entityType}...
|
||||
{:else if isComplete}
|
||||
Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}
|
||||
Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}{#if totalSize && successCount > 0} ({formatBytes(totalSize)}){/if}
|
||||
{:else}
|
||||
Preparing to {operation} {items.length} {entityType}...
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Command from '$lib/components/ui/command';
|
||||
import {
|
||||
@@ -183,7 +183,7 @@
|
||||
// Load data when dialog opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
loadData();
|
||||
untrack(() => loadData());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { AlertCircle, Copy, Check, AlertTriangle, CheckCircle2, XCircle } from 'lucide-svelte';
|
||||
import { AlertCircle, Copy, Check, XCircle, AlertTriangle, CheckCircle2 } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -12,7 +14,7 @@
|
||||
}
|
||||
|
||||
let { open = $bindable(), title, message, details, onClose }: Props = $props();
|
||||
let copied = $state(false);
|
||||
let copied = $state<'ok' | 'error' | null>(null);
|
||||
|
||||
interface ParsedOutput {
|
||||
warnings: string[];
|
||||
@@ -87,9 +89,9 @@
|
||||
|
||||
async function copyError() {
|
||||
const text = details ? `${message}\n\n${details}` : message;
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
const ok = await copyToClipboard(text);
|
||||
copied = ok ? 'ok' : 'error';
|
||||
setTimeout(() => (copied = null), 2000);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@@ -145,7 +147,14 @@
|
||||
class="absolute top-2 right-2 p-1 rounded text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-opacity"
|
||||
title="Copy error"
|
||||
>
|
||||
{#if copied}
|
||||
{#if copied === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-3.5 h-3.5 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copied === 'ok'}
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.18",
|
||||
"date": "2026-02-16",
|
||||
"comingSoon": false,
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "Dockhand self-update from the UI" },
|
||||
{ "type": "feature", "text": "Show freed disk space after image removal and pruning" },
|
||||
{ "type": "feature", "text": "Handle dynamically-spawned child containers in stack stop/down/restart/remove" },
|
||||
{ "type": "feature", "text": "Git webhooks are logged to the audit log" },
|
||||
{ "type": "fix", "text": "Fix file upload CSRF 403 error on plain HTTP deployments" },
|
||||
{ "type": "fix", "text": "Fix scanner container /wait timeout causing empty scan output" },
|
||||
{ "type": "fix", "text": "Fix saving adopted external stack failing with 'Stack directory not found'" },
|
||||
{ "type": "fix", "text": "Add Bearer token auth support for ntfy notifications" },
|
||||
{ "type": "feature", "text": "Add Mattermost notification support" },
|
||||
{ "type": "fix", "text": "Fix git SSH failing with 'No user exists for uid' with arbitrary UIDs" },
|
||||
{ "type": "fix", "text": "Fix command palette flooding API requests on open" },
|
||||
{ "type": "fix", "text": "Normalize stack names when adopting to prevent uppercase rejection" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.18"
|
||||
},
|
||||
{
|
||||
"version": "1.0.17",
|
||||
"date": "2026-02-09",
|
||||
"comingSoon": false,
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "Fix scanner failure on rootless Docker" },
|
||||
{ "type": "fix", "text": "Increase Hawser compose operation timeout" },
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/autocomplete"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/commands",
|
||||
"version": "6.10.0",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/codemirror/commands"
|
||||
},
|
||||
{
|
||||
"name": "@codemirror/commands",
|
||||
"version": "6.10.1",
|
||||
@@ -553,7 +547,7 @@
|
||||
},
|
||||
{
|
||||
"name": "svelte",
|
||||
"version": "5.47.1",
|
||||
"version": "5.46.4",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/svelte"
|
||||
},
|
||||
|
||||
@@ -779,6 +779,7 @@ export const NOTIFICATION_EVENT_TYPES = [
|
||||
{ id: 'container_restarted', label: 'Container restarted', description: 'When a container restarts (manual or automatic)', group: 'container', scope: 'environment' },
|
||||
{ id: 'container_exited', label: 'Container exited', description: 'When a container exits unexpectedly', group: 'container', scope: 'environment' },
|
||||
{ id: 'container_unhealthy', label: 'Container unhealthy', description: 'When a container health check fails', group: 'container', scope: 'environment' },
|
||||
{ id: 'container_healthy', label: 'Container healthy', description: 'When a container health check recovers', group: 'container', scope: 'environment' },
|
||||
{ id: 'container_oom', label: 'Out of memory', description: 'When a container is killed due to out of memory', group: 'container', scope: 'environment' },
|
||||
{ id: 'container_updated', label: 'Container updated', description: 'When a container image is updated', group: 'container', scope: 'environment' },
|
||||
{ id: 'image_pulled', label: 'Image pulled', description: 'When a new image is pulled', group: 'container', scope: 'environment' },
|
||||
@@ -2982,7 +2983,7 @@ export async function deleteOldScans(keepDays = 30): Promise<number> {
|
||||
export type AuditAction =
|
||||
| 'create' | 'update' | 'delete' | 'start' | 'stop' | 'restart' | 'down'
|
||||
| 'pause' | 'unpause' | 'pull' | 'push' | 'prune' | 'login'
|
||||
| 'logout' | 'view' | 'exec' | 'connect' | 'disconnect' | 'deploy' | 'sync' | 'rename';
|
||||
| 'logout' | 'view' | 'exec' | 'connect' | 'disconnect' | 'deploy' | 'sync' | 'rename' | 'webhook';
|
||||
|
||||
export type AuditEntityType =
|
||||
| 'container' | 'image' | 'stack' | 'volume' | 'network'
|
||||
|
||||
+67
-10
@@ -807,7 +807,7 @@ export async function unpauseContainer(id: string, envId?: number | null) {
|
||||
|
||||
export async function removeContainer(id: string, force = false, envId?: number | null) {
|
||||
const response = await dockerFetch(`/containers/${id}?force=${force}`, { method: 'DELETE' }, envId);
|
||||
if (!response.ok) {
|
||||
if (!response.ok && response.status !== 404) {
|
||||
const errorBody = await response.text();
|
||||
let errorMessage = `Failed to remove container ${id}`;
|
||||
try {
|
||||
@@ -1338,6 +1338,43 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
return { id: result.Id, start: () => startContainer(result.Id, envId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-diff two objects recursively, returning all paths that differ.
|
||||
*/
|
||||
export function deepDiff(a: any, b: any, path = ''): string[] {
|
||||
const diffs: string[] = [];
|
||||
|
||||
if (a === b) return diffs;
|
||||
if (a === null || b === null || typeof a !== typeof b) {
|
||||
diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
||||
return diffs;
|
||||
}
|
||||
if (typeof a !== 'object') {
|
||||
if (a !== b) diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`);
|
||||
return diffs;
|
||||
}
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
const aStr = JSON.stringify(a);
|
||||
const bStr = JSON.stringify(b);
|
||||
if (aStr !== bStr) diffs.push(`${path}: ${aStr} → ${bStr}`);
|
||||
return diffs;
|
||||
}
|
||||
|
||||
const allKeys = Array.from(new Set([...Object.keys(a), ...Object.keys(b)]));
|
||||
for (const key of allKeys) {
|
||||
const childPath = path ? `${path}.${key}` : key;
|
||||
if (!(key in a)) {
|
||||
diffs.push(`${childPath}: <missing> → ${JSON.stringify(b[key])}`);
|
||||
} else if (!(key in b)) {
|
||||
diffs.push(`${childPath}: ${JSON.stringify(a[key])} → <missing>`);
|
||||
} else {
|
||||
diffs.push(...deepDiff(a[key], b[key], childPath));
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate a container using full Config/HostConfig passthrough from inspect data.
|
||||
* Passes Config and HostConfig directly from inspect to create, only changing
|
||||
@@ -1519,7 +1556,23 @@ export async function recreateContainerFromInspect(
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Remove old container (best effort)
|
||||
// 8. Log config diff between old and new container
|
||||
try {
|
||||
const newInspect = await inspectContainer(newContainerId, envId);
|
||||
const diffs = deepDiff(inspectData, newInspect);
|
||||
if (diffs.length === 0) {
|
||||
log?.(`[${name}] Config diff: no differences (all settings preserved)`);
|
||||
} else {
|
||||
log?.(`[${name}] Config diff: ${diffs.length} difference(s):`);
|
||||
for (const d of diffs) {
|
||||
log?.(` [${name}] ${d}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, don't fail the update
|
||||
}
|
||||
|
||||
// 9. Remove old container (best effort)
|
||||
log?.('Removing old container...');
|
||||
await removeContainer(oldContainerId, true, envId).catch(() => {});
|
||||
|
||||
@@ -2649,13 +2702,17 @@ export async function getHawserInfo(envId: number): Promise<{
|
||||
mode: string;
|
||||
uptime: number;
|
||||
} | null> {
|
||||
try {
|
||||
const response = await dockerFetch('/_hawser/info', {}, envId);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const response = await dockerFetch('/_hawser/info', {}, envId);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
console.warn(`[Hawser] Info endpoint returned ${response.status} for env ${envId}`);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[Hawser] Failed to fetch info for env ${envId} (attempt ${attempt + 1}): ${msg}`);
|
||||
}
|
||||
} catch {
|
||||
// Hawser info not available
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3275,7 +3332,7 @@ export async function runContainer(options: {
|
||||
|
||||
// Wait for container to finish
|
||||
console.log(`[runContainer] Waiting for container ${containerId} to finish...`);
|
||||
const waitResponse = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
|
||||
const waitResponse = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST', streaming: true }, options.envId);
|
||||
const waitResult = await waitResponse.json().catch(() => ({}));
|
||||
console.log(`[runContainer] Container ${containerId} finished with exit code:`, waitResult?.StatusCode);
|
||||
|
||||
@@ -3367,7 +3424,7 @@ export async function runContainerWithStreaming(options: {
|
||||
// Wait for container to exit - this is the reliable signal
|
||||
let exitCode: number | undefined;
|
||||
try {
|
||||
const waitResult = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
|
||||
const waitResult = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST', streaming: true }, options.envId);
|
||||
const waitData = await waitResult.json() as { StatusCode?: number };
|
||||
exitCode = waitData.StatusCode;
|
||||
console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode}`);
|
||||
|
||||
+90
-2
@@ -49,6 +49,80 @@ interface GitEnv {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const NSS_WRAPPER_LIB = '/usr/lib/libnss_wrapper.so';
|
||||
const TMP_PASSWD = '/tmp/dockhand-passwd';
|
||||
const TMP_GROUP = '/tmp/dockhand-group';
|
||||
|
||||
// Cache the check so we only do it once per process
|
||||
let _nssWrapperChecked = false;
|
||||
let _nssWrapperNeeded = false;
|
||||
|
||||
/**
|
||||
* Ensures the current UID exists in /etc/passwd for git/SSH operations.
|
||||
* SSH calls getpwuid() which fails with "No user exists for uid XXXX" if the
|
||||
* UID isn't in /etc/passwd (common with Docker --user or read-only containers).
|
||||
* Creates a temp passwd file and configures LD_PRELOAD with libnss_wrapper.
|
||||
*/
|
||||
async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||
if (_nssWrapperChecked) {
|
||||
if (_nssWrapperNeeded) {
|
||||
env.LD_PRELOAD = NSS_WRAPPER_LIB;
|
||||
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
|
||||
env.NSS_WRAPPER_GROUP = TMP_GROUP;
|
||||
}
|
||||
return;
|
||||
}
|
||||
_nssWrapperChecked = true;
|
||||
|
||||
// Check if current UID is in /etc/passwd
|
||||
const uid = process.getuid?.();
|
||||
if (uid === undefined || uid === 0) return; // root or not available
|
||||
|
||||
try {
|
||||
const passwd = await Bun.file('/etc/passwd').text();
|
||||
const uidStr = `:${uid}:`;
|
||||
if (passwd.split('\n').some(line => {
|
||||
const parts = line.split(':');
|
||||
return parts[2] === String(uid);
|
||||
})) {
|
||||
return; // UID exists, nothing to do
|
||||
}
|
||||
} catch {
|
||||
return; // can't read passwd, bail
|
||||
}
|
||||
|
||||
// UID not found — check if libnss_wrapper is available
|
||||
if (!existsSync(NSS_WRAPPER_LIB)) {
|
||||
console.warn(`[git] UID ${uid} not in /etc/passwd and libnss_wrapper not found — SSH may fail`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temp passwd/group with the missing entry
|
||||
try {
|
||||
const gid = process.getgid?.() ?? uid;
|
||||
const passwd = await Bun.file('/etc/passwd').text();
|
||||
const group = await Bun.file('/etc/group').text();
|
||||
|
||||
const passwdEntry = `dockhand:x:${uid}:${gid}:Dockhand:/home/dockhand:/bin/sh`;
|
||||
await Bun.write(TMP_PASSWD, passwd.trimEnd() + '\n' + passwdEntry + '\n');
|
||||
|
||||
const gidExists = group.split('\n').some(line => line.split(':')[2] === String(gid));
|
||||
if (gidExists) {
|
||||
await Bun.write(TMP_GROUP, group);
|
||||
} else {
|
||||
await Bun.write(TMP_GROUP, group.trimEnd() + '\n' + `dockhand:x:${gid}:\n`);
|
||||
}
|
||||
|
||||
_nssWrapperNeeded = true;
|
||||
env.LD_PRELOAD = NSS_WRAPPER_LIB;
|
||||
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
|
||||
env.NSS_WRAPPER_GROUP = TMP_GROUP;
|
||||
console.log(`[git] Created temp passwd for UID ${uid} with libnss_wrapper`);
|
||||
} catch (err) {
|
||||
console.warn(`[git] Failed to create temp passwd:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
const env: GitEnv = {
|
||||
...process.env as GitEnv,
|
||||
@@ -57,6 +131,9 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
SSH_AUTH_SOCK: ''
|
||||
};
|
||||
|
||||
// Ensure current UID is resolvable for SSH/git operations
|
||||
await ensurePasswdEntry(env);
|
||||
|
||||
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
|
||||
// Create a temporary SSH key file (use absolute path so SSH can find it)
|
||||
const sshKeyPath = resolve(join(GIT_REPOS_DIR, `.ssh-key-${credential.id}`));
|
||||
@@ -72,9 +149,20 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
// Bun.write's mode option doesn't always work reliably, so use chmodSync
|
||||
chmodSync(sshKeyPath, 0o600);
|
||||
|
||||
// If key has a passphrase, decrypt it in-place so SSH can use it non-interactively
|
||||
if (credential.sshPassphrase) {
|
||||
const result = Bun.spawnSync([
|
||||
'ssh-keygen', '-p', '-f', sshKeyPath,
|
||||
'-P', credential.sshPassphrase, '-N', ''
|
||||
], { env, stderr: 'pipe' });
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr.toString().trim();
|
||||
console.warn(`[git] Failed to decrypt SSH key: ${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Configure SSH to use ONLY this key (no agent, no default keys)
|
||||
const sshCommand = `ssh -i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes`;
|
||||
env.GIT_SSH_COMMAND = sshCommand;
|
||||
env.GIT_SSH_COMMAND = `ssh -i "${sshKeyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes`;
|
||||
} else {
|
||||
// No SSH credential - prevent using any keys (IdentitiesOnly=yes with no -i means no keys)
|
||||
env.GIT_SSH_COMMAND = 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes -o PasswordAuthentication=no -o PubkeyAuthentication=no';
|
||||
|
||||
@@ -32,7 +32,7 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
|
||||
/**
|
||||
* Get our own container ID
|
||||
*/
|
||||
function getOwnContainerId(): string | null {
|
||||
export function getOwnContainerId(): string | null {
|
||||
// Method 1: From cgroup (works in most cases)
|
||||
try {
|
||||
const cgroup = readFileSync('/proc/self/cgroup', 'utf-8');
|
||||
|
||||
@@ -299,14 +299,19 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
// Gotify
|
||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// gotify://hostname/token or gotifys://hostname/token
|
||||
// gotify://hostname/subpath/token (subpath support)
|
||||
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
||||
}
|
||||
|
||||
const [, hostname, token] = match;
|
||||
const [, hostname, pathPart] = match;
|
||||
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
|
||||
const url = `${protocol}://${hostname}/message?token=${token}`;
|
||||
// Token is always the last path segment; anything before it is a subpath
|
||||
const lastSlash = pathPart.lastIndexOf('/');
|
||||
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
|
||||
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
|
||||
const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@@ -506,6 +511,7 @@ function mapActionToEventType(action: string): NotificationEventType | null {
|
||||
'kill': 'container_exited',
|
||||
'oom': 'container_oom',
|
||||
'health_status: unhealthy': 'container_unhealthy',
|
||||
'health_status: healthy': 'container_healthy',
|
||||
'pull': 'image_pulled'
|
||||
};
|
||||
return mapping[action] || null;
|
||||
|
||||
@@ -124,6 +124,11 @@ export async function runEnvUpdateCheckJob(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSystemContainer(imageName)) {
|
||||
await log(` [${container.name}] Skipping - system container`);
|
||||
continue;
|
||||
}
|
||||
|
||||
checkedCount++;
|
||||
await log(` Checking: ${container.name} (${imageName})`);
|
||||
|
||||
@@ -222,16 +227,6 @@ export async function runEnvUpdateCheckJob(
|
||||
const blockedContainers: { name: string; reason: string; scannerResults?: { scanner: string; critical: number; high: number; medium: number; low: number }[] }[] = [];
|
||||
|
||||
for (const update of updatesAvailable) {
|
||||
// Skip system containers (Dockhand/Hawser) - cannot update themselves
|
||||
const systemContainerType = isSystemContainer(update.imageName);
|
||||
if (systemContainerType) {
|
||||
const reason = systemContainerType === 'dockhand'
|
||||
? 'cannot auto-update Dockhand itself'
|
||||
: 'cannot auto-update Hawser agent';
|
||||
await log(`\n[${update.containerName}] Skipping - ${reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await log(`\nUpdating: ${update.containerName}`);
|
||||
|
||||
|
||||
@@ -116,12 +116,14 @@ export async function runImagePrune(
|
||||
}
|
||||
});
|
||||
|
||||
// Send success notification
|
||||
await sendEventNotification('image_prune_success', {
|
||||
title: 'Image prune completed',
|
||||
message: `${imagesRemoved} unused images removed, ${formatBytes(spaceReclaimed)} disk space reclaimed`,
|
||||
type: 'success'
|
||||
}, envId);
|
||||
// Send success notification only when something was actually cleaned up
|
||||
if (imagesRemoved > 0) {
|
||||
await sendEventNotification('image_prune_success', {
|
||||
title: 'Image prune completed',
|
||||
message: `${imagesRemoved} unused images removed, ${formatBytes(spaceReclaimed)} disk space reclaimed`,
|
||||
type: 'success'
|
||||
}, envId);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
await log(`Error: ${error.message}`);
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface ScanResult {
|
||||
/**
|
||||
* Normalize a stack name to be valid (lowercase alphanumeric with hyphens/underscores)
|
||||
*/
|
||||
function normalizeStackName(name: string): string {
|
||||
export function normalizeStackName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
@@ -223,7 +223,7 @@ export async function adoptStack(
|
||||
}
|
||||
|
||||
// Check for name conflict within the same environment
|
||||
let finalName = stack.name;
|
||||
let finalName = normalizeStackName(stack.name);
|
||||
const existingNames = new Set(
|
||||
existingSources
|
||||
.filter((s) => s.environmentId === environmentId)
|
||||
|
||||
@@ -1562,6 +1562,43 @@ export async function getStackPathHints(
|
||||
return { workingDir, configFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop or remove orphan containers that belong to a stack but aren't defined in the compose file.
|
||||
* These are dynamically-spawned child containers (e.g., nextcloud-aio master creates worker containers).
|
||||
* Best-effort: errors are logged but don't fail the overall operation.
|
||||
*/
|
||||
async function cleanupOrphanStackContainers(
|
||||
stackName: string,
|
||||
envId: number | null | undefined,
|
||||
operation: 'stop' | 'remove' | 'restart'
|
||||
): Promise<void> {
|
||||
try {
|
||||
const containers = await getStackContainers(stackName, envId);
|
||||
const targets = containers.filter(
|
||||
(c) => c.state === 'running' || c.state === 'restarting'
|
||||
);
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const { stopContainer, removeContainer, restartContainer } = await import('./docker.js');
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((c) => {
|
||||
if (operation === 'remove') return removeContainer(c.id, true, envId);
|
||||
if (operation === 'restart') return restartContainer(c.id, envId);
|
||||
return stopContainer(c.id, envId);
|
||||
})
|
||||
);
|
||||
|
||||
const failures = results.filter((r) => r.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
console.warn(
|
||||
`[stacks] ${failures.length} orphan container(s) failed to ${operation} for stack "${stackName}"`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[stacks] Failed to cleanup orphan containers for stack "${stackName}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to perform container-based operations for external stacks
|
||||
* Used as fallback when no compose file exists.
|
||||
@@ -1767,13 +1804,18 @@ export async function stopStack(
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
return executeComposeCommand(
|
||||
const composeResult = await executeComposeCommand(
|
||||
'stop',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
);
|
||||
|
||||
// Stop any dynamically-spawned child containers not in the compose file
|
||||
await cleanupOrphanStackContainers(stackName, envId, 'stop');
|
||||
|
||||
return composeResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1791,13 +1833,18 @@ export async function restartStack(
|
||||
return withContainerFallback(stackName, envId, 'restart');
|
||||
}
|
||||
|
||||
return executeComposeCommand(
|
||||
const composeResult = await executeComposeCommand(
|
||||
'restart',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
);
|
||||
|
||||
// Restart any dynamically-spawned child containers not in the compose file
|
||||
await cleanupOrphanStackContainers(stackName, envId, 'restart');
|
||||
|
||||
return composeResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1816,13 +1863,18 @@ export async function downStack(
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
return executeComposeCommand(
|
||||
const composeResult = await executeComposeCommand(
|
||||
'down',
|
||||
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
|
||||
result.content!,
|
||||
result.nonSecretVars,
|
||||
result.secretVars
|
||||
);
|
||||
|
||||
// Remove any dynamically-spawned child containers not in the compose file
|
||||
await cleanupOrphanStackContainers(stackName, envId, 'remove');
|
||||
|
||||
return composeResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1861,6 +1913,9 @@ export async function removeStack(
|
||||
if (!downResult.success && !force) {
|
||||
return downResult;
|
||||
}
|
||||
|
||||
// Remove any dynamically-spawned child containers not handled by compose
|
||||
await cleanupOrphanStackContainers(stackName, envId, 'remove');
|
||||
} else {
|
||||
// External stack - remove containers directly in parallel
|
||||
const { removeContainer } = await import('./docker.js');
|
||||
|
||||
@@ -20,7 +20,7 @@ const MAX_RECONNECT_DELAY = 60000; // 1 minute max
|
||||
const environmentOnlineStatus: Map<number, boolean> = new Map();
|
||||
|
||||
// Active collectors per environment (for streaming mode)
|
||||
const collectors: Map<number, AbortController> = new Map();
|
||||
const collectors: Map<number, { controller: AbortController; reconnectTimeout: ReturnType<typeof setTimeout> | null }> = new Map();
|
||||
|
||||
// Poll intervals per environment (for polling mode)
|
||||
const pollIntervals: Map<number, ReturnType<typeof setInterval>> = new Map();
|
||||
@@ -313,7 +313,8 @@ async function startEnvironmentCollector(envId: number, envName: string) {
|
||||
stopEnvironmentCollector(envId);
|
||||
|
||||
const controller = new AbortController();
|
||||
collectors.set(envId, controller);
|
||||
const entry = { controller, reconnectTimeout: null as ReturnType<typeof setTimeout> | null };
|
||||
collectors.set(envId, entry);
|
||||
|
||||
let reconnectDelay = RECONNECT_DELAY;
|
||||
|
||||
@@ -415,7 +416,8 @@ async function startEnvironmentCollector(envId: number, envName: string) {
|
||||
if (controller.signal.aborted || isShuttingDown) return;
|
||||
|
||||
console.log(`[EventSubprocess] Reconnecting to ${envName} in ${reconnectDelay / 1000}s...`);
|
||||
setTimeout(() => {
|
||||
entry.reconnectTimeout = setTimeout(() => {
|
||||
entry.reconnectTimeout = null;
|
||||
if (!controller.signal.aborted && !isShuttingDown) {
|
||||
connect();
|
||||
}
|
||||
@@ -468,9 +470,12 @@ function stopEnvironmentPoller(envId: number) {
|
||||
* Stop collecting events for a specific environment (streaming mode)
|
||||
*/
|
||||
function stopEnvironmentCollector(envId: number) {
|
||||
const controller = collectors.get(envId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
const entry = collectors.get(envId);
|
||||
if (entry) {
|
||||
if (entry.reconnectTimeout !== null) {
|
||||
clearTimeout(entry.reconnectTimeout);
|
||||
}
|
||||
entry.controller.abort();
|
||||
collectors.delete(envId);
|
||||
environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
@@ -528,6 +533,15 @@ async function refreshEventCollectors() {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale map entries for deleted environments
|
||||
const allEnvIds = new Set(environments.map((e) => e.id));
|
||||
for (const envId of environmentOnlineStatus.keys()) {
|
||||
if (!allEnvIds.has(envId)) environmentOnlineStatus.delete(envId);
|
||||
}
|
||||
for (const envId of lastPollTime.keys()) {
|
||||
if (!allEnvIds.has(envId)) lastPollTime.delete(envId);
|
||||
}
|
||||
|
||||
// Start collectors based on mode
|
||||
for (const env of environments) {
|
||||
// Skip Hawser Edge (handled by main process)
|
||||
|
||||
@@ -368,6 +368,12 @@ async function checkDiskSpace() {
|
||||
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up stale lastDiskWarning entries for deleted environments
|
||||
const activeEnvIds = new Set(environments.map((e) => e.id));
|
||||
for (const envId of lastDiskWarning.keys()) {
|
||||
if (!activeEnvIds.has(envId)) lastDiskWarning.delete(envId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[MetricsSubprocess] Disk space check error: ${message}`);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copy text to clipboard with execCommand fallback for HTTP.
|
||||
* Returns true on success, false on failure.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
/* fall through to execCommand */
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hidden textarea + execCommand for HTTP contexts
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (ok) return true;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface UpdateCheckResult {
|
||||
newDigest?: string;
|
||||
error?: string;
|
||||
isLocalImage?: boolean;
|
||||
systemContainer?: 'dockhand' | 'hawser' | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,8 +40,8 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
const allContainers = await listContainers(true, envIdNum);
|
||||
|
||||
// Filter out system containers (Dockhand, Hawser) - they cannot be updated from within Dockhand
|
||||
const containers = allContainers.filter(c => !isSystemContainer(c.image));
|
||||
// Include all containers (system containers get flagged, not filtered)
|
||||
const containers = allContainers;
|
||||
|
||||
// Check container for updates
|
||||
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
|
||||
@@ -56,7 +57,8 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
containerName: container.name,
|
||||
imageName: container.image,
|
||||
hasUpdate: false,
|
||||
error: 'Could not determine image name'
|
||||
error: 'Could not determine image name',
|
||||
systemContainer: isSystemContainer(container.image) || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +73,8 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
currentDigest: result.currentDigest,
|
||||
newDigest: result.registryDigest,
|
||||
error: result.error,
|
||||
isLocalImage: result.isLocalImage
|
||||
isLocalImage: result.isLocalImage,
|
||||
systemContainer: isSystemContainer(imageName) || null
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -79,7 +82,8 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
containerName: container.name,
|
||||
imageName: container.image,
|
||||
hasUpdate: false,
|
||||
error: error.message
|
||||
error: error.message,
|
||||
systemContainer: isSystemContainer(container.image) || null
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -90,9 +94,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
const updatesFound = results.filter(r => r.hasUpdate).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
// Skip system containers (Dockhand/Hawser) - they use their own update paths
|
||||
if (envIdNum) {
|
||||
for (const result of results) {
|
||||
if (result.hasUpdate) {
|
||||
if (result.hasUpdate && !result.systemContainer) {
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
|
||||
@@ -25,11 +25,15 @@ import { parseLabels } from '$lib/utils/label-colors';
|
||||
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
|
||||
|
||||
// Helper to add timeout to promises
|
||||
// IMPORTANT: Clears the timeout to prevent memory leaks from accumulated timer closures
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
|
||||
]);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely
|
||||
|
||||
@@ -70,24 +70,27 @@ export const POST: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const info = await getDockerInfo(env.id) as any;
|
||||
|
||||
// For Hawser Standard mode, fetch Hawser info (Edge mode handled above with early return)
|
||||
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
|
||||
// (sequential calls can fail due to Bun TLS connection reuse issues)
|
||||
let info: any;
|
||||
let hawserInfo = null;
|
||||
if (env.connectionType === 'hawser-standard') {
|
||||
try {
|
||||
hawserInfo = await getHawserInfo(id);
|
||||
if (hawserInfo?.hawserVersion) {
|
||||
await updateEnvironment(id, {
|
||||
hawserVersion: hawserInfo.hawserVersion,
|
||||
hawserAgentId: hawserInfo.agentId,
|
||||
hawserAgentName: hawserInfo.agentName,
|
||||
hawserLastSeen: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Hawser info fetch failed, continue without it
|
||||
const [dockerResult, hawserResult] = await Promise.all([
|
||||
getDockerInfo(env.id),
|
||||
getHawserInfo(id)
|
||||
]);
|
||||
info = dockerResult;
|
||||
hawserInfo = hawserResult;
|
||||
if (hawserInfo?.hawserVersion) {
|
||||
await updateEnvironment(id, {
|
||||
hawserVersion: hawserInfo.hawserVersion,
|
||||
hawserAgentId: hawserInfo.agentId,
|
||||
hawserAgentName: hawserInfo.agentName,
|
||||
hawserLastSeen: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
info = await getDockerInfo(env.id);
|
||||
}
|
||||
|
||||
return json({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitStack } from '$lib/server/db';
|
||||
import { deployGitStack } from '$lib/server/git';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
|
||||
@@ -26,7 +27,14 @@ function verifySignature(payload: string, signature: string | null, secret: stri
|
||||
return signature === secret;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
function detectSource(request: Request): string {
|
||||
if (request.headers.get('x-hub-signature-256')) return 'github';
|
||||
if (request.headers.get('x-gitlab-token')) return 'gitlab';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request } = event;
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
@@ -42,6 +50,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 });
|
||||
}
|
||||
|
||||
const source = detectSource(request);
|
||||
|
||||
// Verify webhook secret if set
|
||||
if (gitStack.webhookSecret) {
|
||||
const payload = await request.text();
|
||||
@@ -51,12 +61,18 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const signature = githubSignature || gitlabToken;
|
||||
|
||||
if (!verifySignature(payload, signature, gitStack.webhookSecret)) {
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'POST', source, error: 'invalid_signature'
|
||||
});
|
||||
return json({ error: 'Invalid webhook signature' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy the git stack (syncs and deploys only if there are changes)
|
||||
const result = await deployGitStack(id, { force: false });
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'POST', source, result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Webhook error:', error);
|
||||
@@ -65,7 +81,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
};
|
||||
|
||||
// Also support GET for simple polling/manual triggers
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { params, url } = event;
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
@@ -84,11 +101,17 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
// Verify secret via query parameter for GET requests
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (gitStack.webhookSecret && secret !== gitStack.webhookSecret) {
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'GET', source: 'get', error: 'invalid_secret'
|
||||
});
|
||||
return json({ error: 'Invalid webhook secret' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Deploy the git stack (syncs and deploys only if there are changes)
|
||||
const result = await deployGitStack(id, { force: false });
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'GET', source: 'get', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Webhook GET error:', error);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json, text } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitRepository } from '$lib/server/db';
|
||||
import { deployFromRepository } from '$lib/server/git';
|
||||
import { auditGitRepository } from '$lib/server/audit';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
function verifySignature(payload: string, signature: string | null, secret: string): boolean {
|
||||
@@ -26,7 +27,14 @@ function verifySignature(payload: string, signature: string | null, secret: stri
|
||||
return signature === secret;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
function detectSource(request: Request): string {
|
||||
if (request.headers.get('x-hub-signature-256')) return 'github';
|
||||
if (request.headers.get('x-gitlab-token')) return 'gitlab';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request } = event;
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
@@ -42,6 +50,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
return json({ error: 'Webhook is not enabled for this repository' }, { status: 403 });
|
||||
}
|
||||
|
||||
const source = detectSource(request);
|
||||
|
||||
// Verify webhook secret if set
|
||||
if (repository.webhookSecret) {
|
||||
const payload = await request.text();
|
||||
@@ -51,6 +61,9 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const signature = githubSignature || gitlabToken;
|
||||
|
||||
if (!verifySignature(payload, signature, repository.webhookSecret)) {
|
||||
await auditGitRepository(event, 'webhook', id, repository.name, {
|
||||
method: 'POST', source, error: 'invalid_signature'
|
||||
});
|
||||
return json({ error: 'Invalid webhook signature' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -63,6 +76,9 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
|
||||
// Deploy from repository
|
||||
const result = await deployFromRepository(id);
|
||||
await auditGitRepository(event, 'webhook', id, repository.name, {
|
||||
method: 'POST', source, result: result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Webhook error:', error);
|
||||
@@ -71,7 +87,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
};
|
||||
|
||||
// Also support GET for simple polling/manual triggers
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { params, url } = event;
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) {
|
||||
@@ -90,11 +107,17 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
// Verify secret via query parameter for GET requests
|
||||
const secret = url.searchParams.get('secret');
|
||||
if (repository.webhookSecret && secret !== repository.webhookSecret) {
|
||||
await auditGitRepository(event, 'webhook', id, repository.name, {
|
||||
method: 'GET', source: 'get', error: 'invalid_secret'
|
||||
});
|
||||
return json({ error: 'Invalid webhook secret' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Deploy from repository
|
||||
const result = await deployFromRepository(id);
|
||||
await auditGitRepository(event, 'webhook', id, repository.name, {
|
||||
method: 'GET', source: 'get', result: result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Webhook GET error:', error);
|
||||
|
||||
@@ -78,7 +78,15 @@ export const POST: RequestHandler = async (event) => {
|
||||
// Audit log
|
||||
await auditNotification(event, 'create', setting.id, setting.name);
|
||||
|
||||
return json(setting);
|
||||
// Don't expose passwords in response
|
||||
const safeSetting = setting.type === 'smtp' ? {
|
||||
...setting,
|
||||
config: {
|
||||
...setting.config,
|
||||
password: setting.config.password ? '********' : undefined
|
||||
}
|
||||
} : setting;
|
||||
return json(safeSetting);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating notification setting:', error);
|
||||
return json({ error: error.message || 'Failed to create notification setting' }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId, getHostDockerSocket } from '$lib/server/host-path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const UPDATER_IMAGE = 'fnsys/dockhand-updater:latest';
|
||||
const UPDATER_LABEL = 'dockhand.updater';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly.
|
||||
* Self-update always operates on the local engine — no environment routing needed.
|
||||
*/
|
||||
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
...options,
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an image via local Docker socket, streaming progress via callback.
|
||||
*/
|
||||
async function pullImageLocal(imageName: string, onProgress?: (line: string) => void): Promise<void> {
|
||||
let fromImage = imageName;
|
||||
let tag = 'latest';
|
||||
if (imageName.includes(':')) {
|
||||
const lastColon = imageName.lastIndexOf(':');
|
||||
const potentialTag = imageName.substring(lastColon + 1);
|
||||
if (!potentialTag.includes('/')) {
|
||||
fromImage = imageName.substring(0, lastColon);
|
||||
tag = potentialTag;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await localDockerFetch(
|
||||
`/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Failed to pull image: ${text}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (reader) {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!onProgress || !value) continue;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.error) {
|
||||
onProgress(`Error: ${json.error}`);
|
||||
} else if (json.status) {
|
||||
let msg = json.status;
|
||||
if (json.id) msg = `${json.id}: ${msg}`;
|
||||
if (json.progress) msg += ` ${json.progress}`;
|
||||
onProgress(msg);
|
||||
}
|
||||
} catch {
|
||||
onProgress(line.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker socket is mounted read-write
|
||||
*/
|
||||
async function isDockerSocketWritable(containerId: string): Promise<boolean> {
|
||||
const response = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!response.ok) return false;
|
||||
|
||||
const info = await response.json() as {
|
||||
Mounts?: Array<{ Source: string; Destination: string; RW: boolean }>;
|
||||
};
|
||||
|
||||
const socketDest = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
const socketMount = info.Mounts?.find(m => m.Destination === socketDest);
|
||||
return socketMount?.RW ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any existing updater containers
|
||||
*/
|
||||
async function cleanupExistingUpdaters(): Promise<void> {
|
||||
const response = await localDockerFetch(
|
||||
`/containers/json?all=true&filters=${encodeURIComponent(JSON.stringify({ label: [UPDATER_LABEL + '=true'] }))}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const containers = await response.json() as Array<{ Id: string; State: string }>;
|
||||
for (const container of containers) {
|
||||
if (container.State === 'running') {
|
||||
await localDockerFetch(`/containers/${container.Id}/stop`, { method: 'POST' });
|
||||
}
|
||||
await localDockerFetch(`/containers/${container.Id}?force=true`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the container create config from inspect data (same logic as recreateContainerFromInspect).
|
||||
* Does NOT include NetworkingConfig — the new container is created without networks
|
||||
* to avoid static IP conflicts with the still-running old container.
|
||||
*/
|
||||
function buildCreateConfig(inspectData: any, newImage: string): any {
|
||||
const config = inspectData.Config || {};
|
||||
const hostConfig = inspectData.HostConfig || {};
|
||||
|
||||
const createConfig: any = {
|
||||
...config,
|
||||
Image: newImage,
|
||||
HostConfig: { ...hostConfig }
|
||||
};
|
||||
|
||||
// Clear MacAddress for Docker API < 1.44 compatibility
|
||||
delete createConfig.MacAddress;
|
||||
|
||||
// Clear Hostname so Docker assigns the new container's own ID
|
||||
// Otherwise the old container's hostname is inherited, breaking self-identification
|
||||
delete createConfig.Hostname;
|
||||
|
||||
// Preserve anonymous volumes from Mounts not in HostConfig.Binds
|
||||
const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => {
|
||||
const parts = b.split(':');
|
||||
return parts.length >= 2 ? parts[1] : parts[0];
|
||||
}));
|
||||
const mounts = inspectData.Mounts || [];
|
||||
const additionalBinds: string[] = [];
|
||||
for (const mount of mounts) {
|
||||
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
|
||||
if (!existingBinds.has(mount.Destination)) {
|
||||
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (additionalBinds.length > 0) {
|
||||
createConfig.HostConfig = {
|
||||
...createConfig.HostConfig,
|
||||
Binds: [...(createConfig.HostConfig.Binds || []), ...additionalBinds]
|
||||
};
|
||||
}
|
||||
|
||||
// No NetworkingConfig — avoids static IP conflicts with still-running old container.
|
||||
// Networks are connected by the sidecar after the old container is removed.
|
||||
|
||||
return createConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build NETWORKS and NETWORK_OPTS_* env vars from inspect data's NetworkSettings.
|
||||
* The sidecar uses these to reconnect networks via `docker network connect` CLI.
|
||||
*/
|
||||
function buildNetworkEnvVars(inspectData: any): string[] {
|
||||
const networks: Record<string, any> = inspectData.NetworkSettings?.Networks || {};
|
||||
const entries = Object.entries(networks);
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const networkNames: string[] = [];
|
||||
const envVars: string[] = [];
|
||||
|
||||
for (const [netName, netConfig] of entries) {
|
||||
networkNames.push(netName);
|
||||
|
||||
const nc = netConfig as any;
|
||||
const opts: string[] = [];
|
||||
|
||||
if (nc.IPAMConfig?.IPv4Address) {
|
||||
opts.push(`--ip ${nc.IPAMConfig.IPv4Address}`);
|
||||
}
|
||||
if (nc.IPAMConfig?.IPv6Address) {
|
||||
opts.push(`--ip6 ${nc.IPAMConfig.IPv6Address}`);
|
||||
}
|
||||
if (nc.Aliases && nc.Aliases.length > 0) {
|
||||
for (const alias of nc.Aliases) {
|
||||
opts.push(`--alias ${alias}`);
|
||||
}
|
||||
}
|
||||
if (nc.Links && nc.Links.length > 0) {
|
||||
for (const link of nc.Links) {
|
||||
opts.push(`--link ${link}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.length > 0) {
|
||||
// Env var name: dots and dashes become underscores
|
||||
const safeNetName = netName.replace(/[.-]/g, '_');
|
||||
envVars.push(`NETWORK_OPTS_${safeNetName}=${opts.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
envVars.unshift(`NETWORKS=${networkNames.join(' ')}`);
|
||||
return envVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE stream endpoint for self-update.
|
||||
* Pulls image, creates new container, then launches minimal sidecar.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAdmin) {
|
||||
return json({ error: 'Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({})) as { newImage?: string };
|
||||
const newImage = body.newImage;
|
||||
if (!newImage) {
|
||||
return json({ error: 'newImage is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Fail-fast validation before starting SSE stream
|
||||
const containerId = getOwnContainerId();
|
||||
if (!containerId) {
|
||||
return json({ error: 'Not running in Docker' }, { status: 400 });
|
||||
}
|
||||
|
||||
const writable = await isDockerSocketWritable(containerId);
|
||||
if (!writable) {
|
||||
return json({
|
||||
error: 'Docker socket is mounted read-only. Self-update requires read-write Docker socket access.'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const nameResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!nameResponse.ok) {
|
||||
return json({ error: 'Failed to inspect own container' }, { status: 500 });
|
||||
}
|
||||
const nameInfo = await nameResponse.json() as { Name?: string };
|
||||
const containerName = nameInfo.Name?.replace(/^\//, '') || '';
|
||||
if (!containerName) {
|
||||
return json({ error: 'Failed to determine container name' }, { status: 500 });
|
||||
}
|
||||
|
||||
const socketHostPath = getHostDockerSocket();
|
||||
|
||||
// Start SSE stream for preparation progress
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (event: string, data: any) => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
};
|
||||
|
||||
const sendStep = (step: string, status: string, message: string) => {
|
||||
send('step', { step, status, message });
|
||||
};
|
||||
|
||||
let newContainerId: string | null = null;
|
||||
|
||||
try {
|
||||
// Step 1: Pull the new Dockhand image
|
||||
sendStep('pulling_image', 'active', `Pulling ${newImage}...`);
|
||||
send('log', { message: `Pulling ${newImage}...` });
|
||||
await pullImageLocal(newImage, (msg) => send('log', { message: msg }));
|
||||
sendStep('pulling_image', 'completed', 'Image pulled');
|
||||
send('log', { message: 'Image pulled successfully' });
|
||||
|
||||
// Step 2: Build container config from self-inspect
|
||||
sendStep('building_config', 'active', 'Building container config...');
|
||||
send('log', { message: `Inspecting container ${containerId.substring(0, 12)}...` });
|
||||
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!inspectResponse.ok) {
|
||||
throw new Error('Failed to inspect own container');
|
||||
}
|
||||
const inspectData = await inspectResponse.json();
|
||||
const createConfig = buildCreateConfig(inspectData, newImage);
|
||||
const networkEnvVars = buildNetworkEnvVars(inspectData);
|
||||
send('log', { message: `Networks: ${networkEnvVars.length > 0 ? networkEnvVars[0] : 'default'}` });
|
||||
sendStep('building_config', 'completed', 'Config ready');
|
||||
|
||||
// Step 3: Pull the updater image
|
||||
sendStep('pulling_updater', 'active', 'Pulling updater image...');
|
||||
send('log', { message: `Pulling ${UPDATER_IMAGE}...` });
|
||||
await pullImageLocal(UPDATER_IMAGE, (msg) => send('log', { message: msg }));
|
||||
sendStep('pulling_updater', 'completed', 'Updater ready');
|
||||
send('log', { message: 'Updater image ready' });
|
||||
|
||||
// Step 4: Create new container with temp name (no NetworkingConfig)
|
||||
sendStep('creating_container', 'active', 'Creating new container...');
|
||||
send('log', { message: 'Cleaning up previous updater containers...' });
|
||||
await cleanupExistingUpdaters();
|
||||
|
||||
// Also clean up any leftover -updating containers from previous attempts
|
||||
const staleResponse = await localDockerFetch(
|
||||
`/containers/json?all=true&filters=${encodeURIComponent(JSON.stringify({ name: [`${containerName}-updating`] }))}`
|
||||
);
|
||||
if (staleResponse.ok) {
|
||||
const stale = await staleResponse.json() as Array<{ Id: string; State: string }>;
|
||||
for (const c of stale) {
|
||||
if (c.State === 'running') {
|
||||
await localDockerFetch(`/containers/${c.Id}/stop`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
await localDockerFetch(`/containers/${c.Id}?force=true`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const tempName = `${containerName}-updating`;
|
||||
const createResponse = await localDockerFetch(
|
||||
`/containers/create?name=${encodeURIComponent(tempName)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(createConfig)
|
||||
}
|
||||
);
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const errText = await createResponse.text();
|
||||
throw new Error(`Failed to create container: ${errText}`);
|
||||
}
|
||||
|
||||
const createResult = await createResponse.json() as { Id: string };
|
||||
newContainerId = createResult.Id;
|
||||
console.log(`[SelfUpdate] New container created: ${newContainerId.substring(0, 12)} (${tempName})`);
|
||||
send('log', { message: `Container created: ${newContainerId.substring(0, 12)} (${tempName})` });
|
||||
sendStep('creating_container', 'completed', 'Container created');
|
||||
|
||||
// Step 5: Launch updater sidecar (point of no return)
|
||||
sendStep('launching_updater', 'active', 'Launching updater...');
|
||||
|
||||
const updaterEnv = [
|
||||
`OLD_CONTAINER_ID=${containerId}`,
|
||||
`NEW_CONTAINER_ID=${newContainerId}`,
|
||||
`CONTAINER_NAME=${containerName}`,
|
||||
...networkEnvVars
|
||||
];
|
||||
|
||||
console.log('[SelfUpdate] Creating updater container...');
|
||||
const updaterResponse = await localDockerFetch('/containers/create?name=dockhand-updater', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Image: UPDATER_IMAGE,
|
||||
Env: updaterEnv,
|
||||
Labels: {
|
||||
[UPDATER_LABEL]: 'true'
|
||||
},
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
Binds: [
|
||||
`${socketHostPath}:/var/run/docker.sock`
|
||||
]
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!updaterResponse.ok) {
|
||||
const errText = await updaterResponse.text();
|
||||
throw new Error(`Failed to create updater container: ${errText}`);
|
||||
}
|
||||
|
||||
const { Id: updaterId } = await updaterResponse.json() as { Id: string };
|
||||
|
||||
// Start the updater
|
||||
const startResponse = await localDockerFetch(`/containers/${updaterId}/start`, { method: 'POST' });
|
||||
if (!startResponse.ok) {
|
||||
await localDockerFetch(`/containers/${updaterId}?force=true`, { method: 'DELETE' });
|
||||
throw new Error('Failed to start updater container');
|
||||
}
|
||||
|
||||
console.log(`[SelfUpdate] Updater started (${updaterId.substring(0, 12)}). Dockhand will be stopped shortly.`);
|
||||
send('log', { message: `Updater started: ${updaterId.substring(0, 12)}` });
|
||||
send('log', { message: 'Handing off to updater sidecar...' });
|
||||
sendStep('launching_updater', 'completed', 'Updater launched');
|
||||
send('launched', { updaterId });
|
||||
} catch (err: any) {
|
||||
console.error('[SelfUpdate] Error:', err);
|
||||
send('error', { step: 'preparation', message: err.message || String(err) });
|
||||
|
||||
// Clean up the pre-created container on failure
|
||||
if (newContainerId) {
|
||||
await localDockerFetch(`/containers/${newContainerId}?force=true`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
if (!controllerClosed) {
|
||||
try { controller.close(); } catch { /* already closed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId } from '$lib/server/host-path';
|
||||
import { getRegistryManifestDigest } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly (not through environment routing)
|
||||
*/
|
||||
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
...options,
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Dockhand update is available.
|
||||
* Admin-only. Auto-checked when Settings > About is opened.
|
||||
*
|
||||
* Uses localDockerFetch exclusively to avoid environment routing issues
|
||||
* when the image comes from a private registry.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAdmin) {
|
||||
return json({ error: 'Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const containerId = getOwnContainerId();
|
||||
if (!containerId) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Not running in Docker'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Inspect own container to get current image info
|
||||
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!inspectResponse.ok) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Failed to inspect own container'
|
||||
});
|
||||
}
|
||||
|
||||
const inspectData = await inspectResponse.json() as {
|
||||
Config?: { Image?: string; Labels?: Record<string, string> };
|
||||
Image?: string;
|
||||
Name?: string;
|
||||
};
|
||||
|
||||
const currentImage = inspectData.Config?.Image || '';
|
||||
const currentImageId = inspectData.Image || '';
|
||||
const containerName = inspectData.Name?.replace(/^\//, '') || '';
|
||||
|
||||
if (!currentImage) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Could not determine current image'
|
||||
});
|
||||
}
|
||||
|
||||
// Detect if managed by Docker Compose
|
||||
const isComposeManaged = !!inspectData.Config?.Labels?.['com.docker.compose.project'];
|
||||
|
||||
// Digest-based images (e.g. image@sha256:...) can't be checked for updates
|
||||
if (currentImage.includes('@sha256:')) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
currentDigest: currentImage.split('@')[1],
|
||||
containerName,
|
||||
isComposeManaged
|
||||
});
|
||||
}
|
||||
|
||||
// Inspect image via local Docker socket to get RepoDigests
|
||||
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
|
||||
if (!imageResponse.ok) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Could not inspect current image'
|
||||
});
|
||||
}
|
||||
|
||||
const imageInfo = await imageResponse.json() as { RepoDigests?: string[] };
|
||||
const repoDigests = imageInfo.RepoDigests || [];
|
||||
|
||||
// Extract local digests from RepoDigests entries (format: "registry/image@sha256:...")
|
||||
const localDigests = repoDigests
|
||||
.map((rd: string) => {
|
||||
const at = rd.lastIndexOf('@');
|
||||
return at > -1 ? rd.substring(at + 1) : null;
|
||||
})
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
if (localDigests.length === 0) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
isLocalImage: true
|
||||
});
|
||||
}
|
||||
|
||||
// Query registry for latest digest
|
||||
const registryDigest = await getRegistryManifestDigest(currentImage);
|
||||
if (!registryDigest) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
containerName,
|
||||
isComposeManaged,
|
||||
error: 'Could not query registry'
|
||||
});
|
||||
}
|
||||
|
||||
const hasUpdate = !localDigests.includes(registryDigest);
|
||||
|
||||
return json({
|
||||
updateAvailable: hasUpdate,
|
||||
currentImage,
|
||||
currentDigest: localDigests[0],
|
||||
newDigest: registryDigest,
|
||||
containerName,
|
||||
isComposeManaged
|
||||
});
|
||||
} catch (err) {
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Check failed: ' + String(err)
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Fetch from the local Docker socket directly
|
||||
*/
|
||||
async function localDockerFetch(path: string): Promise<Response> {
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return fetch(`http://localhost${path}`, {
|
||||
// @ts-ignore - Bun supports unix sockets
|
||||
unix: socketPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Docker log stream multiplexing headers.
|
||||
* Docker prefixes each frame with an 8-byte header:
|
||||
* [stream_type(1)] [0(3)] [size(4 big-endian)]
|
||||
*/
|
||||
function stripDockerLogHeaders(raw: Uint8Array): string {
|
||||
const lines: string[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < raw.length) {
|
||||
// Check if we have a Docker stream header (8 bytes)
|
||||
if (offset + 8 <= raw.length) {
|
||||
const streamType = raw[offset];
|
||||
// Stream type should be 0 (stdin), 1 (stdout), or 2 (stderr)
|
||||
if (streamType <= 2) {
|
||||
// Read the 4-byte big-endian size
|
||||
const size = (raw[offset + 4] << 24) | (raw[offset + 5] << 16) | (raw[offset + 6] << 8) | raw[offset + 7];
|
||||
if (size > 0 && offset + 8 + size <= raw.length) {
|
||||
const frameData = new TextDecoder().decode(raw.slice(offset + 8, offset + 8 + size));
|
||||
const frameLines = frameData.split('\n');
|
||||
for (const line of frameLines) {
|
||||
if (line.trim()) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
offset += 8 + size;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: decode remaining as plain text
|
||||
const remaining = new TextDecoder().decode(raw.slice(offset));
|
||||
const remainingLines = remaining.split('\n');
|
||||
for (const line of remainingLines) {
|
||||
if (line.trim()) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll updater container logs and status for progress tracking.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAdmin) {
|
||||
return json({ error: 'Admin access required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const containerId = url.searchParams.get('id');
|
||||
if (!containerId) {
|
||||
return json({ error: 'Container ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check container state
|
||||
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
|
||||
if (!inspectResponse.ok) {
|
||||
if (inspectResponse.status === 404) {
|
||||
// Container removed (AutoRemove after exit)
|
||||
return json({ logs: '', status: 'removed' });
|
||||
}
|
||||
return json({ error: 'Failed to inspect container' }, { status: 500 });
|
||||
}
|
||||
|
||||
const info = await inspectResponse.json() as {
|
||||
State?: { Status: string; ExitCode: number; Running: boolean };
|
||||
};
|
||||
|
||||
const status = info.State?.Running ? 'running' : 'exited';
|
||||
const exitCode = info.State?.ExitCode ?? 0;
|
||||
|
||||
// Fetch logs
|
||||
const logsResponse = await localDockerFetch(
|
||||
`/containers/${containerId}/logs?stdout=true&stderr=true×tamps=false`
|
||||
);
|
||||
|
||||
let logs = '';
|
||||
if (logsResponse.ok && logsResponse.body) {
|
||||
const rawBytes = new Uint8Array(await logsResponse.arrayBuffer());
|
||||
logs = stripDockerLogHeaders(rawBytes);
|
||||
}
|
||||
|
||||
return json({ logs, status, exitCode });
|
||||
} catch (err) {
|
||||
return json({ error: 'Failed to fetch progress: ' + String(err) }, { status: 500 });
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -128,7 +128,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
|
||||
// Only write if we have a valid path
|
||||
if (!envFilePath) {
|
||||
return json({ error: 'Stack directory not found' }, { status: 404 });
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
let content = body.content;
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
AlertCircle
|
||||
} from 'lucide-svelte';
|
||||
import { broom } from '@lucide/lab';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import CreateContainerModal from './CreateContainerModal.svelte';
|
||||
import EditContainerModal from './EditContainerModal.svelte';
|
||||
import TerminalPanel from '../terminal/TerminalPanel.svelte';
|
||||
@@ -1280,15 +1281,18 @@
|
||||
}
|
||||
|
||||
let copiedCommand = $state<string | null>(null);
|
||||
let copyFailed = $state(false);
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
async function copyCommand(text: string) {
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) {
|
||||
copiedCommand = text;
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => { copiedCommand = null; }, 2000);
|
||||
}).catch(() => {
|
||||
toast.error('Failed to copy to clipboard');
|
||||
});
|
||||
} else {
|
||||
copyFailed = true;
|
||||
setTimeout(() => { copyFailed = false; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function parseUptimeToSeconds(status: string): number {
|
||||
@@ -1715,7 +1719,7 @@
|
||||
let classes = '';
|
||||
if (currentLogsContainerId === container.id) classes += 'bg-blue-500/10 hover:bg-blue-500/15 ';
|
||||
if (currentTerminalContainerId === container.id) classes += 'bg-green-500/10 hover:bg-green-500/15 ';
|
||||
if ($appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) && !container.systemContainer) classes += 'has-update ';
|
||||
if ($appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id)) classes += 'has-update ';
|
||||
return classes;
|
||||
}}
|
||||
onRowClick={(container, e) => {
|
||||
@@ -1755,38 +1759,19 @@
|
||||
<Tooltip.Content side="right" class="w-auto p-3">
|
||||
{#if container.systemContainer === 'dockhand'}
|
||||
{#if hasUpdate}
|
||||
{@const composeCmd = 'docker compose pull && docker compose up -d'}
|
||||
{@const dockerCmd = `docker stop ${container.name} && docker pull fnsys/dockhand:latest && docker start ${container.name}`}
|
||||
<div class="space-y-2">
|
||||
<p class="font-medium text-sm flex items-center gap-1.5">
|
||||
<CircleArrowUp class="w-4 h-4 text-amber-500" />
|
||||
Update available
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">Cannot be updated from within Dockhand. Update manually:</p>
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-muted-foreground text-2xs">Using Compose:</p>
|
||||
<div class="flex items-center gap-2 bg-muted rounded p-2">
|
||||
<code class="text-2xs font-mono whitespace-nowrap">{composeCmd}</code>
|
||||
<Button size="icon" variant="ghost" class="h-5 w-5 shrink-0" onclick={(e) => { e.stopPropagation(); copyToClipboard(composeCmd); }}>
|
||||
{#if copiedCommand === composeCmd}
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-3 h-3" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-2xs">Using Docker CLI:</p>
|
||||
<div class="flex items-center gap-2 bg-muted rounded p-2">
|
||||
<code class="text-2xs font-mono whitespace-nowrap">{dockerCmd}</code>
|
||||
<Button size="icon" variant="ghost" class="h-5 w-5 shrink-0" onclick={(e) => { e.stopPropagation(); copyToClipboard(dockerCmd); }}>
|
||||
{#if copiedCommand === dockerCmd}
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-3 h-3" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">Update Dockhand from the About page:</p>
|
||||
<a
|
||||
href="/settings?tab=about"
|
||||
class="text-primary hover:underline text-xs flex items-center gap-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Settings > About > Update now
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm whitespace-nowrap">Dockhand management container</p>
|
||||
@@ -1819,8 +1804,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.id === 'image'}
|
||||
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) && !container.systemContainer ? 'update-border' : ''}">
|
||||
{#if containersWithUpdatesSet.has(container.id) && !container.systemContainer}
|
||||
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) ? 'update-border' : ''}">
|
||||
{#if containersWithUpdatesSet.has(container.id)}
|
||||
<span title="Update available">
|
||||
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
|
||||
</span>
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu } from 'lucide-svelte';
|
||||
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment';
|
||||
@@ -41,18 +43,20 @@
|
||||
|
||||
// Raw JSON modal state
|
||||
let showRawJson = $state(false);
|
||||
let jsonCopied = $state(false);
|
||||
let jsonCopied = $state<'ok' | 'error' | null>(null);
|
||||
|
||||
// Label copy state
|
||||
let copiedLabel = $state<string | null>(null);
|
||||
let copyLabelFailed = $state(false);
|
||||
|
||||
async function copyLabel(key: string, value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${key}=${value}`);
|
||||
const ok = await copyToClipboard(`${key}=${value}`);
|
||||
if (ok) {
|
||||
copiedLabel = key;
|
||||
setTimeout(() => copiedLabel = null, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
} else {
|
||||
copyLabelFailed = true;
|
||||
setTimeout(() => copyLabelFailed = false, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,13 +395,9 @@
|
||||
|
||||
async function copyJson() {
|
||||
if (containerData) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(containerData, null, 2));
|
||||
jsonCopied = true;
|
||||
setTimeout(() => jsonCopied = false, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
const ok = await copyToClipboard(JSON.stringify(containerData, null, 2));
|
||||
jsonCopied = ok ? 'ok' : 'error';
|
||||
setTimeout(() => jsonCopied = null, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1368,9 +1368,17 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={copyJson}
|
||||
title={jsonCopied ? 'Copied!' : 'Copy to clipboard'}
|
||||
title={jsonCopied === 'ok' ? 'Copied!' : 'Copy to clipboard'}
|
||||
>
|
||||
{#if jsonCopied}
|
||||
{#if jsonCopied === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-4 h-4 mr-1.5 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span class="text-red-500">Failed</span>
|
||||
{:else if jsonCopied === 'ok'}
|
||||
<Check class="w-4 h-4 mr-1.5 text-green-500" />
|
||||
<span class="text-green-500">Copied!</span>
|
||||
{:else}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed, CircleDot, Circle, Filter } from 'lucide-svelte';
|
||||
import { broom, whale } from '@lucide/lab';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import ImageHistoryModal from './ImageHistoryModal.svelte';
|
||||
@@ -144,17 +146,20 @@
|
||||
let batchOpTitle = $state('');
|
||||
let batchOpOperation = $state('');
|
||||
let batchOpItems = $state<Array<{ id: string; name: string }>>([]);
|
||||
let batchOpTotalSize = $state<number | undefined>(undefined);
|
||||
|
||||
// Copy ID state
|
||||
let copiedId = $state<string | null>(null);
|
||||
let copyIdFailed = $state(false);
|
||||
|
||||
async function copyImageId(imageId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(imageId);
|
||||
const ok = await copyToClipboard(imageId);
|
||||
if (ok) {
|
||||
copiedId = imageId;
|
||||
pendingTimeouts.push(setTimeout(() => copiedId = null, 2000));
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
} else {
|
||||
copyIdFailed = true;
|
||||
pendingTimeouts.push(setTimeout(() => copyIdFailed = false, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +471,7 @@
|
||||
: img.id.slice(7, 19);
|
||||
return { id: img.id, name: displayName };
|
||||
});
|
||||
batchOpTotalSize = selectedInFilter.reduce((sum, img) => sum + img.size, 0);
|
||||
showBatchOpModal = true;
|
||||
}
|
||||
|
||||
@@ -481,7 +487,15 @@
|
||||
const response = await fetch(appendEnvParam('/api/prune/images', envId), { method: 'POST' });
|
||||
if (response.ok) {
|
||||
pruneStatus = 'success';
|
||||
toast.success('Dangling images pruned');
|
||||
const data = await response.json();
|
||||
const deleted = data.result?.ImagesDeleted;
|
||||
const spaceReclaimed = data.result?.SpaceReclaimed ?? 0;
|
||||
const count = deleted?.length ?? 0;
|
||||
if (count > 0) {
|
||||
toast.success(`Pruned ${count} image${count !== 1 ? 's' : ''}, freed ${formatBytes(spaceReclaimed)}`);
|
||||
} else {
|
||||
toast.success('No dangling images to prune');
|
||||
}
|
||||
await fetchImages();
|
||||
} else {
|
||||
pruneStatus = 'error';
|
||||
@@ -501,7 +515,15 @@
|
||||
const response = await fetch(appendEnvParam('/api/prune/images?dangling=false', envId), { method: 'POST' });
|
||||
if (response.ok) {
|
||||
pruneUnusedStatus = 'success';
|
||||
toast.success('Unused images pruned');
|
||||
const data = await response.json();
|
||||
const deleted = data.result?.ImagesDeleted;
|
||||
const spaceReclaimed = data.result?.SpaceReclaimed ?? 0;
|
||||
const count = deleted?.length ?? 0;
|
||||
if (count > 0) {
|
||||
toast.success(`Pruned ${count} image${count !== 1 ? 's' : ''}, freed ${formatBytes(spaceReclaimed)}`);
|
||||
} else {
|
||||
toast.success('No unused images to prune');
|
||||
}
|
||||
await fetchImages();
|
||||
} else {
|
||||
pruneUnusedStatus = 'error';
|
||||
@@ -516,6 +538,7 @@
|
||||
|
||||
async function removeImage(id: string, tagName: string) {
|
||||
deleteError = null;
|
||||
const imageSize = images.find(img => img.id === id)?.size;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/images/${encodeURIComponent(id)}?force=true`, envId), { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
@@ -527,7 +550,8 @@
|
||||
}, 5000));
|
||||
return;
|
||||
}
|
||||
toast.success(`Deleted ${tagName}`);
|
||||
const sizeStr = imageSize ? ` (${formatBytes(imageSize)})` : '';
|
||||
toast.success(`Deleted ${tagName}${sizeStr}`);
|
||||
await fetchImages();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove image:', error);
|
||||
@@ -608,6 +632,14 @@
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatImageDate(timestamp: number): string {
|
||||
return formatDate(new Date(timestamp * 1000));
|
||||
}
|
||||
@@ -1186,6 +1218,7 @@
|
||||
items={batchOpItems}
|
||||
envId={envId ?? undefined}
|
||||
options={{ force: true }}
|
||||
totalSize={batchOpTotalSize}
|
||||
onClose={() => showBatchOpModal = false}
|
||||
onComplete={handleBatchComplete}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
|
||||
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal } from 'lucide-svelte';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
import type { ContainerInfo } from '$lib/types';
|
||||
@@ -1135,11 +1136,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
? mergedLogs.map(l => `[${l.containerName}] ${l.text}`).join('')
|
||||
: logs;
|
||||
if (textToCopy) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
await copyToClipboard(textToCopy);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type } from 'lucide-svelte';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import { getMonospaceFont } from '$lib/themes';
|
||||
@@ -59,11 +60,7 @@
|
||||
// Copy logs to clipboard
|
||||
async function copyLogs() {
|
||||
if (logs) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(logs);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
await copyToClipboard(logs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play } from 'lucide-svelte';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
@@ -422,11 +423,7 @@
|
||||
// Copy logs to clipboard
|
||||
async function copyLogs() {
|
||||
if (logs) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(logs);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
await copyToClipboard(logs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
import { Trash2, Search, Plus, Eye, Check, XCircle, RefreshCw, Icon, AlertTriangle, X, Network, Link, Copy, CopyPlus, Share2, Server, Globe, MonitorSmartphone, Cpu, CircleOff } from 'lucide-svelte';
|
||||
import { broom } from '@lucide/lab';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import NetworkInspectModal from './NetworkInspectModal.svelte';
|
||||
@@ -375,10 +376,10 @@
|
||||
}
|
||||
|
||||
async function copyNetworkId(id: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(id);
|
||||
const ok = await copyToClipboard(id);
|
||||
if (ok) {
|
||||
toast.success('Network ID copied to clipboard');
|
||||
} catch {
|
||||
} else {
|
||||
toast.error('Failed to copy ID');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { QrCode, RefreshCw, ShieldCheck, TriangleAlert, Copy, Download, Check } from 'lucide-svelte';
|
||||
import { QrCode, RefreshCw, ShieldCheck, TriangleAlert, Copy, Download, Check, XCircle } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
@@ -23,14 +25,14 @@
|
||||
let error = $state('');
|
||||
let backupCodes = $state<string[]>([]);
|
||||
let showBackupCodes = $state(false);
|
||||
let copied = $state(false);
|
||||
let copied = $state<'ok' | 'error' | null>(null);
|
||||
|
||||
function resetForm() {
|
||||
token = '';
|
||||
error = '';
|
||||
backupCodes = [];
|
||||
showBackupCodes = false;
|
||||
copied = false;
|
||||
copied = null;
|
||||
}
|
||||
|
||||
async function verifyAndEnableMfa() {
|
||||
@@ -69,13 +71,9 @@
|
||||
}
|
||||
|
||||
async function copyBackupCodes() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatBackupCodes());
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 2000);
|
||||
} catch {
|
||||
error = 'Failed to copy to clipboard';
|
||||
}
|
||||
const ok = await copyToClipboard(formatBackupCodes());
|
||||
copied = ok ? 'ok' : 'error';
|
||||
setTimeout(() => copied = null, 2000);
|
||||
}
|
||||
|
||||
function downloadBackupCodes() {
|
||||
@@ -133,7 +131,15 @@
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="flex-1" onclick={copyBackupCodes}>
|
||||
{#if copied}
|
||||
{#if copied === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-4 h-4 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
Failed
|
||||
{:else if copied === 'ok'}
|
||||
<Check class="w-4 h-4" />
|
||||
Copied!
|
||||
{:else}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { CheckCircle2, XCircle, Download, Upload, Server, Settings2, Copy, Check, Clipboard, Icon, ShieldCheck, ShieldAlert, ShieldX, ArrowBigRight } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
import PullTab from '$lib/components/PullTab.svelte';
|
||||
import ScanTab from '$lib/components/ScanTab.svelte';
|
||||
@@ -68,7 +70,7 @@
|
||||
let scanResults = $state<ScanResult[]>([]);
|
||||
let pushStatus = $state<'idle' | 'pushing' | 'complete' | 'error'>('idle');
|
||||
let pushStarted = $state(false);
|
||||
let copiedToClipboard = $state(false);
|
||||
let copiedToClipboard = $state<'ok' | 'error' | null>(null);
|
||||
|
||||
// Computed
|
||||
const sourceRegistry = $derived(registries.find(r => r.id === sourceRegistryId));
|
||||
@@ -233,9 +235,9 @@
|
||||
}
|
||||
|
||||
async function copyTargetToClipboard() {
|
||||
await navigator.clipboard.writeText(targetImageName());
|
||||
copiedToClipboard = true;
|
||||
setTimeout(() => copiedToClipboard = false, 2000);
|
||||
const ok = await copyToClipboard(targetImageName());
|
||||
copiedToClipboard = ok ? 'ok' : 'error';
|
||||
setTimeout(() => copiedToClipboard = null, 2000);
|
||||
}
|
||||
|
||||
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
|
||||
@@ -390,7 +392,14 @@
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors cursor-pointer"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{#if copiedToClipboard}
|
||||
{#if copiedToClipboard === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-3 h-3 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copiedToClipboard === 'ok'}
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
{:else}
|
||||
<Clipboard class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
// Trigger rebuild for debug logging changes
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database } from 'lucide-svelte';
|
||||
import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database, CircleArrowUp, Loader2, CheckCircle2 } from 'lucide-svelte';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { browser } from '$app/environment';
|
||||
import LicenseModal from './LicenseModal.svelte';
|
||||
import PrivacyModal from './PrivacyModal.svelte';
|
||||
import SelfUpdateDialog from './SelfUpdateDialog.svelte';
|
||||
|
||||
interface Dependency {
|
||||
name: string;
|
||||
@@ -253,6 +255,65 @@
|
||||
let showLicenseModal = $state(false);
|
||||
let showPrivacyModal = $state(false);
|
||||
|
||||
// Self-update state
|
||||
let checkingUpdate = $state(false);
|
||||
let updateCheckDone = $state(false);
|
||||
let updateAvailable = $state(false);
|
||||
let updateInfo = $state<{
|
||||
currentImage: string;
|
||||
newImage: string;
|
||||
currentDigest: string;
|
||||
newDigest: string;
|
||||
containerName: string;
|
||||
isComposeManaged: boolean;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
let updateCheckError = $state<string | null>(null);
|
||||
let showSelfUpdateDialog = $state(false);
|
||||
|
||||
async function checkForUpdates() {
|
||||
checkingUpdate = true;
|
||||
updateCheckDone = false;
|
||||
updateAvailable = false;
|
||||
updateInfo = null;
|
||||
updateCheckError = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/self-update/check');
|
||||
if (!response.ok) {
|
||||
updateCheckError = 'Failed to check for updates';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error && !data.updateAvailable) {
|
||||
// Not in Docker or other non-critical issue
|
||||
updateCheckError = data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
updateAvailable = data.updateAvailable;
|
||||
updateCheckDone = true;
|
||||
|
||||
if (data.updateAvailable) {
|
||||
updateInfo = {
|
||||
currentImage: data.currentImage,
|
||||
newImage: data.currentImage,
|
||||
currentDigest: data.currentDigest || '',
|
||||
newDigest: data.newDigest || '',
|
||||
containerName: data.containerName,
|
||||
isComposeManaged: data.isComposeManaged
|
||||
};
|
||||
showSelfUpdateDialog = true;
|
||||
}
|
||||
} catch (err) {
|
||||
updateCheckError = 'Check failed: ' + String(err);
|
||||
} finally {
|
||||
checkingUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
@@ -364,6 +425,7 @@
|
||||
fetchSystemInfo();
|
||||
fetchDependencies();
|
||||
fetchChangelog();
|
||||
checkForUpdates();
|
||||
// Increment uptime every second for real-time display
|
||||
uptimeInterval = setInterval(() => {
|
||||
if (serverUptime !== null) {
|
||||
@@ -419,9 +481,29 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Version Badge -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Version Badge + Update Check -->
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<Badge variant="secondary" class="text-xs">Version {currentVersion}</Badge>
|
||||
{#if checkingUpdate}
|
||||
<span class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
Checking for updates...
|
||||
</span>
|
||||
{:else if updateAvailable && updateInfo}
|
||||
<button class="flex items-center gap-1 text-xs text-amber-500 hover:text-amber-400 transition-colors" onclick={() => showSelfUpdateDialog = true}>
|
||||
<CircleArrowUp class="w-3.5 h-3.5" />
|
||||
Update available — click to see what's new
|
||||
</button>
|
||||
{:else if updateCheckDone && !updateAvailable}
|
||||
<button class="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 hover:text-emerald-500 dark:hover:text-emerald-300 transition-colors" onclick={checkForUpdates}>
|
||||
<CheckCircle2 class="w-3.5 h-3.5" />
|
||||
Up to date
|
||||
</button>
|
||||
{:else if updateCheckError}
|
||||
<button class="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" onclick={checkForUpdates} title={updateCheckError}>
|
||||
Check for updates
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Build & Uptime Info -->
|
||||
@@ -871,3 +953,16 @@
|
||||
|
||||
<LicenseModal bind:open={showLicenseModal} />
|
||||
<PrivacyModal bind:open={showPrivacyModal} />
|
||||
|
||||
{#if updateInfo}
|
||||
<SelfUpdateDialog
|
||||
bind:open={showSelfUpdateDialog}
|
||||
currentImage={updateInfo.currentImage}
|
||||
newImage={updateInfo.newImage}
|
||||
currentDigest={updateInfo.currentDigest}
|
||||
newDigest={updateInfo.newDigest}
|
||||
containerName={updateInfo.containerName}
|
||||
isComposeManaged={updateInfo.isComposeManaged}
|
||||
onclose={() => showSelfUpdateDialog = false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { CircleArrowUp, CheckCircle2, XCircle, Loader2, Circle, Ship, Sparkles, Bug, Wrench, RotateCcw, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
declare const __APP_VERSION__: string | null;
|
||||
|
||||
interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
changes: Array<{ type: string; text: string }>;
|
||||
imageTag?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
currentImage: string;
|
||||
newImage: string;
|
||||
currentDigest?: string;
|
||||
newDigest?: string;
|
||||
containerName: string;
|
||||
isComposeManaged?: boolean;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), currentImage, newImage, currentDigest = '', newDigest = '', containerName, isComposeManaged = false, onclose }: Props = $props();
|
||||
|
||||
// Phase management
|
||||
type Phase = 'confirm' | 'preparing' | 'updating' | 'restarting' | 'completed' | 'error';
|
||||
let phase = $state<Phase>('confirm');
|
||||
let updaterId = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let pollTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
let healthTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
let consecutivePollFailures = $state(0);
|
||||
|
||||
// Release notes
|
||||
let releaseNotes = $state<ChangelogEntry[]>([]);
|
||||
let loadingNotes = $state(false);
|
||||
|
||||
const currentVersion = __APP_VERSION__?.replace(/^v/, '') || '';
|
||||
|
||||
// All steps in a single unified list
|
||||
interface StepState {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'active' | 'completed' | 'error';
|
||||
logs: string[];
|
||||
showLogs: boolean;
|
||||
}
|
||||
|
||||
const ALL_STEPS = [
|
||||
// Preparation (from SSE)
|
||||
{ id: 'pulling_image', label: 'Pulling new image' },
|
||||
{ id: 'building_config', label: 'Building container config' },
|
||||
{ id: 'pulling_updater', label: 'Pulling updater' },
|
||||
{ id: 'creating_container', label: 'Creating new container' },
|
||||
{ id: 'launching_updater', label: 'Launching updater' },
|
||||
// Update (from updater container logs)
|
||||
{ id: 'stopping', label: 'Stopping Dockhand' },
|
||||
{ id: 'removing', label: 'Removing old container' },
|
||||
{ id: 'renaming', label: 'Renaming container' },
|
||||
{ id: 'connecting', label: 'Connecting networks' },
|
||||
{ id: 'starting', label: 'Starting Dockhand' },
|
||||
// Reconnect
|
||||
{ id: 'reconnecting', label: 'Waiting for Dockhand' }
|
||||
] as const;
|
||||
|
||||
// Updater log markers → step id mapping
|
||||
const UPDATER_STEP_MARKERS: { start: string; end: string; id: string }[] = [
|
||||
{ start: 'Stopping container', end: 'Container stopped', id: 'stopping' },
|
||||
{ start: 'Removing old container', end: 'Old container removed', id: 'removing' },
|
||||
{ start: 'Renaming container', end: 'Container renamed', id: 'renaming' },
|
||||
{ start: 'Connecting to network', end: 'Networks connected', id: 'connecting' },
|
||||
{ start: 'Starting container', end: 'Container is running', id: 'starting' }
|
||||
];
|
||||
|
||||
let steps = $state<StepState[]>(ALL_STEPS.map(s => ({ id: s.id, label: s.label, status: 'pending', logs: [], showLogs: false })));
|
||||
let scrollTick = $state(0);
|
||||
let stepsListEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Auto-scroll steps list
|
||||
$effect(() => {
|
||||
scrollTick;
|
||||
if (stepsListEl) {
|
||||
requestAnimationFrame(() => {
|
||||
stepsListEl?.scrollTo({ top: stepsListEl.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch release notes when dialog opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
fetchReleaseNotes();
|
||||
} else {
|
||||
if (phase === 'confirm' || phase === 'completed' || phase === 'error') {
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function resetState() {
|
||||
phase = 'confirm';
|
||||
updaterId = null;
|
||||
errorMessage = null;
|
||||
consecutivePollFailures = 0;
|
||||
releaseNotes = [];
|
||||
lastParsedLogCount = 0;
|
||||
steps = ALL_STEPS.map(s => ({ id: s.id, label: s.label, status: 'pending', logs: [], showLogs: false }));
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
if (healthTimer) { clearTimeout(healthTimer); healthTimer = null; }
|
||||
}
|
||||
|
||||
function getStep(id: string): StepState | undefined {
|
||||
return steps.find(s => s.id === id);
|
||||
}
|
||||
|
||||
function setStepStatus(id: string, status: StepState['status']) {
|
||||
const step = getStep(id);
|
||||
if (step) {
|
||||
step.status = status;
|
||||
steps = [...steps];
|
||||
scrollTick++;
|
||||
}
|
||||
}
|
||||
|
||||
function addStepLog(id: string, message: string) {
|
||||
const step = getStep(id);
|
||||
if (step) {
|
||||
step.logs = [...step.logs, message];
|
||||
step.showLogs = true;
|
||||
steps = [...steps];
|
||||
scrollTick++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the currently active step id */
|
||||
function activeStepId(): string | null {
|
||||
const active = steps.find(s => s.status === 'active');
|
||||
return active?.id ?? null;
|
||||
}
|
||||
|
||||
async function fetchReleaseNotes() {
|
||||
loadingNotes = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://raw.githubusercontent.com/Finsys/dockhand/main/src/lib/data/changelog.json',
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const changelog: ChangelogEntry[] = await response.json();
|
||||
if (currentVersion && changelog.length > 0) {
|
||||
const newer = changelog.filter(entry => compareVersions(entry.version, currentVersion) > 0);
|
||||
releaseNotes = newer.length > 0 ? newer : changelog.slice(0, 1);
|
||||
} else if (changelog.length > 0) {
|
||||
releaseNotes = changelog.slice(0, 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
loadingNotes = false;
|
||||
}
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const pa = a.replace(/^v/, '').split('.').map(Number);
|
||||
const pb = b.replace(/^v/, '').split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const va = pa[i] || 0;
|
||||
const vb = pb[i] || 0;
|
||||
if (va > vb) return 1;
|
||||
if (va < vb) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getChangeIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'feature': return Sparkles;
|
||||
case 'fix': return Bug;
|
||||
case 'improvement': return Wrench;
|
||||
default: return Wrench;
|
||||
}
|
||||
}
|
||||
|
||||
function getChangeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'feature': return 'text-emerald-500';
|
||||
case 'fix': return 'text-amber-500';
|
||||
case 'improvement': return 'text-blue-500';
|
||||
default: return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update Flow ---
|
||||
|
||||
async function startUpdate() {
|
||||
phase = 'preparing';
|
||||
errorMessage = null;
|
||||
steps = ALL_STEPS.map(s => ({ id: s.id, label: s.label, status: 'pending', logs: [], showLogs: false }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/self-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ newImage })
|
||||
});
|
||||
|
||||
// Check for JSON error response (fail-fast validation)
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
phase = 'error';
|
||||
errorMessage = data.error || 'Update failed';
|
||||
return;
|
||||
}
|
||||
|
||||
// It's an SSE stream
|
||||
if (!response.body) {
|
||||
phase = 'error';
|
||||
errorMessage = 'No response body';
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
let eventType = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
eventType = line.substring(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
handleSSEEvent(eventType, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (phase === 'preparing') {
|
||||
phase = 'error';
|
||||
errorMessage = 'Connection lost: ' + String(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSSEEvent(event: string, data: any) {
|
||||
if (event === 'step') {
|
||||
const stepId = data.step;
|
||||
if (data.status === 'completed') {
|
||||
setStepStatus(stepId, 'completed');
|
||||
} else {
|
||||
setStepStatus(stepId, 'active');
|
||||
}
|
||||
} else if (event === 'log') {
|
||||
// Attach log to the currently active step
|
||||
const currentId = activeStepId();
|
||||
if (currentId) {
|
||||
addStepLog(currentId, data.message);
|
||||
}
|
||||
} else if (event === 'launched') {
|
||||
updaterId = data.updaterId;
|
||||
phase = 'updating';
|
||||
consecutivePollFailures = 0;
|
||||
startProgressPolling();
|
||||
} else if (event === 'error') {
|
||||
phase = 'error';
|
||||
errorMessage = data.message || 'Update failed';
|
||||
// Mark current active step as error
|
||||
const currentId = activeStepId();
|
||||
if (currentId) {
|
||||
setStepStatus(currentId, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startProgressPolling() {
|
||||
pollProgress();
|
||||
}
|
||||
|
||||
async function pollProgress() {
|
||||
if (!updaterId || phase === 'restarting' || phase === 'completed' || phase === 'error') return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/self-update/progress?id=${updaterId}`, {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data = await response.json() as {
|
||||
logs: string;
|
||||
status: 'running' | 'exited' | 'removed';
|
||||
exitCode?: number;
|
||||
};
|
||||
|
||||
consecutivePollFailures = 0;
|
||||
parseLogsIntoSteps(data.logs);
|
||||
|
||||
if (data.status === 'removed') {
|
||||
markAllUpdateStepsComplete();
|
||||
switchToRestarting();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'exited') {
|
||||
if (data.exitCode === 0) {
|
||||
markAllUpdateStepsComplete();
|
||||
switchToRestarting();
|
||||
} else {
|
||||
phase = 'error';
|
||||
errorMessage = `Updater exited with code ${data.exitCode}`;
|
||||
const currentId = activeStepId();
|
||||
if (currentId) setStepStatus(currentId, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(pollProgress, 1500);
|
||||
} catch {
|
||||
consecutivePollFailures++;
|
||||
if (consecutivePollFailures >= 3) {
|
||||
markAllUpdateStepsComplete();
|
||||
switchToRestarting();
|
||||
} else {
|
||||
pollTimer = setTimeout(pollProgress, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastParsedLogCount = 0;
|
||||
|
||||
function parseLogsIntoSteps(logText: string) {
|
||||
if (!logText) return;
|
||||
|
||||
const rawLines = logText.split('\n').filter(l => l.trim());
|
||||
|
||||
// Add new log lines to the appropriate step
|
||||
if (rawLines.length > lastParsedLogCount) {
|
||||
for (let i = lastParsedLogCount; i < rawLines.length; i++) {
|
||||
const line = rawLines[i];
|
||||
const cleanLine = line.replace(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\s*/, '');
|
||||
|
||||
// Find which step this log belongs to
|
||||
let stepId = activeStepId();
|
||||
for (const marker of UPDATER_STEP_MARKERS) {
|
||||
if (cleanLine.includes(marker.start)) {
|
||||
stepId = marker.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (stepId) {
|
||||
addStepLog(stepId, line);
|
||||
}
|
||||
}
|
||||
lastParsedLogCount = rawLines.length;
|
||||
}
|
||||
|
||||
// Update step statuses based on full log
|
||||
let currentStepIdx = -1;
|
||||
for (const line of rawLines) {
|
||||
const cleanLine = line.replace(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\s*/, '');
|
||||
|
||||
for (let i = 0; i < UPDATER_STEP_MARKERS.length; i++) {
|
||||
const marker = UPDATER_STEP_MARKERS[i];
|
||||
if (cleanLine.includes(marker.start)) {
|
||||
currentStepIdx = i;
|
||||
setStepStatus(marker.id, 'active');
|
||||
// Complete all prior updater steps
|
||||
for (let j = 0; j < i; j++) {
|
||||
setStepStatus(UPDATER_STEP_MARKERS[j].id, 'completed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStepIdx >= 0) {
|
||||
const marker = UPDATER_STEP_MARKERS[currentStepIdx];
|
||||
if (cleanLine.includes(marker.end)) {
|
||||
setStepStatus(marker.id, 'completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function markAllUpdateStepsComplete() {
|
||||
for (const marker of UPDATER_STEP_MARKERS) {
|
||||
setStepStatus(marker.id, 'completed');
|
||||
}
|
||||
}
|
||||
|
||||
function switchToRestarting() {
|
||||
phase = 'restarting';
|
||||
stopPolling();
|
||||
setStepStatus('reconnecting', 'active');
|
||||
startHealthPolling();
|
||||
}
|
||||
|
||||
function startHealthPolling() {
|
||||
pollHealth();
|
||||
}
|
||||
|
||||
async function pollHealth() {
|
||||
if (phase !== 'restarting') return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/health', { signal: AbortSignal.timeout(2000) });
|
||||
if (response.ok) {
|
||||
phase = 'completed';
|
||||
const step = getStep('reconnecting');
|
||||
if (step) {
|
||||
step.label = 'Dockhand is back online';
|
||||
step.status = 'completed';
|
||||
steps = [...steps];
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Still down
|
||||
}
|
||||
|
||||
healthTimer = setTimeout(pollHealth, 3000);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (phase === 'preparing' || phase === 'updating' || phase === 'restarting') {
|
||||
return;
|
||||
}
|
||||
stopPolling();
|
||||
resetState();
|
||||
onclose();
|
||||
}
|
||||
|
||||
function getIconComponent(status: string) {
|
||||
switch (status) {
|
||||
case 'completed': return CheckCircle2;
|
||||
case 'active': return Loader2;
|
||||
case 'error': return XCircle;
|
||||
default: return Circle;
|
||||
}
|
||||
}
|
||||
|
||||
function getIconClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed': return 'text-green-600 dark:text-green-400';
|
||||
case 'active': return 'text-blue-600 dark:text-blue-400 animate-spin';
|
||||
case 'error': return 'text-red-600 dark:text-red-400';
|
||||
default: return 'text-muted-foreground/30';
|
||||
}
|
||||
}
|
||||
|
||||
const canClose = $derived(phase === 'confirm' || phase === 'completed' || phase === 'error');
|
||||
const visibleSteps = $derived(steps.filter(s => s.status !== 'pending'));
|
||||
|
||||
const activeStep = $derived(steps.find(s => s.status === 'active'));
|
||||
const completedCount = $derived(steps.filter(s => s.status === 'completed').length);
|
||||
const progressPercentage = $derived(Math.round((completedCount / ALL_STEPS.length) * 100));
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen) handleClose(); }}>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] overflow-hidden flex flex-col" onInteractOutside={(e) => { if (!canClose) e.preventDefault(); }}>
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<CircleArrowUp class="w-5 h-5 text-amber-500" />
|
||||
{#if phase === 'confirm'}
|
||||
Update Dockhand
|
||||
{:else}
|
||||
Updating Dockhand
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
{#if phase !== 'confirm'}
|
||||
<Dialog.Description>
|
||||
{#if activeStep}
|
||||
<span class="text-primary font-medium">{activeStep.label}...</span>
|
||||
<span class="text-muted-foreground ml-2">({completedCount}/{ALL_STEPS.length})</span>
|
||||
{:else if phase === 'completed'}
|
||||
Update complete
|
||||
{:else if phase === 'error'}
|
||||
Update failed
|
||||
{:else}
|
||||
Preparing...
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
|
||||
{#if phase === 'confirm'}
|
||||
<!-- Confirmation View -->
|
||||
<div class="space-y-4 py-2 overflow-y-auto min-h-0 flex-1">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Container</span>
|
||||
<span class="font-medium flex items-center gap-1.5">
|
||||
<Ship class="w-3.5 h-3.5" />
|
||||
{containerName}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Image</span>
|
||||
<Badge variant="secondary" class="font-mono text-xs">{currentImage}</Badge>
|
||||
</div>
|
||||
{#if currentDigest || newDigest}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Current digest</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">{currentDigest ? currentDigest.replace('sha256:', '').slice(0, 12) : 'unknown'}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">New digest</span>
|
||||
<span class="font-mono text-xs text-amber-500">{newDigest ? newDigest.replace('sha256:', '').slice(0, 12) : 'unknown'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingNotes}
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
Loading release notes...
|
||||
</div>
|
||||
{:else if releaseNotes.length > 0}
|
||||
<div class="border rounded-md overflow-hidden">
|
||||
<div class="bg-muted/50 px-3 py-2 border-b">
|
||||
<p class="text-sm font-medium">What's new</p>
|
||||
</div>
|
||||
<div class="p-3 space-y-3 max-h-48 overflow-y-auto">
|
||||
{#each releaseNotes as entry}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="text-2xs font-mono">v{entry.version}</Badge>
|
||||
<span class="text-2xs text-muted-foreground">{entry.date}</span>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each entry.changes as change}
|
||||
{@const ChangeIcon = getChangeIcon(change.type)}
|
||||
<li class="flex items-start gap-1.5 text-xs">
|
||||
<ChangeIcon class="w-3 h-3 mt-0.5 shrink-0 {getChangeColor(change.type)}" />
|
||||
<span class="text-muted-foreground">{change.text}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isComposeManaged}
|
||||
<div class="rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<span class="font-medium text-blue-400">Note:</span> This container is managed by Docker Compose. After update it will continue to work but may lose Compose tracking. Use <code class="text-2xs">docker compose pull && docker compose up -d</code> for Compose-aware updates.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={startUpdate}>
|
||||
<CircleArrowUp class="w-4 h-4 mr-2" />
|
||||
Update now
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
{:else}
|
||||
<!-- Progress View -->
|
||||
<div class="flex-1 min-h-0 space-y-4 py-4 overflow-hidden flex flex-col">
|
||||
<!-- Progress bar -->
|
||||
<div class="space-y-2 shrink-0">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">Progress</span>
|
||||
<Badge variant="secondary">{completedCount}/{ALL_STEPS.length}</Badge>
|
||||
</div>
|
||||
<Progress value={progressPercentage} class="h-2" />
|
||||
</div>
|
||||
|
||||
<!-- Steps list -->
|
||||
{#if visibleSteps.length > 0}
|
||||
<div bind:this={stepsListEl} class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
|
||||
{#each visibleSteps as step (step.id)}
|
||||
{@const StepIcon = getIconComponent(step.status)}
|
||||
{@const hasLogs = step.logs.length > 0}
|
||||
<div class="text-sm">
|
||||
<!-- Step header -->
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<StepIcon class="w-4 h-4 shrink-0 {getIconClass(step.status)}" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium">{step.label}</div>
|
||||
</div>
|
||||
{#if step.status === 'completed'}
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
{:else if step.status === 'error'}
|
||||
<XCircle class="w-4 h-4 text-red-600 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Logs (always visible) -->
|
||||
{#if hasLogs}
|
||||
<div class="bg-muted/50 px-3 py-2 font-mono text-xs border-t overflow-x-hidden">
|
||||
{#each step.logs as line}
|
||||
<div class="text-muted-foreground break-all">{line}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error message -->
|
||||
{#if phase === 'error' && errorMessage}
|
||||
<div class="flex items-start gap-2 text-sm text-red-600 dark:text-red-400 p-3 bg-red-50 dark:bg-red-950/30 rounded-lg overflow-hidden shrink-0">
|
||||
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span class="break-all">{errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0">
|
||||
{#if phase === 'completed'}
|
||||
<Button onclick={() => window.location.reload()}>
|
||||
<RotateCcw class="w-4 h-4 mr-2" />
|
||||
Reload
|
||||
</Button>
|
||||
{:else if phase === 'error'}
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="outline" disabled>
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Updating...
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -57,7 +57,8 @@
|
||||
X,
|
||||
Tags,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
ChevronRight,
|
||||
XCircle
|
||||
} from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
@@ -70,6 +71,7 @@
|
||||
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
|
||||
import { ShieldOff } from 'lucide-svelte';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { authStore, canAccess } from '$lib/stores/auth';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { formatDateTime, formatDate } from '$lib/stores/settings';
|
||||
@@ -321,8 +323,8 @@
|
||||
let hawserTokenLoading = $state(false);
|
||||
let generatingToken = $state(false);
|
||||
let generatedToken = $state<string | null>(null); // Full token shown once after generation
|
||||
let copySuccess = $state(false);
|
||||
let copyCmdSuccess = $state(false);
|
||||
let copySuccess = $state<'ok' | 'error' | null>(null);
|
||||
let copyCmdSuccess = $state<'ok' | 'error' | null>(null);
|
||||
// For add mode - auto-generated token stored until save
|
||||
let pendingToken = $state<string | null>(null);
|
||||
|
||||
@@ -1268,17 +1270,17 @@
|
||||
await generateHawserToken(envId);
|
||||
}
|
||||
|
||||
function copyToken(token: string) {
|
||||
navigator.clipboard.writeText(token);
|
||||
copySuccess = true;
|
||||
setTimeout(() => { copySuccess = false; }, 2000);
|
||||
async function copyToken(token: string) {
|
||||
const ok = await copyToClipboard(token);
|
||||
copySuccess = ok ? 'ok' : 'error';
|
||||
setTimeout(() => { copySuccess = null; }, 2000);
|
||||
}
|
||||
|
||||
function copyCommand(token: string) {
|
||||
async function copyCommand(token: string) {
|
||||
const cmd = `DOCKHAND_SERVER_URL=${getConnectionUrl()} TOKEN=${token} hawser`;
|
||||
navigator.clipboard.writeText(cmd);
|
||||
copyCmdSuccess = true;
|
||||
setTimeout(() => { copyCmdSuccess = false; }, 2000);
|
||||
const ok = await copyToClipboard(cmd);
|
||||
copyCmdSuccess = ok ? 'ok' : 'error';
|
||||
setTimeout(() => { copyCmdSuccess = null; }, 2000);
|
||||
}
|
||||
|
||||
function getConnectionUrl() {
|
||||
@@ -1883,7 +1885,14 @@
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={() => copyToken(pendingToken!)}>
|
||||
{#if copySuccess}
|
||||
{#if copySuccess === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-4 h-4 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copySuccess === 'ok'}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
@@ -1899,7 +1908,14 @@
|
||||
onclick={() => copyCommand(pendingToken!)}
|
||||
title="Copy command"
|
||||
>
|
||||
{#if copyCmdSuccess}
|
||||
{#if copyCmdSuccess === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-3 h-3 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copyCmdSuccess === 'ok'}
|
||||
<Check class="w-3 h-3 text-green-600" />
|
||||
{:else}
|
||||
<Copy class="w-3 h-3" />
|
||||
@@ -1936,7 +1952,14 @@
|
||||
class="font-mono text-xs flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={() => copyToken(generatedToken!)}>
|
||||
{#if copySuccess}
|
||||
{#if copySuccess === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-4 h-4 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copySuccess === 'ok'}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
@@ -1952,7 +1975,14 @@
|
||||
onclick={() => copyCommand(generatedToken!)}
|
||||
title="Copy command"
|
||||
>
|
||||
{#if copyCmdSuccess}
|
||||
{#if copyCmdSuccess === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-3 h-3 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copyCmdSuccess === 'ok'}
|
||||
<Check class="w-3 h-3 text-green-600" />
|
||||
{:else}
|
||||
<Copy class="w-3 h-3" />
|
||||
|
||||
@@ -1626,21 +1626,6 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if $canAccess('stacks', 'stop')}
|
||||
<ConfirmPopover
|
||||
open={confirmDownName === stack.name}
|
||||
action="Down"
|
||||
itemType="stack"
|
||||
itemName={stack.name}
|
||||
title="Down (remove containers)"
|
||||
onConfirm={() => downStack(stack.name)}
|
||||
onOpenChange={(open) => confirmDownName = open ? stack.name : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<ArrowBigDown class="w-3 h-3 {open ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if $canAccess('stacks', 'start')}
|
||||
<button
|
||||
@@ -1654,6 +1639,21 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $canAccess('stacks', 'stop')}
|
||||
<ConfirmPopover
|
||||
open={confirmDownName === stack.name}
|
||||
action="Down"
|
||||
itemType="stack"
|
||||
itemName={stack.name}
|
||||
title="Down (remove containers)"
|
||||
onConfirm={() => downStack(stack.name)}
|
||||
onOpenChange={(open) => confirmDownName = open ? stack.name : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<ArrowBigDown class="w-3 h-3 {open ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if $canAccess('stacks', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteName === stack.name}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download } from 'lucide-svelte';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
@@ -92,8 +93,8 @@
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
let copiedWebhookUrl = $state(false);
|
||||
let copiedWebhookSecret = $state(false);
|
||||
let copiedWebhookUrl = $state<'ok' | 'error' | null>(null);
|
||||
let copiedWebhookSecret = $state<'ok' | 'error' | null>(null);
|
||||
|
||||
// Environment variables state
|
||||
let formEnvFilePath = $state<string | null>(null);
|
||||
@@ -185,14 +186,15 @@
|
||||
return `${window.location.origin}/api/git/stacks/${stackId}/webhook`;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, type: 'url' | 'secret') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
async function copyWebhookField(text: string, type: 'url' | 'secret') {
|
||||
const ok = await copyToClipboard(text);
|
||||
const state = ok ? 'ok' : 'error';
|
||||
if (type === 'url') {
|
||||
copiedWebhookUrl = true;
|
||||
setTimeout(() => copiedWebhookUrl = false, 2000);
|
||||
copiedWebhookUrl = state;
|
||||
setTimeout(() => copiedWebhookUrl = null, 2000);
|
||||
} else {
|
||||
copiedWebhookSecret = true;
|
||||
setTimeout(() => copiedWebhookSecret = false, 2000);
|
||||
copiedWebhookSecret = state;
|
||||
setTimeout(() => copiedWebhookSecret = null, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +339,8 @@
|
||||
// Clear state BEFORE async loads to avoid race conditions
|
||||
formError = '';
|
||||
errors = {};
|
||||
copiedWebhookUrl = false;
|
||||
copiedWebhookSecret = false;
|
||||
copiedWebhookUrl = null;
|
||||
copiedWebhookSecret = null;
|
||||
envFiles = [];
|
||||
envVars = [];
|
||||
fileEnvVars = {};
|
||||
@@ -806,10 +808,17 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => copyToClipboard(getWebhookUrl(gitStack.id), 'url')}
|
||||
onclick={() => copyWebhookField(getWebhookUrl(gitStack.id), 'url')}
|
||||
title="Copy URL"
|
||||
>
|
||||
{#if copiedWebhookUrl}
|
||||
{#if copiedWebhookUrl === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-4 h-4 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copiedWebhookUrl === 'ok'}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
@@ -831,10 +840,17 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => copyToClipboard(formWebhookSecret, 'secret')}
|
||||
onclick={() => copyWebhookField(formWebhookSecret, 'secret')}
|
||||
title="Copy secret"
|
||||
>
|
||||
{#if copiedWebhookSecret}
|
||||
{#if copiedWebhookSecret === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-4 h-4 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copiedWebhookSecret === 'ok'}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
|
||||
@@ -182,7 +182,8 @@
|
||||
|
||||
try {
|
||||
const parentDir = entry.path.replace(/\/[^/]+$/, '');
|
||||
const stackName = parentDir.split('/').pop() || 'adopted-stack';
|
||||
const rawName = parentDir.split('/').pop() || 'adopted-stack';
|
||||
const stackName = rawName.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'adopted-stack';
|
||||
const envFilePath = `${parentDir}/.env`;
|
||||
|
||||
const stack: DiscoveredStack = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Copy, Check, FolderOpen, FolderSync } from 'lucide-svelte';
|
||||
import { Copy, Check, XCircle, FolderOpen, FolderSync } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
@@ -10,7 +11,7 @@
|
||||
onChangeLocation?: () => void; // Optional: relocate entire folder
|
||||
defaultText?: string;
|
||||
isSuggested?: boolean;
|
||||
copied?: boolean;
|
||||
copied?: 'ok' | 'error' | null;
|
||||
sourceHint?: string; // e.g., "Using default location"
|
||||
}
|
||||
|
||||
@@ -23,7 +24,7 @@
|
||||
onChangeLocation,
|
||||
defaultText = 'Default location',
|
||||
isSuggested = false,
|
||||
copied = false,
|
||||
copied = null,
|
||||
sourceHint
|
||||
}: Props = $props();
|
||||
|
||||
@@ -79,7 +80,14 @@
|
||||
title="Copy path"
|
||||
disabled={!path}
|
||||
>
|
||||
{#if copied}
|
||||
{#if copied === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-3.5 h-3.5 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else if copied === 'ok'}
|
||||
<Check class="w-3.5 h-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync } from 'lucide-svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync } from 'lucide-svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import FilesystemBrowser from './FilesystemBrowser.svelte';
|
||||
import PathBarItem from './PathBarItem.svelte';
|
||||
@@ -17,6 +17,7 @@
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { ErrorDialog } from '$lib/components/ui/error-dialog';
|
||||
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
|
||||
@@ -89,9 +90,9 @@
|
||||
|
||||
|
||||
// UI state
|
||||
let composePathCopied = $state(false);
|
||||
let envPathCopied = $state(false);
|
||||
let composeContentCopied = $state(false);
|
||||
let composePathCopied = $state<'ok' | 'error' | null>(null);
|
||||
let envPathCopied = $state<'ok' | 'error' | null>(null);
|
||||
let composeContentCopied = $state<'ok' | 'error' | null>(null);
|
||||
let needsFileLocation = $state(false);
|
||||
|
||||
// Container info for untracked stacks
|
||||
@@ -326,11 +327,11 @@
|
||||
}
|
||||
|
||||
// Generic copy function that returns a reset callback
|
||||
function copyToClipboard(text: string | null, setCopied: (v: boolean) => void) {
|
||||
async function copyText(text: string | null, setCopied: (v: 'ok' | 'error' | null) => void) {
|
||||
if (text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
const ok = await copyToClipboard(text);
|
||||
setCopied(ok ? 'ok' : 'error');
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1463,7 +1464,7 @@ services:
|
||||
path={workingComposePath || null}
|
||||
placeholder="/path/to/compose.yaml"
|
||||
copied={composePathCopied}
|
||||
onCopy={() => copyToClipboard(workingComposePath, (v) => composePathCopied = v)}
|
||||
onCopy={() => copyText(workingComposePath, (v) => composePathCopied = v)}
|
||||
onBrowse={openComposeBrowser}
|
||||
onChangeLocation={mode === 'edit' && !needsFileLocation ? openChangeLocationBrowser : undefined}
|
||||
defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'}
|
||||
@@ -1480,7 +1481,7 @@ services:
|
||||
selectedPath={workingEnvPath || suggestedEnvPath || ''}
|
||||
placeholder="/path/to/.env (optional)"
|
||||
copied={envPathCopied}
|
||||
onCopy={() => copyToClipboard(displayEnvPath, (v) => envPathCopied = v)}
|
||||
onCopy={() => copyText(displayEnvPath, (v) => envPathCopied = v)}
|
||||
onBrowse={openEnvBrowser}
|
||||
isEditable={true}
|
||||
isCustom={!!workingEnvPath}
|
||||
@@ -1525,10 +1526,18 @@ services:
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
onclick={() => copyToClipboard(composeContent, (v) => composeContentCopied = v)}
|
||||
onclick={() => copyText(composeContent, (v) => composeContentCopied = v)}
|
||||
disabled={!composeContent}
|
||||
>
|
||||
{#if composeContentCopied}
|
||||
{#if composeContentCopied === 'error'}
|
||||
<Tooltip.Root open>
|
||||
<Tooltip.Trigger>
|
||||
<XCircle class="w-3 h-3 text-red-500" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
Failed
|
||||
{:else if composeContentCopied === 'ok'}
|
||||
<Check class="w-3 h-3 text-green-500" />
|
||||
Copied
|
||||
{:else}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import { getMonospaceFont } from '$lib/themes';
|
||||
|
||||
@@ -56,11 +57,7 @@
|
||||
text += line.translateToString(true) + '\n';
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.trim());
|
||||
} catch {
|
||||
// Ignore clipboard errors
|
||||
}
|
||||
await copyToClipboard(text.trim());
|
||||
terminal.focus();
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { RefreshCw, Copy, Trash2, Type } from 'lucide-svelte';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
|
||||
// Dynamic imports for browser-only xterm
|
||||
@@ -53,11 +54,7 @@
|
||||
text += line.translateToString(true) + '\n';
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text.trim());
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
await copyToClipboard(text.trim());
|
||||
terminal.focus();
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -8,7 +8,10 @@ const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build'
|
||||
})
|
||||
}),
|
||||
csrf: {
|
||||
trustedOrigins: ['*']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user