This commit is contained in:
jarek
2026-06-15 14:56:51 +02:00
parent d465ecfe96
commit 83c3a5ea09
85 changed files with 10118 additions and 1046 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \ " - busybox" \
" - tzdata" \ " - tzdata" \
" - docker-cli" \ " - docker-cli" \
" - docker-compose=5.1.4-r4" \ " - docker-compose=5.1.4-r5" \
" - docker-cli-buildx" \ " - docker-cli-buildx" \
" - sqlite" \ " - sqlite" \
" - postgresql-client" \ " - postgresql-client" \
+1 -1
View File
@@ -1 +1 @@
v1.0.32 v1.0.33
+66 -20
View File
@@ -221,13 +221,19 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
baseURL = "http://localhost" baseURL = "http://localhost"
case "http": case "http":
// Explicit dial timeout and TCP keepalive so connections over dead
// tunnels (VPN/Tailscale drops) are detected at kernel level instead
// of hanging indefinitely.
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
transport = &http.Transport{ transport = &http.Transport{
DialContext: tcpDial,
MaxIdleConns: 16, MaxIdleConns: 16,
MaxIdleConnsPerHost: 16, MaxIdleConnsPerHost: 16,
MaxConnsPerHost: 16, MaxConnsPerHost: 16,
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
} }
streamTransport = &http.Transport{ streamTransport = &http.Transport{
DialContext: tcpDial,
MaxIdleConns: 4, MaxIdleConns: 4,
MaxIdleConnsPerHost: 4, MaxIdleConnsPerHost: 4,
MaxConnsPerHost: 4, MaxConnsPerHost: 4,
@@ -242,7 +248,9 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
} }
streamTLSCfg := tlsCfg.Clone() streamTLSCfg := tlsCfg.Clone()
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
transport = &http.Transport{ transport = &http.Transport{
DialContext: tcpDial,
TLSClientConfig: tlsCfg, TLSClientConfig: tlsCfg,
MaxIdleConns: 16, MaxIdleConns: 16,
MaxIdleConnsPerHost: 16, MaxIdleConnsPerHost: 16,
@@ -250,6 +258,7 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
} }
streamTransport = &http.Transport{ streamTransport = &http.Transport{
DialContext: tcpDial,
TLSClientConfig: streamTLSCfg, TLSClientConfig: streamTLSCfg,
MaxIdleConns: 4, MaxIdleConns: 4,
MaxIdleConnsPerHost: 4, MaxIdleConnsPerHost: 4,
@@ -322,15 +331,32 @@ func (e *environment) doStreamRequest(ctx context.Context, method, path string)
return e.streamClient.Do(req) return e.streamClient.Do(req)
} }
func (e *environment) ping(ctx context.Context) bool { func (e *environment) ping(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) attempt := func() error {
defer cancel() pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
resp, err := e.doRequest(ctx, "GET", "/_ping") defer cancel()
if err != nil { resp, err := e.doRequest(pingCtx, "GET", "/_ping")
return false if err != nil {
return err
}
drainAndClose(resp)
if resp.StatusCode != 200 {
return fmt.Errorf("ping returned status %d", resp.StatusCode)
}
return nil
} }
drainAndClose(resp)
return resp.StatusCode == 200 if err := attempt(); err == nil {
return nil
} else if ctx.Err() != nil {
return err
}
// Stale pooled connections (e.g. after a VPN/tunnel drop) hang requests
// until timeout while the host is actually reachable. Evict the pool and
// retry once on a guaranteed-fresh connection.
e.closeTransports()
return attempt()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -358,11 +384,11 @@ func (m *manager) runMetrics(env *environment) {
} }
func (m *manager) collectMetrics(env *environment) { func (m *manager) collectMetrics(env *environment) {
if !env.ping(env.ctx) { if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported { if env.online || !env.statusReported {
env.online = false env.online = false
env.statusReported = true env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"}) m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
} }
return return
} }
@@ -558,11 +584,11 @@ func (m *manager) runEvents(env *environment) {
} }
// Stream mode // Stream mode
if !env.ping(env.ctx) { if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported { if env.online || !env.statusReported {
env.online = false env.online = false
env.statusReported = true env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"}) m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
} }
if !waitOrCancel(reconnectDelay) { if !waitOrCancel(reconnectDelay) {
return return
@@ -609,12 +635,32 @@ func (m *manager) runEvents(env *environment) {
// Force-close the body on context cancellation so scanner.Scan() // Force-close the body on context cancellation so scanner.Scan()
// unblocks. Without this, the goroutine can leak if the transport's // unblocks. Without this, the goroutine can leak if the transport's
// internal cancel watcher doesn't fire (Go runtime implementation detail). // internal cancel watcher doesn't fire (Go runtime implementation detail).
//
// The watchdog ticker handles half-open connections (e.g. after a
// VPN/tunnel drop): the stream client has no timeout, so Scan() would
// otherwise block forever on a dead connection that never errors.
// A failed ping (which retries on a fresh connection internally)
// means the host is unreachable — close the body so the reconnect
// loop takes over.
bodyDone := make(chan struct{}) bodyDone := make(chan struct{})
var closeBodyOnce sync.Once
closeBody := func() { closeBodyOnce.Do(func() { resp.Body.Close() }) }
go func() { go func() {
select { watchdog := time.NewTicker(90 * time.Second)
case <-env.ctx.Done(): defer watchdog.Stop()
resp.Body.Close() for {
case <-bodyDone: select {
case <-env.ctx.Done():
closeBody()
return
case <-bodyDone:
return
case <-watchdog.C:
if env.ping(env.ctx) != nil {
closeBody()
return
}
}
} }
}() }()
@@ -638,7 +684,7 @@ func (m *manager) runEvents(env *environment) {
} }
} }
close(bodyDone) close(bodyDone)
resp.Body.Close() closeBody()
if env.ctx.Err() != nil { if env.ctx.Err() != nil {
return return
@@ -653,11 +699,11 @@ func (m *manager) runEvents(env *environment) {
} }
func (m *manager) pollEvents(env *environment) { func (m *manager) pollEvents(env *environment) {
if !env.ping(env.ctx) { if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported { if env.online || !env.statusReported {
env.online = false env.online = false
env.statusReported = true env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"}) m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
} }
return return
} }
@@ -736,7 +782,7 @@ func (m *manager) runDiskChecks(env *environment) {
} }
func (m *manager) checkDisk(env *environment) { func (m *manager) checkDisk(env *environment) {
if !env.ping(env.ctx) { if env.ping(env.ctx) != nil {
return return
} }
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "git_stacks" ADD COLUMN "synced_files" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -50,6 +50,13 @@
"when": 1777220350655, "when": 1777220350655,
"tag": "0006_add_git_stack_context_dir", "tag": "0006_add_git_stack_context_dir",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781158711008,
"tag": "0007_add_synced_files",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `git_stacks` ADD `synced_files` text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -50,6 +50,13 @@
"when": 1777220350655, "when": 1777220350655,
"tag": "0006_add_git_stack_context_dir", "tag": "0006_add_git_stack_context_dir",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1781158702731,
"tag": "0007_add_synced_files",
"breakpoints": true
} }
] ]
} }
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "dockhand", "name": "dockhand",
"private": true, "private": true,
"version": "1.0.27", "version": "1.0.33",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "npx vite dev", "dev": "npx vite dev",
@@ -63,8 +63,9 @@
"@codemirror/lang-python": "6.2.1", "@codemirror/lang-python": "6.2.1",
"@codemirror/lang-sql": "6.10.0", "@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-xml": "6.1.0", "@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2", "@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.1", "@codemirror/language": "6.12.1",
"@codemirror/legacy-modes": "6.5.3",
"@codemirror/search": "6.6.0", "@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4", "@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "6.1.3", "@codemirror/theme-one-dark": "6.1.3",
+82 -8
View File
@@ -8,11 +8,12 @@
* Usage: node ./server.js * Usage: node ./server.js
*/ */
import { createServer, request as httpRequest } from 'node:http'; import { createServer as createHttpServer, request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https'; import { createServer as createHttpsServer, request as httpsRequest } from 'node:https';
import { createConnection } from 'node:net'; import { createConnection } from 'node:net';
import { connect as tlsConnect, rootCertificates } from 'node:tls'; import { connect as tlsConnect, rootCertificates } from 'node:tls';
import { randomUUID } from 'node:crypto'; import { randomUUID, X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { handler } from './build/handler.js'; import { handler } from './build/handler.js';
@@ -28,10 +29,82 @@ console.warn = (...args) => _warn(ts(), ...args);
const PORT = parseInt(process.env.PORT || '3000', 10); const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST || '0.0.0.0';
// Create HTTP server with SvelteKit handler // Optional native HTTPS listener (#1102). Off by default to keep existing
const server = createServer((req, res) => { // deployments unchanged. When HTTPS_MODE=on, HTTPS_CERT_PATH and
handler(req, res); // HTTPS_KEY_PATH must both point to readable PEM files.
}); const HTTPS_MODE = (process.env.HTTPS_MODE || 'off').toLowerCase();
const useHttps = HTTPS_MODE === 'on';
let server;
if (useHttps) {
const certPath = process.env.HTTPS_CERT_PATH;
const keyPath = process.env.HTTPS_KEY_PATH;
const caPath = process.env.HTTPS_CA_PATH;
console.log('[HTTPS] mode=on');
console.log(`[HTTPS] cert=${certPath || '(missing)'}`);
console.log(`[HTTPS] key=${keyPath || '(missing)'}`);
console.log(`[HTTPS] ca=${caPath || '(none)'}`);
if (!certPath || !keyPath) {
console.error('[HTTPS] HTTPS_MODE=on requires HTTPS_CERT_PATH and HTTPS_KEY_PATH');
process.exit(1);
}
let certPem, keyPem, caPem;
try {
certPem = readFileSync(certPath);
keyPem = readFileSync(keyPath);
if (caPath) caPem = readFileSync(caPath);
} catch (e) {
console.error(`[HTTPS] Failed to read cert/key file: ${e.message}`);
process.exit(1);
}
// Parse cert metadata so operators can confirm they mounted the right file.
try {
const x509 = new X509Certificate(certPem);
console.log(`[HTTPS] cert subject: ${x509.subject.replace(/\n/g, ', ')}`);
console.log(`[HTTPS] cert issuer: ${x509.issuer.replace(/\n/g, ', ')}`);
console.log(`[HTTPS] cert SAN: ${x509.subjectAltName || '(none)'}`);
console.log(`[HTTPS] cert valid: ${x509.validFrom}${x509.validTo}`);
const expiresAt = new Date(x509.validTo).getTime();
const daysLeft = Math.floor((expiresAt - Date.now()) / 86400000);
if (daysLeft < 0) {
console.warn(`[HTTPS] WARNING: certificate expired ${-daysLeft} day(s) ago`);
} else if (daysLeft < 30) {
console.warn(`[HTTPS] WARNING: certificate expires in ${daysLeft} day(s)`);
} else {
console.log(`[HTTPS] cert expires in ${daysLeft} day(s)`);
}
} catch (e) {
console.error(`[HTTPS] Failed to parse certificate: ${e.message}`);
process.exit(1);
}
const tlsOptions = { cert: certPem, key: keyPem };
if (caPem) tlsOptions.ca = caPem;
// HSTS — only meaningful over HTTPS, so wired only here. Default 1 year;
// set HSTS_MAX_AGE=0 to disable.
const hstsMaxAge = parseInt(process.env.HSTS_MAX_AGE ?? '31536000', 10);
const hstsHeader = hstsMaxAge > 0 ? `max-age=${hstsMaxAge}` : null;
if (hstsHeader) {
console.log(`[HTTPS] HSTS enabled: ${hstsHeader}`);
} else {
console.log('[HTTPS] HSTS disabled (HSTS_MAX_AGE=0)');
}
server = createHttpsServer(tlsOptions, (req, res) => {
if (hstsHeader) res.setHeader('Strict-Transport-Security', hstsHeader);
handler(req, res);
});
} else {
console.log(`[HTTPS] mode=off (set HTTPS_MODE=on to enable native TLS)`);
server = createHttpServer((req, res) => {
handler(req, res);
});
}
// Create WebSocket server attached to the HTTP server // Create WebSocket server attached to the HTTP server
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
@@ -458,7 +531,8 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Start the server // Start the server
server.listen(PORT, HOST, () => { server.listen(PORT, HOST, () => {
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`); const scheme = useHttps ? 'https' : 'http';
console.log(`Listening on ${scheme}://${HOST}:${PORT}/ with WebSocket`);
}); });
+10
View File
@@ -1341,6 +1341,16 @@ html {
line-height: 14px; line-height: 14px;
} }
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
common Tailwind animation utilities collapse to no-op. This keeps the
layout (spinners still occupy space) but removes the motion. */
html.no-icon-animation .animate-spin,
html.no-icon-animation .animate-pulse,
html.no-icon-animation .animate-bounce,
html.no-icon-animation .animate-ping {
animation: none !important;
}
/* Icon glow utilities - standard size (4px blur, 0.6 opacity) */ /* Icon glow utilities - standard size (4px blur, 0.6 opacity) */
.glow-green { filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.6)); } .glow-green { filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.6)); }
.glow-green-sm { filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5)); } .glow-green-sm { filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5)); }
@@ -0,0 +1,37 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
}
let { userId }: Props = $props();
// Same "skip applying" rule as ThemeSelector: don't toggle the live document
// when the admin is editing the global default while logged in (their own
// per-user preference still drives their session).
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
let checked = $state(true);
$effect(() => {
checked = $themeStore.animateIcons;
});
function onToggle(value: boolean) {
checked = value;
themeStore.setPreference('animateIcons', value, userId, skipApply);
toast.success(value ? 'Icon animation enabled' : 'Icon animation disabled');
}
</script>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Animate icons</Label>
<TogglePill {checked} onchange={onToggle} />
</div>
<p class="text-xs text-muted-foreground">Spinners during pulls, scans and updates.</p>
</div>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { GitPullRequestArrow } from 'lucide-svelte';
import { parseChangelogTokens, tokenHref, type ChangelogToken } from '$lib/utils/changelog-tokens';
let { text }: { text: string } = $props();
type Group = { kind: 'text'; value: string } | { kind: 'refs'; refs: ChangelogToken[] };
const groups = $derived.by<Group[]>(() => {
const tokens = parseChangelogTokens(text);
const result: Group[] = [];
let textBuf = '';
let refBuf: ChangelogToken[] = [];
const flushText = () => {
if (textBuf) {
result.push({ kind: 'text', value: textBuf });
textBuf = '';
}
};
const flushRefs = () => {
if (refBuf.length) {
result.push({ kind: 'refs', refs: refBuf });
refBuf = [];
}
};
for (const t of tokens) {
if (t.kind === 'text') {
// If the gap between consecutive ref groups is only "glue" (whitespace,
// commas, parens), keep collecting into the same refs group. Otherwise
// it ends the group.
if (refBuf.length && /^[\s,()]*$/.test(t.value)) {
continue;
}
if (refBuf.length) {
flushRefs();
}
// Strip a trailing " (" left over before the upcoming refs group.
textBuf += t.value;
} else {
// Trim trailing glue from textBuf so we don't render "foo (".
if (refBuf.length === 0) {
textBuf = textBuf.replace(/[\s(]+$/, '');
}
flushText();
refBuf.push(t);
}
}
flushRefs();
// Trim trailing glue (e.g. ")") from leftover text.
textBuf = textBuf.replace(/^[\s,)]+/, '');
flushText();
return result;
});
function refLabel(token: ChangelogToken): string {
if (token.kind === 'issue') return `#${token.num}`;
if (token.kind === 'pr') return `#${token.num}`;
if (token.kind === 'user') return `@${token.name}`;
return '';
}
function refTitle(token: ChangelogToken): string {
if (token.kind === 'issue') return `Issue #${token.num}`;
if (token.kind === 'pr') return `Pull request #${token.num}`;
if (token.kind === 'user') return `@${token.name} on GitHub`;
return '';
}
</script>
<span class="text-sm">
{#each groups as group, i (i)}
{#if group.kind === 'text'}
{group.value}
{:else}
<span class="changelog-refs">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{#each group.refs as ref, j (j)}
{#if j > 0}<span class="changelog-refs-sep"> · </span>{/if}
<a
href={tokenHref(ref)}
target="_blank"
rel="noopener noreferrer"
title={refTitle(ref)}
class="changelog-refs-link"
>{#if ref.kind === 'pr'}<GitPullRequestArrow class="changelog-pr-icon" />{/if}{refLabel(ref)}</a>
{/each}
</span>
{/if}
{/each}
</span>
<style>
.changelog-refs {
display: inline;
opacity: 0.55;
margin-left: 4px;
font-size: 0.75em;
}
.changelog-refs svg {
display: inline;
width: 10px;
height: 10px;
vertical-align: -1px;
margin-right: 3px;
}
.changelog-refs-link {
color: inherit;
text-decoration: none;
}
.changelog-refs-link:hover {
text-decoration: underline;
}
.changelog-refs-sep {
color: inherit;
}
.changelog-refs-link :global(.changelog-pr-icon) {
display: inline;
width: 10px;
height: 10px;
vertical-align: -1px;
margin-right: 2px;
}
</style>
+34 -7
View File
@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state'; import { EditorState, StateField, StateEffect, RangeSet, Prec } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view'; import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
// Note: Secret masking was removed - secrets are now excluded from the raw editor entirely // Note: Secret masking was removed - secrets are now excluded from the raw editor entirely
// and are only stored in the database (never written to .env file) // and are only stored in the database (never written to .env file)
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language'; import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit, StreamLanguage, type StreamParser } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark'; import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { properties } from '@codemirror/legacy-modes/mode/properties';
// Simple dotenv/env file language parser // Simple dotenv/env file language parser
const dotenvParser: StreamParser<{ inValue: boolean }> = { const dotenvParser: StreamParser<{ inValue: boolean }> = {
@@ -496,6 +500,21 @@
initialSpacer: () => new VariableGutterMarker('required') initialSpacer: () => new VariableGutterMarker('required')
}); });
// YAML Enter handler: after a key-only line ending with ":", indent one level
// deeper than what the default indent service returns (it can't predict child
// indent when no child content exists yet).
function yamlNewlineAndIndent(view: EditorView): boolean {
const { state } = view;
const line = state.doc.lineAt(state.selection.main.head);
const withoutComment = line.text.trimEnd().replace(/#.*$/, '').trimEnd();
if (!withoutComment.endsWith(':')) return false;
insertNewlineAndIndent(view);
const unit = state.facet(indentUnit);
const cursor = view.state.selection.main.head;
view.dispatch({ changes: { from: cursor, insert: unit }, selection: { anchor: cursor + unit.length } });
return true;
}
// Get language extension based on language name // Get language extension based on language name
function getLanguageExtension(lang: string) { function getLanguageExtension(lang: string) {
switch (lang) { switch (lang) {
@@ -527,12 +546,18 @@
return xml(); return xml();
case 'sql': case 'sql':
return sql(); return sql();
case 'dockerfile':
case 'shell': case 'shell':
case 'bash': case 'bash':
case 'sh': case 'sh':
// No dedicated shell/dockerfile support, use basic highlighting return StreamLanguage.define(shell);
return []; case 'dockerfile':
return StreamLanguage.define(dockerFile);
case 'toml':
return StreamLanguage.define(toml);
case 'ini':
case 'conf':
case 'properties':
return StreamLanguage.define(properties);
case 'dotenv': case 'dotenv':
case 'env': case 'env':
return StreamLanguage.define(dotenvParser); return StreamLanguage.define(dotenvParser);
@@ -671,7 +696,9 @@
]), ]),
...themeExtensions, ...themeExtensions,
EditorView.lineWrapping, EditorView.lineWrapping,
getLanguageExtension(language) EditorState.tabSize.of(2),
getLanguageExtension(language),
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
].flat(); ].flat();
if (readonly) { if (readonly) {
+2 -2
View File
@@ -309,7 +309,7 @@
class="h-10" class="h-10"
> >
{#if isPulling} {#if isPulling}
<Loader2 class="w-4 h-4 mr-2 animate-spin" /> <Download class="w-4 h-4 mr-2 animate-spin" />
Pulling... Pulling...
{:else} {:else}
<Download class="w-4 h-4" /> <Download class="w-4 h-4" />
@@ -327,7 +327,7 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if status === 'pulling'} {#if status === 'pulling'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" /> <Download class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Pulling layers...</span> <span class="text-sm">Pulling layers...</span>
{:else if status === 'complete'} {:else if status === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" /> <CheckCircle2 class="w-4 h-4 text-green-600" />
+1 -1
View File
@@ -228,7 +228,7 @@
<Shield class="w-4 h-4 text-muted-foreground" /> <Shield class="w-4 h-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground">Ready to scan</span> <span class="text-sm text-muted-foreground">Ready to scan</span>
{:else if status === 'scanning'} {:else if status === 'scanning'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" /> <Shield class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Scanning for vulnerabilities...</span> <span class="text-sm">Scanning for vulnerabilities...</span>
{:else if status === 'complete'} {:else if status === 'complete'}
{#if hasCriticalOrHigh} {#if hasCriticalOrHigh}
+2 -1
View File
@@ -3,6 +3,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte'; import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte';
import { compareVersions } from '$lib/utils/version'; import { compareVersions } from '$lib/utils/version';
import ChangelogText from '$lib/components/ChangelogText.svelte';
interface ChangelogEntry { interface ChangelogEntry {
version: string; version: string;
@@ -66,7 +67,7 @@
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)} {@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" /> <Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
<span class="text-sm">{change.text}</span> <ChangelogText text={change.text} />
</div> </div>
{/each} {/each}
</div> </div>
+1
View File
@@ -76,6 +76,7 @@ export const volumeColumns: ColumnConfig[] = [
{ id: 'select', label: '', fixed: 'start', width: 32, resizable: false }, { id: 'select', label: '', fixed: 'start', width: 32, resizable: false },
{ id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 400, minWidth: 150, grow: true }, { id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 400, minWidth: 150, grow: true },
{ id: 'driver', label: 'Driver', sortable: true, sortField: 'driver', width: 80, minWidth: 60 }, { id: 'driver', label: 'Driver', sortable: true, sortField: 'driver', width: 80, minWidth: 60 },
{ id: 'type', label: 'Type', sortable: true, sortField: 'type', width: 80, minWidth: 60 },
{ id: 'scope', label: 'Scope', width: 70, minWidth: 50 }, { id: 'scope', label: 'Scope', width: 70, minWidth: 50 },
{ id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 120, minWidth: 80 }, { id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 120, minWidth: 80 },
{ id: 'usedBy', label: 'Used by', width: 150, minWidth: 80 }, { id: 'usedBy', label: 'Used by', width: 150, minWidth: 80 },
+27
View File
@@ -1,4 +1,31 @@
[ [
{
"version": "1.0.33",
"date": "2026-06-15",
"changes": [
{ "type": "feature", "text": "in-place container property updates without restart — restart policy, CPU/memory limits (#1153)" },
{ "type": "feature", "text": "clickable stack badge in container and volume inspect modals (#1121)" },
{ "type": "feature", "text": "clickable stack badge in volumes list row (#1122)" },
{ "type": "feature", "text": "volumes list shows driver_opts type (NFS, CIFS, etc.) with sort and filter (#1123)" },
{ "type": "feature", "text": "Bark iOS notifications (#1095, PR#1097, @undirectlookable)" },
{ "type": "feature", "text": "Signal notifications via signal-cli-rest-api (#1099)" },
{ "type": "feature", "text": "Apprise passthrough — forward to a self-hosted caronc/apprise-api server (#1099)" },
{ "type": "fix", "text": "env editor flagged Docker/Compose built-ins as MISSING (#141)" },
{ "type": "fix", "text": "YAML editor indentation was inconsistent when pressing Enter (#1156)" },
{ "type": "feature", "text": "`dockhand.update=false`, `dockhand.hidden=true` and `localhost/*` images skip registry polling (#1083)" },
{ "type": "fix", "text": "registry authentication for image pulls (#1105)" },
{ "type": "feature", "text": "native HTTPS listener, off by default (#1102)" },
{ "type": "fix", "text": "environments stuck \"Failed\" after VPN/Tailscale tunnel drops until agent restart (#1160)" },
{ "type": "fix", "text": "health_status events flooding container_events table (#1165)" },
{ "type": "fix", "text": "git stack sync removes files deleted from the repo (hash-verified) (#966, #1162)" },
{ "type": "feature", "text": "upload TLS/mTLS certificate files in environment editor (#125)" },
{ "type": "feature", "text": "syntax highlighting for shell, Dockerfile, TOML, INI/conf and .env files in the file browser viewer (#1055)" },
{ "type": "feature", "text": "Animated icons now configurable (#1169)" },
{ "type": "fix", "text": "stack deploys ignored the env's configured socket path (#1172)" },
{ "type": "fix", "text": "environment names with characters that break path resolution (e.g. `*`) are now rejected (#1179)" }
],
"imageTag": "fnsys/dockhand:v1.0.33"
},
{ {
"version": "1.0.32", "version": "1.0.32",
"date": "2026-06-06", "date": "2026-06-06",
+31 -8
View File
@@ -388,15 +388,17 @@ export async function getUserThemePreferences(userId: number): Promise<{
gridFontSize: string; gridFontSize: string;
terminalFont: string; terminalFont: string;
editorFont: string; editorFont: string;
animateIcons: boolean;
}> { }> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([ const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
getUserSetting(userId, 'light_theme'), getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'), getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'), getUserSetting(userId, 'font'),
getUserSetting(userId, 'font_size'), getUserSetting(userId, 'font_size'),
getUserSetting(userId, 'grid_font_size'), getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'), getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font') getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons')
]); ]);
return { return {
lightTheme: lightTheme || 'default', lightTheme: lightTheme || 'default',
@@ -405,13 +407,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
fontSize: fontSize || 'normal', fontSize: fontSize || 'normal',
gridFontSize: gridFontSize || 'normal', gridFontSize: gridFontSize || 'normal',
terminalFont: terminalFont || 'system-mono', terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono' editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true
}; };
} }
export async function setUserThemePreferences( export async function setUserThemePreferences(
userId: number, userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string } prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
): Promise<void> { ): Promise<void> {
const updates: Promise<void>[] = []; const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) { if (prefs.lightTheme !== undefined) {
@@ -435,6 +439,9 @@ export async function setUserThemePreferences(
if (prefs.editorFont !== undefined) { if (prefs.editorFont !== undefined) {
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont)); updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
} }
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
await Promise.all(updates); await Promise.all(updates);
} }
@@ -2097,6 +2104,7 @@ export interface GitStackData {
lastCommit: string | null; lastCommit: string | null;
syncStatus: GitSyncStatus; syncStatus: GitSyncStatus;
syncError: string | null; syncError: string | null;
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -2303,6 +2311,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
lastCommit: gitStacks.lastCommit, lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus, syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError, syncError: gitStacks.syncError,
syncedFiles: gitStacks.syncedFiles,
createdAt: gitStacks.createdAt, createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt, updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name, repoName: gitRepositories.name,
@@ -2337,6 +2346,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
lastCommit: row.lastCommit, lastCommit: row.lastCommit,
syncStatus: row.syncStatus, syncStatus: row.syncStatus,
syncError: row.syncError, syncError: row.syncError,
syncedFiles: row.syncedFiles ?? null,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
repository: { repository: {
@@ -2548,6 +2558,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit; if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus; if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
if (data.syncError !== undefined) updateData.syncError = data.syncError; if (data.syncError !== undefined) updateData.syncError = data.syncError;
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id)); await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
return getGitStack(id); return getGitStack(id);
@@ -3592,9 +3603,15 @@ export async function getContainerEventActions(): Promise<string[]> {
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> { export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString(); const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
await db.delete(containerEvents) const countResult = await db.select({ count: sql<number>`count(*)` })
.from(containerEvents)
.where(sql`timestamp < ${cutoffDate}`); .where(sql`timestamp < ${cutoffDate}`);
return 0; const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
}
return count;
} }
/** /**
@@ -4082,9 +4099,15 @@ export async function getRecentExecutionsForSchedule(
export async function cleanupOldExecutions(retentionDays: number): Promise<number> { export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString(); const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
const result = await db.delete(scheduleExecutions) const countResult = await db.select({ count: sql<number>`count(*)` })
.from(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`); .where(sql`triggered_at < ${cutoffDate}`);
return 0; // SQLite/PG don't return count consistently const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
}
return count;
} }
// Settings helpers for retention // Settings helpers for retention
+1
View File
@@ -324,6 +324,7 @@ export const gitStacks = sqliteTable('git_stacks', {
lastCommit: text('last_commit'), lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'), syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'), syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({ }, (table) => ({
+1
View File
@@ -327,6 +327,7 @@ export const gitStacks = pgTable('git_stacks', {
lastCommit: text('last_commit'), lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'), syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'), syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({ }, (table) => ({
+157 -17
View File
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
import type { Environment } from './db'; import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db'; import { getStackEnvVarsAsRecord } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe'; import { getAdditionalVolumeBinds } from './mount-dedupe';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer } from './scheduler/tasks/update-utils'; import { isSystemContainer } from './scheduler/tasks/update-utils';
import { deepDiff } from '../utils/diff.js'; import { deepDiff } from '../utils/diff.js';
@@ -286,7 +287,7 @@ const envCache = new Map<number, CachedEnv>();
const CACHE_TTL = 30 * 60 * 1000; const CACHE_TTL = 30 * 60 * 1000;
// All known Docker Hub hostname variations for credential matching // All known Docker Hub hostname variations for credential matching
const DOCKER_HUB_HOSTS = new Set([ export const DOCKER_HUB_HOSTS = new Set([
'docker.io', 'hub.docker.com', 'registry.hub.docker.com', 'docker.io', 'hub.docker.com', 'registry.hub.docker.com',
'index.docker.io', 'registry-1.docker.io', 'registry.docker.io', 'docker.com' 'index.docker.io', 'registry-1.docker.io', 'registry.docker.io', 'docker.com'
]); ]);
@@ -1215,6 +1216,61 @@ export async function renameContainer(id: string, newName: string, envId?: numbe
await assertDockerResponse(response); await assertDockerResponse(response);
} }
/**
* In-place container property update. Wraps Docker's POST /containers/{id}/update,
* which is the only API that changes container properties WITHOUT recreating it.
*
* The accepted body field set is fixed by Docker anything outside this set
* requires container recreation (image, env, ports, networks, mounts, etc).
* Whitelist enforced here so callers can't accidentally smuggle a
* recreate-only field through this code path.
*/
export const IN_PLACE_UPDATE_FIELDS = [
// Restart policy — the headline use case (#1153)
'RestartPolicy',
// CPU
'CpuShares', 'CpuPeriod', 'CpuQuota', 'CpuRealtimePeriod', 'CpuRealtimeRuntime',
'CpusetCpus', 'CpusetMems', 'NanoCpus',
// Memory
'Memory', 'MemorySwap', 'MemoryReservation', 'MemorySwappiness', 'KernelMemory',
// Block I/O
'BlkioWeight', 'BlkioWeightDevice',
'BlkioDeviceReadBps', 'BlkioDeviceWriteBps',
'BlkioDeviceReadIOps', 'BlkioDeviceWriteIOps',
// Misc
'PidsLimit'
] as const;
export type InPlaceUpdateField = typeof IN_PLACE_UPDATE_FIELDS[number];
export interface UpdateContainerRuntimeResult {
Warnings: string[] | null;
}
/**
* Apply an in-place update. `updates` keys MUST be from IN_PLACE_UPDATE_FIELDS;
* unknown keys are silently dropped (not passed to Docker) so a malicious or
* confused caller can't sneak a recreate-only field through.
*/
export async function updateContainerRuntime(
id: string,
updates: Partial<Record<InPlaceUpdateField, unknown>>,
envId?: number | null
): Promise<UpdateContainerRuntimeResult> {
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
const body: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (allowed.has(key) && value !== undefined) body[key] = value;
}
if (Object.keys(body).length === 0) {
throw new Error('No updatable fields provided');
}
return dockerJsonRequest<UpdateContainerRuntimeResult>(
`/containers/${id}/update`,
{ method: 'POST', body: JSON.stringify(body) },
envId
);
}
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> { export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
// Check if container has TTY enabled // Check if container has TTY enabled
const info = await inspectContainer(id, envId); const info = await inspectContainer(id, envId);
@@ -2553,6 +2609,28 @@ export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
})); }));
} }
/**
* Diagnostic log for registry auth headers (#1105).
* Logs lengths and boundary char codes never the plaintext credentials.
* Header prefix is safe to log: it encodes the start of `{"username":"...`.
*/
function logAuthDiagnostics(
tag: string,
registry: string,
serveraddress: string,
username: string,
password: string,
authHeader: string
): void {
const userLast = username.length ? username.charCodeAt(username.length - 1).toString(16) : 'na';
const pwLast = password.length ? password.charCodeAt(password.length - 1).toString(16) : 'na';
console.log(
`${tag} auth: registry=${registry} user(len=${username.length},last=0x${userLast}) ` +
`pw(len=${password.length},last=0x${pwLast}) serveraddress=${serveraddress} ` +
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
);
}
/** /**
* Build X-Registry-Auth header for authenticated Docker image pulls. * Build X-Registry-Auth header for authenticated Docker image pulls.
* Looks up stored registry credentials and returns a headers object * Looks up stored registry credentials and returns a headers object
@@ -2566,16 +2644,19 @@ export async function buildRegistryAuthHeader(imageName: string): Promise<Record
if (creds) { if (creds) {
// Docker Engine requires 'https://index.docker.io/v1/' as serveraddress // Docker Engine requires 'https://index.docker.io/v1/' as serveraddress
// for Docker Hub auth — just the hostname is treated as unauthenticated // for Docker Hub auth — just the hostname is treated as unauthenticated
const serveraddress = DOCKER_HUB_HOSTS.has(registry) const isHub = DOCKER_HUB_HOSTS.has(registry);
? 'https://index.docker.io/v1/' const serveraddress = isHub ? 'https://index.docker.io/v1/' : registry;
: registry; if (isHub) {
console.log(`[Pull] Using credentials for ${serveraddress} (user: ${creds.username})`); console.log(`[Registry] docker-hub variant '${registry}' canonicalized to https://index.docker.io/v1/ for auth`);
}
const authConfig = { const authConfig = {
username: creds.username, username: creds.username,
password: creds.password, password: creds.password,
serveraddress serveraddress
}; };
headers['X-Registry-Auth'] = Buffer.from(JSON.stringify(authConfig)).toString('base64'); const authHeader = encodeRegistryAuth(authConfig);
headers['X-Registry-Auth'] = authHeader;
logAuthDiagnostics('[Pull]', registry, serveraddress, creds.username, creds.password, authHeader);
} else { } else {
console.log(`[Pull] No credentials found for ${registry}`); console.log(`[Pull] No credentials found for ${registry}`);
} }
@@ -2618,11 +2699,19 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
// Look up registry credentials for authenticated pulls // Look up registry credentials for authenticated pulls
const headers = await buildRegistryAuthHeader(imageName); const headers = await buildRegistryAuthHeader(imageName);
// Diagnostic logging (#1105): what we're sending to the daemon
console.log(`[Pull] POST ${url} headers=${Object.keys(headers).join(',') || '(none)'}`);
// Use streaming: true for longer timeout on edge environments // Use streaming: true for longer timeout on edge environments
const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId); const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId);
// Diagnostic logging (#1105): daemon response status
console.log(`[Pull] response status=${response.status} ${response.statusText}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to pull image: ${await response.text()}`); const body = await response.text();
console.error(`[Pull] error body: ${body}`);
throw new Error(`Failed to pull image: ${body}`);
} }
// Stream the response for progress updates // Stream the response for progress updates
@@ -2644,6 +2733,9 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
if (line.trim()) { if (line.trim()) {
try { try {
const data = JSON.parse(line); const data = JSON.parse(line);
if (data.error || data.errorDetail) {
console.error(`[Pull] stream error: ${line}`);
}
if (onProgress) onProgress(data); if (onProgress) onProgress(data);
} catch { } catch {
// Ignore parse errors // Ignore parse errors
@@ -2780,7 +2872,10 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
if (stored.fullRegistry === requested.fullRegistry || if (stored.fullRegistry === requested.fullRegistry ||
(stored.host === requested.host && !stored.path)) { (stored.host === requested.host && !stored.path)) {
if (reg.username && reg.password) { if (reg.username && reg.password) {
return { username: reg.username, password: reg.password }; const via = stored.fullRegistry === requested.fullRegistry ? 'full' : 'host-only';
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=${via}`);
// Normalize legacy creds saved before #1105 trim fix
return { username: reg.username.trim(), password: reg.password.trim() };
} }
} }
} }
@@ -2792,12 +2887,20 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
const stored = parseRegistryUrl(reg.url); const stored = parseRegistryUrl(reg.url);
if (DOCKER_HUB_HOSTS.has(stored.host)) { if (DOCKER_HUB_HOSTS.has(stored.host)) {
if (reg.username && reg.password) { if (reg.username && reg.password) {
return { username: reg.username, password: reg.password }; console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=hub-alias`);
return { username: reg.username.trim(), password: reg.password.trim() };
} }
} }
} }
} }
// No match — log what we tried so support cases are diagnosable
const candidates = registries.map(r => parseRegistryUrl(r.url).host).join(', ');
console.log(
`[Registry] no match for requested=${registryHost} ` +
`(hub-alias=${DOCKER_HUB_HOSTS.has(requested.host)}); ` +
`candidates=[${candidates || 'none configured'}]`
);
return null; return null;
} catch (e) { } catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e); const errorMsg = e instanceof Error ? e.message : String(e);
@@ -2875,10 +2978,14 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
const service = serviceMatch ? serviceMatch[1] : ''; const service = serviceMatch ? serviceMatch[1] : '';
const scope = `repository:${repo}:pull`; const scope = `repository:${repo}:pull`;
// Step 3: Request token from realm (with credentials if available) // Step 3: Request token from realm (with credentials if available).
// Empty scope is allowed — means "no specific resource permission".
// Useful for credential validation: some registries (Docker Hub)
// reject privileged scopes like registry:catalog:* even for valid
// users, so omitting scope is the only reliable login check.
const tokenUrl = new URL(realm); const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service); if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope); if (scope) tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' }; const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
@@ -2992,10 +3099,14 @@ export async function getRegistryAuthHeader(
const realm = realmMatch[1]; const realm = realmMatch[1];
const service = serviceMatch ? serviceMatch[1] : ''; const service = serviceMatch ? serviceMatch[1] : '';
// Step 3: Request token from realm (with credentials if available) // Step 3: Request token from realm (with credentials if available).
// Empty scope is allowed — means "no specific resource permission".
// Useful for credential validation: some registries (Docker Hub)
// reject privileged scopes like registry:catalog:* even for valid
// users, so omitting scope is the only reliable login check.
const tokenUrl = new URL(realm); const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service); if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope); if (scope) tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' }; const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
@@ -3140,6 +3251,17 @@ export async function checkImageUpdateAvailable(
}; };
} }
// Skip update check for images tagged against a localhost registry.
// These never resolve from inside Dockhand's container and only produce
// ECONNREFUSED noise (see #1083).
if (/^localhost(:\d+)?\//.test(imageName)) {
return {
hasUpdate: false,
isLocalImage: true,
currentDigest: currentImageId
};
}
// Get current image info to get RepoDigests // Get current image info to get RepoDigests
let currentImageInfo: any; let currentImageInfo: any;
try { try {
@@ -3470,7 +3592,11 @@ export async function listVolumes(envId?: number | null): Promise<VolumeInfo[]>
scope: volume.Scope, scope: volume.Scope,
created: volume.CreatedAt, created: volume.CreatedAt,
labels: volume.Labels || {}, labels: volume.Labels || {},
usedBy: volumeUsageMap.get(volume.Name) || [] usedBy: volumeUsageMap.get(volume.Name) || [],
// Surface driver_opts (e.g. NFS/CIFS type+device+o) so the UI can
// distinguish network-backed volumes from plain local ones. Docker's
// /volumes endpoint already includes this — we were dropping it.
options: volume.Options && Object.keys(volume.Options).length > 0 ? volume.Options : undefined
})); }));
} }
@@ -4458,11 +4584,21 @@ export async function pushImage(
// Parse tag to get registry info // Parse tag to get registry info
const [repo, tag = 'latest'] = imageTag.split(':'); const [repo, tag = 'latest'] = imageTag.split(':');
// Create X-Registry-Auth header const authHeader = encodeRegistryAuth(authConfig);
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64'); logAuthDiagnostics(
'[Push]',
authConfig.serveraddress,
authConfig.serveraddress,
authConfig.username ?? '',
authConfig.password ?? '',
authHeader
);
const pushUrl = `/images/${encodeURIComponent(imageTag)}/push`;
console.log(`[Push] POST ${pushUrl} headers=X-Registry-Auth`);
const response = await dockerFetch( const response = await dockerFetch(
`/images/${encodeURIComponent(imageTag)}/push`, pushUrl,
{ {
method: 'POST', method: 'POST',
streaming: true, streaming: true,
@@ -4473,8 +4609,11 @@ export async function pushImage(
envId envId
); );
console.log(`[Push] response status=${response.status} ${response.statusText}`);
if (!response.ok) { if (!response.ok) {
const error = await response.text(); const error = await response.text();
console.error(`[Push] error body: ${error}`);
throw new Error(`Failed to push image: ${error}`); throw new Error(`Failed to push image: ${error}`);
} }
@@ -4498,6 +4637,7 @@ export async function pushImage(
try { try {
const data = JSON.parse(line); const data = JSON.parse(line);
if (data.error) { if (data.error) {
console.error(`[Push] stream error: ${line}`);
throw new Error(data.error); throw new Error(data.error);
} }
if (onProgress) onProgress(data); if (onProgress) onProgress(data);
+435
View File
@@ -0,0 +1,435 @@
/**
* Git stack deletion sync (#966, #1162).
*
* Propagates upstream file deletions to the stack deploy directory using the
* per-stack manifest: a file is deleted ONLY when the manifest of files
* Dockhand wrote on the previous sync lists it, the new clone no longer
* contains it, AND the bytes on disk still match what Dockhand wrote
* (nobody modified it locally).
*
* Every failure mode degrades to "delete less" never to user-data loss:
* - user-created files (volume data) never in the manifest untouchable
* - locally modified files hash mismatch skip
* - first sync after upgrade / fresh DB empty manifest nothing to delete
* - broken clone walk (empty / compose missing) deletionSafetyCheck blocks
* ALL deletions for that sync (guards against mass-deleting managed files
* due to a Dockhand bug; those files are repo-restorable anyway)
*
* History rewrites are irrelevant by design: deletion converges the deploy
* dir toward the clone state, regardless of how the commits got there.
*/
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, unlinkSync, rmdirSync, lstatSync } from 'node:fs';
import { join, resolve, sep, dirname, basename, isAbsolute } from 'node:path';
// =============================================================================
// Types
// =============================================================================
export type DeletionSkipReason =
| 'locally-modified' // disk bytes differ from what Dockhand wrote
| 'load-bearing' // compose/.env files are never auto-deleted
| 'invalid-path' // absolute or escaping the stack directory
| 'already-absent' // nothing to do (benign)
| 'agent-no-support' // Hawser agent too old to apply deletions
| 'apply-failed'; // unexpected error during unlink
export interface FileToDelete {
path: string; // relative to the stack deploy dir, '/' separators
hash: string; // sha256 hex of the content Dockhand wrote
}
export interface DeletionSkip {
path: string;
reason: DeletionSkipReason;
}
export interface DeletionPlan {
toDelete: FileToDelete[];
skipped: DeletionSkip[];
}
export interface DeletionApplyResult {
deleted: string[];
skipped: DeletionSkip[];
}
/** Manifest of files Dockhand wrote on the last successful sync. */
export interface SyncManifest {
/** Full commit hash the manifest files were taken from. Null = legacy/bootstrap. */
commit: string | null;
/** relative path → sha256 hex of written content */
files: Record<string, string>;
}
export interface SyncFileChange {
file: string;
status: 'added' | 'updated' | 'removed' | 'skipped';
reason?: string; // human-readable, only for skipped
}
export interface SyncChangeSummary {
changes: SyncFileChange[];
unchangedCount: number;
}
// =============================================================================
// Constants
// =============================================================================
/** Files that are never auto-deleted, regardless of what the sources say. */
export const LOAD_BEARING_FILES = new Set([
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
'.env'
]);
// NOTE: deletion skips are FINAL by design. A deletion is attempted exactly
// once — at the sync where the file first disappears from the clone. Any
// skip (old agent, hash mismatch, apply error) is logged and the file simply
// stays on disk as unmanaged residue. There is deliberately no
// carry-forward/retry state: it would require tracking per-file retry status
// indefinitely (e.g. waiting for an agent upgrade that may never happen).
// Worst case is always "a stale file survives" — visible in the logs,
// recoverable manually. With an old Hawser agent the behavior is identical
// to before this feature existed: nothing is ever deleted remotely.
/** Human-readable explanation for each skip reason (shown in logs and activity). */
export function skipReasonMessage(reason: DeletionSkipReason): string {
switch (reason) {
case 'locally-modified':
return 'deleted from the repository, but the file was modified on this machine since Dockhand deployed it — refusing to delete local changes';
case 'load-bearing':
return 'core stack file — never auto-deleted';
case 'invalid-path':
return 'invalid path outside the stack directory — ignored';
case 'already-absent':
return 'already absent';
case 'agent-no-support':
return 'the Hawser agent does not support file deletion sync — file left on the remote host (upgrade the agent to enable cleanup of future deletions)';
case 'apply-failed':
return 'could not be deleted — leaving the file in place';
default:
// Unknown reason (e.g., from a newer agent)
return 'could not be deleted — leaving the file in place';
}
}
const KNOWN_SKIP_REASONS: ReadonlySet<string> = new Set<DeletionSkipReason>([
'locally-modified',
'load-bearing',
'invalid-path',
'already-absent',
'agent-no-support',
'apply-failed'
]);
/** Normalize a reason string from an external source (Hawser agent). */
export function normalizeSkipReason(reason: string): DeletionSkipReason {
return (KNOWN_SKIP_REASONS.has(reason) ? reason : 'apply-failed') as DeletionSkipReason;
}
// =============================================================================
// Manifest (de)serialization
// =============================================================================
export function parseManifest(raw: string | null | undefined): SyncManifest {
if (!raw) return { commit: null, files: {} };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof parsed.files === 'object' && parsed.files !== null) {
const files: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed.files)) {
if (typeof v === 'string') files[k] = v;
}
return { commit: typeof parsed.commit === 'string' ? parsed.commit : null, files };
}
} catch {
// Corrupt manifest → behave like a fresh bootstrap (fail closed: no deletions)
}
return { commit: null, files: {} };
}
export function serializeManifest(manifest: SyncManifest): string {
return JSON.stringify(manifest);
}
// =============================================================================
// Hashing
// =============================================================================
export function hashContent(content: Buffer | string): string {
return createHash('sha256').update(content).digest('hex');
}
/**
* Walk a directory and hash every regular file (raw bytes).
* Returns { relativePath: sha256hex } with '/' separators.
* Skips .git directories (mirrors the cpSync filter used by the deploy copy).
*/
export function hashDirFiles(dir: string): Record<string, string> {
const result: Record<string, string> = {};
const root = resolve(dir);
const walk = (current: string, relPrefix: string) => {
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === '.git') continue;
const abs = join(current, entry.name);
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
} else if (entry.isFile()) {
try {
result[rel] = hashContent(readFileSync(abs));
} catch {
// Unreadable file: leave out of the manifest → never a deletion candidate
}
}
// Symlinks and other special entries are intentionally excluded:
// Dockhand only writes regular files, so only regular files are managed.
}
};
walk(root, '');
return result;
}
// =============================================================================
// Path safety
// =============================================================================
/** A relative path is safe when it cannot escape the stack directory. */
export function isSafeRelPath(p: string): boolean {
if (!p || isAbsolute(p) || p.includes('\\')) return false;
const segments = p.split('/');
return segments.every((s) => s !== '' && s !== '.' && s !== '..');
}
/** Resolve relPath inside root; returns null when it would escape root. */
function containedPath(root: string, relPath: string): string | null {
if (!isSafeRelPath(relPath)) return null;
const abs = resolve(root, relPath);
if (abs !== root && abs.startsWith(root + sep)) return abs;
return null;
}
// =============================================================================
// Core: manifest vs clone
// =============================================================================
/**
* Sanity guard run BEFORE computing any deletions: when the new-clone walk
* looks broken (no files at all, or the compose file itself is missing from
* the walk even though it was just read from that tree), every manifest
* entry would become a deletion candidate a Dockhand bug, not a repo
* change. Returns a human-readable reason to skip ALL deletions this sync,
* or null when it is safe to proceed.
*/
export function deletionSafetyCheck(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>,
composeFileName: string | undefined
): string | null {
if (Object.keys(manifestFiles).length === 0) return null; // nothing to delete anyway
if (Object.keys(newFiles).length === 0) {
return 'the new clone appears empty — skipping all deletions this sync (likely a sync problem, not repository changes)';
}
if (composeFileName && !(composeFileName in newFiles)) {
return `the compose file "${composeFileName}" is missing from the new clone walk — skipping all deletions this sync (likely a sync problem, not repository changes)`;
}
return null;
}
/**
* Compute the deletion plan: manifest entries that are absent from the new
* clone. The hash recorded in the manifest travels with each entry the
* applier deletes only files whose disk bytes still match it.
*
* @param manifestFiles files Dockhand wrote on the last sync (path hash)
* @param newFiles files in the new clone that will be written (path hash)
*/
export function computeDeletions(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>
): DeletionPlan {
const toDelete: FileToDelete[] = [];
const skipped: DeletionSkip[] = [];
for (const [path, hash] of Object.entries(manifestFiles)) {
if (path in newFiles) continue; // still present in the repo
if (!isSafeRelPath(path)) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
toDelete.push({ path, hash });
}
return { toDelete, skipped };
}
// =============================================================================
// Applier — the single chokepoint that touches the filesystem
// =============================================================================
/**
* Apply a deletion list inside a stack directory.
*
* Structurally incapable of touching anything outside stackDir:
* every path is containment-checked, only regular files whose content still
* matches the recorded hash are unlinked, and directory cleanup uses rmdir
* (never recursive) so directories holding any other content survive.
*/
export function applyFileDeletions(stackDir: string, files: FileToDelete[]): DeletionApplyResult {
const root = resolve(stackDir);
const deleted: string[] = [];
const skipped: DeletionSkip[] = [];
const parentDirs = new Set<string>();
for (const { path, hash } of files) {
const abs = containedPath(root, path);
if (!abs) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
// Defense in depth: computeDeletions already filters these, but the
// applier also runs on lists from external sources (Hawser payloads).
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
let stat;
try {
stat = lstatSync(abs);
} catch {
skipped.push({ path, reason: 'already-absent' });
continue;
}
// Dockhand only writes regular files. Anything else (symlink, dir,
// socket) means the user replaced it — treat as locally modified.
if (!stat.isFile()) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
try {
if (hashContent(readFileSync(abs)) !== hash) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
unlinkSync(abs);
deleted.push(path);
} catch {
skipped.push({ path, reason: 'apply-failed' });
continue;
}
// Collect parent dir chain (inside root) for empty-dir cleanup
let dir = dirname(abs);
while (dir !== root && dir.startsWith(root + sep)) {
parentDirs.add(dir);
dir = dirname(dir);
}
}
// Deepest-first rmdir; fails harmlessly when a directory still has content
const dirsByDepth = [...parentDirs].sort((a, b) => b.length - a.length);
for (const dir of dirsByDepth) {
try {
rmdirSync(dir);
} catch {
// ENOTEMPTY/ENOENT/etc. — directory stays, which is always safe
}
}
return { deleted, skipped };
}
// =============================================================================
// Manifest evolution
// =============================================================================
/**
* Build the manifest to persist after a sync.
*
* Trivial by design: the manifest is always exactly the files written this
* sync, at this sync's commit. Skipped deletions are FINAL (see note above)
* the affected files drop out of the manifest and become unmanaged residue.
*/
export function buildNextManifest(newCommit: string, newFiles: Record<string, string>): SyncManifest {
return { commit: newCommit, files: { ...newFiles } };
}
// =============================================================================
// Sync summary (per-file status table)
// =============================================================================
export function buildSyncChangeSummary(
previousFiles: Record<string, string>,
newFiles: Record<string, string>,
applyResult: DeletionApplyResult,
planSkipped: DeletionSkip[]
): SyncChangeSummary {
const changes: SyncFileChange[] = [];
let unchangedCount = 0;
for (const [path, hash] of Object.entries(newFiles)) {
const oldHash = previousFiles[path];
if (oldHash === undefined) {
changes.push({ file: path, status: 'added' });
} else if (oldHash !== hash) {
changes.push({ file: path, status: 'updated' });
} else {
unchangedCount++;
}
}
for (const path of applyResult.deleted) {
changes.push({ file: path, status: 'removed' });
}
// Benign "already absent" results are not interesting in the summary
const interestingSkips = [...planSkipped, ...applyResult.skipped].filter(
(s) => s.reason !== 'already-absent'
);
for (const skip of interestingSkips) {
changes.push({ file: skip.path, status: 'skipped', reason: skipReasonMessage(skip.reason) });
}
return { changes, unchangedCount };
}
/** Render the summary as aligned text lines for console and job output. */
export function formatChangeTable(summary: SyncChangeSummary): string[] {
const { changes, unchangedCount } = summary;
const counts = { added: 0, updated: 0, removed: 0, skipped: 0 };
for (const c of changes) counts[c.status]++;
const header = `${counts.added} added, ${counts.updated} updated, ${counts.removed} removed, ${counts.skipped} skipped, ${unchangedCount} unchanged`;
if (changes.length === 0) {
return [header];
}
const fileWidth = Math.min(60, Math.max(4, ...changes.map((c) => c.file.length)));
const lines = [header, `${'STATUS'.padEnd(9)} ${'FILE'.padEnd(fileWidth)} REASON`];
for (const c of changes) {
lines.push(`${c.status.padEnd(9)} ${c.file.padEnd(fileWidth)} ${c.reason ?? ''}`.trimEnd());
}
return lines;
}
+167 -3
View File
@@ -15,6 +15,21 @@ import {
type GitStackWithRepo type GitStackWithRepo
} from './db'; } from './db';
import { deployStack, getStackDir } from './stacks'; import { deployStack, getStackDir } from './stacks';
import {
parseManifest,
serializeManifest,
hashDirFiles,
computeDeletions,
buildNextManifest,
buildSyncChangeSummary,
formatChangeTable,
skipReasonMessage,
deletionSafetyCheck,
type DeletionPlan,
type DeletionApplyResult,
type DeletionSkip,
type SyncManifest
} from './git-deletions';
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt'; const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
let mergedCaBundleReady = false; let mergedCaBundleReady = false;
@@ -363,6 +378,93 @@ async function getChangedFilesInDir(
return { changed: changedFiles.length > 0, files: changedFiles }; return { changed: changedFiles.length > 0, files: changedFiles };
} }
/**
* Compute the deletion plan for a sync: hash the new clone's compose dir and
* diff against the manifest from the last sync. Deletions converge the deploy
* dir toward the clone state; the applier additionally verifies each file's
* disk hash. A sanity guard blocks ALL deletions when the clone walk looks
* broken (empty, or missing the compose file).
*/
async function computeSyncDeletionPlan(options: {
logPrefix: string;
composeDir: string; // absolute path inside the clone
composeFileName: string | undefined; // compose file relative to composeDir
rawManifest: string | null | undefined;
}): Promise<{ plan: DeletionPlan; newFiles: Record<string, string>; previousManifest: SyncManifest }> {
const { logPrefix, composeDir, composeFileName, rawManifest } = options;
const previousManifest = parseManifest(rawManifest);
const newFiles = hashDirFiles(composeDir);
const manifestSize = Object.keys(previousManifest.files).length;
console.log(`${logPrefix} Deletion sync: manifest has ${manifestSize} file(s)${manifestSize === 0 ? ' (first sync — nothing will be deleted)' : ''}`);
// First sync / legacy manifest: nothing was recorded, so nothing can be deleted
if (manifestSize === 0) {
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
}
const blocked = deletionSafetyCheck(previousManifest.files, newFiles, composeFileName);
if (blocked) {
console.warn(`${logPrefix} Deletion sync: ${blocked}`);
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
}
const plan = computeDeletions(previousManifest.files, newFiles);
for (const file of plan.toDelete) {
console.log(`${logPrefix} Deletion sync: will remove "${file.path}" — deleted from the repository`);
}
for (const skip of plan.skipped) {
console.warn(`${logPrefix} Deletion sync: keeping "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
return { plan, newFiles, previousManifest };
}
/**
* Persist the manifest after a deploy and log the per-file change summary.
* Called only after a successful deploy (locally applied or agent-confirmed).
*/
async function finalizeDeletionSync(options: {
stackId: number;
logPrefix: string;
previousManifest: SyncManifest;
newCommitFull: string;
newFiles: Record<string, string>;
plan: DeletionPlan;
applyResult: DeletionApplyResult | undefined;
onLine?: (line: string) => void; // extra sink (job output)
}): Promise<void> {
const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult, onLine } = options;
// No apply result means deletions were requested but nothing reported back
// (defensive — executors always return one). Logged as skips; skips are final.
const effectiveApply: DeletionApplyResult = applyResult ?? {
deleted: [],
skipped: plan.toDelete.map((f): DeletionSkip => ({ path: f.path, reason: 'apply-failed' }))
};
// Pass only the plan-stage skips; buildSyncChangeSummary already merges
// in effectiveApply.skipped itself. Concatenating here duplicated every
// apply-stage skip (locally-modified, agent-no-support, apply-failed).
const summary = buildSyncChangeSummary(previousManifest.files, newFiles, effectiveApply, plan.skipped);
const tableLines = formatChangeTable(summary);
console.log(`${logPrefix} Sync file changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) {
console.log(`${logPrefix} ${line}`);
}
if (onLine) {
onLine(`File changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) onLine(line);
}
const nextManifest = buildNextManifest(newCommitFull, newFiles);
await updateGitStack(stackId, { syncedFiles: serializeManifest(nextManifest) });
console.log(`${logPrefix} Manifest persisted: ${Object.keys(nextManifest.files).length} file(s) at commit ${nextManifest.commit?.substring(0, 7)}`);
}
export interface SyncResult { export interface SyncResult {
success: boolean; success: boolean;
commit?: string; commit?: string;
@@ -375,6 +477,11 @@ export interface SyncResult {
error?: string; error?: string;
updated?: boolean; updated?: boolean;
changedFiles?: string[]; // List of files that changed (for logging/debugging) changedFiles?: string[]; // List of files that changed (for logging/debugging)
// Deletion sync (#966/#1162): manifest-vs-clone data
deletionPlan?: DeletionPlan; // Files safe to delete (manifest entries absent from the new clone) + plan-stage skips
newFiles?: Record<string, string>; // path → sha256 of files in the new clone (next manifest)
newCommitFull?: string; // Full 40-char commit hash (manifest commit)
previousManifest?: SyncManifest; // Manifest from the last successful sync
} }
export interface TestResult { export interface TestResult {
@@ -954,6 +1061,14 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} No env file path configured`); console.log(`${logPrefix} No env file path configured`);
} }
// Deletion sync (#966): manifest-vs-clone deletion plan
const deletionData = await computeSyncDeletionPlan({
logPrefix,
composeDir,
composeFileName,
rawManifest: gitStack.syncedFiles
});
// Update git stack status // Update git stack status
await updateGitStack(stackId, { await updateGitStack(stackId, {
syncStatus: 'synced', syncStatus: 'synced',
@@ -982,7 +1097,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
envFileVars, envFileVars,
envFileName, envFileName,
updated, updated,
changedFiles changedFiles,
deletionPlan: deletionData.plan,
newFiles: deletionData.newFiles,
newCommitFull: newCommit,
previousManifest: deletionData.previousManifest
}; };
} catch (error: any) { } catch (error: any) {
cleanupSshKey(credential); cleanupSshKey(credential);
@@ -1065,7 +1184,8 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
forceRecreate, forceRecreate,
build: gitStack.buildOnDeploy, build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache, noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: syncResult.deletionPlan?.toDelete
}); });
console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} ----------------------------------------`);
@@ -1076,6 +1196,19 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
if (result.error) console.log(`${logPrefix} Error:`, result.error); if (result.error) console.log(`${logPrefix} Error:`, result.error);
if (result.success) { if (result.success) {
// Deletion sync: persist manifest + log per-file change summary
if (syncResult.previousManifest && syncResult.newFiles && syncResult.newCommitFull && syncResult.deletionPlan) {
await finalizeDeletionSync({
stackId,
logPrefix,
previousManifest: syncResult.previousManifest,
newCommitFull: syncResult.newCommitFull,
newFiles: syncResult.newFiles,
plan: syncResult.deletionPlan,
applyResult: result.deletion
});
}
// Record the stack source with resolved compose path for consistency // Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId); const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = syncResult.composeFileName const resolvedComposePath = syncResult.composeFileName
@@ -1306,6 +1439,15 @@ export async function deployGitStackWithProgress(
} }
} }
// Deletion sync (#966): manifest-vs-clone deletion plan
const logPrefix = `[Stack:${gitStack.stackName}]`;
const deletionData = await computeSyncDeletionPlan({
logPrefix,
composeDir,
composeFileName: progressComposeFileName,
rawManifest: gitStack.syncedFiles
});
// Update git stack status // Update git stack status
await updateGitStack(stackId, { await updateGitStack(stackId, {
syncStatus: 'synced', syncStatus: 'synced',
@@ -1319,6 +1461,14 @@ export async function deployGitStackWithProgress(
// Step 5: Deploying stack // Step 5: Deploying stack
// Uses `docker compose up -d --remove-orphans` which only recreates changed services // Uses `docker compose up -d --remove-orphans` which only recreates changed services
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps }); onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
if (deletionData.plan.toDelete.length > 0) {
onProgress({
status: 'deploying',
message: `Removing ${deletionData.plan.toDelete.length} file(s) deleted from the repository...`,
step: 5,
totalSteps
});
}
// Determine env filename relative to compose dir (same logic as syncGitStack) // Determine env filename relative to compose dir (same logic as syncGitStack)
let envFileName: string | undefined; let envFileName: string | undefined;
@@ -1338,10 +1488,24 @@ export async function deployGitStackWithProgress(
envFileName, // Env file relative to compose dir (for --env-file flag, optional) envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy, build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache, noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: deletionData.plan.toDelete
}); });
if (result.success) { if (result.success) {
// Deletion sync: persist manifest + log per-file change summary.
// onLine feeds the per-file change table into the deploy progress popover.
await finalizeDeletionSync({
stackId,
logPrefix,
previousManifest: deletionData.previousManifest,
newCommitFull: newCommit,
newFiles: deletionData.newFiles,
plan: deletionData.plan,
applyResult: result.deletion,
onLine: (line) => onProgress({ status: 'deploying', message: line, step: 5, totalSteps })
});
// Record the stack source with resolved compose path for consistency // Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId); const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = join(stackDir, progressComposeFileName); const resolvedComposePath = join(stackDir, progressComposeFileName);
+8 -1
View File
@@ -8,8 +8,9 @@
import { db, hawserTokens, environments, eq, and } from './db/drizzle.js'; import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
import { logContainerEvent, type ContainerEventAction } from './db.js'; import { logContainerEvent, type ContainerEventAction } from './db.js';
import { containerEventEmitter } from './event-collector.js'; import { containerEventEmitter } from './event-collector.js';
import { sendEnvironmentNotification } from './notifications.js'; import { sendEnvironmentNotification } from './notifications/index.js';
import { isNotifyDisabledByLabel } from './container-labels.js'; import { isNotifyDisabledByLabel } from './container-labels.js';
import { isHealthTransition } from './subprocess-manager.js';
import { pushMetric } from './metrics-store.js'; import { pushMetric } from './metrics-store.js';
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js'; import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
import { hashPassword, verifyPassword } from './auth.js'; import { hashPassword, verifyPassword } from './auth.js';
@@ -178,6 +179,12 @@ export async function handleEdgeContainerEvent(
// Log the event // Log the event
console.log(`[Hawser] Container event from env ${environmentId}: ${event.action} ${event.containerName || event.containerId}`); console.log(`[Hawser] Container event from env ${environmentId}: ${event.action} ${event.containerName || event.containerId}`);
// Only store health_status events on transitions (healthy↔unhealthy)
// to avoid flooding the DB with repeated identical health checks
if (!isHealthTransition(environmentId, event.containerId, event.action)) {
return;
}
// Save to database // Save to database
const savedEvent = await logContainerEvent({ const savedEvent = await logContainerEvent({
environmentId, environmentId,
-822
View File
@@ -1,822 +0,0 @@
import nodemailer from 'nodemailer';
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from './db';
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
/** Drain a response body to release the underlying socket/TLS connection. */
async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
try { await response.arrayBuffer(); } catch {}
}
}
export interface NotificationPayload {
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
}
// Result type for functions that can return detailed errors
export interface NotificationResult {
success: boolean;
error?: string;
}
// Send notification via SMTP
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
: '';
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
</div>
`;
await transporter.sendMail({
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
to: config.to_emails.join(', '),
subject: `[Dockhand]${envText} ${payload.title}`,
text: `${payload.title}${envText}\n\n${payload.message}`,
html
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
// Parse Apprise URL and send notification
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Send to a single Apprise URL
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Extract protocol from Apprise URL format (protocol://...)
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
// Handle different notification services
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'bark':
case 'barks':
return await sendBark(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// Discord webhook
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Slack webhook
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
} else {
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
url = `https://hooks.slack.com/services/${parts.join('/')}`;
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Mattermost webhook
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
// Remove the scheme
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
// Build the webhook URL: {protocol}://{hostname}[:{port}][/{path}]/hooks/{token}
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseTelegramUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
}
const { botToken, chatId, topicId } = parsed;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
// Escape markdown special characters in title and message
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = buildGotifyUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
try {
const response = await fetch(parsed.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
message: payload.message,
priority: parsed.priority ?? defaultPriority
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// ntfy
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
// Extract query parameters (?auth=, ?tags=, ?title=, ?priority=)
let queryAuth: string | null = null;
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const params = new URLSearchParams(path.substring(qIndex + 1));
queryAuth = params.get('auth');
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
cleanPath = path.substring(0, qIndex);
}
// Check for user:pass@host/topic format (Basic auth)
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
// token@host/topic -> Bearer token auth
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
// Fallback to custom server without auth
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
}
} else if (cleanPath.includes('/')) {
// Custom server without auth
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
} else {
// Default ntfy.sh
url = `https://ntfy.sh/${cleanPath}`;
}
// Apply ?auth= as fallback if no explicit auth was set
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultTags = payload.type || 'info';
const headers: Record<string, string> = {
'Title': queryTitle || titleWithEnv,
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Bark
async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// bark://device_key (official api.day.app server)
// bark://host/device_key (custom server over HTTP)
// barks://host/device_key (custom server over HTTPS)
const isSecure = appriseUrl.startsWith('barks');
const path = appriseUrl.replace(/^barks?:\/\//, '');
let url: string;
let deviceKey: string;
if (!path.includes('/')) {
if (!path) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
url = 'https://api.day.app/push';
deviceKey = path;
} else {
const parts = path.split('/');
if (parts.length !== 2) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
const [host, key] = parts;
deviceKey = key;
if (!host || !deviceKey) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
url = `${isSecure ? 'https' : 'http'}://${host}/push`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const body: Record<string, string> = {
device_key: deviceKey,
title: titleWithEnv,
body: payload.message
};
if (payload.type === 'error') {
body.level = 'timeSensitive';
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Pushover
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// pushover://user_key/api_token
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: apiToken,
user: userKey,
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Generic JSON webhook
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
environment: payload.environmentName || null,
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseWorkflowsUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
}
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.2',
body: [
{
type: 'TextBlock',
style: 'heading',
wrap: true,
text: titleWithEnv
},
{
type: 'TextBlock',
style: 'default',
wrap: true,
text: payload.message
}
]
}
}
]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Send notification to all enabled channels
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
const settings = await getEnabledNotificationSettings();
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success: result.success });
}
return {
success: results.every(r => r.success),
results
};
}
// Test a specific notification setting
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
type: 'info'
};
if (setting.type === 'smtp') {
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
function mapActionToEventType(action: string): NotificationEventType | null {
const mapping: Record<string, NotificationEventType> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
return mapping[action] || null;
}
// Scanner image patterns to exclude from notifications
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
// Send notification for an environment-specific event
export async function sendEnvironmentNotification(
environmentId: number,
action: string,
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
image?: string | null
): Promise<{ success: boolean; sent: number }> {
const eventType = mapActionToEventType(action);
if (!eventType) {
// Not a notifiable event type
return { success: true, sent: 0 };
}
// Get environment name
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, sent: 0 };
}
// Get enabled notification channels for this environment and event type
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
if (envNotifications.length === 0) {
return { success: true, sent: 0 };
}
const enrichedPayload: NotificationPayload = {
...payload,
environmentId,
environmentName: env.name
};
// Check if this is a scanner container
const isScanner = isScannerContainer(image);
let sent = 0;
let allSuccess = true;
// Skip all notifications for scanner containers (Trivy, Grype)
if (isScanner) {
return { success: true, sent: 0 };
}
for (const notif of envNotifications) {
try {
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
// Send notification for a specific event type (not mapped from Docker action)
// Used for auto-update, git sync, vulnerability, and system events
export async function sendEventNotification(
eventType: NotificationEventType,
payload: NotificationPayload,
environmentId?: number
): Promise<{ success: boolean; sent: number }> {
// Get environment name if provided
let enrichedPayload = { ...payload };
if (environmentId) {
const env = await getEnvironment(environmentId);
if (env) {
enrichedPayload.environmentId = environmentId;
enrichedPayload.environmentName = env.name;
}
}
// Get enabled notification channels for this event type
let channels: Array<{
channel_type: 'smtp' | 'apprise';
channel_name: string;
config: SmtpConfig | AppriseConfig;
}> = [];
if (environmentId) {
// Environment-specific: get channels subscribed to this env and event type
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
channels = envNotifications
.filter(n => n.channelType && n.channelName)
.map(n => ({
channel_type: n.channelType!,
channel_name: n.channelName!,
config: n.config
}));
} else {
// System-wide: get all globally enabled channels that subscribe to this event type
const globalSettings = await getEnabledNotificationSettings();
channels = globalSettings
.filter(s => s.eventTypes?.includes(eventType))
.map(s => ({
channel_type: s.type,
channel_name: s.name,
config: s.config
}));
}
if (channels.length === 0) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const channel of channels) {
try {
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Apprise passthrough POST to a self-hosted caronc/apprise-api server.
*
* Users configure all their providers (Signal, Matrix, MQTT, IFTTT, AWS SNS,
* dozens more) in their own Apprise server; Dockhand just forwards each
* notification once. The big win: every provider Apprise upstream supports
* is now reachable from Dockhand without us having to write a sender for it.
*
* Supported formats:
* apprise://host[:port]/key → HTTP, stateful (Apprise stored config key)
* apprises://host[:port]/key → HTTPS variant
* apprise://host[:port]/prefix/key → path-prefixed Apprise behind a reverse proxy
* apprise://host[:port]/key?tag=devops → optional tag filter
*
* Setup docs: https://github.com/caronc/apprise-api
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendApprise(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('apprises');
const raw = appriseUrl.replace(/^apprises?:\/\//, '');
let cleanPath = raw;
let queryParams = new URLSearchParams();
const qIndex = raw.indexOf('?');
if (qIndex !== -1) {
queryParams = new URLSearchParams(raw.substring(qIndex + 1));
cleanPath = raw.substring(0, qIndex);
}
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 2) {
return { success: false, error: 'Invalid Apprise URL. Expected: apprise://host[:port]/key' };
}
const hostPort = parts[0];
// The Apprise key is the last path segment. Anything between host and key
// is a path prefix (some users mount Apprise behind a reverse proxy
// at /apprise/ — we preserve that).
const key = parts[parts.length - 1];
const pathPrefix = parts.slice(1, -1).join('/');
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}${pathPrefix ? '/' + pathPrefix : ''}`;
// Map our payload type to Apprise's NotifyType. 'error' → 'failure' is
// the only rename; everything else lines up.
const apprisesType = payload.type === 'error'
? 'failure'
: payload.type === 'warning'
? 'warning'
: payload.type === 'success'
? 'success'
: 'info';
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const body: Record<string, unknown> = {
title: titleWithEnv,
body: payload.message,
type: apprisesType
};
const tag = queryParams.get('tag');
if (tag) body.tag = tag;
const format = queryParams.get('format');
if (format) body.format = format; // text | markdown | html
try {
const response = await fetch(`${baseUrl}/notify/${encodeURIComponent(key)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
// Apprise-API uses specific status codes:
// 200 → success, 204 → key not configured, 424 → at least one
// downstream provider failed or tag didn't match.
if (response.status === 204) {
return { success: false, error: `Apprise: no configuration found for key "${key}"` };
}
if (response.status === 424) {
const text = await response.text().catch(() => '');
return { success: false, error: `Apprise: at least one downstream provider failed${text ? `${text}` : ''}` };
}
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Apprise error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Apprise connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Bark iOS push via bark-server (https://github.com/Finb/bark-server).
*
* Supported formats:
* bark://device_key → uses official api.day.app over HTTPS
* bark://host/device_key → custom server over HTTP
* bark://host[:port]/k1/k2/... → multi-device batch (Apprise convention)
* barks://host[:port]/... → HTTPS variant
*
* Query params honored (per https://bark.day.app/#/en-us/tutorial):
* ?sound=name, ?level=active|timeSensitive|critical|passive,
* ?group=, ?icon=, ?url=, ?badge=N, ?copy=, ?subtitle=,
* ?volume=, ?ttl=, ?call=1, ?autoCopy=1, ?isArchive=1, ?action=none
*/
import type { NotificationPayload, NotificationResult } from './shared';
export async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('barks');
const path = appriseUrl.replace(/^barks?:\/\//, '');
// Split off query string before slicing the path so '?' in a device key
// (in principle possible, though Bark's keys are 22-char base62) doesn't
// confuse the parser.
let cleanPath = path;
let queryParams = new URLSearchParams();
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
queryParams = new URLSearchParams(path.substring(qIndex + 1));
cleanPath = path.substring(0, qIndex);
}
if (!cleanPath) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
let baseUrl: string;
let deviceKeys: string[];
if (!cleanPath.includes('/')) {
// bark://device_key → official server, HTTPS regardless of bark:// vs barks://
baseUrl = 'https://api.day.app';
deviceKeys = [cleanPath];
} else {
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 2) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
const hostPort = parts[0];
deviceKeys = parts.slice(1);
baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
}
// Map our payload type to Bark's `level`. Query-supplied level wins.
// info → active (banner + sound, doesn't bypass DND)
// warning → timeSensitive (cuts through Focus modes)
// error → critical (cuts through silent mode; user must enable)
const defaultLevel = payload.type === 'error'
? 'critical'
: payload.type === 'warning'
? 'timeSensitive'
: 'active';
const level = queryParams.get('level') || defaultLevel;
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const body: Record<string, unknown> = {
title: titleWithEnv,
body: payload.message,
level
};
// Single-target uses device_key; batch uses device_keys (per Bark API v2).
if (deviceKeys.length === 1) {
body.device_key = deviceKeys[0];
} else {
body.device_keys = deviceKeys;
}
// String passthroughs Bark understands. Unknown params are dropped on the
// server side anyway so no point forwarding them.
const passthroughString = ['sound', 'group', 'icon', 'url', 'copy', 'subtitle', 'category', 'ciphertext', 'isArchive', 'autoCopy', 'call', 'action', 'volume'];
for (const key of passthroughString) {
const v = queryParams.get(key);
if (v !== null && v !== '') body[key] = v;
}
// Numeric passthroughs.
for (const key of ['badge', 'ttl']) {
const v = queryParams.get(key);
if (v !== null && v !== '') {
const n = parseInt(v, 10);
if (!Number.isNaN(n)) body[key] = n;
}
}
try {
const response = await fetch(`${baseUrl}/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
}
// Bark returns HTTP 200 with { code, message, timestamp } — `code !== 200`
// signals a logical failure (e.g. invalid device key) that we'd otherwise
// swallow as a success.
const json: any = await response.json().catch(() => null);
if (json && typeof json.code === 'number' && json.code !== 200) {
return { success: false, error: `Bark error: ${json.message || `code ${json.code}`}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Discord webhook notifications. discord:// or discords://. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
@@ -0,0 +1,30 @@
/** Generic JSON webhook. json:// or jsons:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
environment: payload.environmentName || null,
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Gotify. gotify:// or gotifys:// (HTTPS). */
import { buildGotifyUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = buildGotifyUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
try {
const response = await fetch(parsed.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
message: payload.message,
priority: parsed.priority ?? defaultPriority
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+314
View File
@@ -0,0 +1,314 @@
/**
* Notification router picks the right per-provider sender based on the
* channel type (SMTP / Apprise URL) and (for Apprise URLs) the URL scheme.
*
* Public surface used by API routes and the rest of the app:
* - sendNotification (fan out to every enabled channel)
* - testNotification (one channel, with a fixed test payload)
* - sendEnvironmentNotification (Docker container event matching channels)
* - sendEventNotification (auto-update / git / vuln / system events)
* - NotificationPayload, NotificationResult types
*
* Per-provider implementations live in sibling files (./bark, ./discord, ).
* This file orchestrates only it never knows what's inside a Bark or
* Telegram URL.
*/
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from '../db';
import type { NotificationPayload, NotificationResult } from './shared';
export type { NotificationPayload, NotificationResult } from './shared';
import { sendSmtpNotification } from './smtp';
import { sendDiscord } from './discord';
import { sendSlack } from './slack';
import { sendMattermost } from './mattermost';
import { sendTelegram } from './telegram';
import { sendGotify } from './gotify';
import { sendNtfy } from './ntfy';
import { sendBark } from './bark';
import { sendSignal } from './signal';
import { sendApprise } from './apprise';
import { sendPushover } from './pushover';
import { sendGenericWebhook } from './generic-webhook';
import { sendWorkflows } from './workflows';
// Send to every URL in an Apprise channel. Errors are aggregated so a single
// bad URL doesn't silently mask a healthy one.
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Route a single Apprise URL to the right sender. The switch is the ONLY
// place that needs to grow when a new provider is added.
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Custom schemes like 'tgram://' aren't valid URLs to new URL(),
// so we match the prefix directly.
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'bark':
case 'barks':
return await sendBark(url, payload);
case 'signal':
case 'signals':
return await sendSignal(url, payload);
case 'apprise':
case 'apprises':
return await sendApprise(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
const settings = await getEnabledNotificationSettings();
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success: result.success });
}
return {
success: results.every(r => r.success),
results
};
}
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
type: 'info'
};
if (setting.type === 'smtp') {
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
function mapActionToEventType(action: string): NotificationEventType | null {
const mapping: Record<string, NotificationEventType> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
return mapping[action] || null;
}
// Scanner image patterns to exclude from notifications
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
export async function sendEnvironmentNotification(
environmentId: number,
action: string,
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
image?: string | null
): Promise<{ success: boolean; sent: number }> {
const eventType = mapActionToEventType(action);
if (!eventType) {
return { success: true, sent: 0 };
}
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, sent: 0 };
}
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
if (envNotifications.length === 0) {
return { success: true, sent: 0 };
}
const enrichedPayload: NotificationPayload = {
...payload,
environmentId,
environmentName: env.name
};
// Skip all notifications for scanner containers (Trivy, Grype)
if (isScannerContainer(image)) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const notif of envNotifications) {
try {
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
export async function sendEventNotification(
eventType: NotificationEventType,
payload: NotificationPayload,
environmentId?: number
): Promise<{ success: boolean; sent: number }> {
let enrichedPayload = { ...payload };
if (environmentId) {
const env = await getEnvironment(environmentId);
if (env) {
enrichedPayload.environmentId = environmentId;
enrichedPayload.environmentName = env.name;
}
}
let channels: Array<{
channel_type: 'smtp' | 'apprise';
channel_name: string;
config: SmtpConfig | AppriseConfig;
}> = [];
if (environmentId) {
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
channels = envNotifications
.filter(n => n.channelType && n.channelName)
.map(n => ({
channel_type: n.channelType!,
channel_name: n.channelName!,
config: n.config
}));
} else {
const globalSettings = await getEnabledNotificationSettings();
channels = globalSettings
.filter(s => s.eventTypes?.includes(eventType))
.map(s => ({
channel_type: s.type,
channel_name: s.name,
config: s.config
}));
}
if (channels.length === 0) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const channel of channels) {
try {
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
@@ -0,0 +1,55 @@
/** Mattermost incoming webhook. mmost:// or mmosts:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+88
View File
@@ -0,0 +1,88 @@
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
let queryAuth: string | null = null;
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const params = new URLSearchParams(path.substring(qIndex + 1));
queryAuth = params.get('auth');
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
cleanPath = path.substring(0, qIndex);
}
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
}
} else if (cleanPath.includes('/')) {
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
} else {
url = `https://ntfy.sh/${cleanPath}`;
}
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultTags = payload.type || 'info';
const headers: Record<string, string> = {
'Title': queryTitle || titleWithEnv,
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+36
View File
@@ -0,0 +1,36 @@
/** Pushover. pushover://user_key/api_token. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: apiToken,
user: userKey,
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Shared types + helpers used by every notification provider.
*
* Imported by the router (./index.ts) and by every per-provider file
* (discord.ts, slack.ts, ). Keeps the providers free of cross-imports
* each provider only depends on this module.
*/
export interface NotificationPayload {
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
}
export interface NotificationResult {
success: boolean;
error?: string;
}
/** Drain a response body to release the underlying socket/TLS connection. */
export async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
try { await response.arrayBuffer(); } catch {}
}
}
/** Append `[env name]` to a title when present. Used by every provider. */
export function titleWithEnv(payload: NotificationPayload): string {
return payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Signal via bbernhard/signal-cli-rest-api
* (https://github.com/bbernhard/signal-cli-rest-api).
*
* Supported formats:
* signal://host[:port]/+source/+target1[/+target2/...]
* signals://host[:port]/+source/+target1[/+target2/...] (HTTPS)
*
* `+source` is the sender's registered Signal number (E.164 format). The '+'
* is optional in the URL we re-add it. Recipients can be Signal phone
* numbers (numeric, '+' gets added) or group IDs (signal-cli's "group.<base64>"
* form, passed through untouched).
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendSignal(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('signals');
const raw = appriseUrl.replace(/^signals?:\/\//, '');
// Strip query string so a future `?foo=bar` doesn't end up in the last
// recipient. Currently we don't honor any params, but the parsing should
// be forward-compatible.
const qIndex = raw.indexOf('?');
const cleanPath = qIndex === -1 ? raw : raw.substring(0, qIndex);
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 3) {
return { success: false, error: 'Invalid Signal URL. Expected: signal://host[:port]/+source/+target1[/+target2/...]' };
}
const hostPort = parts[0];
// Phone numbers may or may not start with '+' in the URL — Signal needs
// the '+'. Group IDs (signal-cli's "group.<base64>" form) and other
// non-numeric recipients are passed through untouched.
const normalize = (n: string) => {
if (n.startsWith('+')) return n;
if (/^\d+$/.test(n)) return `+${n}`;
return n;
};
const source = normalize(parts[1]);
const recipients = parts.slice(2).map(normalize);
// signal-cli-rest-api uses 'message' for body and 'number' for sender;
// title is prepended to the body since Signal messages don't have a title field.
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const messageText = `${titleWithEnv}\n\n${payload.message}`;
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
try {
const response = await fetch(`${baseUrl}/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
number: source,
recipients,
message: messageText
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Signal error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Signal connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Slack incoming webhook. slack:// or slacks:// or a raw hooks.slack.com URL. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
} else {
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
url = `https://hooks.slack.com/services/${parts.join('/')}`;
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+48
View File
@@ -0,0 +1,48 @@
/** SMTP email notifications via nodemailer. */
import nodemailer from 'nodemailer';
import type { SmtpConfig } from '../db';
import type { NotificationPayload, NotificationResult } from './shared';
export async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
: '';
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
</div>
`;
await transporter.sendMail({
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
to: config.to_emails.join(', '),
subject: `[Dockhand]${envText} ${payload.title}`,
text: `${payload.title}${envText}\n\n${payload.message}`,
html
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
+43
View File
@@ -0,0 +1,43 @@
/** Telegram bot. tgram://bot_token/chat_id[:topic_id]. */
import { escapeTelegramMarkdown, parseTelegramUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseTelegramUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
}
const { botToken, chatId, topicId } = parsed;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+56
View File
@@ -0,0 +1,56 @@
/** Microsoft Power Automate Workflows (e.g. Microsoft Teams). workflows://. */
import { parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseWorkflowsUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
}
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.2',
body: [
{
type: 'TextBlock',
style: 'heading',
wrap: true,
text: titleWithEnv
},
{
type: 'TextBlock',
style: 'default',
wrap: true,
text: payload.message
}
]
}
}
]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Encode the AuthConfig JSON as base64url **with `=` padding** for the
* Docker X-Registry-Auth header. The Docker daemon decodes the header with
* Go's `base64.URLEncoding.DecodeString`, which is base64url with padding
* unpadded base64url (Node's default 'base64url' Buffer encoding) is
* silently treated as malformed, causing the daemon to fall back to
* anonymous and trip the registry rate limit (#1105).
*
* Reference: moby/api/pkg/authconfig/authconfig.go uses
* `base64.URLEncoding.EncodeToString` / `DecodeString`.
*/
export function encodeRegistryAuth(authConfig: object): string {
const unpadded = Buffer.from(JSON.stringify(authConfig)).toString('base64url');
return unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4);
}
@@ -38,7 +38,7 @@ import {
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner'; import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications'; import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels'; import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
// ============================================================================= // =============================================================================
// TYPES // TYPES
@@ -382,6 +382,18 @@ export async function runContainerUpdate(
return; return;
} }
// Hidden containers are excluded from update polling and auto-updates (#1083)
if (isHiddenByLabel(inspectData.Config?.Labels)) {
log(`Skipping - dockhand.hidden=true label set on container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Skipped by dockhand.hidden=true label' }
});
return;
}
// Skip digest-pinned images - they are explicitly locked to a specific version // Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageNameFromConfig)) { if (isDigestBasedImage(imageNameFromConfig)) {
log(`Skipping ${containerName} - image pinned to specific digest`); log(`Skipping ${containerName} - image pinned to specific digest`);
@@ -31,7 +31,7 @@ import {
import { sendEventNotification } from '../../notifications'; import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner'; import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels'; import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
import { recreateContainer } from './container-update'; import { recreateContainer } from './container-update';
interface UpdateInfo { interface UpdateInfo {
@@ -105,9 +105,12 @@ export async function runEnvUpdateCheckJob(
// Clear pending updates at the start - we'll re-add as we discover updates // Clear pending updates at the start - we'll re-add as we discover updates
await clearPendingContainerUpdates(environmentId); await clearPendingContainerUpdates(environmentId);
// Get all containers in this environment // Get all containers in this environment, excluding ones hidden via
const containers = await listContainers(true, environmentId); // dockhand.hidden=true (consistent with manual check-updates, #1083).
await log(`Found ${containers.length} containers`); const allContainers = await listContainers(true, environmentId);
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
const hiddenCount = allContainers.length - containers.length;
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
const updatesAvailable: UpdateInfo[] = []; const updatesAvailable: UpdateInfo[] = [];
let checkedCount = 0; let checkedCount = 0;
+127 -11
View File
@@ -9,6 +9,15 @@ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSyn
import { join, resolve, dirname, basename } from 'node:path'; import { join, resolve, dirname, basename } from 'node:path';
import { spawn as nodeSpawn } from 'node:child_process'; import { spawn as nodeSpawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process'; import type { ChildProcess } from 'node:child_process';
import {
applyFileDeletions,
hashDirFiles,
skipReasonMessage,
normalizeSkipReason,
type FileToDelete,
type DeletionApplyResult,
type DeletionSkipReason
} from './git-deletions';
import { import {
getEnvironment, getEnvironment,
getSecretEnvVarsAsRecord, getSecretEnvVarsAsRecord,
@@ -61,6 +70,8 @@ export interface StackOperationResult {
error?: string; error?: string;
/** The docker compose command that was executed (for debugging/testing) */ /** The docker compose command that was executed (for debugging/testing) */
command?: string; command?: string;
/** Result of applying git deletion sync (files removed / kept, with reasons) */
deletion?: DeletionApplyResult;
} }
/** /**
@@ -111,6 +122,8 @@ export interface DeployStackOptions {
envPath?: string; // Custom env file path (for adopted/imported stacks) envPath?: string; // Custom env file path (for adopted/imported stacks)
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks
/** Git deletion sync (#966): files confirmed safe to delete from the stack dir */
filesToDelete?: FileToDelete[];
} }
// ============================================================================= // =============================================================================
@@ -830,6 +843,10 @@ interface ComposeCommandOptions {
serviceName?: string; serviceName?: string;
/** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */ /** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */
composeFileName?: string; composeFileName?: string;
/** Git deletion sync (#966): files to delete on the Hawser agent's stack dir */
filesToDelete?: FileToDelete[];
/** On down: ask the Hawser agent to remove the stack directory entirely (#1162, stack deletion only) */
removeFiles?: boolean;
} }
/** /**
@@ -1285,7 +1302,9 @@ async function executeComposeViaHawser(
composeFileName?: string, composeFileName?: string,
build?: boolean, build?: boolean,
noBuildCache?: boolean, noBuildCache?: boolean,
pullPolicy?: string pullPolicy?: string,
filesToDelete?: FileToDelete[],
removeFiles?: boolean
): Promise<StackOperationResult> { ): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`; const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency // Import dockerFetch dynamically to avoid circular dependency
@@ -1362,7 +1381,14 @@ async function executeComposeViaHawser(
noBuildCache: (build && noBuildCache) || false, noBuildCache: (build && noBuildCache) || false,
pullPolicy: pullPolicy || '', pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps) serviceName, // Target specific service only (with --no-deps)
// Git deletion sync (#966): agent re-verifies containment + content
// hash per file before deleting. Old agents ignore this field.
filesToDelete: filesToDelete && filesToDelete.length > 0
? filesToDelete.map(f => ({ path: f.path, sha256: f.hash }))
: undefined,
// Stack deletion (#1162): remove the agent-side stack dir on down
removeFiles: removeFiles || false
}); });
console.log(`${logPrefix} Sending request to Hawser agent...`); console.log(`${logPrefix} Sending request to Hawser agent...`);
@@ -1380,6 +1406,8 @@ async function executeComposeViaHawser(
success: boolean; success: boolean;
output?: string; output?: string;
error?: string; error?: string;
deletedFiles?: string[];
skippedFiles?: { path: string; reason: string }[];
}; };
console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} ----------------------------------------`);
@@ -1393,16 +1421,50 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Error:`, result.error); console.log(`${logPrefix} Error:`, result.error);
} }
// Git deletion sync: interpret the agent's report. An agent that supports
// the feature always returns deletedFiles/skippedFiles (possibly empty
// arrays) when filesToDelete was sent. An old agent ignores the field and
// returns neither — every requested deletion is marked agent-no-support.
// Skips are FINAL (no carry-forward, no retry): the files stay on the
// remote host as unmanaged residue, identical to pre-feature behavior.
let deletion: DeletionApplyResult | undefined;
if (filesToDelete && filesToDelete.length > 0) {
if (result.deletedFiles !== undefined || result.skippedFiles !== undefined) {
deletion = {
deleted: result.deletedFiles ?? [],
skipped: (result.skippedFiles ?? []).map(s => ({
path: s.path,
reason: normalizeSkipReason(s.reason || 'apply-failed')
}))
};
for (const path of deletion.deleted) {
console.log(`${logPrefix} Agent removed "${path}" — deleted from the repository`);
}
for (const skip of deletion.skipped) {
if (skip.reason === 'already-absent') continue;
console.warn(`${logPrefix} Agent kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
} else {
deletion = {
deleted: [],
skipped: filesToDelete.map(f => ({ path: f.path, reason: 'agent-no-support' as DeletionSkipReason }))
};
console.warn(`${logPrefix} ${skipReasonMessage('agent-no-support')} (${filesToDelete.length} file(s) affected)`);
}
}
if (result.success) { if (result.success) {
return { return {
success: true, success: true,
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser` output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`,
deletion
}; };
} else { } else {
return { return {
success: false, success: false,
output: result.output || '', output: result.output || '',
error: result.error || `Compose ${operation} failed` error: result.error || `Compose ${operation} failed`,
deletion
}; };
} }
} catch (err: any) { } catch (err: any) {
@@ -1431,7 +1493,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>, envVars?: Record<string, string>,
secretVars?: Record<string, string> secretVars?: Record<string, string>
): Promise<StackOperationResult> { ): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options; const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options;
// Get environment configuration // Get environment configuration
const env = envId ? await getEnvironment(envId) : null; const env = envId ? await getEnvironment(envId) : null;
@@ -1521,7 +1583,9 @@ async function executeComposeCommand(
composeFileName, composeFileName,
build, build,
noBuildCache, noBuildCache,
pullPolicy pullPolicy,
filesToDelete,
removeFiles
); );
} }
@@ -1560,12 +1624,20 @@ async function executeComposeCommand(
} }
case 'socket': case 'socket':
default: default: {
// Honor the environment's configured socket path. Without this,
// docker compose falls back to /var/run/docker.sock regardless of
// the env's setting — wrong daemon for rootless/multi-socket hosts
// (#1172). Default '/var/run/docker.sock' is left as undefined so
// the CLI's own default applies (preserves existing behavior).
const sock = env.socketPath && env.socketPath !== '/var/run/docker.sock'
? `unix://${env.socketPath}`
: undefined;
return executeLocalCompose( return executeLocalCompose(
operation, operation,
stackName, stackName,
composeContent, composeContent,
undefined, // dockerHost sock,
undefined, // tlsConfig undefined, // tlsConfig
envVars, envVars,
secretVars, secretVars,
@@ -1581,6 +1653,7 @@ async function executeComposeCommand(
noBuildCache, noBuildCache,
pullPolicy pullPolicy
); );
}
} }
} }
@@ -2088,6 +2161,24 @@ export async function removeStack(
if (composeResult.success) { if (composeResult.success) {
const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId); const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
// Stack removal cleanup (#1162): the agent deletes ONLY what Dockhand
// explicitly lists. The list is the local staging dir contents — exactly
// the files Dockhand ever wrote for this stack (compose, .env,
// .env.dockhand, git files), never user volume data (that exists only on
// the agent host). Each entry is hash-verified agent-side; the agent's
// stack dir is removed only if nothing else remains in it.
// Only built for Dockhand-managed staging dirs (inside DATA_DIR/stacks).
let removalFiles: FileToDelete[] | undefined;
if (composeResult.stackDir) {
const resolvedStaging = resolve(composeResult.stackDir);
if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) {
removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map(
([path, hash]) => ({ path, hash })
);
}
}
const downResult = await executeComposeCommand( const downResult = await executeComposeCommand(
'down', 'down',
{ {
@@ -2096,7 +2187,10 @@ export async function removeStack(
removeVolumes, removeVolumes,
workingDir: composeResult.stackDir, workingDir: composeResult.stackDir,
composePath: composeResult.composePath ?? undefined, composePath: composeResult.composePath ?? undefined,
envPath: composeResult.envPath ?? undefined envPath: composeResult.envPath ?? undefined,
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
removeFiles: true,
filesToDelete: removalFiles
}, },
composeResult.content!, composeResult.content!,
envVars, envVars,
@@ -2267,7 +2361,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments. * Uses stack locking to prevent concurrent deployments.
*/ */
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> { export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options; const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete } = options;
const logPrefix = `[Stack:${name}]`; const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`); console.log(`${logPrefix} ========================================`);
@@ -2299,6 +2393,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
let actualComposePath: string | undefined; let actualComposePath: string | undefined;
let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks) let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks)
let stackFiles: Record<string, string> | undefined; let stackFiles: Record<string, string> | undefined;
let localDeletionResult: DeletionApplyResult | undefined;
if (composePath) { if (composePath) {
// Adopted/imported stack: use the original compose file location // Adopted/imported stack: use the original compose file location
@@ -2351,6 +2446,21 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git') filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
}); });
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`); console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
// Git deletion sync (#966): remove files that were deleted from the
// repository. The list is manifest entries absent from the new clone;
// the applier re-verifies containment + content hash per file, so
// volume data and locally modified files are never touched.
if (filesToDelete && filesToDelete.length > 0) {
localDeletionResult = applyFileDeletions(workingDir, filesToDelete);
for (const path of localDeletionResult.deleted) {
console.log(`${logPrefix} Removed "${path}" — deleted from the repository`);
}
for (const skip of localDeletionResult.skipped) {
if (skip.reason === 'already-absent') continue;
console.warn(`${logPrefix} Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
}
} else { } else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks) // Internal stack: check if a custom path exists in DB (adopted/imported stacks)
const source = await getStackSource(name, envId); const source = await getStackSource(name, envId);
@@ -2422,7 +2532,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
envPath: actualEnvPath, envPath: actualEnvPath,
useOverrideFile: isGitStack, useOverrideFile: isGitStack,
// Pass compose filename for Hawser (extracted from path or provided explicitly) // Pass compose filename for Hawser (extracted from path or provided explicitly)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined) composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
filesToDelete
}, },
compose, compose,
isGitStack ? dbNonSecretVars : undefined, isGitStack ? dbNonSecretVars : undefined,
@@ -2438,6 +2549,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
if (result.error) { if (result.error) {
console.log(`${logPrefix} Error:`, result.error); console.log(`${logPrefix} Error:`, result.error);
} }
// Deletion result: the remote (Hawser) result is authoritative when present;
// for local deployments the local applier's result is the truth.
if (!result.deletion && localDeletionResult) {
result.deletion = localDeletionResult;
}
return result; return result;
}); });
} }
+35
View File
@@ -98,6 +98,35 @@ const envNames: Map<number, string> = new Map();
// Track which envIds are currently configured in Go // Track which envIds are currently configured in Go
const configuredEnvs: Set<number> = new Set(); const configuredEnvs: Set<number> = new Set();
// Health status transition tracking: only store DB events when status changes
// Key: `${envId}-${containerId}` → last known sub-status (e.g. "healthy", "unhealthy")
const lastHealthStatus: Map<string, string> = new Map();
/**
* Check if a health_status event represents a transition (and should be stored in DB).
* Non-health events always return true. Repeated identical health statuses return false.
* Also clears tracking on container destroy/die events.
*/
export function isHealthTransition(envId: number, containerId: string, action: string): boolean {
// Clear tracking when container is removed
if (action === 'destroy' || action === 'die') {
lastHealthStatus.delete(`${envId}-${containerId}`);
return true;
}
if (!action.startsWith('health_status')) return true;
// Extract sub-status: "health_status: healthy" → "healthy"
const subStatus = action.includes(':') ? action.split(':').pop()!.trim() : action;
const key = `${envId}-${containerId}`;
const previous = lastHealthStatus.get(key);
if (previous === subStatus) return false; // Same status, skip DB write
lastHealthStatus.set(key, subStatus);
return true;
}
// Dedup cleanup interval // Dedup cleanup interval
let dedupCleanupInterval: ReturnType<typeof setInterval> | null = null; let dedupCleanupInterval: ReturnType<typeof setInterval> | null = null;
@@ -265,6 +294,12 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString(); const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
// Skip redundant health_status events (only store transitions: healthy↔unhealthy)
if (!isHealthTransition(msg.envId, containerId, action)) {
rssAfterOp('events', before);
return;
}
// Sub-category: DB insert // Sub-category: DB insert
const dbBefore = rssBeforeOp(); const dbBefore = rssBeforeOp();
try { try {
+12 -2
View File
@@ -20,6 +20,7 @@ export interface ThemePreferences {
gridFontSize: FontSize; gridFontSize: FontSize;
terminalFont: string; terminalFont: string;
editorFont: string; editorFont: string;
animateIcons: boolean;
} }
const STORAGE_KEY = 'dockhand-theme'; const STORAGE_KEY = 'dockhand-theme';
@@ -31,7 +32,8 @@ const defaultPrefs: ThemePreferences = {
fontSize: 'normal', fontSize: 'normal',
gridFontSize: 'normal', gridFontSize: 'normal',
terminalFont: 'system-mono', terminalFont: 'system-mono',
editorFont: 'system-mono' editorFont: 'system-mono',
animateIcons: true
}; };
// Font size scale mapping // Font size scale mapping
@@ -100,7 +102,12 @@ function createThemeStore() {
fontSize: data.fontSize || data.font_size || 'normal', fontSize: data.fontSize || data.font_size || 'normal',
gridFontSize: data.gridFontSize || data.grid_font_size || 'normal', gridFontSize: data.gridFontSize || data.grid_font_size || 'normal',
terminalFont: data.terminalFont || data.terminal_font || 'system-mono', terminalFont: data.terminalFont || data.terminal_font || 'system-mono',
editorFont: data.editorFont || data.editor_font || 'system-mono' editorFont: data.editorFont || data.editor_font || 'system-mono',
// Default ON (#1169)
animateIcons:
data.animateIcons === undefined && data.animate_icons === undefined
? true
: !!(data.animateIcons ?? data.animate_icons)
}; };
set(prefs); set(prefs);
saveToStorage(prefs); saveToStorage(prefs);
@@ -198,6 +205,9 @@ export function applyTheme(prefs: ThemePreferences) {
// Apply editor font // Apply editor font
applyEditorFont(prefs.editorFont); applyEditorFont(prefs.editorFont);
// Apply icon animation toggle (#1169) — single class on <html> drives a CSS rule in app.css
document.documentElement.classList.toggle('no-icon-animation', !prefs.animateIcons);
} }
// Apply font to document // Apply font to document
+4
View File
@@ -65,6 +65,10 @@ export interface VolumeInfo {
createdAt?: string; createdAt?: string;
created: string; // Alias for createdAt, populated by API created: string; // Alias for createdAt, populated by API
usedBy?: VolumeUsage[]; // Containers using this volume usedBy?: VolumeUsage[]; // Containers using this volume
// driver_opts from the underlying volume — present for non-trivially
// configured volumes (NFS, CIFS, BTRFS subvolumes, etc.). The 'type'
// key here is what the volumes list surfaces as the Type column.
options?: Record<string, string>;
} }
export interface NetworkInfo { export interface NetworkInfo {
+45
View File
@@ -0,0 +1,45 @@
export type ChangelogToken =
| { kind: 'text'; value: string }
| { kind: 'issue'; num: number }
| { kind: 'pr'; num: number }
| { kind: 'user'; name: string };
const PATTERN = /PR#(\d+)|#(\d+)|@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}))/g;
export function parseChangelogTokens(text: string): ChangelogToken[] {
const tokens: ChangelogToken[] = [];
let lastIndex = 0;
for (const match of text.matchAll(PATTERN)) {
const start = match.index ?? 0;
if (start > lastIndex) {
tokens.push({ kind: 'text', value: text.slice(lastIndex, start) });
}
if (match[1]) {
tokens.push({ kind: 'pr', num: Number(match[1]) });
} else if (match[2]) {
tokens.push({ kind: 'issue', num: Number(match[2]) });
} else if (match[3]) {
tokens.push({ kind: 'user', name: match[3] });
}
lastIndex = start + match[0].length;
}
if (lastIndex < text.length) {
tokens.push({ kind: 'text', value: text.slice(lastIndex) });
}
return tokens;
}
export const GITHUB_REPO = 'Finsys/dockhand';
export function tokenHref(token: ChangelogToken): string | null {
switch (token.kind) {
case 'issue':
return `https://github.com/${GITHUB_REPO}/issues/${token.num}`;
case 'pr':
return `https://github.com/${GITHUB_REPO}/pull/${token.num}`;
case 'user':
return `https://github.com/${token.name}`;
default:
return null;
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Container resource-string parsers, shared between the create modal, the
* edit modal, and the in-place update Apply buttons in ContainerSettingsTab.
*
* Two of these previously lived in CreateContainerModal AND EditContainerModal
* as identical copies moved here to prevent the drift that started when one
* gained `tb` support and the other didn't.
*/
/**
* Parse a memory string like "512m", "1g", "2.5gb" into bytes.
* Bare numbers are treated as bytes. Returns undefined for empty/garbage input.
*
* Units: k/kb, m/mb, g/gb, t/tb base 1024 (binary), matching Docker's CLI.
*/
export function parseMemory(value: string | number | null | undefined): number | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === 'number') return value > 0 ? Math.floor(value) : undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const match = trimmed.toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?b?)?$/);
if (!match) return undefined;
const num = parseFloat(match[1]);
const unit = match[2] || '';
switch (unit) {
case 'k': case 'kb': return Math.floor(num * 1024);
case 'm': case 'mb': return Math.floor(num * 1024 * 1024);
case 'g': case 'gb': return Math.floor(num * 1024 * 1024 * 1024);
case 't': case 'tb': return Math.floor(num * 1024 * 1024 * 1024 * 1024);
default: return Math.floor(num);
}
}
/**
* Parse a CPU-limit string like "0.5", "1.5", "2" into NanoCpus (1e9 = 1 CPU).
* Returns undefined for empty/garbage input.
*/
export function parseNanoCpus(value: string | number | null | undefined): number | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === 'number') return value > 0 ? Math.floor(value) : undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const num = parseFloat(trimmed);
if (isNaN(num) || num <= 0) return undefined;
return Math.floor(num * 1e9);
}
/** Parse a bare positive integer string. Returns undefined for empty/garbage. */
export function parsePositiveInt(value: string | number | null | undefined): number | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === 'number') return value > 0 ? Math.floor(value) : undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const num = parseInt(trimmed, 10);
if (isNaN(num) || num <= 0) return undefined;
return num;
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Environment-name validation (#1179).
*
* Environment names end up in filesystem paths (e.g. $DATA_DIR/stacks/<name>/...
* in stacks.ts:344) and occasionally in shell-formatted strings, so the name
* has to be quote-safe and glob-free.
*
* The rule is permissive on purpose strict alphanumeric would orphan
* existing real-world env names like "rambo (ARM)" or "docker-websites".
* Anything currently in the wild passes; only NEW characters that actively
* cause breakage are rejected.
*/
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @
// First char: any allowed except space (no leading whitespace).
// Last char: any allowed except space and dot (no trailing whitespace,
// no trailing dot to avoid Windows-style "hidden" trailing-dot issues).
// Length: 1..64.
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-() +@](?:[A-Za-z0-9_.\-() +@]{0,62}[A-Za-z0-9_\-)+@])?$/;
export const ENV_NAME_MAX_LENGTH = 64;
export interface ValidationResult {
ok: boolean;
reason?: string;
}
/**
* Validate an environment name. Returns a structured result so callers can
* decide how to surface the error (server: 400 body, client: inline message).
*/
export function validateEnvName(name: unknown): ValidationResult {
if (typeof name !== 'string') return { ok: false, reason: 'Name is required' };
if (name.length === 0) return { ok: false, reason: 'Name is required' };
if (name.length > ENV_NAME_MAX_LENGTH) {
return { ok: false, reason: `Name must be ${ENV_NAME_MAX_LENGTH} characters or fewer` };
}
if (!ENV_NAME_RE.test(name)) {
return {
ok: false,
reason:
'Name may contain letters, digits, spaces, and any of - _ . ( ) + @ (no leading/trailing whitespace, no trailing dot, no slashes, no wildcards)'
};
}
return { ok: true };
}
/** Convenience boolean variant. */
export function isValidEnvName(name: unknown): boolean {
return validateEnvName(name).ok;
}
@@ -0,0 +1,80 @@
/**
* POST /api/containers/[id]/update-runtime
*
* In-place update of a running container's restart policy, CPU/memory limits,
* blkio weights, and pids limit the only properties Docker can change
* without recreating the container. The body must contain ONLY fields from
* IN_PLACE_UPDATE_FIELDS (see docker.ts); any unknown fields are silently
* dropped so a confused or malicious caller can't sneak a recreate-only
* field (image, env, ports, etc.) through this path.
*
* Returns Docker's response typically `{ Warnings: string[] | null }`.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { inspectContainer, updateContainerRuntime, IN_PLACE_UPDATE_FIELDS, type InPlaceUpdateField } from '$lib/server/docker';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Same permission as the recreate-style update — anyone who could edit
// the container the slow way should be able to edit it the fast way.
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
// Sanity-check: at least one allowed field must be present. Empty/all-unknown
// payloads get a clear error rather than silently calling Docker with {}.
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
const filtered: Partial<Record<InPlaceUpdateField, unknown>> = {};
for (const [k, v] of Object.entries(body)) {
if (allowed.has(k) && v !== undefined) filtered[k as InPlaceUpdateField] = v;
}
if (Object.keys(filtered).length === 0) {
return json({
error: 'No supported fields provided',
supportedFields: Array.from(allowed)
}, { status: 400 });
}
try {
const result = await updateContainerRuntime(params.id, filtered, envIdNum);
// Audit log — include which fields were touched, not their values
// (CPU/memory numbers are non-sensitive, but the audit row stays
// cleaner if we just record the keys).
let containerName = params.id;
try {
const inspect = await inspectContainer(params.id, envIdNum);
containerName = inspect.Name?.replace(/^\//, '') || params.id;
} catch { /* fallback to id */ }
await auditContainer(event, 'update', params.id, containerName, envIdNum, {
inPlace: true,
fields: Object.keys(filtered),
warnings: result.Warnings ?? []
});
return json({ success: true, warnings: result.Warnings ?? [] });
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: 'Container not found' }, { status: 404 });
}
return json({ error: error?.message || 'Update failed' }, { status: 500 });
}
};
@@ -4,7 +4,7 @@ import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker'; import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db'; import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils'; import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels'; import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
import { createJobResponse } from '$lib/server/sse'; import { createJobResponse } from '$lib/server/sse';
export interface UpdateCheckResult { export interface UpdateCheckResult {
@@ -42,7 +42,9 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
} }
const allContainers = await listContainers(true, envIdNum); const allContainers = await listContainers(true, envIdNum);
const containers = allContainers; // Containers labeled dockhand.hidden=true are excluded from update checks —
// they're invisible to the user, so we won't alert on updates for them either (#1083).
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
send('progress', { checked: 0, total: containers.length }); send('progress', { checked: 0, total: containers.length });
@@ -65,8 +67,19 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}; };
} }
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels); const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels);
if (updateDisabled) {
return {
containerId: container.id,
containerName: container.name,
imageName,
hasUpdate: false,
systemContainer: isSystemContainer(imageName) || null,
updateDisabled: true
};
}
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
return { return {
containerId: container.id, containerId: container.id,
+4 -2
View File
@@ -7,6 +7,7 @@ import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem'; import { cleanPem } from '$lib/utils/pem';
import { validateEnvName } from '$lib/utils/env-name';
export const GET: RequestHandler = async ({ cookies }) => { export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
@@ -71,8 +72,9 @@ export const POST: RequestHandler = async (event) => {
try { try {
const data = await request.json(); const data = await request.json();
if (!data.name) { const nameCheck = validateEnvName(data.name);
return json({ error: 'Name is required' }, { status: 400 }); if (!nameCheck.ok) {
return json({ error: nameCheck.reason }, { status: 400 });
} }
// Check if environment with this name already exists // Check if environment with this name already exists
+61 -1
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { join } from 'path'; import { join } from 'path';
import { existsSync, rmSync } from 'fs'; import { existsSync, rmSync, renameSync } from 'fs';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db'; import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker'; import { clearDockerClientCache } from '$lib/server/docker';
@@ -11,6 +11,7 @@ import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem'; import { cleanPem } from '$lib/utils/pem';
import { validateEnvName } from '$lib/utils/env-name';
import { unregisterSchedule } from '$lib/server/scheduler'; import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser'; import { closeEdgeConnection } from '$lib/server/hawser';
import { computeAuditDiff } from '$lib/utils/diff'; import { computeAuditDiff } from '$lib/utils/diff';
@@ -64,6 +65,65 @@ export const PUT: RequestHandler = async (event) => {
const data = await request.json(); const data = await request.json();
// #1179: validate name if it's being changed. Existing invalid names are
// not auto-corrected — only writes go through this check.
const isRename = data.name !== undefined && data.name !== oldEnv.name;
if (isRename) {
const nameCheck = validateEnvName(data.name);
if (!nameCheck.ok) {
return json({ error: nameCheck.reason }, { status: 400 });
}
}
// Rename on-disk directories BEFORE the DB write. If the fs rename
// fails (cross-mount EXDEV, perm error, target exists), we surface a
// 409 and leave the DB untouched — better than the previous behavior
// of silently orphaning stacks under the old name.
//
// Applies to ALL connection types. For socket/direct envs the staging
// dir IS the deployed dir, so containers need a redeploy after rename
// (see client warning). For Hawser envs the agent owns the deployed
// dir on the remote host and isn't affected, but Dockhand still keeps
// a local staging copy under stacks/<envName>/<stackName>/ (for the
// in-app editor) and ALL git stacks clone to git-repos/<envName>/
// regardless of where they ultimately deploy — so the rename matters
// locally for every env type.
if (isRename) {
const stacksDir = getStacksDir();
const gitReposDir = getGitReposDir();
const oldStacks = join(stacksDir, oldEnv.name);
const newStacks = join(stacksDir, data.name);
const oldRepos = join(gitReposDir, oldEnv.name);
const newRepos = join(gitReposDir, data.name);
// Refuse to overwrite a target dir that already holds someone
// else's data.
if (existsSync(oldStacks) && existsSync(newStacks)) {
return json({
error: `Cannot rename: ${newStacks} already exists. Pick a different name or move that directory out of the way.`
}, { status: 409 });
}
if (existsSync(oldRepos) && existsSync(newRepos)) {
return json({
error: `Cannot rename: ${newRepos} already exists. Pick a different name or move that directory out of the way.`
}, { status: 409 });
}
try {
if (existsSync(oldStacks)) renameSync(oldStacks, newStacks);
if (existsSync(oldRepos)) renameSync(oldRepos, newRepos);
} catch (err: any) {
// Best-effort rollback if the second rename failed after the first
// succeeded. Avoids leaving the filesystem in a split state.
try { if (existsSync(newStacks) && !existsSync(oldStacks)) renameSync(newStacks, oldStacks); } catch {}
try { if (existsSync(newRepos) && !existsSync(oldRepos)) renameSync(newRepos, oldRepos); } catch {}
const code = err?.code === 'EXDEV'
? 'EXDEV: stacks dir is on a different filesystem from the rename target. Move it back to the same filesystem to rename this environment.'
: (err?.message || 'Rename failed');
return json({ error: code }, { status: 409 });
}
}
// Clear cached Docker client before updating // Clear cached Docker client before updating
clearDockerClientCache(id); clearDockerClientCache(id);
+16 -3
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { inspectImage, tagImage, pushImage, parseRegistryUrl } from '$lib/server/docker'; import { inspectImage, tagImage, pushImage, parseRegistryUrl } from '$lib/server/docker';
import { encodeRegistryAuth } from '$lib/server/registry-auth';
import { getRegistry, getEnvironment } from '$lib/server/db'; import { getRegistry, getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit'; import { auditImage } from '$lib/server/audit';
@@ -110,8 +111,9 @@ export const POST: RequestHandler = async (event) => {
const authServerAddress = isDockerHub ? 'https://index.docker.io/v1/' : registryHost; const authServerAddress = isDockerHub ? 'https://index.docker.io/v1/' : registryHost;
const authConfig = registry.username && registry.password const authConfig = registry.username && registry.password
? { ? {
username: registry.username, // Trim to neutralize stray whitespace in legacy stored credentials (#1105)
password: registry.password, username: registry.username.trim(),
password: registry.password.trim(),
serveraddress: authServerAddress serveraddress: authServerAddress
} }
: { : {
@@ -148,7 +150,18 @@ export const POST: RequestHandler = async (event) => {
return; return;
} }
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64'); const authHeader = encodeRegistryAuth(authConfig);
{
const u = (authConfig as { username?: string }).username ?? '';
const p = (authConfig as { password?: string }).password ?? '';
const userLast = u.length ? u.charCodeAt(u.length - 1).toString(16) : 'na';
const pwLast = p.length ? p.charCodeAt(p.length - 1).toString(16) : 'na';
console.log(
`[Push/Edge] auth: registry=${registryHost} user(len=${u.length},last=0x${userLast}) ` +
`pw(len=${p.length},last=0x${pwLast}) serveraddress=${authConfig.serveraddress} ` +
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
);
}
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sendEdgeStreamRequest( sendEdgeStreamRequest(
+1 -1
View File
@@ -63,7 +63,7 @@ export const POST: RequestHandler = async (event) => {
} else if (type === 'apprise') { } else if (type === 'apprise') {
const appriseConfig = config as AppriseConfig; const appriseConfig = config as AppriseConfig;
if (!appriseConfig.urls?.length) { if (!appriseConfig.urls?.length) {
return json({ error: 'Apprise config requires at least one URL' }, { status: 400 }); return json({ error: 'Webhook config requires at least one URL' }, { status: 400 });
} }
} }
+1 -1
View File
@@ -82,7 +82,7 @@ export const PUT: RequestHandler = async (event) => {
} else if (existing.type === 'apprise') { } else if (existing.type === 'apprise') {
const appriseConfig = config as AppriseConfig; const appriseConfig = config as AppriseConfig;
if (!appriseConfig.urls?.length) { if (!appriseConfig.urls?.length) {
return json({ error: 'Apprise config requires at least one URL' }, { status: 400 }); return json({ error: 'Webhook config requires at least one URL' }, { status: 400 });
} }
} }
} }
+1 -1
View File
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (data.type === 'apprise') { if (data.type === 'apprise') {
const config = data.config; const config = data.config;
if (!config.urls?.length) { if (!config.urls?.length) {
return json({ error: 'At least one Apprise URL is required' }, { status: 400 }); return json({ error: 'At least one webhook URL is required' }, { status: 400 });
} }
} }
@@ -45,7 +45,7 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
const validTerminalFontIds = monospaceFonts.map(f => f.id); const validTerminalFontIds = monospaceFonts.map(f => f.id);
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge']; const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string } = {}; const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {};
if (data.lightTheme !== undefined) { if (data.lightTheme !== undefined) {
if (!validLightThemeIds.includes(data.lightTheme)) { if (!validLightThemeIds.includes(data.lightTheme)) {
@@ -96,6 +96,13 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
updates.editorFont = data.editorFont; updates.editorFont = data.editorFont;
} }
if (data.animateIcons !== undefined) {
if (typeof data.animateIcons !== 'boolean') {
return json({ error: 'Invalid animateIcons' }, { status: 400 });
}
updates.animateIcons = data.animateIcons;
}
await setUserThemePreferences(currentUser.id, updates); await setUserThemePreferences(currentUser.id, updates);
// Return updated preferences // Return updated preferences
+13 -2
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db'; import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db';
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { auditRegistry } from '$lib/server/audit'; import { auditRegistry } from '$lib/server/audit';
import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker';
export const GET: RequestHandler = async ({ cookies }) => { export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
@@ -38,11 +39,21 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Name and URL are required' }, { status: 400 }); return json({ error: 'Name and URL are required' }, { status: 400 });
} }
// Trim username/password to prevent stray whitespace from copy-paste corrupting
// the X-Registry-Auth / Authorization headers (#1105).
const trimmedUsername = typeof data.username === 'string' ? data.username.trim() : undefined;
const trimmedPassword = typeof data.password === 'string' ? data.password.trim() : undefined;
// Diagnostic logging (#1105) — never logs the plaintext credential
const { host: normalizedHost } = parseRegistryUrl(data.url);
const hubTag = DOCKER_HUB_HOSTS.has(normalizedHost) ? ' (docker-hub)' : '';
console.log(`[Registry] create: url=${data.url} normalized=${normalizedHost}${hubTag} user(len=${trimmedUsername?.length ?? 0}) pw(len=${trimmedPassword?.length ?? 0})`);
const registry = await createRegistry({ const registry = await createRegistry({
name: data.name, name: data.name,
url: data.url, url: data.url,
username: data.username || undefined, username: trimmedUsername || undefined,
password: data.password || undefined, password: trimmedPassword || undefined,
isDefault: data.isDefault || false isDefault: data.isDefault || false
}); });
+14 -2
View File
@@ -4,6 +4,7 @@ import { getRegistry, updateRegistry, deleteRegistry, setDefaultRegistry } from
import { authorize } from '$lib/server/authorize'; import { authorize } from '$lib/server/authorize';
import { auditRegistry } from '$lib/server/audit'; import { auditRegistry } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff'; import { computeAuditDiff } from '$lib/utils/diff';
import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker';
export const GET: RequestHandler = async ({ params, cookies }) => { export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies); const auth = await authorize(cookies);
@@ -51,11 +52,22 @@ export const PUT: RequestHandler = async (event) => {
} }
const data = await request.json(); const data = await request.json();
// Trim username/password to prevent stray whitespace from copy-paste corrupting
// the X-Registry-Auth / Authorization headers (#1105).
const trimmedUsername = typeof data.username === 'string' ? data.username.trim() : data.username;
const trimmedPassword = typeof data.password === 'string' ? data.password.trim() : data.password;
// Diagnostic logging (#1105) — never logs the plaintext credential
const userLen = typeof trimmedUsername === 'string' ? trimmedUsername.length : 0;
const pwLen = typeof trimmedPassword === 'string' ? trimmedPassword.length : 0;
const { host: normalizedHost } = parseRegistryUrl(data.url);
const hubTag = DOCKER_HUB_HOSTS.has(normalizedHost) ? ' (docker-hub)' : '';
console.log(`[Registry] update id=${id}: url=${data.url} normalized=${normalizedHost}${hubTag} user(len=${userLen}) pw(len=${pwLen})`);
const registry = await updateRegistry(id, { const registry = await updateRegistry(id, {
name: data.name, name: data.name,
url: data.url, url: data.url,
username: data.username, username: trimmedUsername,
password: data.password, password: trimmedPassword,
isDefault: data.isDefault isDefault: data.isDefault
}); });
+130
View File
@@ -0,0 +1,130 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { parseRegistryUrl, getRegistryAuthHeader, DOCKER_HUB_HOSTS } from '$lib/server/docker';
import { getRegistry } from '$lib/server/db';
/**
* Test registry connectivity and credentials.
*
* Accepts either inline credentials (from the modal form) or a registry ID
* (to test an already-saved registry using stored credentials).
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('registries', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const data = await request.json();
let url: string;
let username: string | undefined;
let password: string | undefined;
if (data.registryId) {
// Test a saved registry — fetch credentials from DB
const reg = await getRegistry(data.registryId);
if (!reg) return json({ error: 'Registry not found' }, { status: 404 });
url = reg.url;
username = reg.username || undefined;
password = reg.password || undefined;
} else {
url = data.url;
username = data.username?.trim() || undefined;
password = data.password?.trim() || undefined;
}
if (!url) {
return json({ error: 'URL is required' }, { status: 400 });
}
const parsed = parseRegistryUrl(url);
const apiBase = `${parsed.protocol}://${parsed.host}`;
try {
// Step 1: connectivity — can we reach /v2/ at all?
const pingResp = await fetch(`${apiBase}/v2/`, {
method: 'GET',
headers: { 'User-Agent': 'Dockhand/1.0' },
signal: AbortSignal.timeout(10_000)
});
const hasAuth = pingResp.status === 401;
const isOpen = pingResp.ok;
if (!isOpen && !hasAuth) {
return json({
success: false,
connectivity: false,
message: `Registry returned HTTP ${pingResp.status}`
});
}
// Step 2: if credentials provided, test authentication.
// Empty scope — we only care whether the registry accepts the login.
// Asking for a privileged scope like registry:catalog:* causes Docker
// Hub to reject the request with HTTP 400 for non-admin users even
// when their credentials are valid.
if (username && password) {
const authHeader = await getRegistryAuthHeader(url, '', { username, password });
if (!authHeader) {
return json({
success: false,
connectivity: true,
authenticated: false,
message: 'Authentication failed — check username and password'
});
}
// Verify the token works
const authResp = await fetch(`${apiBase}/v2/`, {
method: 'GET',
headers: {
'User-Agent': 'Dockhand/1.0',
'Authorization': authHeader
},
signal: AbortSignal.timeout(10_000)
});
if (!authResp.ok) {
return json({
success: false,
connectivity: true,
authenticated: false,
message: `Authentication token rejected (HTTP ${authResp.status})`
});
}
const isHub = DOCKER_HUB_HOSTS.has(parsed.host);
return json({
success: true,
connectivity: true,
authenticated: true,
message: isHub
? `Connected to Docker Hub as ${username}`
: `Connected and authenticated as ${username}`
});
}
// No credentials — just report connectivity
const isHub = DOCKER_HUB_HOSTS.has(parsed.host);
return json({
success: true,
connectivity: true,
authenticated: null,
message: isHub
? 'Docker Hub is reachable (no credentials to test)'
: isOpen
? 'Registry is reachable (no auth required)'
: 'Registry is reachable (requires authentication)'
});
} catch (e: any) {
const msg = e?.cause?.code || e?.message || String(e);
return json({
success: false,
connectivity: false,
message: `Connection failed: ${msg}`
});
}
};
+19 -7
View File
@@ -90,6 +90,8 @@ export interface GeneralSettings {
defaultComposeTemplate: string; defaultComposeTemplate: string;
// Label filter mode // Label filter mode
labelFilterMode: 'any' | 'all'; labelFilterMode: 'any' | 'all';
// Whether spinning icons (animate-spin etc.) are animated (#1169)
animateIcons: boolean;
} }
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = { const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
@@ -122,6 +124,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
defaultGrypeImage: DEFAULT_GRYPE_IMAGE, defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE, defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
labelFilterMode: 'any' as const, labelFilterMode: 'any' as const,
animateIcons: true,
defaultComposeTemplate: `version: "3.8" defaultComposeTemplate: `version: "3.8"
services: services:
@@ -199,7 +202,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultGrypeImage, defaultGrypeImage,
defaultTrivyImage, defaultTrivyImage,
defaultComposeTemplate, defaultComposeTemplate,
labelFilterMode labelFilterMode,
animateIcons
] = await Promise.all([ ] = await Promise.all([
getSetting('confirm_destructive'), getSetting('confirm_destructive'),
getSetting('show_stopped_containers'), getSetting('show_stopped_containers'),
@@ -238,7 +242,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('default_grype_image'), getSetting('default_grype_image'),
getSetting('default_trivy_image'), getSetting('default_trivy_image'),
getSetting('default_compose_template'), getSetting('default_compose_template'),
getSetting('label_filter_mode') getSetting('label_filter_mode'),
getSetting('animate_icons')
]); ]);
const settings: GeneralSettings = { const settings: GeneralSettings = {
@@ -281,7 +286,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE, defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE, defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate, defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
}; };
return json(settings); return json(settings);
@@ -299,7 +305,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try { try {
const body = await request.json(); const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body; const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, animateIcons } = body;
if (confirmDestructive !== undefined) { if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive); await setSetting('confirm_destructive', confirmDestructive);
@@ -439,6 +445,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) { if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
await setSetting('label_filter_mode', labelFilterMode); await setSetting('label_filter_mode', labelFilterMode);
} }
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
await setSetting('animate_icons', animateIcons);
}
// Fetch all settings in parallel for the response // Fetch all settings in parallel for the response
const [ const [
@@ -479,7 +488,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultGrypeImageVal, defaultGrypeImageVal,
defaultTrivyImageVal, defaultTrivyImageVal,
defaultComposeTemplateVal, defaultComposeTemplateVal,
labelFilterModeVal labelFilterModeVal,
animateIconsVal
] = await Promise.all([ ] = await Promise.all([
getSetting('confirm_destructive'), getSetting('confirm_destructive'),
getSetting('show_stopped_containers'), getSetting('show_stopped_containers'),
@@ -518,7 +528,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('default_grype_image'), getSetting('default_grype_image'),
getSetting('default_trivy_image'), getSetting('default_trivy_image'),
getSetting('default_compose_template'), getSetting('default_compose_template'),
getSetting('label_filter_mode') getSetting('label_filter_mode'),
getSetting('animate_icons')
]); ]);
const settings: GeneralSettings = { const settings: GeneralSettings = {
@@ -561,7 +572,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE, defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE, defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate, defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
}; };
return json(settings); return json(settings);
+8 -4
View File
@@ -13,7 +13,8 @@ const DEFAULT_THEME_SETTINGS = {
fontSize: 'normal', fontSize: 'normal',
gridFontSize: 'normal', gridFontSize: 'normal',
terminalFont: 'system-mono', terminalFont: 'system-mono',
editorFont: 'system-mono' editorFont: 'system-mono',
animateIcons: true
}; };
export const GET: RequestHandler = async () => { export const GET: RequestHandler = async () => {
@@ -25,7 +26,8 @@ export const GET: RequestHandler = async () => {
fontSize, fontSize,
gridFontSize, gridFontSize,
terminalFont, terminalFont,
editorFont editorFont,
animateIcons
] = await Promise.all([ ] = await Promise.all([
getSetting('theme_light'), getSetting('theme_light'),
getSetting('theme_dark'), getSetting('theme_dark'),
@@ -33,7 +35,8 @@ export const GET: RequestHandler = async () => {
getSetting('theme_font_size'), getSetting('theme_font_size'),
getSetting('theme_grid_font_size'), getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'), getSetting('theme_terminal_font'),
getSetting('theme_editor_font') getSetting('theme_editor_font'),
getSetting('animate_icons')
]); ]);
return json({ return json({
@@ -43,7 +46,8 @@ export const GET: RequestHandler = async () => {
fontSize: fontSize ?? DEFAULT_THEME_SETTINGS.fontSize, fontSize: fontSize ?? DEFAULT_THEME_SETTINGS.fontSize,
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize, gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont, terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont,
animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons
}); });
} catch (error) { } catch (error) {
console.error('Failed to get theme settings:', error); console.error('Failed to get theme settings:', error);
+3 -2
View File
@@ -158,8 +158,9 @@ export const POST: RequestHandler = async ({ params, url, cookies, request }) =>
defined = envVars.map(v => v.key).sort(); defined = envVars.map(v => v.key).sort();
} }
// Calculate missing and unused // Calculate missing and unused. Built-in Docker/Compose vars are provided implicitly
const missing = required.filter(v => !defined.includes(v)); // by the runtime, so they're never "missing" even if not in the user's env panel.
const missing = required.filter(v => !defined.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v));
const unused = defined.filter(v => !required.includes(v) && !optional.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v)); const unused = defined.filter(v => !required.includes(v) && !optional.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v));
const result: ValidationResult = { const result: ValidationResult = {
+1 -1
View File
@@ -1929,7 +1929,7 @@
class="inline-flex items-center gap-0.5 text-xs {portUrl ? 'bg-primary/10 hover:bg-primary/20 text-primary' : 'bg-muted hover:bg-blue-500/20 hover:text-blue-500'} px-1 py-0.5 rounded transition-colors shrink-0" class="inline-flex items-center gap-0.5 text-xs {portUrl ? 'bg-primary/10 hover:bg-primary/20 text-primary' : 'bg-muted hover:bg-blue-500/20 hover:text-blue-500'} px-1 py-0.5 rounded transition-colors shrink-0"
title="Open {url} in new tab" title="Open {url} in new tab"
> >
<code>{port.display}</code> <code>{portParsed?.name ?? port.display}</code>
<ExternalLink class="w-2.5 h-2.5 {portUrl ? 'opacity-60' : 'text-muted-foreground'}" /> <ExternalLink class="w-2.5 h-2.5 {portUrl ? 'opacity-60' : 'text-muted-foreground'}" />
</a> </a>
{:else} {:else}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs'; import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -621,6 +622,29 @@
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" /> <Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button> </button>
{/if} {/if}
{@const composeStack = containerData?.Config?.Labels?.['com.docker.compose.project']}
{#if composeStack && !loading}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
onclick={() => {
open = false;
goto(appendEnvParam(`/stacks?search=${encodeURIComponent(composeStack)}`, $currentEnvironment?.id ?? null));
}}
class="cursor-pointer inline-flex items-center"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors gap-1">
<Layers class="w-3 h-3" />
{composeStack}
</Badge>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs whitespace-nowrap">Open stack "{composeStack}"</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if containerData?.State?.Running && !loading} {#if containerData?.State?.Running && !loading}
<span class="inline-flex items-center gap-1.5 ml-2 text-xs {isLiveConnected ? 'text-emerald-500' : 'text-muted-foreground'}" title={isLiveConnected ? 'Receiving live updates' : 'Connection lost'}> <span class="inline-flex items-center gap-1.5 ml-2 text-xs {isLiveConnected ? 'text-emerald-500' : 'text-muted-foreground'}" title={isLiveConnected ? 'Receiving live updates' : 'Connection lost'}>
<Wifi class="w-3.5 h-3.5 {isLiveConnected ? 'animate-pulse' : ''}" /> <Wifi class="w-3.5 h-3.5 {isLiveConnected ? 'animate-pulse' : ''}" />
@@ -1143,7 +1167,7 @@
class="inline-flex items-center gap-1 text-primary hover:underline" class="inline-flex items-center gap-1 text-primary hover:underline"
title="Open {url}" title="Open {url}"
> >
<code>{binding.HostIp || '0.0.0.0'}:{binding.HostPort}</code> <code>{portParsedOverride?.name ?? `${binding.HostIp || '0.0.0.0'}:${binding.HostPort}`}</code>
<ExternalLink class="w-3 h-3" /> <ExternalLink class="w-3 h-3" />
</a> </a>
{:else} {:else}
+218 -49
View File
@@ -8,7 +8,9 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill'; import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, Box, ChevronDown, ChevronsUpDown, Check, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp } from 'lucide-svelte'; import { Plus, Trash2, Settings2, RefreshCw, Network, X, Ban, RotateCw, AlertTriangle, PauseCircle, Share2, Server, CircleOff, Box, ChevronDown, ChevronsUpDown, Check, ChevronRight, Cpu, Shield, HeartPulse, Wifi, HardDrive, Lock, Loader2, CheckCircle2, Package, Gpu, Search, CircleHelp, CornerDownLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { parseMemory, parseNanoCpus, parsePositiveInt } from '$lib/utils/container-resources';
import { parseHostPort, validatePort, validateIp, formatHostPort, expandPortBindings } from '$lib/utils/port-parse'; import { parseHostPort, validatePort, validateIp, formatHostPort, expandPortBindings } from '$lib/utils/port-parse';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { currentEnvironment } from '$lib/stores/environment'; import { currentEnvironment } from '$lib/stores/environment';
@@ -159,6 +161,11 @@
totalVulnerabilities?: number; totalVulnerabilities?: number;
hasCriticalOrHigh?: boolean; hasCriticalOrHigh?: boolean;
}; };
// Edit mode specific — needed for inline "Apply" in-place updates
// (restart policy, CPU/memory limits) without recreating the container.
// Omitted in create mode; the per-field Apply buttons are hidden then.
containerId?: string;
envId?: number;
} }
let { let {
@@ -213,7 +220,9 @@
configSets, configSets,
selectedConfigSetId = $bindable(), selectedConfigSetId = $bindable(),
errors = $bindable(), errors = $bindable(),
imageSummary imageSummary,
containerId,
envId
}: Props = $props(); }: Props = $props();
// Fetch networks and containers from current environment // Fetch networks and containers from current environment
@@ -649,6 +658,111 @@
} }
} }
// ---------------------------------------------------------------------
// In-place ("live") property updates — restart policy + resource limits.
// Calls POST /api/containers/[id]/update-runtime which wraps Docker's
// /containers/{id}/update endpoint. ONLY the property fields documented
// in IN_PLACE_UPDATE_FIELDS (see docker.ts) are eligible — anything else
// would silently fail or, worse, look like it worked. Apply buttons next
// to those fields call this helper; the rest of the form still requires
// the bottom Save button which recreates the container.
// ---------------------------------------------------------------------
type InPlaceFieldKey = 'restart' | 'memory' | 'memoryReservation' | 'nanoCpus' | 'cpuShares' | 'cpuQuota' | 'cpuPeriod' | 'pidsLimit';
let applyingField = $state<InPlaceFieldKey | null>(null);
/** True when this tab is wired for in-place updates (edit mode + we have an id). */
const canApplyInPlace = $derived(mode === 'edit' && !!containerId);
async function applyInPlace(field: InPlaceFieldKey, body: Record<string, unknown>) {
if (!canApplyInPlace || !containerId) return;
applyingField = field;
try {
const url = `/api/containers/${containerId}/update-runtime${envId ? `?env=${envId}` : ''}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
toast.error(data.error || 'Update failed');
return;
}
toast.success('Applied — no restart needed');
// Surface Docker warnings (e.g. "Memory swap will fall back to ...") inline.
if (Array.isArray(data.warnings) && data.warnings.length > 0) {
for (const w of data.warnings) toast.warning(w);
}
} catch (err: any) {
toast.error(err?.message || 'Update failed');
} finally {
applyingField = null;
}
}
function applyRestartPolicy() {
const payload: Record<string, unknown> = { Name: restartPolicy };
if (restartPolicy === 'on-failure' && restartMaxRetries !== '' && restartMaxRetries !== null) {
payload.MaximumRetryCount = Number(restartMaxRetries);
}
return applyInPlace('restart', { RestartPolicy: payload });
}
function applyMemoryLimit() {
const bytes = parseMemory(memoryLimit);
if (memoryLimit && bytes === undefined) {
toast.error('Invalid memory value (e.g. 512m, 1g)');
return;
}
// Docker uses 0 to clear an existing limit.
return applyInPlace('memory', { Memory: bytes ?? 0 });
}
function applyMemoryReservation() {
const bytes = parseMemory(memoryReservation);
if (memoryReservation && bytes === undefined) {
toast.error('Invalid memory value');
return;
}
return applyInPlace('memoryReservation', { MemoryReservation: bytes ?? 0 });
}
function applyNanoCpus() {
const n = parseNanoCpus(nanoCpus);
if (nanoCpus && n === undefined) {
toast.error('Invalid CPU limit (e.g. 0.5, 1.5, 2)');
return;
}
return applyInPlace('nanoCpus', { NanoCpus: n ?? 0 });
}
function applyCpuShares() {
const n = parsePositiveInt(cpuShares);
if (cpuShares && n === undefined) {
toast.error('Invalid CPU shares');
return;
}
return applyInPlace('cpuShares', { CpuShares: n ?? 0 });
}
function applyCpuQuota() {
const n = parsePositiveInt(cpuQuota);
if (cpuQuota && n === undefined) {
toast.error('Invalid CPU quota');
return;
}
return applyInPlace('cpuQuota', { CpuQuota: n ?? 0 });
}
function applyCpuPeriod() {
const n = parsePositiveInt(cpuPeriod);
if (cpuPeriod && n === undefined) {
toast.error('Invalid CPU period');
return;
}
return applyInPlace('cpuPeriod', { CpuPeriod: n ?? 0 });
}
function getDriverBadgeClasses(driver: string): string { function getDriverBadgeClasses(driver: string): string {
const base = 'text-2xs px-1.5 py-0.5 rounded font-medium'; const base = 'text-2xs px-1.5 py-0.5 rounded font-medium';
switch (driver.toLowerCase()) { switch (driver.toLowerCase()) {
@@ -775,48 +889,67 @@
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label class="text-xs font-medium">Restart policy</Label> <Label class="text-xs font-medium">Restart policy</Label>
<Select.Root type="single" bind:value={restartPolicy}> <div class="flex items-center gap-1.5">
<Select.Trigger id="restartPolicy" tabindex={0} class="w-full h-9"> <Select.Root type="single" bind:value={restartPolicy}>
<span class="flex items-center"> <Select.Trigger id="restartPolicy" tabindex={0} class="w-full h-9">
{#if restartPolicy === 'no'} <span class="flex items-center">
<Ban class="w-3.5 h-3.5 mr-2 text-muted-foreground" /> {#if restartPolicy === 'no'}
{:else if restartPolicy === 'always'} <Ban class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
<RotateCw class="w-3.5 h-3.5 mr-2 text-green-500" /> {:else if restartPolicy === 'always'}
{:else if restartPolicy === 'on-failure'} <RotateCw class="w-3.5 h-3.5 mr-2 text-green-500" />
<AlertTriangle class="w-3.5 h-3.5 mr-2 text-amber-500" /> {:else if restartPolicy === 'on-failure'}
<AlertTriangle class="w-3.5 h-3.5 mr-2 text-amber-500" />
{:else}
<PauseCircle class="w-3.5 h-3.5 mr-2 text-blue-500" />
{/if}
{restartPolicy === 'no' ? 'No' : restartPolicy === 'always' ? 'Always' : restartPolicy === 'on-failure' ? 'On failure' : 'Unless stopped'}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="no">
{#snippet children()}
<Ban class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
No
{/snippet}
</Select.Item>
<Select.Item value="always">
{#snippet children()}
<RotateCw class="w-3.5 h-3.5 mr-2 text-green-500" />
Always
{/snippet}
</Select.Item>
<Select.Item value="on-failure">
{#snippet children()}
<AlertTriangle class="w-3.5 h-3.5 mr-2 text-amber-500" />
On failure
{/snippet}
</Select.Item>
<Select.Item value="unless-stopped">
{#snippet children()}
<PauseCircle class="w-3.5 h-3.5 mr-2 text-blue-500" />
Unless stopped
{/snippet}
</Select.Item>
</Select.Content>
</Select.Root>
{#if canApplyInPlace}
<Button
type="button"
variant="outline"
size="sm"
class="h-9 shrink-0 px-2"
disabled={applyingField !== null}
onclick={applyRestartPolicy}
title="Apply"
>
{#if applyingField === 'restart'}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
{:else} {:else}
<PauseCircle class="w-3.5 h-3.5 mr-2 text-blue-500" /> <CornerDownLeft class="w-3.5 h-3.5" />
{/if} {/if}
{restartPolicy === 'no' ? 'No' : restartPolicy === 'always' ? 'Always' : restartPolicy === 'on-failure' ? 'On failure' : 'Unless stopped'} </Button>
</span> {/if}
</Select.Trigger> </div>
<Select.Content>
<Select.Item value="no">
{#snippet children()}
<Ban class="w-3.5 h-3.5 mr-2 text-muted-foreground" />
No
{/snippet}
</Select.Item>
<Select.Item value="always">
{#snippet children()}
<RotateCw class="w-3.5 h-3.5 mr-2 text-green-500" />
Always
{/snippet}
</Select.Item>
<Select.Item value="on-failure">
{#snippet children()}
<AlertTriangle class="w-3.5 h-3.5 mr-2 text-amber-500" />
On failure
{/snippet}
</Select.Item>
<Select.Item value="unless-stopped">
{#snippet children()}
<PauseCircle class="w-3.5 h-3.5 mr-2 text-blue-500" />
Unless stopped
{/snippet}
</Select.Item>
</Select.Content>
</Select.Root>
{#if restartPolicy === 'on-failure'} {#if restartPolicy === 'on-failure'}
<div class="space-y-1.5 mt-2"> <div class="space-y-1.5 mt-2">
<Label class="text-xs font-medium">Max retry count</Label> <Label class="text-xs font-medium">Max retry count</Label>
@@ -1334,36 +1467,72 @@
</button> </button>
{#if showResources} {#if showResources}
<div class="px-3 pb-3 space-y-3 border-t"> <div class="px-3 pb-3 space-y-3 border-t">
<p class="text-xs text-muted-foreground pt-2">Configure memory and CPU limits for this container</p> <p class="text-xs text-muted-foreground pt-2">
Configure memory and CPU limits for this container.
{#if canApplyInPlace}
The <CornerDownLeft class="w-3 h-3 inline-block -mt-0.5" /> button next to each field applies the change without restarting.
{/if}
</p>
{#snippet inlineApplyBtn(field: InPlaceFieldKey, onclick: () => void)}
{#if canApplyInPlace}
<Button type="button" variant="outline" size="sm" class="h-9 shrink-0 px-2" disabled={applyingField !== null} {onclick} title="Apply">
{#if applyingField === field}
<Loader2 class="w-3.5 h-3.5 animate-spin" />
{:else}
<CornerDownLeft class="w-3.5 h-3.5" />
{/if}
</Button>
{/if}
{/snippet}
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="memoryLimit" class="text-xs font-medium">Memory limit</Label> <Label for="memoryLimit" class="text-xs font-medium">Memory limit</Label>
<Input id="memoryLimit" bind:value={memoryLimit} placeholder="e.g., 512m, 1g" class="h-9" /> <div class="flex items-center gap-1.5">
<Input id="memoryLimit" bind:value={memoryLimit} placeholder="e.g., 512m, 1g" class="h-9" />
{@render inlineApplyBtn('memory', applyMemoryLimit)}
</div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="memoryReservation" class="text-xs font-medium">Memory reservation</Label> <Label for="memoryReservation" class="text-xs font-medium">Memory reservation</Label>
<Input id="memoryReservation" bind:value={memoryReservation} placeholder="e.g., 256m" class="h-9" /> <div class="flex items-center gap-1.5">
<Input id="memoryReservation" bind:value={memoryReservation} placeholder="e.g., 256m" class="h-9" />
{@render inlineApplyBtn('memoryReservation', applyMemoryReservation)}
</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="nanoCpus" class="text-xs font-medium">CPU limit</Label> <Label for="nanoCpus" class="text-xs font-medium">CPU limit</Label>
<Input id="nanoCpus" bind:value={nanoCpus} placeholder="e.g., 0.5, 1.5, 2" class="h-9" /> <div class="flex items-center gap-1.5">
<Input id="nanoCpus" bind:value={nanoCpus} placeholder="e.g., 0.5, 1.5, 2" class="h-9" />
{@render inlineApplyBtn('nanoCpus', applyNanoCpus)}
</div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="cpuShares" class="text-xs font-medium">CPU shares</Label> <Label for="cpuShares" class="text-xs font-medium">CPU shares</Label>
<Input id="cpuShares" bind:value={cpuShares} type="number" placeholder="1024" class="h-9" /> <div class="flex items-center gap-1.5">
<Input id="cpuShares" bind:value={cpuShares} type="number" placeholder="1024" class="h-9" />
{@render inlineApplyBtn('cpuShares', applyCpuShares)}
</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="cpuQuota" class="text-xs font-medium">CPU quota</Label> <Label for="cpuQuota" class="text-xs font-medium">CPU quota</Label>
<Input id="cpuQuota" bind:value={cpuQuota} type="number" placeholder="e.g., 50000" class="h-9" /> <div class="flex items-center gap-1.5">
<Input id="cpuQuota" bind:value={cpuQuota} type="number" placeholder="e.g., 50000" class="h-9" />
{@render inlineApplyBtn('cpuQuota', applyCpuQuota)}
</div>
<p class="text-xs text-muted-foreground">Microseconds per period</p> <p class="text-xs text-muted-foreground">Microseconds per period</p>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="cpuPeriod" class="text-xs font-medium">CPU period</Label> <Label for="cpuPeriod" class="text-xs font-medium">CPU period</Label>
<Input id="cpuPeriod" bind:value={cpuPeriod} type="number" placeholder="Default: 100000" class="h-9" /> <div class="flex items-center gap-1.5">
<Input id="cpuPeriod" bind:value={cpuPeriod} type="number" placeholder="Default: 100000" class="h-9" />
{@render inlineApplyBtn('cpuPeriod', applyCpuPeriod)}
</div>
<p class="text-xs text-muted-foreground">Period in microseconds</p> <p class="text-xs text-muted-foreground">Period in microseconds</p>
</div> </div>
</div> </div>
@@ -1110,6 +1110,8 @@
<ContainerSettingsTab <ContainerSettingsTab
mode="edit" mode="edit"
{containerId}
envId={$currentEnvironment?.id ?? undefined}
bind:name bind:name
bind:image bind:image
bind:command bind:command
+29 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
@@ -229,6 +229,29 @@
} }
}); });
// ESC inside the file viewer/editor closes JUST the file, not the whole
// dialog. Captured at window level so the Dialog.Root wrapper never sees
// the key. When no file is open, ESC bubbles normally and closes the dialog.
function handleEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
if (editingFile) {
e.stopPropagation();
e.preventDefault();
closeEditor();
} else if (viewingFile) {
e.stopPropagation();
e.preventDefault();
closeViewer();
}
}
onMount(() => {
window.addEventListener('keydown', handleEscape, true);
});
onDestroy(() => {
window.removeEventListener('keydown', handleEscape, true);
});
function toggleEditorTheme() { function toggleEditorTheme() {
editorTheme = editorTheme === 'light' ? 'dark' : 'light'; editorTheme = editorTheme === 'light' ? 'dark' : 'light';
localStorage.setItem('dockhand-editor-theme', editorTheme); localStorage.setItem('dockhand-editor-theme', editorTheme);
@@ -245,7 +268,7 @@
// Get language from filename for CodeMirror // Get language from filename for CodeMirror
function getLanguageFromFilename(filename: string): string { function getLanguageFromFilename(filename: string): string {
const name = filename.toLowerCase(); const name = filename.toLowerCase();
if (name === 'dockerfile') return 'shell'; if (name === 'dockerfile' || name.endsWith('.dockerfile')) return 'dockerfile';
if (name === 'makefile' || name === 'rakefile') return 'shell'; if (name === 'makefile' || name === 'rakefile') return 'shell';
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml'; if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml';
if (name.endsWith('.json')) return 'json'; if (name.endsWith('.json')) return 'json';
@@ -260,7 +283,10 @@
if (name.endsWith('.css') || name.endsWith('.scss') || name.endsWith('.sass') || name.endsWith('.less')) return 'css'; if (name.endsWith('.css') || name.endsWith('.scss') || name.endsWith('.sass') || name.endsWith('.less')) return 'css';
if (name.endsWith('.xml')) return 'xml'; if (name.endsWith('.xml')) return 'xml';
if (name.endsWith('.sql')) return 'sql'; if (name.endsWith('.sql')) return 'sql';
return 'shell'; if (name.endsWith('.toml')) return 'toml';
if (name === '.env' || name.startsWith('.env.') || name.endsWith('.env')) return 'dotenv';
if (name.endsWith('.ini') || name.endsWith('.conf') || name.endsWith('.cfg') || name.endsWith('.properties')) return 'ini';
return '';
} }
// Check if file is editable // Check if file is editable
+3 -1
View File
@@ -38,6 +38,7 @@
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import ThemeSelector from '$lib/components/ThemeSelector.svelte'; import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
import { themeStore } from '$lib/stores/theme'; import { themeStore } from '$lib/stores/theme';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -723,8 +724,9 @@
</Card.Title> </Card.Title>
<Card.Description>Customize the look of the application</Card.Description> <Card.Description>Customize the look of the application</Card.Description>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content class="space-y-4">
<ThemeSelector userId={profile.id} /> <ThemeSelector userId={profile.id} />
<AnimateIconsToggle userId={profile.id} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
+2 -1
View File
@@ -13,6 +13,7 @@
import LicenseModal from './LicenseModal.svelte'; import LicenseModal from './LicenseModal.svelte';
import PrivacyModal from './PrivacyModal.svelte'; import PrivacyModal from './PrivacyModal.svelte';
import SelfUpdateDialog from './SelfUpdateDialog.svelte'; import SelfUpdateDialog from './SelfUpdateDialog.svelte';
import ChangelogText from '$lib/components/ChangelogText.svelte';
interface Dependency { interface Dependency {
name: string; name: string;
@@ -885,7 +886,7 @@
Fix Fix
</span> </span>
{/if} {/if}
<span>{change.text}</span> <ChangelogText text={change.text} />
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { readJobResponse } from '$lib/utils/sse-fetch'; import { readJobResponse } from '$lib/utils/sse-fetch';
import { validateEnvName } from '$lib/utils/env-name';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
@@ -60,7 +61,9 @@
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
XCircle, XCircle,
ImageUp ImageUp,
Upload,
ArrowRight
} from 'lucide-svelte'; } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import * as Alert from '$lib/components/ui/alert'; import * as Alert from '$lib/components/ui/alert';
@@ -285,6 +288,20 @@
let formPublicIp = $state(''); let formPublicIp = $state('');
let formTimezone = $state('UTC'); let formTimezone = $state('UTC');
let formError = $state(''); let formError = $state('');
// Env-rename confirmation dialog state.
let showRenameConfirm = $state(false);
let renameConfirmFrom = $state('');
let renameConfirmTo = $state('');
let renameStackCount = $state(0);
let renameGitStackCount = $state(0);
// Set when we couldn't reliably enumerate stacks (network/permission
// failure). The dialog falls back to generic text instead of a count, so
// we never silent-rename on a transient error.
let renameCountsUnknown = $state(false);
// Whether running containers will be affected by the rename. True for
// socket/direct (they deploy from the staging dir). False for Hawser
// (the agent's deployed dir doesn't include env name).
let renameAffectsContainers = $state(false);
let formErrors = $state<{ name?: string; host?: string }>({}); let formErrors = $state<{ name?: string; host?: string }>({});
let formSaving = $state(false); let formSaving = $state(false);
@@ -362,6 +379,33 @@
return cleaned || undefined; return cleaned || undefined;
} }
/**
* Read a PEM file picked via <input type="file"> into the bound textarea state.
* Pasting still works; this is purely additive (#125).
*/
async function loadPemFromFile(
event: Event,
assign: (text: string) => void,
label: string
) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
if (!text.includes('-----BEGIN')) {
toast.error(`${file.name} does not look like a PEM file (no BEGIN block)`);
return;
}
assign(text);
toast.success(`Loaded ${label} from ${file.name}`);
} catch (err: any) {
toast.error(`Failed to read ${file.name}: ${err?.message ?? err}`);
} finally {
input.value = ''; // allow re-uploading the same file
}
}
/** /**
* Extract hostname/IP from a URL string * Extract hostname/IP from a URL string
* Handles tcp://, http://, https:// protocols and plain hostnames * Handles tcp://, http://, https:// protocols and plain hostnames
@@ -725,6 +769,12 @@
if (!formName.trim()) { if (!formName.trim()) {
formErrors.name = 'Name is required'; formErrors.name = 'Name is required';
hasErrors = true; hasErrors = true;
} else {
const nameCheck = validateEnvName(formName.trim());
if (!nameCheck.ok) {
formErrors.name = nameCheck.reason!;
hasErrors = true;
}
} }
// Host is only required for direct and hawser-standard connection types // Host is only required for direct and hawser-standard connection types
if (formConnectionType === 'direct' || formConnectionType === 'hawser-standard') { if (formConnectionType === 'direct' || formConnectionType === 'hawser-standard') {
@@ -843,6 +893,12 @@
if (!formName.trim()) { if (!formName.trim()) {
formErrors.name = 'Name is required'; formErrors.name = 'Name is required';
hasErrors = true; hasErrors = true;
} else {
const nameCheck = validateEnvName(formName.trim());
if (!nameCheck.ok) {
formErrors.name = nameCheck.reason!;
hasErrors = true;
}
} }
// Host is only required for direct and hawser-standard connection types // Host is only required for direct and hawser-standard connection types
if (formConnectionType === 'direct' || formConnectionType === 'hawser-standard') { if (formConnectionType === 'direct' || formConnectionType === 'hawser-standard') {
@@ -860,6 +916,61 @@
if (hasErrors) return; if (hasErrors) return;
// Every env type stores something locally under the env name:
// - socket/direct: the deploy staging dir AND the in-app editor source
// (containers' compose project labels point here, so redeploy is
// required after rename or next restart fails)
// - hawser-standard/edge: the in-app editor source for ALL stacks
// PLUS the git-repos clones for git stacks (the agent's deployed
// dir lives on the remote host and is not affected)
// → fire the warning whenever the name changes.
const newName = formName.trim();
const willRenameOnDisk = newName !== environment.name;
// "Affects containers" = redeploy required. Only true for envs where
// Dockhand deploys directly (socket/direct), not Hawser.
renameAffectsContainers =
environment.connectionType === 'socket' || environment.connectionType === 'direct';
if (willRenameOnDisk) {
// Count actual stacks on this env. Fail closed: if either request
// fails (network, 403, parse error) we treat the counts as
// unknown and still show the warning — better to spook the user
// than silent-rename when stacks might exist.
const [stacksRes, gitRes] = await Promise.all([
fetch(`/api/stacks?env=${environment.id}`).catch(() => null),
fetch(`/api/git/stacks?env=${environment.id}`).catch(() => null)
]);
let unknown = false;
let stacks: unknown[] = [];
let gitStacks: unknown[] = [];
if (stacksRes?.ok) {
try { stacks = await stacksRes.json(); } catch { unknown = true; }
} else {
unknown = true;
}
if (gitRes?.ok) {
try { gitStacks = await gitRes.json(); } catch { unknown = true; }
} else {
unknown = true;
}
renameStackCount = Array.isArray(stacks) ? stacks.length : 0;
renameGitStackCount = Array.isArray(gitStacks) ? gitStacks.length : 0;
renameCountsUnknown = unknown;
renameConfirmFrom = environment.name;
renameConfirmTo = newName;
showRenameConfirm = true;
return;
}
await commitEnvironmentUpdate();
}
// Extracted from updateEnvironment() so the rename-confirm dialog can call
// it after the user clicks "Rename and continue".
async function commitEnvironmentUpdate() {
if (!environment) return;
formSaving = true; formSaving = true;
formError = ''; formError = '';
@@ -1475,7 +1586,7 @@
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
<div class="overflow-y-auto py-4 h-[520px]"> <div class="overflow-y-auto py-4 h-[520px] [scrollbar-gutter:stable] pr-1">
<!-- General Tab (Connection Settings) --> <!-- General Tab (Connection Settings) -->
<Tabs.Content value="general" class="space-y-4 mt-0 h-full"> <Tabs.Content value="general" class="space-y-4 mt-0 h-full">
<!-- Name field --> <!-- Name field -->
@@ -1833,9 +1944,22 @@
</div> </div>
{#if formProtocol === 'https'} {#if formProtocol === 'https'}
<div class="space-y-4 pt-2 border-t"> <div class="space-y-4 pt-2 border-t">
<p class="text-xs text-muted-foreground">TLS certificates for mTLS authentication (RSA or ECDSA)</p> <p class="text-xs text-muted-foreground">TLS certificates for mTLS authentication (RSA or ECDSA). Paste the PEM content or upload a file.</p>
<div class="space-y-2"> <div class="space-y-2">
<Label for="edit-env-tls_ca">CA certificate</Label> <div class="flex items-center justify-between gap-2">
<Label for="edit-env-tls_ca">CA certificate</Label>
<Button variant="ghost" size="sm" type="button" class="h-7 px-2 text-xs" onclick={() => document.getElementById('edit-env-tls_ca-file')?.click()}>
<Upload class="w-3 h-3 mr-1" />
Upload file
</Button>
<input
id="edit-env-tls_ca-file"
type="file"
accept=".pem,.crt,.cer,.ca,.cert,application/x-pem-file,application/x-x509-ca-cert"
class="hidden"
onchange={(e) => loadPemFromFile(e, (t) => (formTlsCa = t), 'CA certificate')}
/>
</div>
<textarea <textarea
id="edit-env-tls_ca" id="edit-env-tls_ca"
bind:value={formTlsCa} bind:value={formTlsCa}
@@ -1844,7 +1968,20 @@
></textarea> ></textarea>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="edit-env-tls_cert">Client certificate</Label> <div class="flex items-center justify-between gap-2">
<Label for="edit-env-tls_cert">Client certificate</Label>
<Button variant="ghost" size="sm" type="button" class="h-7 px-2 text-xs" onclick={() => document.getElementById('edit-env-tls_cert-file')?.click()}>
<Upload class="w-3 h-3 mr-1" />
Upload file
</Button>
<input
id="edit-env-tls_cert-file"
type="file"
accept=".pem,.crt,.cer,.cert,application/x-pem-file"
class="hidden"
onchange={(e) => loadPemFromFile(e, (t) => (formTlsCert = t), 'client certificate')}
/>
</div>
<textarea <textarea
id="edit-env-tls_cert" id="edit-env-tls_cert"
bind:value={formTlsCert} bind:value={formTlsCert}
@@ -1853,7 +1990,20 @@
></textarea> ></textarea>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="edit-env-tls_key">Client key</Label> <div class="flex items-center justify-between gap-2">
<Label for="edit-env-tls_key">Client key</Label>
<Button variant="ghost" size="sm" type="button" class="h-7 px-2 text-xs" onclick={() => document.getElementById('edit-env-tls_key-file')?.click()}>
<Upload class="w-3 h-3 mr-1" />
Upload file
</Button>
<input
id="edit-env-tls_key-file"
type="file"
accept=".pem,.key,application/x-pem-file"
class="hidden"
onchange={(e) => loadPemFromFile(e, (t) => (formTlsKey = t), 'client key')}
/>
</div>
<textarea <textarea
id="edit-env-tls_key" id="edit-env-tls_key"
bind:value={formTlsKey} bind:value={formTlsKey}
@@ -1919,7 +2069,20 @@
</div> </div>
{#if formProtocol === 'https'} {#if formProtocol === 'https'}
<div class="space-y-2"> <div class="space-y-2">
<Label for="edit-env-hawser-tls-ca">CA certificate (for self-signed)</Label> <div class="flex items-center justify-between gap-2">
<Label for="edit-env-hawser-tls-ca">CA certificate (for self-signed)</Label>
<Button variant="ghost" size="sm" type="button" class="h-7 px-2 text-xs" disabled={formTlsSkipVerify} onclick={() => document.getElementById('edit-env-hawser-tls-ca-file')?.click()}>
<Upload class="w-3 h-3 mr-1" />
Upload file
</Button>
<input
id="edit-env-hawser-tls-ca-file"
type="file"
accept=".pem,.crt,.cer,.ca,.cert,application/x-pem-file,application/x-x509-ca-cert"
class="hidden"
onchange={(e) => loadPemFromFile(e, (t) => (formTlsCa = t), 'CA certificate')}
/>
</div>
<textarea <textarea
id="edit-env-hawser-tls-ca" id="edit-env-hawser-tls-ca"
bind:value={formTlsCa} bind:value={formTlsCa}
@@ -1927,7 +2090,7 @@
class="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring font-mono text-xs" class="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring font-mono text-xs"
disabled={formTlsSkipVerify} disabled={formTlsSkipVerify}
></textarea> ></textarea>
<p class="text-xs text-muted-foreground">Paste the CA certificate if agent uses self-signed TLS (RSA or ECDSA).</p> <p class="text-xs text-muted-foreground">Paste the CA certificate or upload a file if agent uses self-signed TLS (RSA or ECDSA).</p>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
@@ -2819,3 +2982,95 @@
/> />
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<!-- Env-rename confirmation: only fires for socket/direct envs where the
server actually moves $DATA_DIR/stacks/<oldName><newName>. -->
<Dialog.Root bind:open={showRenameConfirm}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<AlertTriangle class="w-5 h-5 text-amber-500" />
Rename environment?
</Dialog.Title>
<Dialog.Description class="pt-2 space-y-3 text-sm">
<p>The following directories will be moved on the Dockhand host:</p>
<div class="space-y-1 text-xs font-mono bg-muted/40 rounded-md p-3 border overflow-x-auto">
<div class="flex items-center gap-2 whitespace-nowrap">
<code class="whitespace-nowrap">$DATA_DIR/stacks/{renameConfirmFrom}/</code>
<ArrowRight class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<code class="whitespace-nowrap">$DATA_DIR/stacks/{renameConfirmTo}/</code>
</div>
<div class="flex items-center gap-2 whitespace-nowrap">
<code class="whitespace-nowrap">$DATA_DIR/git-repos/{renameConfirmFrom}/</code>
<ArrowRight class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<code class="whitespace-nowrap">$DATA_DIR/git-repos/{renameConfirmTo}/</code>
</div>
</div>
{#if renameCountsUnknown}
<p>
Couldn't list the stacks on this environment — proceed only if
you're sure what's deployed here.
{#if renameAffectsContainers}
<strong>Any existing stacks will need to be redeployed after
the rename</strong> or their next container restart will fail.
{/if}
</p>
{:else if renameStackCount === 0 && renameGitStackCount === 0}
<p>No stacks are currently deployed on this environment, so the rename is safe.</p>
{:else if renameAffectsContainers}
<p>
{#if renameStackCount > 0 && renameGitStackCount > 0}
<strong>{renameStackCount} stack{renameStackCount === 1 ? '' : 's'}</strong>
and <strong>{renameGitStackCount} git stack{renameGitStackCount === 1 ? '' : 's'}</strong>
on this environment will need to be redeployed after the rename.
{:else if renameStackCount > 0}
<strong>{renameStackCount} stack{renameStackCount === 1 ? '' : 's'}</strong>
on this environment will need to be redeployed after the rename.
{:else}
<strong>{renameGitStackCount} git stack{renameGitStackCount === 1 ? '' : 's'}</strong>
on this environment will need to be redeployed after the rename.
{/if}
</p>
<p>
Running containers will keep working, but their compose project labels
still reference the old path. Without a redeploy, the next container
restart will fail because the old path no longer exists.
</p>
{:else}
<p>
{#if renameStackCount > 0 && renameGitStackCount > 0}
<strong>{renameStackCount} stack{renameStackCount === 1 ? '' : 's'}</strong>
and <strong>{renameGitStackCount} git stack{renameGitStackCount === 1 ? '' : 's'}</strong>
are tracked on this environment.
{:else if renameStackCount > 0}
<strong>{renameStackCount} stack{renameStackCount === 1 ? '' : 's'}</strong>
{renameStackCount === 1 ? 'is' : 'are'} tracked on this environment.
{:else}
<strong>{renameGitStackCount} git stack{renameGitStackCount === 1 ? '' : 's'}</strong>
{renameGitStackCount === 1 ? 'is' : 'are'} tracked on this environment.
{/if}
</p>
<p>
Their containers run on the Hawser agent host, where the deploy
directory doesn't include the env name — those keep working without
a redeploy. Only the local editor source and git clone caches move.
</p>
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end gap-2 mt-4">
<Button variant="outline" onclick={() => (showRenameConfirm = false)}>
Cancel
</Button>
<Button
variant="default"
onclick={async () => {
showRenameConfirm = false;
await commitEnvironmentUpdate();
}}
>
Rename and continue
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -24,8 +24,10 @@
Unplug, Unplug,
CircleArrowUp, CircleArrowUp,
CircleFadingArrowUp, CircleFadingArrowUp,
Clock Clock,
AlertTriangle
} from 'lucide-svelte'; } from 'lucide-svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { broom, whale } from '@lucide/lab'; import { broom, whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth'; import { canAccess } from '$lib/stores/auth';
@@ -100,7 +102,13 @@
let testingEnvs = $state<Set<number>>(new Set()); let testingEnvs = $state<Set<number>>(new Set());
let pruneStatus = $state<{ [id: number]: 'pruning' | 'success' | 'error' | null }>({}); let pruneStatus = $state<{ [id: number]: 'pruning' | 'success' | 'error' | null }>({});
let confirmPruneEnvId = $state<number | null>(null); let confirmPruneEnvId = $state<number | null>(null);
let confirmDeleteEnvId = $state<number | null>(null); // Delete confirmation dialog state — shows the on-disk paths that will be
// wiped + the stacks-tracked counts so the user knows what they're losing.
let showDeleteConfirm = $state(false);
let deleteEnvTarget = $state<Environment | null>(null);
let deleteStackCount = $state(0);
let deleteGitStackCount = $state(0);
let deleteCountsUnknown = $state(false);
// Track which environments have scanner enabled (for shield indicator) // Track which environments have scanner enabled (for shield indicator)
let envScannerStatus = $state<{ [id: number]: boolean }>({}); let envScannerStatus = $state<{ [id: number]: boolean }>({});
@@ -192,6 +200,47 @@
} }
} }
// Two-step delete: fetch the stack counts first (fail-closed) and pop the
// dialog that lists the on-disk paths being wiped. Only on explicit
// confirmation do we actually call deleteEnvironment().
async function requestDeleteEnvironment(id: number) {
const env = environments.find(e => e.id === id);
if (!env) return;
const [stacksRes, gitRes] = await Promise.all([
fetch(`/api/stacks?env=${id}`).catch(() => null),
fetch(`/api/git/stacks?env=${id}`).catch(() => null)
]);
let unknown = false;
let stacks: unknown[] = [];
let gitStacks: unknown[] = [];
if (stacksRes?.ok) {
try { stacks = await stacksRes.json(); } catch { unknown = true; }
} else {
unknown = true;
}
if (gitRes?.ok) {
try { gitStacks = await gitRes.json(); } catch { unknown = true; }
} else {
unknown = true;
}
deleteStackCount = Array.isArray(stacks) ? stacks.length : 0;
deleteGitStackCount = Array.isArray(gitStacks) ? gitStacks.length : 0;
deleteCountsUnknown = unknown;
deleteEnvTarget = env;
showDeleteConfirm = true;
}
async function confirmAndDelete() {
const target = deleteEnvTarget;
showDeleteConfirm = false;
deleteEnvTarget = null;
if (target) {
await deleteEnvironment(target.id);
}
}
async function testConnection(id: number) { async function testConnection(id: number) {
testingEnvs.add(id); testingEnvs.add(id);
testingEnvs = new Set(testingEnvs); testingEnvs = new Set(testingEnvs);
@@ -605,27 +654,15 @@
</ConfirmPopover> </ConfirmPopover>
{/if} {/if}
{#if $canAccess('environments', 'delete')} {#if $canAccess('environments', 'delete')}
<ConfirmPopover <Button
open={confirmDeleteEnvId === env.id} variant="ghost"
action="Delete" size="sm"
itemType="environment" class="h-7 px-2 text-muted-foreground hover:text-destructive"
itemName={env.name} title="Delete environment"
title="Remove" onclick={() => requestDeleteEnvironment(env.id)}
position="left"
onConfirm={() => deleteEnvironment(env.id)}
onOpenChange={(open) => confirmDeleteEnvId = open ? env.id : null}
> >
{#snippet children({ open })} <Trash2 class="w-3.5 h-3.5" />
<Button </Button>
variant="ghost"
size="sm"
class="h-7 px-2 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}"
title="Delete environment"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
{/snippet}
</ConfirmPopover>
{/if} {/if}
</div> </div>
</Table.Cell> </Table.Cell>
@@ -646,3 +683,72 @@
onSaved={handleSaved} onSaved={handleSaved}
onScannerStatusChange={handleScannerStatusChange} onScannerStatusChange={handleScannerStatusChange}
/> />
<!-- Delete environment confirmation. Lists the on-disk paths that will be
wiped + the count of stacks tracked under this env. Fail-closed:
if the stack-count fetch fails, the dialog still shows but the body
warns that the count couldn't be enumerated. -->
<Dialog.Root bind:open={showDeleteConfirm}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<AlertTriangle class="w-5 h-5 text-destructive" />
Delete environment?
</Dialog.Title>
<Dialog.Description class="pt-2 space-y-3 text-sm">
{#if deleteEnvTarget}
<p>
The environment
<code class="text-xs bg-muted px-1 py-0.5 rounded">{deleteEnvTarget.name}</code>
and the following directories will be permanently removed from the Dockhand host:
</p>
<div class="space-y-1 text-xs font-mono bg-muted/40 rounded-md p-3 border overflow-x-auto">
<div class="flex items-center gap-2 whitespace-nowrap">
<Trash2 class="w-3.5 h-3.5 shrink-0 text-destructive" />
<code class="whitespace-nowrap">$DATA_DIR/stacks/{deleteEnvTarget.name}/</code>
</div>
<div class="flex items-center gap-2 whitespace-nowrap">
<Trash2 class="w-3.5 h-3.5 shrink-0 text-destructive" />
<code class="whitespace-nowrap">$DATA_DIR/git-repos/{deleteEnvTarget.name}/</code>
</div>
</div>
{#if deleteCountsUnknown}
<p>
Couldn't list the stacks on this environment — proceed only
if you're sure what's deployed here.
</p>
{:else if deleteStackCount === 0 && deleteGitStackCount === 0}
<p>No stacks are currently tracked on this environment.</p>
{:else}
<p>
{#if deleteStackCount > 0 && deleteGitStackCount > 0}
<strong>{deleteStackCount} stack{deleteStackCount === 1 ? '' : 's'}</strong>
and <strong>{deleteGitStackCount} git stack{deleteGitStackCount === 1 ? '' : 's'}</strong>
tracked here will be removed from Dockhand's database.
{:else if deleteStackCount > 0}
<strong>{deleteStackCount} stack{deleteStackCount === 1 ? '' : 's'}</strong>
tracked here will be removed from Dockhand's database.
{:else}
<strong>{deleteGitStackCount} git stack{deleteGitStackCount === 1 ? '' : 's'}</strong>
tracked here will be removed from Dockhand's database.
{/if}
</p>
{/if}
<p class="text-muted-foreground">
Running containers on the Docker/Hawser host are <strong>not</strong> stopped.
You can stop or remove them separately.
</p>
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end gap-2 mt-4">
<Button variant="outline" onclick={() => (showDeleteConfirm = false)}>
Cancel
</Button>
<Button variant="destructive" onclick={confirmAndDelete}>
<Trash2 class="w-4 h-4 mr-2" />
Delete environment
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -14,6 +14,7 @@
import { canAccess, authStore } from '$lib/stores/auth'; import { canAccess, authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import ThemeSelector from '$lib/components/ThemeSelector.svelte'; import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
// General settings state - these derive from the store // General settings state - these derive from the store
@@ -375,6 +376,7 @@ services:
<!-- Right column: Theme settings (always shown, with hint when auth enabled) --> <!-- Right column: Theme settings (always shown, with hint when auth enabled) -->
<div class="space-y-4"> <div class="space-y-4">
<ThemeSelector /> <ThemeSelector />
<AnimateIconsToggle />
{#if $authStore.authEnabled} {#if $authStore.authEnabled}
<div class="text-xs text-muted-foreground flex items-start gap-1.5 mt-2 p-2 bg-muted/50 rounded-md"> <div class="text-xs text-muted-foreground flex items-start gap-1.5 mt-2 p-2 bg-muted/50 rounded-md">
<HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5" /> <HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5" />
@@ -159,7 +159,7 @@
} }
} else { } else {
if (!config.urls?.length) { if (!config.urls?.length) {
return 'At least one Apprise URL is required'; return 'At least one webhook URL is required';
} }
} }
return null; return null;
@@ -234,7 +234,7 @@
} }
} else { } else {
if (!config.urls?.length) { if (!config.urls?.length) {
formError = 'At least one Apprise URL is required'; formError = 'At least one webhook URL is required';
return; return;
} }
} }
@@ -311,7 +311,7 @@
<Label>Type</Label> <Label>Type</Label>
{#if isEditing} {#if isEditing}
<Badge variant="secondary" class="h-9 flex items-center justify-center"> <Badge variant="secondary" class="h-9 flex items-center justify-center">
{formType === 'smtp' ? 'SMTP (Email)' : 'Apprise (Webhooks)'} {formType === 'smtp' ? 'SMTP (Email)' : 'Webhooks'}
</Badge> </Badge>
{:else} {:else}
<Select.Root <Select.Root
@@ -324,7 +324,7 @@
{#if formType === 'smtp'} {#if formType === 'smtp'}
<Mail class="w-4 h-4" />SMTP (Email) <Mail class="w-4 h-4" />SMTP (Email)
{:else} {:else}
<Zap class="w-4 h-4" />Apprise (Webhooks) <Zap class="w-4 h-4" />Webhooks
{/if} {/if}
</span> </span>
</Select.Trigger> </Select.Trigger>
@@ -333,7 +333,7 @@
<span class="flex items-center gap-2"><Mail class="w-4 h-4" />SMTP (Email)</span> <span class="flex items-center gap-2"><Mail class="w-4 h-4" />SMTP (Email)</span>
</Select.Item> </Select.Item>
<Select.Item value="apprise"> <Select.Item value="apprise">
<span class="flex items-center gap-2"><Zap class="w-4 h-4" />Apprise (Webhooks)</span> <span class="flex items-center gap-2"><Zap class="w-4 h-4" />Webhooks</span>
</Select.Item> </Select.Item>
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
@@ -409,9 +409,9 @@
</div> </div>
{:else} {:else}
<div class="space-y-4 border-t pt-4 min-h-[380px]"> <div class="space-y-4 border-t pt-4 min-h-[380px]">
<p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Apprise configuration</p> <p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Webhook configuration</p>
<div class="space-y-2"> <div class="space-y-2">
<Label for="notif-apprise-urls">Apprise URLs * (one per line)</Label> <Label for="notif-apprise-urls">Webhook URLs * (one per line)</Label>
<textarea <textarea
id="notif-apprise-urls" id="notif-apprise-urls"
bind:value={formAppriseUrls} bind:value={formAppriseUrls}
@@ -430,11 +430,13 @@ workflows://hostname/workflow/signature
bark://bark_key bark://bark_key
bark://host/bark_key bark://host/bark_key
barks://host/bark_key barks://host/bark_key
signal://host:8080/+sender/+recipient
apprise://host:8000/your-key
jsons://hostname/webhook/path" jsons://hostname/webhook/path"
class="flex min-h-[220px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" class="flex min-h-[220px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea> ></textarea>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Supports Gotify (gotify:// or gotifys:// for HTTPS), Discord, Slack, Mattermost (mmost:// or mmosts://), Telegram, ntfy, Bark, Pushover, Workflows (for e.g. Microsoft Teams), and generic JSON webhooks. Supports Discord, Slack, Mattermost, Telegram, ntfy, Gotify, Pushover, Bark, Signal (via signal-cli-rest-api), Microsoft Teams (via Workflows), and generic JSON. Or use <code>apprise://</code> to forward to a self-hosted <a href="https://github.com/caronc/apprise-api" target="_blank" rel="noopener">caronc/apprise-api</a> server for any provider Apprise upstream supports.
</p> </p>
</div> </div>
</div> </div>
@@ -133,7 +133,7 @@
<div> <div>
<p class="text-sm font-medium">Notification channels</p> <p class="text-sm font-medium">Notification channels</p>
<p class="text-xs text-muted-foreground mt-1"> <p class="text-xs text-muted-foreground mt-1">
Configure notification channels to receive alerts about Docker events. Supports SMTP email and Apprise URLs (Discord, Slack, Telegram, ntfy, and more). Configure notification channels to receive alerts about Docker events. Supports SMTP email and webhook URLs (Discord, Slack, Telegram, ntfy, Bark, Signal, Apprise, and more).
</p> </p>
<p class="text-xs text-amber-600 dark:text-amber-500 mt-2 flex items-center gap-1"> <p class="text-xs text-amber-600 dark:text-amber-500 mt-2 flex items-center gap-1">
<Info class="w-3 h-3" /> <Info class="w-3 h-3" />
@@ -199,7 +199,7 @@
{#if notif.type === 'smtp'} {#if notif.type === 'smtp'}
<span>SMTP: {notif.config.host}:{notif.config.port}</span> <span>SMTP: {notif.config.host}:{notif.config.port}</span>
{:else} {:else}
<span>Apprise: {notif.config.urls?.length || 0} URLs</span> <span>Webhook: {notif.config.urls?.length || 0} URL{notif.config.urls?.length === 1 ? '' : 's'}</span>
{/if} {/if}
</div> </div>
@@ -5,7 +5,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Plus, Trash2, Pencil, Star, Key, Download, Icon } from 'lucide-svelte'; import { Plus, Trash2, Pencil, Star, Key, Download, Icon, Wifi, RefreshCw, CircleCheck, CircleX } from 'lucide-svelte';
import { whale } from '@lucide/lab'; import { whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth'; import { canAccess } from '$lib/stores/auth';
@@ -38,6 +38,8 @@
let showRegModal = $state(false); let showRegModal = $state(false);
let editingReg = $state<Registry | null>(null); let editingReg = $state<Registry | null>(null);
let confirmDeleteRegistryId = $state<number | null>(null); let confirmDeleteRegistryId = $state<number | null>(null);
let testingRegistryId = $state<number | null>(null);
let testResults = $state<Record<number, { success: boolean; message: string }>>({});
async function fetchRegistries() { async function fetchRegistries() {
regLoading = true; regLoading = true;
@@ -75,6 +77,30 @@
} }
} }
async function testRegistry(id: number) {
testingRegistryId = id;
delete testResults[id];
try {
const response = await fetch('/api/registries/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ registryId: id })
});
const result = await response.json();
testResults[id] = result;
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
} catch {
testResults[id] = { success: false, message: 'Connection failed' };
toast.error('Connection test failed');
} finally {
testingRegistryId = null;
}
}
async function setRegDefault(id: number) { async function setRegDefault(id: number) {
try { try {
const response = await fetch(`/api/registries/${id}/default`, { const response = await fetch(`/api/registries/${id}/default`, {
@@ -171,6 +197,23 @@
Set default Set default
</Button> </Button>
{/if} {/if}
<Button
variant="outline"
size="sm"
onclick={() => testRegistry(registry.id)}
disabled={testingRegistryId === registry.id}
title="Test connectivity"
>
{#if testingRegistryId === registry.id}
<RefreshCw class="w-3 h-3 animate-spin" />
{:else if testResults[registry.id]?.success}
<CircleCheck class="w-3 h-3 text-green-500" />
{:else if testResults[registry.id] && !testResults[registry.id].success}
<CircleX class="w-3 h-3 text-red-500" />
{:else}
<Wifi class="w-3 h-3" />
{/if}
</Button>
{#if $canAccess('registries', 'edit')} {#if $canAccess('registries', 'edit')}
<Button <Button
variant="outline" variant="outline"
@@ -3,7 +3,7 @@
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Plus, Check, RefreshCw } from 'lucide-svelte'; import { Plus, Check, RefreshCw, Wifi, CircleCheck, CircleX } from 'lucide-svelte';
import { focusFirstInput } from '$lib/utils'; import { focusFirstInput } from '$lib/utils';
export interface Registry { export interface Registry {
@@ -32,6 +32,8 @@
let formPassword = $state(''); let formPassword = $state('');
let formError = $state(''); let formError = $state('');
let formSaving = $state(false); let formSaving = $state(false);
let testResult = $state<{ success: boolean; message: string } | null>(null);
let testLoading = $state(false);
function resetForm() { function resetForm() {
formName = ''; formName = '';
@@ -40,6 +42,8 @@
formPassword = ''; formPassword = '';
formError = ''; formError = '';
formSaving = false; formSaving = false;
testResult = null;
testLoading = false;
} }
// Initialize form when registry changes or modal opens // Initialize form when registry changes or modal opens
@@ -51,12 +55,52 @@
formUsername = registry.username || ''; formUsername = registry.username || '';
formPassword = ''; formPassword = '';
formError = ''; formError = '';
testResult = null;
} else { } else {
resetForm(); resetForm();
} }
} }
}); });
async function testConnection() {
if (!formUrl.trim()) {
formError = 'URL is required to test';
return;
}
testLoading = true;
testResult = null;
formError = '';
try {
const body: Record<string, any> = { url: formUrl.trim() };
// For edit mode with no new password, use the saved registry's credentials
if (isEditing && !formPassword && registry?.id) {
// If username present but no new password, test via registry ID to use stored creds
if (formUsername.trim()) {
body.registryId = registry.id;
delete body.url;
}
} else {
if (formUsername.trim()) body.username = formUsername.trim();
if (formPassword) body.password = formPassword;
}
const response = await fetch('/api/registries/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
testResult = await response.json();
} catch {
testResult = { success: false, message: 'Failed to test connection' };
} finally {
testLoading = false;
}
}
async function save() { async function save() {
if (!formName.trim() || !formUrl.trim()) { if (!formName.trim() || !formUrl.trim()) {
formError = 'Name and URL are required'; formError = 'Name and URL are required';
@@ -136,7 +180,26 @@
</div> </div>
</div> </div>
</div> </div>
{#if testResult}
<div class="flex items-center gap-2 text-sm {testResult.success ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
{#if testResult.success}
<CircleCheck class="w-4 h-4 shrink-0" />
{:else}
<CircleX class="w-4 h-4 shrink-0" />
{/if}
<span>{testResult.message}</span>
</div>
{/if}
<Dialog.Footer> <Dialog.Footer>
<Button variant="outline" onclick={testConnection} disabled={testLoading}>
{#if testLoading}
<RefreshCw class="w-4 h-4 animate-spin" />
{:else}
<Wifi class="w-4 h-4" />
{/if}
Test
</Button>
<div class="flex-1"></div>
<Button variant="outline" onclick={handleClose}>Cancel</Button> <Button variant="outline" onclick={handleClose}>Cancel</Button>
<Button onclick={save} disabled={formSaving}> <Button onclick={save} disabled={formSaving}>
{#if formSaving} {#if formSaving}
+1 -1
View File
@@ -2076,7 +2076,7 @@
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded {portUrl ? 'bg-primary/10 text-primary hover:bg-primary/20' : 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800'} transition-colors" class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded {portUrl ? 'bg-primary/10 text-primary hover:bg-primary/20' : 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800'} transition-colors"
title="Open {url} in new tab" title="Open {url} in new tab"
> >
<code>{port.display}</code> <code>{portParsed?.name ?? port.display}</code>
<ExternalLink class="w-2.5 h-2.5 {portUrl ? 'opacity-60' : ''}" /> <ExternalLink class="w-2.5 h-2.5 {portUrl ? 'opacity-60' : ''}" />
</a> </a>
{:else} {:else}
+24 -3
View File
@@ -4,6 +4,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -28,7 +29,7 @@
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state'; import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import { DataGrid } from '$lib/components/data-grid'; import { DataGrid } from '$lib/components/data-grid';
type SortField = 'name' | 'driver' | 'stack' | 'created'; type SortField = 'name' | 'driver' | 'type' | 'stack' | 'created';
type SortDirection = 'asc' | 'desc'; type SortDirection = 'asc' | 'desc';
let volumes = $state<VolumeInfo[]>([]); let volumes = $state<VolumeInfo[]>([]);
@@ -202,7 +203,10 @@
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
result = result.filter(vol => result = result.filter(vol =>
vol.name.toLowerCase().includes(query) || vol.name.toLowerCase().includes(query) ||
(vol.labels['com.docker.compose.project'] || '').toLowerCase().includes(query) (vol.labels['com.docker.compose.project'] || '').toLowerCase().includes(query) ||
// Match the driver_opts type (e.g. "nfs", "cifs") so users can
// quickly find network-mounted volumes by typing the protocol.
(vol.options?.type || '').toLowerCase().includes(query)
); );
} }
@@ -216,6 +220,10 @@
case 'driver': case 'driver':
cmp = a.driver.localeCompare(b.driver); cmp = a.driver.localeCompare(b.driver);
break; break;
case 'type':
// Volumes without driver_opts.type sort below those with one.
cmp = (a.options?.type || '').localeCompare(b.options?.type || '');
break;
case 'stack': case 'stack':
const stackA = a.labels['com.docker.compose.project'] || ''; const stackA = a.labels['com.docker.compose.project'] || '';
const stackB = b.labels['com.docker.compose.project'] || ''; const stackB = b.labels['com.docker.compose.project'] || '';
@@ -539,11 +547,24 @@
<code class="text-xs truncate block" title={volume.name}>{volume.name}</code> <code class="text-xs truncate block" title={volume.name}>{volume.name}</code>
{:else if column.id === 'driver'} {:else if column.id === 'driver'}
<Badge variant="outline" class="text-xs py-0 px-1.5 shadow-sm rounded-sm">{volume.driver}</Badge> <Badge variant="outline" class="text-xs py-0 px-1.5 shadow-sm rounded-sm">{volume.driver}</Badge>
{:else if column.id === 'type'}
{#if volume.options?.type}
<Badge variant="outline" class="text-xs py-0 px-1.5 shadow-sm rounded-sm" title={Object.entries(volume.options).map(([k, v]) => `${k}=${v}`).join('\n')}>{volume.options.type}</Badge>
{:else}
<span class="text-muted-foreground text-xs">-</span>
{/if}
{:else if column.id === 'scope'} {:else if column.id === 'scope'}
<span class="text-xs">{volume.scope}</span> <span class="text-xs">{volume.scope}</span>
{:else if column.id === 'stack'} {:else if column.id === 'stack'}
{#if stack} {#if stack}
<Badge variant="secondary" class="text-xs py-0 px-1.5 shadow-sm rounded-sm">{stack}</Badge> <button
type="button"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
class="cursor-pointer"
title={`Open stack "${stack}"`}
>
<Badge variant="secondary" class="text-xs py-0 px-1.5 shadow-sm rounded-sm hover:bg-primary/10 hover:border-primary/50 transition-colors">{stack}</Badge>
</button>
{:else} {:else}
<span class="text-muted-foreground text-xs">-</span> <span class="text-muted-foreground text-xs">-</span>
{/if} {/if}
+27 -2
View File
@@ -2,7 +2,9 @@
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Loader2, HardDrive } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip';
import { Loader2, HardDrive, Layers } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
import { formatDateTime } from '$lib/stores/settings'; import { formatDateTime } from '$lib/stores/settings';
@@ -50,9 +52,32 @@
<Dialog.Root bind:open> <Dialog.Root bind:open>
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col"> <Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col">
<Dialog.Header class="shrink-0"> <Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2"> <Dialog.Title class="flex items-center gap-2 flex-wrap">
<HardDrive class="w-5 h-5" /> <HardDrive class="w-5 h-5" />
Volume details: <span class="text-muted-foreground font-normal break-all">{volumeName}</span> Volume details: <span class="text-muted-foreground font-normal break-all">{volumeName}</span>
{@const composeStack = volumeData?.Labels?.['com.docker.compose.project']}
{#if composeStack && !loading}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
onclick={() => {
open = false;
goto(appendEnvParam(`/stacks?search=${encodeURIComponent(composeStack)}`, $currentEnvironment?.id ?? null));
}}
class="cursor-pointer inline-flex items-center"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors gap-1">
<Layers class="w-3 h-3" />
{composeStack}
</Badge>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-xs whitespace-nowrap">Open stack "{composeStack}"</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</Dialog.Title> </Dialog.Title>
</Dialog.Header> </Dialog.Header>