mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.33
This commit is contained in:
+1
-1
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose=5.1.4-r4" \
|
||||
" - docker-compose=5.1.4-r5" \
|
||||
" - docker-cli-buildx" \
|
||||
" - sqlite" \
|
||||
" - postgresql-client" \
|
||||
|
||||
+60
-14
@@ -221,13 +221,19 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
baseURL = "http://localhost"
|
||||
|
||||
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{
|
||||
DialContext: tcpDial,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
MaxConnsPerHost: 16,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
MaxConnsPerHost: 4,
|
||||
@@ -242,7 +248,9 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
}
|
||||
streamTLSCfg := tlsCfg.Clone()
|
||||
|
||||
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
|
||||
transport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
TLSClientConfig: tlsCfg,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
@@ -250,6 +258,7 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
TLSClientConfig: streamTLSCfg,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
@@ -322,15 +331,32 @@ func (e *environment) doStreamRequest(ctx context.Context, method, path string)
|
||||
return e.streamClient.Do(req)
|
||||
}
|
||||
|
||||
func (e *environment) ping(ctx context.Context) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
func (e *environment) ping(ctx context.Context) error {
|
||||
attempt := func() error {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
resp, err := e.doRequest(ctx, "GET", "/_ping")
|
||||
resp, err := e.doRequest(pingCtx, "GET", "/_ping")
|
||||
if err != nil {
|
||||
return false
|
||||
return err
|
||||
}
|
||||
drainAndClose(resp)
|
||||
return resp.StatusCode == 200
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("ping returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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) {
|
||||
if !env.ping(env.ctx) {
|
||||
if err := env.ping(env.ctx); err != nil {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
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
|
||||
}
|
||||
@@ -558,11 +584,11 @@ func (m *manager) runEvents(env *environment) {
|
||||
}
|
||||
|
||||
// Stream mode
|
||||
if !env.ping(env.ctx) {
|
||||
if err := env.ping(env.ctx); err != nil {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
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) {
|
||||
return
|
||||
@@ -609,12 +635,32 @@ func (m *manager) runEvents(env *environment) {
|
||||
// Force-close the body on context cancellation so scanner.Scan()
|
||||
// unblocks. Without this, the goroutine can leak if the transport's
|
||||
// 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{})
|
||||
var closeBodyOnce sync.Once
|
||||
closeBody := func() { closeBodyOnce.Do(func() { resp.Body.Close() }) }
|
||||
go func() {
|
||||
watchdog := time.NewTicker(90 * time.Second)
|
||||
defer watchdog.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
resp.Body.Close()
|
||||
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)
|
||||
resp.Body.Close()
|
||||
closeBody()
|
||||
|
||||
if env.ctx.Err() != nil {
|
||||
return
|
||||
@@ -653,11 +699,11 @@ func (m *manager) runEvents(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 {
|
||||
env.online = false
|
||||
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
|
||||
}
|
||||
@@ -736,7 +782,7 @@ func (m *manager) runDiskChecks(env *environment) {
|
||||
}
|
||||
|
||||
func (m *manager) checkDisk(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if env.ping(env.ctx) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "synced_files" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781158711008,
|
||||
"tag": "0007_add_synced_files",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `git_stacks` ADD `synced_files` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1781158702731,
|
||||
"tag": "0007_add_synced_files",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.27",
|
||||
"version": "1.0.33",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
@@ -63,8 +63,9 @@
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-sql": "6.10.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/legacy-modes": "6.5.3",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
* Usage: node ./server.js
|
||||
*/
|
||||
|
||||
import { createServer, request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { createServer as createHttpServer, request as httpRequest } from 'node:http';
|
||||
import { createServer as createHttpsServer, request as httpsRequest } from 'node:https';
|
||||
import { createConnection } from 'node:net';
|
||||
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 { handler } from './build/handler.js';
|
||||
|
||||
@@ -28,10 +29,82 @@ console.warn = (...args) => _warn(ts(), ...args);
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
// Create HTTP server with SvelteKit handler
|
||||
const server = createServer((req, res) => {
|
||||
// Optional native HTTPS listener (#1102). Off by default to keep existing
|
||||
// deployments unchanged. When HTTPS_MODE=on, HTTPS_CERT_PATH and
|
||||
// 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
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
@@ -458,7 +531,8 @@ function handleHawserConnection(ws, connId, remoteIp) {
|
||||
|
||||
// Start the server
|
||||
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
@@ -1341,6 +1341,16 @@ html {
|
||||
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) */
|
||||
.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)); }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
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';
|
||||
// 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)
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||
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
|
||||
const dotenvParser: StreamParser<{ inValue: boolean }> = {
|
||||
@@ -496,6 +500,21 @@
|
||||
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
|
||||
function getLanguageExtension(lang: string) {
|
||||
switch (lang) {
|
||||
@@ -527,12 +546,18 @@
|
||||
return xml();
|
||||
case 'sql':
|
||||
return sql();
|
||||
case 'dockerfile':
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
// No dedicated shell/dockerfile support, use basic highlighting
|
||||
return [];
|
||||
return StreamLanguage.define(shell);
|
||||
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 'env':
|
||||
return StreamLanguage.define(dotenvParser);
|
||||
@@ -671,7 +696,9 @@
|
||||
]),
|
||||
...themeExtensions,
|
||||
EditorView.lineWrapping,
|
||||
getLanguageExtension(language)
|
||||
EditorState.tabSize.of(2),
|
||||
getLanguageExtension(language),
|
||||
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
|
||||
].flat();
|
||||
|
||||
if (readonly) {
|
||||
|
||||
@@ -309,7 +309,7 @@
|
||||
class="h-10"
|
||||
>
|
||||
{#if isPulling}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
<Download class="w-4 h-4 mr-2 animate-spin" />
|
||||
Pulling...
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
@@ -327,7 +327,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
{#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>
|
||||
{:else if status === 'complete'}
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600" />
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
<Shield class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">Ready to scan</span>
|
||||
{: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>
|
||||
{:else if status === 'complete'}
|
||||
{#if hasCriticalOrHigh}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte';
|
||||
import { compareVersions } from '$lib/utils/version';
|
||||
import ChangelogText from '$lib/components/ChangelogText.svelte';
|
||||
|
||||
interface ChangelogEntry {
|
||||
version: string;
|
||||
@@ -66,7 +67,7 @@
|
||||
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
|
||||
<span class="text-sm">{change.text}</span>
|
||||
<ChangelogText text={change.text} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,7 @@ export const volumeColumns: ColumnConfig[] = [
|
||||
{ id: 'select', label: '', fixed: 'start', width: 32, resizable: false },
|
||||
{ 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: 'type', label: 'Type', sortable: true, sortField: 'type', width: 80, minWidth: 60 },
|
||||
{ id: 'scope', label: 'Scope', width: 70, minWidth: 50 },
|
||||
{ id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 120, minWidth: 80 },
|
||||
{ id: 'usedBy', label: 'Used by', width: 150, minWidth: 80 },
|
||||
|
||||
@@ -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",
|
||||
"date": "2026-06-06",
|
||||
|
||||
+30
-7
@@ -388,15 +388,17 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
gridFontSize: string;
|
||||
terminalFont: 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, 'dark_theme'),
|
||||
getUserSetting(userId, 'font'),
|
||||
getUserSetting(userId, 'font_size'),
|
||||
getUserSetting(userId, 'grid_font_size'),
|
||||
getUserSetting(userId, 'terminal_font'),
|
||||
getUserSetting(userId, 'editor_font')
|
||||
getUserSetting(userId, 'editor_font'),
|
||||
getUserSetting(userId, 'animate_icons')
|
||||
]);
|
||||
return {
|
||||
lightTheme: lightTheme || 'default',
|
||||
@@ -405,13 +407,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
fontSize: fontSize || 'normal',
|
||||
gridFontSize: gridFontSize || 'normal',
|
||||
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(
|
||||
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> {
|
||||
const updates: Promise<void>[] = [];
|
||||
if (prefs.lightTheme !== undefined) {
|
||||
@@ -435,6 +439,9 @@ export async function setUserThemePreferences(
|
||||
if (prefs.editorFont !== undefined) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2097,6 +2104,7 @@ export interface GitStackData {
|
||||
lastCommit: string | null;
|
||||
syncStatus: GitSyncStatus;
|
||||
syncError: string | null;
|
||||
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2303,6 +2311,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
syncError: gitStacks.syncError,
|
||||
syncedFiles: gitStacks.syncedFiles,
|
||||
createdAt: gitStacks.createdAt,
|
||||
updatedAt: gitStacks.updatedAt,
|
||||
repoName: gitRepositories.name,
|
||||
@@ -2337,6 +2346,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
syncError: row.syncError,
|
||||
syncedFiles: row.syncedFiles ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
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.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
|
||||
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));
|
||||
return getGitStack(id);
|
||||
@@ -3592,9 +3603,15 @@ export async function getContainerEventActions(): Promise<string[]> {
|
||||
|
||||
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
|
||||
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
const countResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(containerEvents)
|
||||
.where(sql`timestamp < ${cutoffDate}`);
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
if (count > 0) {
|
||||
await db.delete(containerEvents)
|
||||
.where(sql`timestamp < ${cutoffDate}`);
|
||||
return 0;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4082,9 +4099,15 @@ export async function getRecentExecutionsForSchedule(
|
||||
|
||||
export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
|
||||
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}`);
|
||||
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
|
||||
|
||||
@@ -324,6 +324,7 @@ export const gitStacks = sqliteTable('git_stacks', {
|
||||
lastCommit: text('last_commit'),
|
||||
syncStatus: text('sync_status').default('pending'),
|
||||
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`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
|
||||
@@ -327,6 +327,7 @@ export const gitStacks = pgTable('git_stacks', {
|
||||
lastCommit: text('last_commit'),
|
||||
syncStatus: text('sync_status').default('pending'),
|
||||
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(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
|
||||
+157
-17
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
|
||||
import type { Environment } from './db';
|
||||
import { getStackEnvVarsAsRecord } from './db';
|
||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
||||
import { encodeRegistryAuth } from './registry-auth';
|
||||
import { isSystemContainer } from './scheduler/tasks/update-utils';
|
||||
import { deepDiff } from '../utils/diff.js';
|
||||
|
||||
@@ -286,7 +287,7 @@ const envCache = new Map<number, CachedEnv>();
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
|
||||
// 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',
|
||||
'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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
// Check if container has TTY enabled
|
||||
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.
|
||||
* Looks up stored registry credentials and returns a headers object
|
||||
@@ -2566,16 +2644,19 @@ export async function buildRegistryAuthHeader(imageName: string): Promise<Record
|
||||
if (creds) {
|
||||
// Docker Engine requires 'https://index.docker.io/v1/' as serveraddress
|
||||
// for Docker Hub auth — just the hostname is treated as unauthenticated
|
||||
const serveraddress = DOCKER_HUB_HOSTS.has(registry)
|
||||
? 'https://index.docker.io/v1/'
|
||||
: registry;
|
||||
console.log(`[Pull] Using credentials for ${serveraddress} (user: ${creds.username})`);
|
||||
const isHub = DOCKER_HUB_HOSTS.has(registry);
|
||||
const serveraddress = isHub ? 'https://index.docker.io/v1/' : registry;
|
||||
if (isHub) {
|
||||
console.log(`[Registry] docker-hub variant '${registry}' canonicalized to https://index.docker.io/v1/ for auth`);
|
||||
}
|
||||
const authConfig = {
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
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 {
|
||||
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
|
||||
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
|
||||
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) {
|
||||
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
|
||||
@@ -2644,6 +2733,9 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error || data.errorDetail) {
|
||||
console.error(`[Pull] stream error: ${line}`);
|
||||
}
|
||||
if (onProgress) onProgress(data);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
@@ -2780,7 +2872,10 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
|
||||
if (stored.fullRegistry === requested.fullRegistry ||
|
||||
(stored.host === requested.host && !stored.path)) {
|
||||
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);
|
||||
if (DOCKER_HUB_HOSTS.has(stored.host)) {
|
||||
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;
|
||||
} catch (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 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);
|
||||
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' };
|
||||
|
||||
@@ -2992,10 +3099,14 @@ export async function getRegistryAuthHeader(
|
||||
const realm = realmMatch[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);
|
||||
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' };
|
||||
|
||||
@@ -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
|
||||
let currentImageInfo: any;
|
||||
try {
|
||||
@@ -3470,7 +3592,11 @@ export async function listVolumes(envId?: number | null): Promise<VolumeInfo[]>
|
||||
scope: volume.Scope,
|
||||
created: volume.CreatedAt,
|
||||
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
|
||||
const [repo, tag = 'latest'] = imageTag.split(':');
|
||||
|
||||
// Create X-Registry-Auth header
|
||||
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
const authHeader = encodeRegistryAuth(authConfig);
|
||||
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(
|
||||
`/images/${encodeURIComponent(imageTag)}/push`,
|
||||
pushUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
streaming: true,
|
||||
@@ -4473,8 +4609,11 @@ export async function pushImage(
|
||||
envId
|
||||
);
|
||||
|
||||
console.log(`[Push] response status=${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error(`[Push] error body: ${error}`);
|
||||
throw new Error(`Failed to push image: ${error}`);
|
||||
}
|
||||
|
||||
@@ -4498,6 +4637,7 @@ export async function pushImage(
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error) {
|
||||
console.error(`[Push] stream error: ${line}`);
|
||||
throw new Error(data.error);
|
||||
}
|
||||
if (onProgress) onProgress(data);
|
||||
|
||||
@@ -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
@@ -15,6 +15,21 @@ import {
|
||||
type GitStackWithRepo
|
||||
} from './db';
|
||||
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';
|
||||
let mergedCaBundleReady = false;
|
||||
@@ -363,6 +378,93 @@ async function getChangedFilesInDir(
|
||||
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 {
|
||||
success: boolean;
|
||||
commit?: string;
|
||||
@@ -375,6 +477,11 @@ export interface SyncResult {
|
||||
error?: string;
|
||||
updated?: boolean;
|
||||
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 {
|
||||
@@ -954,6 +1061,14 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
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
|
||||
await updateGitStack(stackId, {
|
||||
syncStatus: 'synced',
|
||||
@@ -982,7 +1097,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
envFileVars,
|
||||
envFileName,
|
||||
updated,
|
||||
changedFiles
|
||||
changedFiles,
|
||||
deletionPlan: deletionData.plan,
|
||||
newFiles: deletionData.newFiles,
|
||||
newCommitFull: newCommit,
|
||||
previousManifest: deletionData.previousManifest
|
||||
};
|
||||
} catch (error: any) {
|
||||
cleanupSshKey(credential);
|
||||
@@ -1065,7 +1184,8 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
forceRecreate,
|
||||
build: gitStack.buildOnDeploy,
|
||||
noBuildCache: gitStack.noBuildCache,
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
||||
filesToDelete: syncResult.deletionPlan?.toDelete
|
||||
});
|
||||
|
||||
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.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
|
||||
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
|
||||
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
|
||||
await updateGitStack(stackId, {
|
||||
syncStatus: 'synced',
|
||||
@@ -1319,6 +1461,14 @@ export async function deployGitStackWithProgress(
|
||||
// Step 5: Deploying stack
|
||||
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
|
||||
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)
|
||||
let envFileName: string | undefined;
|
||||
@@ -1338,10 +1488,24 @@ export async function deployGitStackWithProgress(
|
||||
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
||||
build: gitStack.buildOnDeploy,
|
||||
noBuildCache: gitStack.noBuildCache,
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
||||
filesToDelete: deletionData.plan.toDelete
|
||||
});
|
||||
|
||||
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
|
||||
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
|
||||
const resolvedComposePath = join(stackDir, progressComposeFileName);
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
|
||||
import { logContainerEvent, type ContainerEventAction } from './db.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 { isHealthTransition } from './subprocess-manager.js';
|
||||
import { pushMetric } from './metrics-store.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
@@ -178,6 +179,12 @@ export async function handleEdgeContainerEvent(
|
||||
// Log the event
|
||||
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
|
||||
const savedEvent = await logContainerEvent({
|
||||
environmentId,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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 { sendEventNotification } from '../../notifications';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -382,6 +382,18 @@ export async function runContainerUpdate(
|
||||
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
|
||||
if (isDigestBasedImage(imageNameFromConfig)) {
|
||||
log(`Skipping ${containerName} - image pinned to specific digest`);
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
import { recreateContainer } from './container-update';
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -105,9 +105,12 @@ export async function runEnvUpdateCheckJob(
|
||||
// Clear pending updates at the start - we'll re-add as we discover updates
|
||||
await clearPendingContainerUpdates(environmentId);
|
||||
|
||||
// Get all containers in this environment
|
||||
const containers = await listContainers(true, environmentId);
|
||||
await log(`Found ${containers.length} containers`);
|
||||
// Get all containers in this environment, excluding ones hidden via
|
||||
// dockhand.hidden=true (consistent with manual check-updates, #1083).
|
||||
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[] = [];
|
||||
let checkedCount = 0;
|
||||
|
||||
+127
-11
@@ -9,6 +9,15 @@ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSyn
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { spawn as nodeSpawn } 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 {
|
||||
getEnvironment,
|
||||
getSecretEnvVarsAsRecord,
|
||||
@@ -61,6 +70,8 @@ export interface StackOperationResult {
|
||||
error?: string;
|
||||
/** The docker compose command that was executed (for debugging/testing) */
|
||||
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)
|
||||
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
|
||||
/** Git deletion sync (#966): files confirmed safe to delete from the stack dir */
|
||||
filesToDelete?: FileToDelete[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -830,6 +843,10 @@ interface ComposeCommandOptions {
|
||||
serviceName?: string;
|
||||
/** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */
|
||||
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,
|
||||
build?: boolean,
|
||||
noBuildCache?: boolean,
|
||||
pullPolicy?: string
|
||||
pullPolicy?: string,
|
||||
filesToDelete?: FileToDelete[],
|
||||
removeFiles?: boolean
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
// Import dockerFetch dynamically to avoid circular dependency
|
||||
@@ -1362,7 +1381,14 @@ async function executeComposeViaHawser(
|
||||
noBuildCache: (build && noBuildCache) || false,
|
||||
pullPolicy: pullPolicy || '',
|
||||
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...`);
|
||||
@@ -1380,6 +1406,8 @@ async function executeComposeViaHawser(
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
deletedFiles?: string[];
|
||||
skippedFiles?: { path: string; reason: string }[];
|
||||
};
|
||||
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
@@ -1393,16 +1421,50 @@ async function executeComposeViaHawser(
|
||||
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) {
|
||||
return {
|
||||
success: true,
|
||||
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`
|
||||
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`,
|
||||
deletion
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
output: result.output || '',
|
||||
error: result.error || `Compose ${operation} failed`
|
||||
error: result.error || `Compose ${operation} failed`,
|
||||
deletion
|
||||
};
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -1431,7 +1493,7 @@ async function executeComposeCommand(
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>
|
||||
): 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
|
||||
const env = envId ? await getEnvironment(envId) : null;
|
||||
@@ -1521,7 +1583,9 @@ async function executeComposeCommand(
|
||||
composeFileName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
pullPolicy,
|
||||
filesToDelete,
|
||||
removeFiles
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1560,12 +1624,20 @@ async function executeComposeCommand(
|
||||
}
|
||||
|
||||
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(
|
||||
operation,
|
||||
stackName,
|
||||
composeContent,
|
||||
undefined, // dockerHost
|
||||
sock,
|
||||
undefined, // tlsConfig
|
||||
envVars,
|
||||
secretVars,
|
||||
@@ -1583,6 +1655,7 @@ async function executeComposeCommand(
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STACK DISCOVERY
|
||||
@@ -2088,6 +2161,24 @@ export async function removeStack(
|
||||
if (composeResult.success) {
|
||||
const envVars = await getNonSecretEnvVarsAsRecord(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(
|
||||
'down',
|
||||
{
|
||||
@@ -2096,7 +2187,10 @@ export async function removeStack(
|
||||
removeVolumes,
|
||||
workingDir: composeResult.stackDir,
|
||||
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!,
|
||||
envVars,
|
||||
@@ -2267,7 +2361,7 @@ export async function removeStack(
|
||||
* Uses stack locking to prevent concurrent deployments.
|
||||
*/
|
||||
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}]`;
|
||||
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
@@ -2299,6 +2393,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
let actualComposePath: string | undefined;
|
||||
let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks)
|
||||
let stackFiles: Record<string, string> | undefined;
|
||||
let localDeletionResult: DeletionApplyResult | undefined;
|
||||
|
||||
if (composePath) {
|
||||
// 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')
|
||||
});
|
||||
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 {
|
||||
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
|
||||
const source = await getStackSource(name, envId);
|
||||
@@ -2422,7 +2532,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
envPath: actualEnvPath,
|
||||
useOverrideFile: isGitStack,
|
||||
// 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,
|
||||
isGitStack ? dbNonSecretVars : undefined,
|
||||
@@ -2438,6 +2549,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
if (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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,6 +98,35 @@ const envNames: Map<number, string> = new Map();
|
||||
// Track which envIds are currently configured in Go
|
||||
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
|
||||
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();
|
||||
|
||||
// Skip redundant health_status events (only store transitions: healthy↔unhealthy)
|
||||
if (!isHealthTransition(msg.envId, containerId, action)) {
|
||||
rssAfterOp('events', before);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sub-category: DB insert
|
||||
const dbBefore = rssBeforeOp();
|
||||
try {
|
||||
|
||||
+12
-2
@@ -20,6 +20,7 @@ export interface ThemePreferences {
|
||||
gridFontSize: FontSize;
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
animateIcons: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dockhand-theme';
|
||||
@@ -31,7 +32,8 @@ const defaultPrefs: ThemePreferences = {
|
||||
fontSize: 'normal',
|
||||
gridFontSize: 'normal',
|
||||
terminalFont: 'system-mono',
|
||||
editorFont: 'system-mono'
|
||||
editorFont: 'system-mono',
|
||||
animateIcons: true
|
||||
};
|
||||
|
||||
// Font size scale mapping
|
||||
@@ -100,7 +102,12 @@ function createThemeStore() {
|
||||
fontSize: data.fontSize || data.font_size || 'normal',
|
||||
gridFontSize: data.gridFontSize || data.grid_font_size || 'normal',
|
||||
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);
|
||||
saveToStorage(prefs);
|
||||
@@ -198,6 +205,9 @@ export function applyTheme(prefs: ThemePreferences) {
|
||||
|
||||
// Apply editor font
|
||||
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
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface VolumeInfo {
|
||||
createdAt?: string;
|
||||
created: string; // Alias for createdAt, populated by API
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||
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';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
@@ -42,7 +42,9 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
@@ -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);
|
||||
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 {
|
||||
containerId: container.id,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { validateEnvName } from '$lib/utils/env-name';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -71,8 +72,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
if (!data.name) {
|
||||
return json({ error: 'Name is required' }, { status: 400 });
|
||||
const nameCheck = validateEnvName(data.name);
|
||||
if (!nameCheck.ok) {
|
||||
return json({ error: nameCheck.reason }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if environment with this name already exists
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { join } from 'path';
|
||||
import { existsSync, rmSync } from 'fs';
|
||||
import { existsSync, rmSync, renameSync } from 'fs';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
|
||||
import { clearDockerClientCache } from '$lib/server/docker';
|
||||
@@ -11,6 +11,7 @@ import { auditEnvironment } from '$lib/server/audit';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { validateEnvName } from '$lib/utils/env-name';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { closeEdgeConnection } from '$lib/server/hawser';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
@@ -64,6 +65,65 @@ export const PUT: RequestHandler = async (event) => {
|
||||
|
||||
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
|
||||
clearDockerClientCache(id);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectImage, tagImage, pushImage, parseRegistryUrl } from '$lib/server/docker';
|
||||
import { encodeRegistryAuth } from '$lib/server/registry-auth';
|
||||
import { getRegistry, getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
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 authConfig = registry.username && registry.password
|
||||
? {
|
||||
username: registry.username,
|
||||
password: registry.password,
|
||||
// Trim to neutralize stray whitespace in legacy stored credentials (#1105)
|
||||
username: registry.username.trim(),
|
||||
password: registry.password.trim(),
|
||||
serveraddress: authServerAddress
|
||||
}
|
||||
: {
|
||||
@@ -148,7 +150,18 @@ export const POST: RequestHandler = async (event) => {
|
||||
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) => {
|
||||
sendEdgeStreamRequest(
|
||||
|
||||
@@ -63,7 +63,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
} else if (type === 'apprise') {
|
||||
const appriseConfig = config as AppriseConfig;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
} else if (existing.type === 'apprise') {
|
||||
const appriseConfig = config as AppriseConfig;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (data.type === 'apprise') {
|
||||
const config = data.config;
|
||||
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 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 (!validLightThemeIds.includes(data.lightTheme)) {
|
||||
@@ -96,6 +96,13 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
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);
|
||||
|
||||
// Return updated preferences
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditRegistry } from '$lib/server/audit';
|
||||
import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker';
|
||||
|
||||
export const GET: RequestHandler = async ({ 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 });
|
||||
}
|
||||
|
||||
// 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({
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
username: data.username || undefined,
|
||||
password: data.password || undefined,
|
||||
username: trimmedUsername || undefined,
|
||||
password: trimmedPassword || undefined,
|
||||
isDefault: data.isDefault || false
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getRegistry, updateRegistry, deleteRegistry, setDefaultRegistry } from
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditRegistry } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -51,11 +52,22 @@ export const PUT: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
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, {
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
username: trimmedUsername,
|
||||
password: trimmedPassword,
|
||||
isDefault: data.isDefault
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -90,6 +90,8 @@ export interface GeneralSettings {
|
||||
defaultComposeTemplate: string;
|
||||
// Label filter mode
|
||||
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'> = {
|
||||
@@ -122,6 +124,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
|
||||
labelFilterMode: 'any' as const,
|
||||
animateIcons: true,
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -199,7 +202,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultGrypeImage,
|
||||
defaultTrivyImage,
|
||||
defaultComposeTemplate,
|
||||
labelFilterMode
|
||||
labelFilterMode,
|
||||
animateIcons
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -238,7 +242,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode')
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('animate_icons')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -281,7 +286,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
@@ -299,7 +305,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
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) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -439,6 +445,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
|
||||
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
|
||||
const [
|
||||
@@ -479,7 +488,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultGrypeImageVal,
|
||||
defaultTrivyImageVal,
|
||||
defaultComposeTemplateVal,
|
||||
labelFilterModeVal
|
||||
labelFilterModeVal,
|
||||
animateIconsVal
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -518,7 +528,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode')
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('animate_icons')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -561,7 +572,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
|
||||
@@ -13,7 +13,8 @@ const DEFAULT_THEME_SETTINGS = {
|
||||
fontSize: 'normal',
|
||||
gridFontSize: 'normal',
|
||||
terminalFont: 'system-mono',
|
||||
editorFont: 'system-mono'
|
||||
editorFont: 'system-mono',
|
||||
animateIcons: true
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
@@ -25,7 +26,8 @@ export const GET: RequestHandler = async () => {
|
||||
fontSize,
|
||||
gridFontSize,
|
||||
terminalFont,
|
||||
editorFont
|
||||
editorFont,
|
||||
animateIcons
|
||||
] = await Promise.all([
|
||||
getSetting('theme_light'),
|
||||
getSetting('theme_dark'),
|
||||
@@ -33,7 +35,8 @@ export const GET: RequestHandler = async () => {
|
||||
getSetting('theme_font_size'),
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font'),
|
||||
getSetting('theme_editor_font')
|
||||
getSetting('theme_editor_font'),
|
||||
getSetting('animate_icons')
|
||||
]);
|
||||
|
||||
return json({
|
||||
@@ -43,7 +46,8 @@ export const GET: RequestHandler = async () => {
|
||||
fontSize: fontSize ?? DEFAULT_THEME_SETTINGS.fontSize,
|
||||
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
|
||||
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) {
|
||||
console.error('Failed to get theme settings:', error);
|
||||
|
||||
+3
-2
@@ -158,8 +158,9 @@ export const POST: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
defined = envVars.map(v => v.key).sort();
|
||||
}
|
||||
|
||||
// Calculate missing and unused
|
||||
const missing = required.filter(v => !defined.includes(v));
|
||||
// Calculate missing and unused. Built-in Docker/Compose vars are provided implicitly
|
||||
// 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 result: ValidationResult = {
|
||||
|
||||
@@ -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"
|
||||
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'}" />
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -621,6 +622,29 @@
|
||||
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
{/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}
|
||||
<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' : ''}" />
|
||||
@@ -1143,7 +1167,7 @@
|
||||
class="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
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" />
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
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 * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
@@ -159,6 +161,11 @@
|
||||
totalVulnerabilities?: number;
|
||||
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 {
|
||||
@@ -213,7 +220,9 @@
|
||||
configSets,
|
||||
selectedConfigSetId = $bindable(),
|
||||
errors = $bindable(),
|
||||
imageSummary
|
||||
imageSummary,
|
||||
containerId,
|
||||
envId
|
||||
}: Props = $props();
|
||||
|
||||
// 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 {
|
||||
const base = 'text-2xs px-1.5 py-0.5 rounded font-medium';
|
||||
switch (driver.toLowerCase()) {
|
||||
@@ -775,6 +889,7 @@
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs font-medium">Restart policy</Label>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Select.Root type="single" bind:value={restartPolicy}>
|
||||
<Select.Trigger id="restartPolicy" tabindex={0} class="w-full h-9">
|
||||
<span class="flex items-center">
|
||||
@@ -817,6 +932,24 @@
|
||||
</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}
|
||||
<CornerDownLeft class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if restartPolicy === 'on-failure'}
|
||||
<div class="space-y-1.5 mt-2">
|
||||
<Label class="text-xs font-medium">Max retry count</Label>
|
||||
@@ -1334,36 +1467,72 @@
|
||||
</button>
|
||||
{#if showResources}
|
||||
<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="space-y-1.5">
|
||||
<Label for="memoryLimit" class="text-xs font-medium">Memory limit</Label>
|
||||
<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 class="space-y-1.5">
|
||||
<Label for="memoryReservation" class="text-xs font-medium">Memory reservation</Label>
|
||||
<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 class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="nanoCpus" class="text-xs font-medium">CPU limit</Label>
|
||||
<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 class="space-y-1.5">
|
||||
<Label for="cpuShares" class="text-xs font-medium">CPU shares</Label>
|
||||
<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 class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="cpuQuota" class="text-xs font-medium">CPU quota</Label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="cpuPeriod" class="text-xs font-medium">CPU period</Label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1110,6 +1110,8 @@
|
||||
|
||||
<ContainerSettingsTab
|
||||
mode="edit"
|
||||
{containerId}
|
||||
envId={$currentEnvironment?.id ?? undefined}
|
||||
bind:name
|
||||
bind:image
|
||||
bind:command
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
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() {
|
||||
editorTheme = editorTheme === 'light' ? 'dark' : 'light';
|
||||
localStorage.setItem('dockhand-editor-theme', editorTheme);
|
||||
@@ -245,7 +268,7 @@
|
||||
// Get language from filename for CodeMirror
|
||||
function getLanguageFromFilename(filename: string): string {
|
||||
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.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml';
|
||||
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('.xml')) return 'xml';
|
||||
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
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
|
||||
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
|
||||
@@ -723,8 +724,9 @@
|
||||
</Card.Title>
|
||||
<Card.Description>Customize the look of the application</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Card.Content class="space-y-4">
|
||||
<ThemeSelector userId={profile.id} />
|
||||
<AnimateIconsToggle userId={profile.id} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import LicenseModal from './LicenseModal.svelte';
|
||||
import PrivacyModal from './PrivacyModal.svelte';
|
||||
import SelfUpdateDialog from './SelfUpdateDialog.svelte';
|
||||
import ChangelogText from '$lib/components/ChangelogText.svelte';
|
||||
|
||||
interface Dependency {
|
||||
name: string;
|
||||
@@ -885,7 +886,7 @@
|
||||
Fix
|
||||
</span>
|
||||
{/if}
|
||||
<span>{change.text}</span>
|
||||
<ChangelogText text={change.text} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { readJobResponse } from '$lib/utils/sse-fetch';
|
||||
import { validateEnvName } from '$lib/utils/env-name';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
@@ -60,7 +61,9 @@
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
XCircle,
|
||||
ImageUp
|
||||
ImageUp,
|
||||
Upload,
|
||||
ArrowRight
|
||||
} from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
@@ -285,6 +288,20 @@
|
||||
let formPublicIp = $state('');
|
||||
let formTimezone = $state('UTC');
|
||||
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 formSaving = $state(false);
|
||||
|
||||
@@ -362,6 +379,33 @@
|
||||
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
|
||||
* Handles tcp://, http://, https:// protocols and plain hostnames
|
||||
@@ -725,6 +769,12 @@
|
||||
if (!formName.trim()) {
|
||||
formErrors.name = 'Name is required';
|
||||
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
|
||||
if (formConnectionType === 'direct' || formConnectionType === 'hawser-standard') {
|
||||
@@ -843,6 +893,12 @@
|
||||
if (!formName.trim()) {
|
||||
formErrors.name = 'Name is required';
|
||||
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
|
||||
if (formConnectionType === 'direct' || formConnectionType === 'hawser-standard') {
|
||||
@@ -860,6 +916,61 @@
|
||||
|
||||
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;
|
||||
formError = '';
|
||||
|
||||
@@ -1475,7 +1586,7 @@
|
||||
</Tabs.Trigger>
|
||||
</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) -->
|
||||
<Tabs.Content value="general" class="space-y-4 mt-0 h-full">
|
||||
<!-- Name field -->
|
||||
@@ -1833,9 +1944,22 @@
|
||||
</div>
|
||||
{#if formProtocol === 'https'}
|
||||
<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="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
|
||||
id="edit-env-tls_ca"
|
||||
bind:value={formTlsCa}
|
||||
@@ -1844,7 +1968,20 @@
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<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
|
||||
id="edit-env-tls_cert"
|
||||
bind:value={formTlsCert}
|
||||
@@ -1853,7 +1990,20 @@
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<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
|
||||
id="edit-env-tls_key"
|
||||
bind:value={formTlsKey}
|
||||
@@ -1919,7 +2069,20 @@
|
||||
</div>
|
||||
{#if formProtocol === 'https'}
|
||||
<div class="space-y-2">
|
||||
<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
|
||||
id="edit-env-hawser-tls-ca"
|
||||
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"
|
||||
disabled={formTlsSkipVerify}
|
||||
></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 class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -2819,3 +2982,95 @@
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</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,
|
||||
CircleArrowUp,
|
||||
CircleFadingArrowUp,
|
||||
Clock
|
||||
Clock,
|
||||
AlertTriangle
|
||||
} from 'lucide-svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { broom, whale } from '@lucide/lab';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
@@ -100,7 +102,13 @@
|
||||
let testingEnvs = $state<Set<number>>(new Set());
|
||||
let pruneStatus = $state<{ [id: number]: 'pruning' | 'success' | 'error' | 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)
|
||||
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) {
|
||||
testingEnvs.add(id);
|
||||
testingEnvs = new Set(testingEnvs);
|
||||
@@ -605,27 +654,15 @@
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if $canAccess('environments', 'delete')}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteEnvId === env.id}
|
||||
action="Delete"
|
||||
itemType="environment"
|
||||
itemName={env.name}
|
||||
title="Remove"
|
||||
position="left"
|
||||
onConfirm={() => deleteEnvironment(env.id)}
|
||||
onOpenChange={(open) => confirmDeleteEnvId = open ? env.id : null}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}"
|
||||
class="h-7 px-2 text-muted-foreground hover:text-destructive"
|
||||
title="Delete environment"
|
||||
onclick={() => requestDeleteEnvironment(env.id)}
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
@@ -646,3 +683,72 @@
|
||||
onSaved={handleSaved}
|
||||
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 { toast } from 'svelte-sonner';
|
||||
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
|
||||
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
// General settings state - these derive from the store
|
||||
@@ -375,6 +376,7 @@ services:
|
||||
<!-- Right column: Theme settings (always shown, with hint when auth enabled) -->
|
||||
<div class="space-y-4">
|
||||
<ThemeSelector />
|
||||
<AnimateIconsToggle />
|
||||
{#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">
|
||||
<HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
}
|
||||
} else {
|
||||
if (!config.urls?.length) {
|
||||
return 'At least one Apprise URL is required';
|
||||
return 'At least one webhook URL is required';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -234,7 +234,7 @@
|
||||
}
|
||||
} else {
|
||||
if (!config.urls?.length) {
|
||||
formError = 'At least one Apprise URL is required';
|
||||
formError = 'At least one webhook URL is required';
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -311,7 +311,7 @@
|
||||
<Label>Type</Label>
|
||||
{#if isEditing}
|
||||
<Badge variant="secondary" class="h-9 flex items-center justify-center">
|
||||
{formType === 'smtp' ? 'SMTP (Email)' : 'Apprise (Webhooks)'}
|
||||
{formType === 'smtp' ? 'SMTP (Email)' : 'Webhooks'}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Select.Root
|
||||
@@ -324,7 +324,7 @@
|
||||
{#if formType === 'smtp'}
|
||||
<Mail class="w-4 h-4" />SMTP (Email)
|
||||
{:else}
|
||||
<Zap class="w-4 h-4" />Apprise (Webhooks)
|
||||
<Zap class="w-4 h-4" />Webhooks
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
@@ -333,7 +333,7 @@
|
||||
<span class="flex items-center gap-2"><Mail class="w-4 h-4" />SMTP (Email)</span>
|
||||
</Select.Item>
|
||||
<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.Content>
|
||||
</Select.Root>
|
||||
@@ -409,9 +409,9 @@
|
||||
</div>
|
||||
{:else}
|
||||
<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">
|
||||
<Label for="notif-apprise-urls">Apprise URLs * (one per line)</Label>
|
||||
<Label for="notif-apprise-urls">Webhook URLs * (one per line)</Label>
|
||||
<textarea
|
||||
id="notif-apprise-urls"
|
||||
bind:value={formAppriseUrls}
|
||||
@@ -430,11 +430,13 @@ workflows://hostname/workflow/signature
|
||||
bark://bark_key
|
||||
bark://host/bark_key
|
||||
barks://host/bark_key
|
||||
signal://host:8080/+sender/+recipient
|
||||
apprise://host:8000/your-key
|
||||
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"
|
||||
></textarea>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
<div>
|
||||
<p class="text-sm font-medium">Notification channels</p>
|
||||
<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 class="text-xs text-amber-600 dark:text-amber-500 mt-2 flex items-center gap-1">
|
||||
<Info class="w-3 h-3" />
|
||||
@@ -199,7 +199,7 @@
|
||||
{#if notif.type === 'smtp'}
|
||||
<span>SMTP: {notif.config.host}:{notif.config.port}</span>
|
||||
{: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}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
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 ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
@@ -38,6 +38,8 @@
|
||||
let showRegModal = $state(false);
|
||||
let editingReg = $state<Registry | 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() {
|
||||
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) {
|
||||
try {
|
||||
const response = await fetch(`/api/registries/${id}/default`, {
|
||||
@@ -171,6 +197,23 @@
|
||||
Set default
|
||||
</Button>
|
||||
{/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')}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
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';
|
||||
|
||||
export interface Registry {
|
||||
@@ -32,6 +32,8 @@
|
||||
let formPassword = $state('');
|
||||
let formError = $state('');
|
||||
let formSaving = $state(false);
|
||||
let testResult = $state<{ success: boolean; message: string } | null>(null);
|
||||
let testLoading = $state(false);
|
||||
|
||||
function resetForm() {
|
||||
formName = '';
|
||||
@@ -40,6 +42,8 @@
|
||||
formPassword = '';
|
||||
formError = '';
|
||||
formSaving = false;
|
||||
testResult = null;
|
||||
testLoading = false;
|
||||
}
|
||||
|
||||
// Initialize form when registry changes or modal opens
|
||||
@@ -51,12 +55,52 @@
|
||||
formUsername = registry.username || '';
|
||||
formPassword = '';
|
||||
formError = '';
|
||||
testResult = null;
|
||||
} else {
|
||||
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() {
|
||||
if (!formName.trim() || !formUrl.trim()) {
|
||||
formError = 'Name and URL are required';
|
||||
@@ -136,7 +180,26 @@
|
||||
</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>
|
||||
<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 onclick={save} disabled={formSaving}>
|
||||
{#if formSaving}
|
||||
|
||||
@@ -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"
|
||||
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' : ''}" />
|
||||
</a>
|
||||
{:else}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -28,7 +29,7 @@
|
||||
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
import { DataGrid } from '$lib/components/data-grid';
|
||||
|
||||
type SortField = 'name' | 'driver' | 'stack' | 'created';
|
||||
type SortField = 'name' | 'driver' | 'type' | 'stack' | 'created';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
let volumes = $state<VolumeInfo[]>([]);
|
||||
@@ -202,7 +203,10 @@
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(vol =>
|
||||
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':
|
||||
cmp = a.driver.localeCompare(b.driver);
|
||||
break;
|
||||
case 'type':
|
||||
// Volumes without driver_opts.type sort below those with one.
|
||||
cmp = (a.options?.type || '').localeCompare(b.options?.type || '');
|
||||
break;
|
||||
case 'stack':
|
||||
const stackA = a.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>
|
||||
{:else if column.id === 'driver'}
|
||||
<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'}
|
||||
<span class="text-xs">{volume.scope}</span>
|
||||
{:else if column.id === '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}
|
||||
<span class="text-muted-foreground text-xs">-</span>
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
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 { formatDateTime } from '$lib/stores/settings';
|
||||
|
||||
@@ -50,9 +52,32 @@
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<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" />
|
||||
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.Header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user