mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 11:29:56 +03:00
Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50bc746660 | |||
| 81c03b5dc5 | |||
| 4a7c971cf8 | |||
| faa2b9d571 | |||
| 2ca41703f2 | |||
| c19d73c509 | |||
| 7e869b582a | |||
| d0e5edcc98 | |||
| a621f7abbc | |||
| 725798f327 | |||
| 83adb275cd | |||
| 80a9c8b60a | |||
| 07be45ace5 | |||
| f9bc2a13d1 | |||
| a84c11113c | |||
| 464fcb4231 | |||
| 0c894d906f | |||
| 1c16efd872 | |||
| 77ec974d09 | |||
| e9e521656c | |||
| c618328d83 | |||
| 76e8faef83 | |||
| 32c2919f05 | |||
| b2b4d3d975 | |||
| fa7f3be2f5 | |||
| c525a99d57 | |||
| 3f23dfb9f1 | |||
| e0548f69ef | |||
| d4eb5a5237 | |||
| c2b1708b66 | |||
| 5633e063e1 | |||
| eade47e962 | |||
| 3f99719cda | |||
| de243ce06d | |||
| dd0e778bf9 | |||
| 52de17e4e6 | |||
| 3140e4f074 | |||
| 988e65bd5b | |||
| a5360e9d53 | |||
| c9239f195a | |||
| 9daa647709 | |||
| 38fa758d8a | |||
| e829e60217 | |||
| 7ed20ece39 | |||
| 6149b3d935 | |||
| 139e798e77 | |||
| 2f7f5efc27 | |||
| 4cd7f1c4ef | |||
| 2e1cb7fdaf | |||
| a46154acf7 | |||
| 4627b70fcf | |||
| 54a14889de | |||
| 79c02984f0 | |||
| b2989d0aaf | |||
| f9fdfef4cb | |||
| 927858578b | |||
| afb0e734ee | |||
| 6122fa43da | |||
| 45bedca86d | |||
| 1aca2a10cb | |||
| 70e2166548 | |||
| ced84b583d | |||
| 53be8f8b20 | |||
| 236475577b | |||
| 7d6f6f2efd | |||
| 193dc44a71 | |||
| 1036cd0ec6 | |||
| 1a95f5ad05 | |||
| fd35a0adc0 | |||
| dd6c5fd3e5 | |||
| 0303f54e2b | |||
| 7f9862f9a0 | |||
| 750c9c1910 | |||
| 566d80019d | |||
| 261d94032c | |||
| 6cb948e84c | |||
| 80a5bbde99 | |||
| fd744ed9a2 | |||
| 6d9b509493 | |||
| e8ab07ec3f | |||
| 107e9c3758 | |||
| f972378117 | |||
| f588ed787b | |||
| 6baf6c23e8 | |||
| 6382b4083e | |||
| b269b8d50d | |||
| 410d542c58 | |||
| a02115e6bc | |||
| 86e4c9eb56 | |||
| c46870afd1 | |||
| a8a5623c10 | |||
| 059ecbb1dc | |||
| 3eab42169c | |||
| 6a7116a5b7 | |||
| 215f52b1f0 | |||
| de62327a07 | |||
| cd6544aedb | |||
| c60db2930c | |||
| 695acd922e | |||
| fcb36c4646 | |||
| 53ca99ac77 | |||
| 81fcc28d0b | |||
| 522154cd68 | |||
| 9db6e67a61 | |||
| ba05d16d79 | |||
| f4a57ecfd3 | |||
| ab8743bdae | |||
| e536388a7a | |||
| 497fbdb635 | |||
| 53d60fdddd |
+4
-4
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose=5.1.4-r3" \
|
||||
" - docker-compose=5.0.2-r1" \
|
||||
" - docker-cli-buildx" \
|
||||
" - sqlite" \
|
||||
" - postgresql-client" \
|
||||
@@ -77,8 +77,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN MAKEFLAGS="-j$(nproc)" npm ci --ignore-scripts \
|
||||
&& MAKEFLAGS="-j$(nproc)" npm rebuild better-sqlite3 argon2
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 argon2
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
@@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
|
||||
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
|
||||
|
||||
# Build Go collector
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.10 AS go-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.8 AS go-builder
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
|
||||
+13
-26
@@ -18,33 +18,25 @@ FROM node:24-alpine AS app-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git curl python3 make g++ gcc musl-dev
|
||||
RUN apk add --no-cache git curl python3 make g++
|
||||
|
||||
# Build getrandom shim for old kernels (< 3.17) that lack the syscall
|
||||
COPY shims/getrandom-shim.c /tmp/
|
||||
RUN gcc -shared -fPIC -O2 -o /tmp/libgetrandom-shim.so /tmp/getrandom-shim.c
|
||||
|
||||
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
|
||||
# Copy package files and install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 argon2
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production dependencies only
|
||||
# Preserve better-sqlite3 native addon (no prebuilds exist for Node 24 ABI 137)
|
||||
RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
|
||||
&& rm -rf node_modules \
|
||||
&& npm ci --omit=dev --ignore-scripts \
|
||||
&& cp -r /tmp/better-sqlite3-build node_modules/better-sqlite3/build \
|
||||
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
|
||||
# Production dependencies only (rebuilds native addons against musl)
|
||||
RUN rm -rf node_modules \
|
||||
&& npm ci --omit=dev \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Go Collector Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM golang:1.25.8 AS go-builder
|
||||
FROM golang:1.24 AS go-builder
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
|
||||
@@ -70,10 +62,9 @@ RUN apk add --no-cache \
|
||||
su-exec \
|
||||
libstdc++
|
||||
|
||||
# Create docker compose plugin symlink (skip if package already installed it there)
|
||||
# Create docker compose plugin symlink
|
||||
RUN mkdir -p /usr/libexec/docker/cli-plugins \
|
||||
&& [ -x /usr/libexec/docker/cli-plugins/docker-compose ] \
|
||||
|| ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
&& ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
|
||||
# Create dockhand user and group
|
||||
RUN addgroup -g 1001 dockhand \
|
||||
@@ -89,8 +80,7 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
|
||||
DATA_DIR=/app/data \
|
||||
HOME=/home/dockhand \
|
||||
PUID=1001 \
|
||||
PGID=1001 \
|
||||
LD_PRELOAD=/usr/lib/libgetrandom-shim.so
|
||||
PGID=1001
|
||||
|
||||
# Copy application files with correct ownership
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
|
||||
@@ -108,9 +98,6 @@ COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
|
||||
# Copy legal documents
|
||||
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
|
||||
|
||||
# Copy getrandom shim for old kernels (Synology DS1513+ with kernel 3.10.x)
|
||||
COPY --from=app-builder /tmp/libgetrandom-shim.so /usr/lib/libgetrandom-shim.so
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint-node.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
@@ -126,7 +113,7 @@ RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD []
|
||||
CMD ["node", "/app/server.js"]
|
||||
|
||||
@@ -36,12 +36,6 @@ Dockhand is a modern, efficient Docker management application providing real-tim
|
||||
- **Database**: SQLite or PostgreSQL via Drizzle ORM
|
||||
- **Docker**: direct docker API calls.
|
||||
|
||||
## Screenshots
|
||||
| Light Mode | Dark Mode |
|
||||
| --- | --- |
|
||||
| <img src="docs/dashboard1.webp" width="600" alt="Dashboard 1 Light"> | <img src="docs/dashboard2.webp" width="600" alt="Dashboard 2 Dark"> |
|
||||
| <img src="docs/dashboard3.webp" width="600" alt="Dashboard 3 Light"> | <img src="docs/dashboard4.webp" width="600" alt="Dashboard 4 Dark"> |
|
||||
|
||||
## License
|
||||
|
||||
Dockhand is licensed under the [Business Source License 1.1](LICENSE.txt) (BSL 1.1).
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
module github.com/Finsys/dockhand/collector
|
||||
|
||||
go 1.25.10
|
||||
go 1.25
|
||||
|
||||
+1
-1
@@ -421,7 +421,7 @@ func (m *manager) collectMetrics(env *environment) {
|
||||
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
|
||||
defer sCancel()
|
||||
|
||||
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false", id))
|
||||
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
|
||||
if sErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ PGID=${PGID:-1001}
|
||||
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
|
||||
|
||||
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
|
||||
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs, git ops auto-merge with system CAs)
|
||||
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs)
|
||||
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
|
||||
if [ "$MEMORY_MONITOR" = "true" ]; then
|
||||
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 224 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 283 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 281 KiB |
@@ -1,21 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "api_tokens" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"token_prefix" text NOT NULL,
|
||||
"last_used" timestamp,
|
||||
"expires_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "api_tokens_token_prefix_idx" ON "api_tokens" USING btree ("token_prefix");
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "context_dir" text;--> statement-breakpoint
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "no_build_cache" boolean DEFAULT false;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "cefce4cc-994a-4b79-b55a-e995211b8f6a",
|
||||
"prevId": "b10cba96-4947-484f-84a2-efb65205381f",
|
||||
"id": "b10cba96-4947-484f-84a2-efb65205381f",
|
||||
"prevId": "eef8322a-0ccc-418c-b0f6-f51972a1850e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -36,20 +36,6 @@
|
||||
"when": 1774155653752,
|
||||
"tag": "0004_add_git_stack_deploy_options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1775312212996,
|
||||
"tag": "0005_add_api_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
CREATE TABLE `api_tokens` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`token_hash` text NOT NULL,
|
||||
`token_prefix` text NOT NULL,
|
||||
`last_used` text,
|
||||
`expires_at` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `api_tokens_token_hash_unique` ON `api_tokens` (`token_hash`);--> statement-breakpoint
|
||||
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `api_tokens_token_prefix_idx` ON `api_tokens` (`token_prefix`);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
|
||||
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -36,20 +36,6 @@
|
||||
"when": 1774155653752,
|
||||
"tag": "0004_add_git_stack_deploy_options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1775311743346,
|
||||
"tag": "0005_add_api_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+8
-9
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.31",
|
||||
"version": "1.0.23",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
@@ -76,19 +76,19 @@
|
||||
"better-sqlite3": "11.7.0",
|
||||
"croner": "9.1.0",
|
||||
"cronstrue": "3.9.0",
|
||||
"devalue": "5.8.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"fast-xml-parser": "5.7.3",
|
||||
"devalue": "5.6.4",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"fast-xml-parser": "5.5.8",
|
||||
"js-yaml": "4.1.1",
|
||||
"ldapts": "8.1.3",
|
||||
"nodemailer": "8.0.5",
|
||||
"nodemailer": "8.0.4",
|
||||
"otpauth": "9.4.1",
|
||||
"postgres": "3.4.8",
|
||||
"qrcode": "1.5.4",
|
||||
"rollup": "4.60.0",
|
||||
"svelte-sonner": "1.0.7",
|
||||
"undici": "7.24.5",
|
||||
"ws": "8.20.1"
|
||||
"ws": "8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "5.55.7",
|
||||
"svelte": "5.53.5",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-easy-crop": "^5.0.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -136,7 +136,6 @@
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@lezer/common": "1.5.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"devalue": "5.8.1"
|
||||
"@lezer/highlight": "1.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,12 +430,7 @@ function handleHawserConnection(ws, connId, remoteIp) {
|
||||
|
||||
// Use the global hawser message handler injected by the SvelteKit app
|
||||
if (typeof globalThis.__hawserHandleMessage === 'function') {
|
||||
try {
|
||||
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
|
||||
} catch (handlerError) {
|
||||
console.error('[Hawser WS] Handler error:', handlerError);
|
||||
// Don't close connection - let it recover
|
||||
}
|
||||
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
|
||||
} else {
|
||||
console.warn('[Hawser WS] No global handler registered');
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
|
||||
|
||||
-52
@@ -74,30 +74,6 @@ html {
|
||||
max-width: calc(90px * var(--grid-font-size-scale, 1)) !important;
|
||||
}
|
||||
|
||||
/* Scrollbar theming */
|
||||
* {
|
||||
scrollbar-color: hsl(var(--border) / 0.5) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border) / 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--border) / 0.7);
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
|
||||
@@ -1777,31 +1753,3 @@ html {
|
||||
.ansi-dim { opacity: 0.7; }
|
||||
.ansi-italic { font-style: italic; }
|
||||
.ansi-underline { text-decoration: underline; }
|
||||
|
||||
/* Log line numbers */
|
||||
.log-line {
|
||||
min-height: 1.2em;
|
||||
}
|
||||
pre.show-line-numbers {
|
||||
counter-reset: log-line;
|
||||
}
|
||||
pre.show-line-numbers .log-line {
|
||||
counter-increment: log-line;
|
||||
padding-left: 4.5em;
|
||||
position: relative;
|
||||
}
|
||||
pre.show-line-numbers .log-line::before {
|
||||
content: counter(log-line);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 3.5em;
|
||||
text-align: right;
|
||||
padding-right: 0.75em;
|
||||
user-select: none;
|
||||
color: rgb(113 113 122); /* zinc-500 */
|
||||
border-right: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
}
|
||||
:where(.light, .light *) pre.show-line-numbers .log-line::before {
|
||||
color: rgb(156 163 175); /* gray-400 */
|
||||
border-right-color: rgb(209 213 219); /* gray-300 */
|
||||
}
|
||||
|
||||
Vendored
+4
-5
@@ -3,12 +3,11 @@
|
||||
|
||||
import type { AuthenticatedUser } from '$lib/server/auth';
|
||||
|
||||
declare global {
|
||||
// Build-time constants injected by Vite
|
||||
const __APP_VERSION__: string | null;
|
||||
const __BUILD_DATE__: string | null;
|
||||
const __BUILD_COMMIT__: string | null;
|
||||
// Build-time constants injected by Vite
|
||||
declare const __BUILD_DATE__: string | null;
|
||||
declare const __BUILD_COMMIT__: string | null;
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
|
||||
+7
-91
@@ -4,8 +4,6 @@ import { initDatabase, hasAdminUser } from '$lib/server/db';
|
||||
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
|
||||
import { startScheduler } from '$lib/server/scheduler';
|
||||
import { isAuthEnabled, validateSession } from '$lib/server/auth';
|
||||
import { validateApiToken } from '$lib/server/api-tokens';
|
||||
import { requestContext } from '$lib/server/request-context';
|
||||
import { setServerStartTime } from '$lib/server/uptime';
|
||||
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
|
||||
import { initCryptoFallback } from '$lib/server/crypto-fallback';
|
||||
@@ -200,58 +198,6 @@ if (!initialized) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bearer token auth failure rate limiting (per IP, 5-minute cooldown after 10 failures)
|
||||
const bearerFailCounts = new Map<string, { count: number; firstFail: number }>();
|
||||
const BEARER_FAIL_WINDOW_MS = 60_000; // 1-minute sliding window
|
||||
const BEARER_FAIL_MAX = 15; // max failures per window
|
||||
const BEARER_COOLDOWN_MS = 5 * 60 * 1000; // 5-minute cooldown after exceeding limit
|
||||
const bearerCooldowns = new Map<string, number>(); // IP → cooldown-until timestamp
|
||||
|
||||
// Periodic cleanup
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, until] of bearerCooldowns) {
|
||||
if (now > until) bearerCooldowns.delete(ip);
|
||||
}
|
||||
for (const [ip, entry] of bearerFailCounts) {
|
||||
if (now - entry.firstFail > BEARER_FAIL_WINDOW_MS) bearerFailCounts.delete(ip);
|
||||
}
|
||||
}, BEARER_COOLDOWN_MS).unref?.();
|
||||
|
||||
function getClientIp(event: { request: Request; getClientAddress?: () => string }): string {
|
||||
// Prefer socket-level IP (SvelteKit resolves proxy headers via adapter config)
|
||||
// This prevents X-Forwarded-For spoofing to bypass rate limiting
|
||||
try {
|
||||
const addr = event.getClientAddress?.();
|
||||
if (addr) return addr;
|
||||
} catch { /* getClientAddress may throw if unavailable */ }
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function recordBearerFailure(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = bearerFailCounts.get(ip);
|
||||
if (!entry || now - entry.firstFail > BEARER_FAIL_WINDOW_MS) {
|
||||
bearerFailCounts.set(ip, { count: 1, firstFail: now });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count >= BEARER_FAIL_MAX) {
|
||||
bearerCooldowns.set(ip, now + BEARER_COOLDOWN_MS);
|
||||
bearerFailCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
function isBearerRateLimited(ip: string): boolean {
|
||||
const until = bearerCooldowns.get(ip);
|
||||
if (!until) return false;
|
||||
if (Date.now() > until) {
|
||||
bearerCooldowns.delete(ip);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Routes that don't require authentication
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
@@ -301,51 +247,21 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Check if auth is enabled
|
||||
const authEnabled = await isAuthEnabled();
|
||||
|
||||
// If auth is disabled, allow everything
|
||||
// If auth is disabled, allow everything (app works as before)
|
||||
if (!authEnabled) {
|
||||
event.locals.user = null;
|
||||
event.locals.authEnabled = false;
|
||||
const ctx = { user: null, authEnabled: false, authMethod: 'none' as const };
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
}
|
||||
|
||||
// Auth is enabled - check session first
|
||||
let user = await validateSession(event.cookies);
|
||||
let authMethod: 'cookie' | 'bearer' | 'none' = user ? 'cookie' : 'none';
|
||||
|
||||
// If no session, try Bearer token on API routes
|
||||
if (!user && event.url.pathname.startsWith('/api/')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer dh_') && authHeader.length <= 207) {
|
||||
const clientIp = getClientIp(event);
|
||||
|
||||
// Rate limit failed Bearer attempts
|
||||
if (isBearerRateLimited(clientIp)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Too many failed authentication attempts' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '300' } }
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // strip "Bearer "
|
||||
user = await validateApiToken(token);
|
||||
|
||||
if (user) {
|
||||
authMethod = 'bearer';
|
||||
} else {
|
||||
recordBearerFailure(clientIp);
|
||||
}
|
||||
}
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
}
|
||||
|
||||
// Auth is enabled - check session
|
||||
const user = await validateSession(event.cookies);
|
||||
event.locals.user = user;
|
||||
event.locals.authEnabled = true;
|
||||
|
||||
const ctx = { user, authEnabled: true, authMethod };
|
||||
|
||||
// Public paths don't require authentication
|
||||
if (isPublicPath(event.url.pathname)) {
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
}
|
||||
|
||||
// If not authenticated
|
||||
@@ -354,7 +270,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// This enables the first admin user to be created during initial setup
|
||||
const noAdminSetupMode = !(await hasAdminUser());
|
||||
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
}
|
||||
|
||||
// API routes return 401
|
||||
@@ -373,7 +289,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
redirect(307, `/login?redirect=${redirectUrl}`);
|
||||
}
|
||||
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
} finally {
|
||||
rssAfterOp('http', httpBefore);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
const progressText: Record<string, string> = {
|
||||
remove: 'removing',
|
||||
|
||||
@@ -405,7 +405,7 @@
|
||||
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\?`),
|
||||
new RegExp(`(?<!\\$)\\$\\{${marker.name}:\\+`),
|
||||
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\+`),
|
||||
new RegExp(`(?<![A-Za-z0-9\\$])\\$${marker.name}(?![a-zA-Z0-9_])`)
|
||||
new RegExp(`(?<!\\$)\\$${marker.name}(?![a-zA-Z0-9_])`)
|
||||
];
|
||||
|
||||
const hasVariable = varPatterns.some(p => p.test(line));
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: Snippet<[{ open: boolean }]>;
|
||||
extraContent?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -36,8 +35,7 @@
|
||||
disabled = false,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
children,
|
||||
extraContent
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const triggerClass = $derived(unstyled
|
||||
@@ -105,16 +103,11 @@
|
||||
align={position === 'left' ? 'start' : 'end'}
|
||||
sideOffset={8}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
|
||||
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
{#if extraContent}
|
||||
{@render extraContent()}
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
|
||||
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { Sun, Moon } from 'lucide-svelte';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
logs: string | null;
|
||||
darkMode?: boolean;
|
||||
timezone?: string;
|
||||
onToggleTheme?: () => void;
|
||||
}
|
||||
|
||||
let { logs, darkMode = true, timezone, onToggleTheme }: Props = $props();
|
||||
let { logs, darkMode = true, onToggleTheme }: Props = $props();
|
||||
|
||||
// Parse log lines with timestamp and content
|
||||
function parseLogLine(line: string): { timestamp: string; content: string; type: 'trivy' | 'grype' | 'error' | 'default' } {
|
||||
@@ -46,15 +44,7 @@
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const d = new Date(timestamp);
|
||||
if (isNaN(d.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: timezone || undefined,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: getTimeFormat() === '12h'
|
||||
}).format(d);
|
||||
return timestamp.split('T')[1]?.replace('Z', '') || timestamp;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
interface LayerProgress {
|
||||
id: string;
|
||||
@@ -99,6 +98,12 @@
|
||||
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
imageName: string;
|
||||
envId?: number | null;
|
||||
autoStart?: boolean;
|
||||
activeScanner?: 'grype' | 'trivy';
|
||||
onComplete?: (results: ScanResult[]) => void;
|
||||
onError?: (error: string) => void;
|
||||
onStatusChange?: (status: ScanStatus) => void;
|
||||
@@ -48,7 +47,6 @@
|
||||
imageName,
|
||||
envId = null,
|
||||
autoStart = false,
|
||||
activeScanner = $bindable<'grype' | 'trivy'>('grype'),
|
||||
onComplete,
|
||||
onError,
|
||||
onStatusChange
|
||||
@@ -364,7 +362,7 @@
|
||||
{:else}
|
||||
<!-- Scan Results -->
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<ScanResultsView {results} bind:activeScanner />
|
||||
<ScanResultsView {results} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,12 @@
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
const value = trimmed.slice(eqIndex + 1);
|
||||
let value = trimmed.slice(eqIndex + 1);
|
||||
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
@@ -195,8 +200,8 @@
|
||||
* Sync rawContent TO variables.
|
||||
* Parses raw content for non-secrets, preserves existing secrets.
|
||||
*/
|
||||
function syncRawToVariables(content?: string) {
|
||||
const { vars, warnings } = parseRawContent(content ?? rawContent);
|
||||
function syncRawToVariables() {
|
||||
const { vars, warnings } = parseRawContent(rawContent);
|
||||
parseWarnings = warnings;
|
||||
|
||||
// Preserve existing secrets (they're not in rawContent)
|
||||
@@ -235,9 +240,8 @@
|
||||
// Form → Text: sync variables to raw (preserves comments)
|
||||
syncVariablesToRaw();
|
||||
} else if (newMode === 'form' && viewMode === 'text') {
|
||||
// Text → Form: use textEditorContent which falls back to generatedRawContent
|
||||
// when rawContent is empty (fixes vars lost on view switch for git stacks)
|
||||
syncRawToVariables(textEditorContent);
|
||||
// Text → Form: sync raw to variables (preserves secrets)
|
||||
syncRawToVariables();
|
||||
}
|
||||
|
||||
viewMode = newMode;
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="text-muted-foreground font-normal">({release.date})</span>
|
||||
</h3>
|
||||
<div class="space-y-1.5 ml-1">
|
||||
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) as change}
|
||||
{#each release.changes as change}
|
||||
{@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}" />
|
||||
|
||||
@@ -27,10 +27,6 @@
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { authStore, hasAnyAccess } from '$lib/stores/auth';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
const appVersion = __APP_VERSION__ || 'unknown';
|
||||
const buildCommit = __BUILD_COMMIT__ ?? null;
|
||||
|
||||
import type { Permissions } from '$lib/stores/auth';
|
||||
|
||||
@@ -159,25 +155,6 @@
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
|
||||
<!-- Version (expanded sidebar only) -->
|
||||
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
|
||||
{appVersion}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
|
||||
{#if buildCommit}
|
||||
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
<!-- User info footer (only when auth is enabled) -->
|
||||
{#if $authStore.authEnabled && $authStore.authenticated && $authStore.user}
|
||||
<Sidebar.Footer class="border-t">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
// Detect schedule type from cron expression
|
||||
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 5) return 'custom';
|
||||
if (parts.length < 5) return 'custom';
|
||||
|
||||
const [min, hr, day, month, dow] = parts;
|
||||
|
||||
@@ -137,15 +137,23 @@
|
||||
onchange(newValue);
|
||||
}
|
||||
|
||||
// Validate cron expression (supports 5-field and 6-field with seconds)
|
||||
// Validate cron expression
|
||||
function isValidCron(cron: string): boolean {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5 && parts.length !== 6) return false;
|
||||
if (parts.length !== 5) return false;
|
||||
|
||||
const [min, hr, day, month, dow] = parts;
|
||||
|
||||
// Basic pattern validation (number, *, */n, range, list)
|
||||
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
|
||||
|
||||
return parts.every((part) => cronFieldPattern.test(part));
|
||||
return (
|
||||
cronFieldPattern.test(min) &&
|
||||
cronFieldPattern.test(hr) &&
|
||||
cronFieldPattern.test(day) &&
|
||||
cronFieldPattern.test(month) &&
|
||||
cronFieldPattern.test(dow)
|
||||
);
|
||||
}
|
||||
|
||||
// Human-readable description using cronstrue
|
||||
|
||||
@@ -329,40 +329,18 @@
|
||||
onExpandChange?.(key, nowExpanded);
|
||||
}
|
||||
|
||||
// Sort persistence
|
||||
const SORT_STORAGE_KEY = `dockhand-${gridId}-sort`;
|
||||
let sortInitialized = false;
|
||||
|
||||
// Restore saved sort on mount
|
||||
onMount(() => {
|
||||
if (!onSortChange) return;
|
||||
try {
|
||||
const saved = localStorage.getItem(SORT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as DataGridSortState;
|
||||
if (parsed.field && parsed.direction) {
|
||||
onSortChange(parsed);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
sortInitialized = true;
|
||||
});
|
||||
|
||||
// Persist sort state whenever it changes (after init)
|
||||
$effect(() => {
|
||||
if (!sortInitialized || !sortState) return;
|
||||
try { localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sortState)); } catch {}
|
||||
});
|
||||
|
||||
// Sort helpers
|
||||
function toggleSort(field: string) {
|
||||
if (!onSortChange) return;
|
||||
|
||||
const newState: DataGridSortState = sortState?.field === field
|
||||
? { field, direction: sortState.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { field, direction: 'asc' };
|
||||
|
||||
onSortChange(newState);
|
||||
if (sortState?.field === field) {
|
||||
onSortChange({
|
||||
field,
|
||||
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
|
||||
});
|
||||
} else {
|
||||
onSortChange({ field, direction: 'asc' });
|
||||
}
|
||||
}
|
||||
|
||||
// Virtual scroll state
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { themeStore, type FontSize } from '$lib/stores/theme';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
// Font size scaling for header
|
||||
let fontSize = $state<FontSize>('normal');
|
||||
@@ -219,6 +218,14 @@
|
||||
(diskUsage.Volumes?.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0) || 0);
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function switchEnvironment(envId: number) {
|
||||
// Don't switch if already on this environment
|
||||
if (Number(envId) === Number(currentEnvId)) {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const containerColumns: ColumnConfig[] = [
|
||||
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
|
||||
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
|
||||
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' },
|
||||
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, align: 'right' },
|
||||
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 60, minWidth: 50, align: 'right' },
|
||||
{ id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' },
|
||||
{ id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' },
|
||||
{ id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 },
|
||||
|
||||
@@ -1,156 +1,4 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.31",
|
||||
"date": "2026-05-30",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "502 Bad Gateway behind nginx-based reverse proxies — SvelteKit 2.51+ bloated the Link response header, pinned to 2.50.0 (#1114)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.31"
|
||||
},
|
||||
{
|
||||
"version": "1.0.30",
|
||||
"date": "2026-05-30",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "time range filter for log viewer — filter logs by From/To date and time (#1068)" },
|
||||
{ "type": "feature", "text": "configurable tail line count in log viewer — choose from 100 to all lines (#1066)" },
|
||||
{ "type": "feature", "text": "toggleable line numbers in log viewer (#1067)" },
|
||||
{ "type": "feature", "text": "\"some unused\" image filter — show images with both used and unused tags for selective cleanup (#621)" },
|
||||
{ "type": "feature", "text": "IP binding and port ranges in container port mappings (#581)" },
|
||||
{ "type": "feature", "text": "remove individual containers directly from stacks page (#576)" },
|
||||
{ "type": "fix", "text": "scan cache lookup by tag name never matched — results now resolved via image digest (#1064)" },
|
||||
{ "type": "fix", "text": "image-baked env vars not updated during auto-update container recreation (#1061)" },
|
||||
{ "type": "fix", "text": "git stack deploy via Hawser fails with \"Invalid string length\" when repo has large files (#1040)" },
|
||||
{ "type": "feature", "text": "Gotify notification priority via URL query param — gotify://host/token?priority=5 (#1033)" },
|
||||
{ "type": "fix", "text": "consistent action button order across container and stack views (#1079)" },
|
||||
{ "type": "feature", "text": "named custom URL labels — dockhand.url=[Name](https://...) markdown syntax (#1065)" },
|
||||
{ "type": "fix", "text": "HTTPS git credentials no longer leaked in process arguments (#1081)" },
|
||||
{ "type": "feature", "text": "bump Docker Compose to 5.1.4 (GHSA-pmwq-pjrm-6p5r)" },
|
||||
{ "type": "feature", "text": "dockhand.order label to control container display order within stacks (#847)" },
|
||||
{ "type": "feature", "text": "live network attach/detach for running containers — join or leave Docker networks without restarting (#1051)" },
|
||||
{ "type": "fix", "text": "environment variable values with nested quotes progressively corrupted on each save (#1036, #1086)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.30"
|
||||
},
|
||||
{
|
||||
"version": "1.0.29",
|
||||
"date": "2026-05-17",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "optionally display internal (exposed) container ports alongside published ports (#193)" },
|
||||
{ "type": "feature", "text": "show app version in sidebar with build info tooltip (#209)" },
|
||||
{ "type": "feature", "text": "central label management — rename or delete labels across all environments (#661)" },
|
||||
{ "type": "feature", "text": "find next available host port when creating or editing containers (#116)" },
|
||||
{ "type": "feature", "text": "theme-aware scrollbar styling — scrollbars adapt to dark/light mode and color palettes (#462)" },
|
||||
{ "type": "fix", "text": "update buttons (single, selected, and all) now respect the \"confirm dangerous actions\" setting (#638, #751)" },
|
||||
{ "type": "feature", "text": "custom URL labels - dockhand.url or dockhand.port.{port}.url to add links alongside container ports (#266)" },
|
||||
{ "type": "feature", "text": "generate and copy token for Hawser Standard mode with run command hint (#337)" },
|
||||
{ "type": "fix", "text": "environment stack directory not cleaned up when environment is deleted (#1023)" },
|
||||
{ "type": "feature", "text": "toggle to hide timestamps and container name prefix in log viewer (#124)" },
|
||||
{ "type": "fix", "text": "Podman containers health status not showing (#737)" },
|
||||
{ "type": "fix", "text": "containers with exit code 0 (init/migration) no longer cause stack \"partial\" status (#1026)" },
|
||||
{ "type": "fix", "text": "stats stream 400 on reconnect by skipping overlapping fetches (#1044)" },
|
||||
{ "type": "fix", "text": "env var validation false positive for values containing $ followed by text (#1048)" },
|
||||
{ "type": "fix", "text": "git-repos directory not cleaned up when environment is deleted (#1049)" },
|
||||
{ "type": "fix", "text": "webhook secret auto-generated when left empty despite hint saying otherwise (#1050)" },
|
||||
{ "type": "feature", "text": "scan reports — combined or individual Grype/Trivy (#1056)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.29"
|
||||
},
|
||||
{
|
||||
"version": "1.0.28",
|
||||
"date": "2026-05-09",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "context directory for git stacks — reference files from anywhere in the repo (#864)" },
|
||||
{ "type": "feature", "text": "no-cache build option for git stacks (#880)" },
|
||||
{ "type": "fix", "text": "env vars lost when switching between raw/form view (#964)" },
|
||||
{ "type": "fix", "text": "compose name property not respected during stack scan (#922)" },
|
||||
{ "type": "feature", "text": "editable schedule for scanner cache cleanup (#979)" },
|
||||
{ "type": "fix", "text": "container labels cannot be deleted (#984)" },
|
||||
{ "type": "fix", "text": "env var values leaked in deploy logs — now all values are redacted (#985)" },
|
||||
{ "type": "fix", "text": "volume export keeps helper container alive, preventing volume prune/deletion (#983)" },
|
||||
{ "type": "fix", "text": "ntfy self-hosted notifications fail when using ?auth= query parameter (#840)" },
|
||||
{ "type": "fix", "text": "scrollbar appears in dashboard tiles when content overflows (#969)" },
|
||||
{ "type": "fix", "text": "case-sensitive environment sort order — lowercase names sorted after uppercase (#975)" },
|
||||
{ "type": "fix", "text": "inaccurate dashboard CPU gauge caused by one-shot stats flag (#932)" },
|
||||
{ "type": "feature", "text": "ntfy notifications support ?tags=, ?title=, and ?priority= URL query parameters (#689)" },
|
||||
{ "type": "fix", "text": "stack .env file wiped when saving from graph view (#988)" },
|
||||
{ "type": "feature", "text": "dismiss update available indicators without updating (#853)" },
|
||||
{ "type": "feature", "text": "public IP setting available for hawser-edge environments — enables clickable port links (#350)" },
|
||||
{ "type": "fix", "text": "git stack creation silently destroys existing stacks with the same name (#1001)" },
|
||||
{ "type": "feature", "text": "static IP/MAC address configuration for containers (#297)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.28"
|
||||
},
|
||||
{
|
||||
"version": "1.0.27",
|
||||
"comingSoon": false,
|
||||
"date": "2026-04-26",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "network graph visualization on networks page (#894, @Penlane)" },
|
||||
{ "type": "feature", "text": "customizable compose template for new stacks in settings (#632, @oratory)" },
|
||||
{ "type": "feature", "text": "Microsoft Teams notifications via Power Automate Workflows (#355, @slokhorst)" },
|
||||
{ "type": "feature", "text": "container label controls: dockhand.update, dockhand.hidden, dockhand.notify (#6, #53, #94, #215)" },
|
||||
{ "type": "feature", "text": "configurable label filter matching mode (any/all) for environment dashboard (#607)" },
|
||||
{ "type": "feature", "text": "log search filter mode to hide non-matching lines (#916)" },
|
||||
{ "type": "feature", "text": "inline terminal on logs page with resizable split layout (#900)" },
|
||||
{ "type": "fix", "text": "disable Telegram link preview in notifications (#910, @deenle)" },
|
||||
{ "type": "fix", "text": "cron editor rejects 6-field expressions with seconds (#839, @GiulioSavini)" },
|
||||
{ "type": "fix", "text": "mirror Dockhand's ExtraHosts into scanner and self-update containers (#836, @YewFence)" },
|
||||
{ "type": "fix", "text": "duplicate volume binds during container recreate (#765, @itsDNNS)" },
|
||||
{ "type": "fix", "text": "log timestamp formatting not applied on main logs page (#882)" },
|
||||
{ "type": "fix", "text": "uploaded files now inherit container user ownership (#732, @ivanjx)" },
|
||||
{ "type": "fix", "text": "extraneous backslash in Telegram notification environment name (#955)" },
|
||||
{ "type": "fix", "text": "collapse ports into ranges only if 3 or more consecutive ports" },
|
||||
{ "type": "fix", "text": "git operations auto-merge system CAs with custom cert (#967)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.27"
|
||||
},
|
||||
{
|
||||
"version": "1.0.26",
|
||||
"date": "2026-04-19",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "persist sort order across page navigation for all data grids (#861, #912)" },
|
||||
{ "type": "feature", "text": "show git repository URL and branch in git stack edit modal (#856)" },
|
||||
{ "type": "feature", "text": "show memory limit alongside usage in containers and stacks views (#893)" },
|
||||
{ "type": "feature", "text": "option to delete associated volumes when removing a stack (#655)" },
|
||||
{ "type": "feature", "text": "collapse consecutive port mappings into ranges in container list (#821)" },
|
||||
{ "type": "fix", "text": "bearer token authentication fails with enterprise license active" },
|
||||
{ "type": "fix", "text": "clicking stack name toggles stats accordion instead of just opening editor (#628)" },
|
||||
{ "type": "fix", "text": "scheduled image prune notifications missing environment name (#770)" },
|
||||
{ "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" },
|
||||
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.26"
|
||||
},
|
||||
{
|
||||
"version": "1.0.25",
|
||||
"date": "2026-04-18",
|
||||
"comingSoon": false,
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "API token authentication — Bearer tokens for CI/CD pipelines and scripts" },
|
||||
{ "type": "feature", "text": "Telegram topic support — send notifications to supergroup topics (#855)" },
|
||||
{ "type": "fix", "text": "allow removing healthcheck, ports, and honor startAfterUpdate=false during container edit (#892)" },
|
||||
{ "type": "fix", "text": "validate stack names and prevent broken DB entries on invalid input (#876)" },
|
||||
{ "type": "fix", "text": "use per-environment timezone for schedule execution log timestamps (#882)" },
|
||||
{ "type": "fix", "text": "\"Pull image before update\" and \"Start after update\" settings ignored (#909)" },
|
||||
{ "type": "fix", "text": "image prune timeout on hawser-standard when pruning many images (#905)" },
|
||||
{ "type": "fix", "text": "bump Docker Compose to 5.1.3" },
|
||||
{ "type": "fix", "text": "mask secret environment variables in container inspect modal (#924)" },
|
||||
{ "type": "fix", "text": "viewer role can toggle, delete, and run schedules (#923)" },
|
||||
{ "type": "fix", "text": "settings show defaults instead of saved values after login until page refresh (#921)" },
|
||||
{ "type": "fix", "text": "settings toggle notifications show wrong state (#931)" },
|
||||
{ "type": "fix", "text": "stack memory tooltip shows inflated total on multi-container stacks (#936)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.25"
|
||||
},
|
||||
{
|
||||
"version": "1.0.24",
|
||||
"date": "2026-04-03",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "browsing HTTP registries fails with SSL error (#868)" },
|
||||
{ "type": "fix", "text": "git stack deploy options (build, re-pull, force redeploy) not persisted in edit dialog" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.24"
|
||||
},
|
||||
{
|
||||
"version": "1.0.23",
|
||||
"date": "2026-04-03",
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* API Token Management
|
||||
*
|
||||
* Provides Bearer token authentication for CI/CD pipelines and scripts.
|
||||
* Tokens use `dh_` prefix, Argon2id hashing, and prefix-based lookup.
|
||||
*
|
||||
* Performance: An in-memory cache (SHA-256 key, 60s TTL) avoids running
|
||||
* Argon2id on every request. First request: ~100ms. Subsequent: ~0ms.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db, eq, and } from '$lib/server/db/drizzle';
|
||||
import { hashPassword, verifyPassword, type AuthenticatedUser } from './auth';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
import { getUserRoles, userHasAdminRole, type Permissions } from './db';
|
||||
import { isEnterprise } from './license';
|
||||
import { tokenCache, ensureCleanupInterval, invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
|
||||
|
||||
// Re-export cache functions so existing consumers don't need to change imports
|
||||
export { invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
|
||||
|
||||
// Dynamic schema import (same pattern as db.ts)
|
||||
let apiTokensTable: any;
|
||||
|
||||
async function getApiTokensTable() {
|
||||
if (apiTokensTable) return apiTokensTable;
|
||||
const isPostgres = !!(process.env.DATABASE_URL && (
|
||||
process.env.DATABASE_URL.startsWith('postgres://') ||
|
||||
process.env.DATABASE_URL.startsWith('postgresql://')
|
||||
));
|
||||
const schema = isPostgres
|
||||
? await import('./db/schema/pg-schema.js')
|
||||
: await import('./db/schema/index.js');
|
||||
apiTokensTable = schema.apiTokens;
|
||||
return apiTokensTable;
|
||||
}
|
||||
|
||||
// Token format: dh_ + 32 bytes base64url = dh_ + 43 chars
|
||||
const TOKEN_PREFIX = 'dh_';
|
||||
const TOKEN_BYTES = 32;
|
||||
const PREFIX_LENGTH = 8; // chars after dh_ stored for identification
|
||||
const MAX_TOKEN_LENGTH = 200;
|
||||
const CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
function cacheKey(rawToken: string): string {
|
||||
return createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
|
||||
// Pre-computed dummy hash for timing protection on invalid prefixes
|
||||
let dummyHash: string | null = null;
|
||||
|
||||
async function getDummyHash(): Promise<string> {
|
||||
if (!dummyHash) {
|
||||
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
|
||||
}
|
||||
return dummyHash;
|
||||
}
|
||||
|
||||
// Initialize dummy hash on import (fire and forget)
|
||||
void getDummyHash();
|
||||
|
||||
/**
|
||||
* Generate a new API token.
|
||||
* Returns the plaintext token (shown once) and the database record.
|
||||
*/
|
||||
export async function generateApiToken(
|
||||
userId: number,
|
||||
name: string,
|
||||
expiresAt?: string | null
|
||||
): Promise<{ token: string; id: number; tokenPrefix: string }> {
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Generate random token
|
||||
const randomBytes = secureRandomBytes(TOKEN_BYTES);
|
||||
const rawToken = TOKEN_PREFIX + randomBytes.toString('base64url');
|
||||
const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
|
||||
|
||||
// Hash for storage
|
||||
const tokenHash = await hashPassword(rawToken);
|
||||
|
||||
const result = await db.insert(table).values({
|
||||
userId,
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
expiresAt: expiresAt || null
|
||||
}).returning();
|
||||
|
||||
return {
|
||||
token: rawToken,
|
||||
id: result[0].id,
|
||||
tokenPrefix
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Bearer token and return the associated user.
|
||||
* Uses cache to avoid Argon2id on every request.
|
||||
*/
|
||||
export async function validateApiToken(rawToken: string): Promise<AuthenticatedUser | null> {
|
||||
// Input validation
|
||||
if (!rawToken || rawToken.length > MAX_TOKEN_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
ensureCleanupInterval();
|
||||
const key = cacheKey(rawToken);
|
||||
const cached = tokenCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Extract prefix for lookup
|
||||
const prefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
|
||||
|
||||
// Find tokens with matching prefix (deleted tokens are gone, no isActive filter needed)
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.tokenPrefix, prefix));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
// Timing protection: run Argon2id anyway
|
||||
await verifyPassword(rawToken, await getDummyHash());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify against each candidate (usually just one)
|
||||
for (const candidate of candidates) {
|
||||
const valid = await verifyPassword(rawToken, candidate.tokenHash);
|
||||
if (!valid) continue;
|
||||
|
||||
// Check expiration AFTER hash verification to avoid timing oracle
|
||||
if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build AuthenticatedUser from the token's user
|
||||
const user = await buildUserFromToken(candidate);
|
||||
if (!user) continue;
|
||||
|
||||
// Update lastUsed (fire and forget — non-critical audit field)
|
||||
void db.update(table)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(eq(table.id, candidate.id))
|
||||
.catch((err) => {
|
||||
if (typeof process !== 'undefined' && process.env.DB_VERBOSE_LOGGING === 'true') {
|
||||
console.debug('[api-tokens] lastUsed update failed:', err?.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result — cap TTL at token expiry time if sooner
|
||||
let cacheTtl = CACHE_TTL;
|
||||
if (candidate.expiresAt) {
|
||||
const timeUntilExpiry = new Date(candidate.expiresAt).getTime() - Date.now();
|
||||
if (timeUntilExpiry < cacheTtl) {
|
||||
cacheTtl = Math.max(0, timeUntilExpiry);
|
||||
}
|
||||
}
|
||||
tokenCache.set(key, { user, expiresAt: Date.now() + cacheTtl });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AuthenticatedUser from a token's database record.
|
||||
*/
|
||||
async function buildUserFromToken(tokenRecord: any): Promise<AuthenticatedUser | null> {
|
||||
// Import getUserWithoutPassword dynamically to avoid circular deps
|
||||
// This avoids keeping passwordHash in memory unnecessarily
|
||||
const { getUserWithoutPassword } = await import('./db');
|
||||
|
||||
const dbUser = await getUserWithoutPassword(tokenRecord.userId);
|
||||
if (!dbUser || !dbUser.isActive) return null;
|
||||
|
||||
const enterprise = await isEnterprise();
|
||||
let isAdmin = false;
|
||||
let permissions: Permissions;
|
||||
|
||||
if (!enterprise) {
|
||||
// Free edition: everyone is effectively admin
|
||||
isAdmin = true;
|
||||
const { getRoleByName } = await import('./db');
|
||||
const adminRole = await getRoleByName('Admin');
|
||||
permissions = adminRole?.permissions ?? {} as Permissions;
|
||||
} else {
|
||||
isAdmin = await userHasAdminRole(dbUser.id);
|
||||
const userRoleAssignments = await getUserRoles(dbUser.id);
|
||||
// Merge permissions from all roles
|
||||
permissions = {} as Permissions;
|
||||
for (const assignment of userRoleAssignments) {
|
||||
if (!assignment.role) continue;
|
||||
const rolePerms = typeof assignment.role.permissions === 'string'
|
||||
? JSON.parse(assignment.role.permissions)
|
||||
: assignment.role.permissions;
|
||||
if (!rolePerms) continue;
|
||||
for (const [key, actions] of Object.entries(rolePerms)) {
|
||||
if (!permissions[key as keyof Permissions]) {
|
||||
permissions[key as keyof Permissions] = [];
|
||||
}
|
||||
for (const action of actions as string[]) {
|
||||
if (!permissions[key as keyof Permissions].includes(action)) {
|
||||
permissions[key as keyof Permissions].push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine provider from authProvider field
|
||||
let provider: 'local' | 'ldap' | 'oidc' = 'local';
|
||||
if (dbUser.authProvider?.startsWith('ldap')) provider = 'ldap';
|
||||
else if (dbUser.authProvider?.startsWith('oidc')) provider = 'oidc';
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email ?? undefined,
|
||||
displayName: dbUser.displayName ?? undefined,
|
||||
avatar: dbUser.avatar ?? undefined,
|
||||
isAdmin,
|
||||
provider,
|
||||
permissions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens for a user (no hashes returned).
|
||||
*/
|
||||
export async function listUserTokens(userId: number) {
|
||||
const table = await getApiTokensTable();
|
||||
return db
|
||||
.select({
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
tokenPrefix: table.tokenPrefix,
|
||||
lastUsed: table.lastUsed,
|
||||
expiresAt: table.expiresAt,
|
||||
createdAt: table.createdAt
|
||||
})
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token. Owner or admin can revoke.
|
||||
*/
|
||||
export async function revokeApiToken(tokenId: number, requestingUserId: number, isAdmin: boolean): Promise<boolean> {
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Find the token
|
||||
const [token] = await db.select().from(table).where(eq(table.id, tokenId));
|
||||
if (!token) return false;
|
||||
|
||||
// Check ownership or admin
|
||||
if (token.userId !== requestingUserId && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hard-delete
|
||||
await db.delete(table).where(eq(table.id, tokenId));
|
||||
|
||||
// Clear cache — we can't map prefix to SHA-256 cache keys, so clear all
|
||||
clearTokenCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { isEnterprise } from './license';
|
||||
import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db';
|
||||
import { authorize } from './authorize';
|
||||
import { getRequestContext } from './request-context';
|
||||
|
||||
export interface AuditContext {
|
||||
userId?: number | null;
|
||||
@@ -22,8 +21,7 @@ export interface AuditContext {
|
||||
* Extract audit context from a request event
|
||||
*/
|
||||
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
|
||||
const ctx = getRequestContext();
|
||||
const user = ctx?.user ?? (await authorize(event.cookies)).user;
|
||||
const auth = await authorize(event.cookies);
|
||||
|
||||
// Get IP address from various headers (proxied requests)
|
||||
const forwardedFor = event.request.headers.get('x-forwarded-for');
|
||||
@@ -42,8 +40,8 @@ export async function getAuditContext(event: RequestEvent): Promise<AuditContext
|
||||
const userAgent = event.request.headers.get('user-agent') || null;
|
||||
|
||||
return {
|
||||
userId: user?.id ?? null,
|
||||
username: user?.username ?? 'anonymous',
|
||||
userId: auth.user?.id ?? null,
|
||||
username: auth.user?.username ?? 'anonymous',
|
||||
ipAddress,
|
||||
userAgent
|
||||
};
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
import { Client as LdapClient } from 'ldapts';
|
||||
import { isEnterprise } from './license';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
import { invalidateTokenCacheForUser } from './token-cache';
|
||||
|
||||
// Session cookie name
|
||||
const SESSION_COOKIE_NAME = 'dockhand_session';
|
||||
@@ -223,7 +222,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r
|
||||
path: '/',
|
||||
httpOnly: true, // Prevents XSS attacks from reading cookie
|
||||
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
|
||||
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
|
||||
sameSite: 'strict', // CSRF protection
|
||||
maxAge: maxAge // Session timeout in seconds
|
||||
});
|
||||
}
|
||||
@@ -736,9 +735,6 @@ async function tryLdapAuth(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached token permissions after role sync
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
}
|
||||
@@ -1453,9 +1449,6 @@ export async function handleOidcCallback(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached token permissions after role sync
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import type { Permissions } from './db';
|
||||
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
|
||||
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
|
||||
import { isEnterprise } from './license';
|
||||
import { getRequestContext } from './request-context';
|
||||
|
||||
export interface AuthorizationContext {
|
||||
/** Whether authentication is enabled globally */
|
||||
@@ -114,10 +113,7 @@ export interface AuthorizationContext {
|
||||
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
const enterprise = await isEnterprise();
|
||||
|
||||
// Try request context first (set by hook — handles both cookie and Bearer)
|
||||
const reqCtx = getRequestContext();
|
||||
const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null);
|
||||
const user = authEnabled ? await validateSession(cookies) : null;
|
||||
|
||||
// Determine admin status:
|
||||
// - Free edition: all authenticated users are effectively admins (full access)
|
||||
@@ -159,8 +155,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return false;
|
||||
|
||||
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return true;
|
||||
// Admins can access all environments
|
||||
if (user.isAdmin) return true;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return true;
|
||||
@@ -176,8 +172,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return [];
|
||||
|
||||
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return null;
|
||||
// Admins can access all environments
|
||||
if (user.isAdmin) return null;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return null;
|
||||
@@ -193,8 +189,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return false;
|
||||
|
||||
// Admins can always manage users (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return true;
|
||||
// Admins can always manage users
|
||||
if (user.isAdmin) return true;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return true;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Merge container env vars with new image env vars during auto-update.
|
||||
* Image-baked vars get updated to the new image's values.
|
||||
* User-set vars (not present in old image) are preserved.
|
||||
* Env vars removed from the new image are dropped.
|
||||
*/
|
||||
export function mergeImageEnvVars(
|
||||
containerEnv: string[],
|
||||
oldImageEnv: string[],
|
||||
newImageEnv: string[]
|
||||
): string[] {
|
||||
const getKey = (entry: string) => entry.split('=')[0];
|
||||
const oldImageKeys = new Set(oldImageEnv.map(getKey));
|
||||
|
||||
const merged: string[] = [];
|
||||
|
||||
// Keep user-set env vars (key not present in old image)
|
||||
for (const entry of containerEnv) {
|
||||
if (!oldImageKeys.has(getKey(entry))) {
|
||||
merged.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all new image env vars (updates changed values, adds new ones)
|
||||
for (const entry of newImageEnv) {
|
||||
const key = getKey(entry);
|
||||
// Skip if user already set this key (user wins)
|
||||
if (!merged.some(e => getKey(e) === key)) {
|
||||
merged.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Dockhand Container Label Controls
|
||||
*
|
||||
* Docker container labels that control Dockhand behavior:
|
||||
* - dockhand.update=false — Skip this container during auto-updates and batch updates
|
||||
* - dockhand.hidden=true — Hide this container from the Dockhand UI
|
||||
* - dockhand.notify=false — Suppress notifications for this container's events
|
||||
* - dockhand.url=<url> — Custom clickable URL displayed alongside container ports
|
||||
* - dockhand.port.<hostPort>.url=<url> — Override the click URL for a specific published port
|
||||
* - dockhand.order=<int> — Controls display order within a stack (lower = first, default 0)
|
||||
*
|
||||
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
|
||||
* The opt-out model means labels override DB settings (label wins).
|
||||
*/
|
||||
|
||||
/** Recognized Dockhand label keys */
|
||||
export const DOCKHAND_LABELS = {
|
||||
UPDATE: 'dockhand.update',
|
||||
HIDDEN: 'dockhand.hidden',
|
||||
NOTIFY: 'dockhand.notify',
|
||||
URL: 'dockhand.url',
|
||||
ORDER: 'dockhand.order',
|
||||
} as const;
|
||||
|
||||
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
|
||||
const FALSY_VALUES = new Set(['false', 'no', '0']);
|
||||
|
||||
/**
|
||||
* Parse a label value as a boolean.
|
||||
* Returns true for: true, TRUE, yes, YES, 1
|
||||
* Returns false for: false, FALSE, no, NO, 0
|
||||
* Returns undefined for missing or unrecognized values.
|
||||
*/
|
||||
function parseLabelBool(value: string | undefined | null): boolean | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (TRUTHY_VALUES.has(normalized)) return true;
|
||||
if (FALSY_VALUES.has(normalized)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a label value from a Docker labels object.
|
||||
*/
|
||||
function getLabel(labels: Record<string, string> | undefined | null, key: string): string | undefined {
|
||||
if (!labels) return undefined;
|
||||
return labels[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container should be skipped during auto-updates.
|
||||
* Returns true if dockhand.update is explicitly set to false/no/0.
|
||||
* Default (no label): allow updates (opt-out model).
|
||||
*/
|
||||
export function isUpdateDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.UPDATE));
|
||||
return value === false; // explicitly disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container should be hidden from the UI.
|
||||
* Returns true if dockhand.hidden is explicitly set to true/yes/1.
|
||||
* Default (no label): visible (opt-out model).
|
||||
*/
|
||||
export function isHiddenByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.HIDDEN));
|
||||
return value === true; // explicitly hidden
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notifications should be suppressed for this container.
|
||||
* Returns true if dockhand.notify is explicitly set to false/no/0.
|
||||
* Default (no label): send notifications (opt-out model).
|
||||
*/
|
||||
export function isNotifyDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.NOTIFY));
|
||||
return value === false; // explicitly disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the custom URL from dockhand.url label.
|
||||
* Returns the URL string if set, or undefined.
|
||||
*/
|
||||
export function getCustomUrl(labels: Record<string, string> | undefined | null): string | undefined {
|
||||
const value = getLabel(labels, DOCKHAND_LABELS.URL);
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sort order value from dockhand.order label.
|
||||
* Returns the parsed integer, or 0 for missing/invalid values.
|
||||
*/
|
||||
export function getOrderValue(labels: Record<string, string> | undefined | null): number {
|
||||
const value = getLabel(labels, DOCKHAND_LABELS.ORDER);
|
||||
if (value == null) return 0;
|
||||
const parsed = parseInt(value.trim(), 10);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all Dockhand label states from a container's labels.
|
||||
* Useful for including in API responses so the frontend knows about label overrides.
|
||||
*/
|
||||
export function getDockhandLabels(labels: Record<string, string> | undefined | null): {
|
||||
updateDisabled: boolean;
|
||||
hidden: boolean;
|
||||
notifyDisabled: boolean;
|
||||
customUrl?: string;
|
||||
} {
|
||||
return {
|
||||
updateDisabled: isUpdateDisabledByLabel(labels),
|
||||
hidden: isHiddenByLabel(labels),
|
||||
notifyDisabled: isNotifyDisabledByLabel(labels),
|
||||
customUrl: getCustomUrl(labels),
|
||||
};
|
||||
}
|
||||
+8
-198
@@ -78,7 +78,6 @@ import {
|
||||
|
||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||
import { encrypt, decrypt } from './encryption.js';
|
||||
import { parseEnvInterpolation } from './env-interpolation';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { db, isPostgres, isSqlite };
|
||||
@@ -113,7 +112,7 @@ export function initDatabase() {
|
||||
// =============================================================================
|
||||
|
||||
export async function getEnvironments(): Promise<Environment[]> {
|
||||
const results = await db.select().from(environments).orderBy(sql`lower(${environments.name})`);
|
||||
const results = await db.select().from(environments).orderBy(asc(environments.name));
|
||||
return results.map((e: Environment) => ({
|
||||
...e,
|
||||
tlsKey: decrypt(e.tlsKey),
|
||||
@@ -1186,37 +1185,6 @@ export async function getUser(id: number): Promise<UserData | null> {
|
||||
return results[0] as UserData || null;
|
||||
}
|
||||
|
||||
export interface SafeUserData {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
avatar: string | null;
|
||||
authProvider: string | null;
|
||||
mfaEnabled: boolean;
|
||||
isActive: boolean;
|
||||
lastLogin: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function getUserWithoutPassword(id: number): Promise<SafeUserData | null> {
|
||||
const results = await db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
avatar: users.avatar,
|
||||
authProvider: users.authProvider,
|
||||
mfaEnabled: users.mfaEnabled,
|
||||
isActive: users.isActive,
|
||||
lastLogin: users.lastLogin,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt
|
||||
}).from(users).where(eq(users.id, id));
|
||||
return results[0] as SafeUserData || null;
|
||||
}
|
||||
|
||||
export async function hasAdminUser(): Promise<boolean> {
|
||||
// Check if any user has the Admin role assigned
|
||||
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
|
||||
@@ -2067,7 +2035,6 @@ export async function getGitStacksByRepositoryId(repositoryId: number): Promise<
|
||||
}
|
||||
|
||||
export async function deleteGitRepository(id: number): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`);
|
||||
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
|
||||
return true;
|
||||
}
|
||||
@@ -2088,9 +2055,7 @@ export interface GitStackData {
|
||||
autoUpdateCron: string;
|
||||
webhookEnabled: boolean;
|
||||
webhookSecret: string | null;
|
||||
contextDir: string | null;
|
||||
buildOnDeploy: boolean;
|
||||
noBuildCache: boolean;
|
||||
repullImages: boolean;
|
||||
forceRedeploy: boolean;
|
||||
lastSync: string | null;
|
||||
@@ -2126,11 +2091,6 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2159,11 +2119,6 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2192,9 +2147,7 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
@@ -2227,9 +2180,7 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
@@ -2260,9 +2211,7 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
@@ -2294,9 +2243,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
@@ -2328,9 +2275,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
@@ -2362,9 +2307,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
@@ -2401,9 +2344,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
@@ -2435,9 +2376,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
@@ -2469,9 +2408,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
@@ -2501,9 +2438,7 @@ export async function createGitStack(data: {
|
||||
autoUpdateCron?: string;
|
||||
webhookEnabled?: boolean;
|
||||
webhookSecret?: string | null;
|
||||
contextDir?: string | null;
|
||||
buildOnDeploy?: boolean;
|
||||
noBuildCache?: boolean;
|
||||
repullImages?: boolean;
|
||||
forceRedeploy?: boolean;
|
||||
}): Promise<GitStackWithRepo> {
|
||||
@@ -2513,14 +2448,12 @@ export async function createGitStack(data: {
|
||||
repositoryId: data.repositoryId,
|
||||
composePath: data.composePath || 'compose.yaml',
|
||||
envFilePath: data.envFilePath || null,
|
||||
contextDir: data.contextDir || null,
|
||||
autoUpdate: data.autoUpdate || false,
|
||||
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
|
||||
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
||||
webhookEnabled: data.webhookEnabled || false,
|
||||
webhookSecret: data.webhookSecret || null,
|
||||
buildOnDeploy: data.buildOnDeploy ?? false,
|
||||
noBuildCache: data.noBuildCache ?? false,
|
||||
repullImages: data.repullImages ?? false,
|
||||
forceRedeploy: data.forceRedeploy ?? false
|
||||
}).returning();
|
||||
@@ -2539,9 +2472,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
|
||||
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
|
||||
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
|
||||
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
|
||||
if (data.contextDir !== undefined) updateData.contextDir = data.contextDir;
|
||||
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
|
||||
if (data.noBuildCache !== undefined) updateData.noBuildCache = data.noBuildCache;
|
||||
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
|
||||
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
|
||||
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
|
||||
@@ -2554,7 +2485,6 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
|
||||
}
|
||||
|
||||
export async function deleteGitStack(id: number): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting git_stacks row id=${id}`);
|
||||
await db.delete(gitStacks).where(eq(gitStacks.id, id));
|
||||
return true;
|
||||
}
|
||||
@@ -2579,9 +2509,7 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
@@ -2611,9 +2539,7 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
@@ -2644,9 +2570,7 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
contextDir: gitStacks.contextDir,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
noBuildCache: gitStacks.noBuildCache,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
@@ -2675,9 +2599,7 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
|
||||
autoUpdateCron: row.autoUpdateCron,
|
||||
webhookEnabled: row.webhookEnabled,
|
||||
webhookSecret: row.webhookSecret,
|
||||
contextDir: row.contextDir ?? null,
|
||||
buildOnDeploy: row.buildOnDeploy ?? false,
|
||||
noBuildCache: row.noBuildCache ?? false,
|
||||
repullImages: row.repullImages ?? false,
|
||||
forceRedeploy: row.forceRedeploy ?? false,
|
||||
lastSync: row.lastSync,
|
||||
@@ -2822,21 +2744,11 @@ export async function upsertStackSource(data: {
|
||||
const existing = await getStackSource(data.stackName, data.environmentId);
|
||||
|
||||
if (existing) {
|
||||
const newRepoId = data.gitRepositoryId || null;
|
||||
const newStackId = data.gitStackId || null;
|
||||
const changes: string[] = [];
|
||||
if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType} → ${data.sourceType}`);
|
||||
if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId} → ${newRepoId}`);
|
||||
if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId} → ${newStackId}`);
|
||||
if (changes.length > 0) {
|
||||
console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`);
|
||||
}
|
||||
|
||||
await db.update(stackSources)
|
||||
.set({
|
||||
sourceType: data.sourceType,
|
||||
gitRepositoryId: newRepoId,
|
||||
gitStackId: newStackId,
|
||||
gitRepositoryId: data.gitRepositoryId || null,
|
||||
gitStackId: data.gitStackId || null,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null,
|
||||
updatedAt: new Date().toISOString()
|
||||
@@ -2844,7 +2756,6 @@ export async function upsertStackSource(data: {
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
||||
} else {
|
||||
console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`);
|
||||
await db.insert(stackSources).values({
|
||||
stackName: data.stackName,
|
||||
environmentId: data.environmentId ?? null,
|
||||
@@ -2878,7 +2789,6 @@ export async function updateStackSource(
|
||||
}
|
||||
|
||||
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
|
||||
// Delete matching record (either with specific envId or NULL)
|
||||
await db.delete(stackSources)
|
||||
.where(and(
|
||||
@@ -3130,7 +3040,7 @@ export type AuditAction =
|
||||
export type AuditEntityType =
|
||||
| 'container' | 'image' | 'stack' | 'volume' | 'network'
|
||||
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token';
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
|
||||
|
||||
export interface AuditLogData {
|
||||
id: number;
|
||||
@@ -3246,16 +3156,14 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<Audit
|
||||
// Labels filter - find environments with matching labels first
|
||||
let labelFilteredEnvIds: number[] | undefined;
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
|
||||
// Get environments that have ANY of the specified labels
|
||||
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
|
||||
labelFilteredEnvIds = allEnvs
|
||||
.filter(env => {
|
||||
if (!env.labels) return false;
|
||||
try {
|
||||
const envLabels = JSON.parse(env.labels) as string[];
|
||||
return labelFilterMode === 'all'
|
||||
? filters.labels!.every(label => envLabels.includes(label))
|
||||
: filters.labels!.some(label => envLabels.includes(label));
|
||||
return filters.labels!.some(label => envLabels.includes(label));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -3463,16 +3371,14 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P
|
||||
// Labels filter - find environments with matching labels first
|
||||
let labelFilteredEnvIds: number[] | undefined;
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
|
||||
// Get environments that have ANY of the specified labels
|
||||
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
|
||||
labelFilteredEnvIds = allEnvs
|
||||
.filter(env => {
|
||||
if (!env.labels) return false;
|
||||
try {
|
||||
const envLabels = JSON.parse(env.labels) as string[];
|
||||
return labelFilterMode === 'all'
|
||||
? filters.labels!.every(label => envLabels.includes(label))
|
||||
: filters.labels!.some(label => envLabels.includes(label));
|
||||
return filters.labels!.some(label => envLabels.includes(label));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -4095,11 +4001,8 @@ const SCHEDULE_CLEANUP_CRON_KEY = 'schedule_cleanup_cron';
|
||||
const EVENT_CLEANUP_CRON_KEY = 'event_cleanup_cron';
|
||||
const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled';
|
||||
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
|
||||
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
|
||||
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
|
||||
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
|
||||
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
|
||||
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
|
||||
|
||||
export async function getScheduleRetentionDays(): Promise<number> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
|
||||
@@ -4233,50 +4136,6 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getScannerCleanupCron(): Promise<string> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
|
||||
if (result[0]) {
|
||||
return result[0].value || DEFAULT_SCANNER_CLEANUP_CRON;
|
||||
}
|
||||
return DEFAULT_SCANNER_CLEANUP_CRON;
|
||||
}
|
||||
|
||||
export async function setScannerCleanupCron(cron: string): Promise<void> {
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: cron, updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: SCANNER_CLEANUP_CRON_KEY,
|
||||
value: cron
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getScannerCleanupEnabled(): Promise<boolean> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
|
||||
if (result[0]) {
|
||||
return result[0].value === 'true';
|
||||
}
|
||||
return true; // Enabled by default
|
||||
}
|
||||
|
||||
export async function setScannerCleanupEnabled(enabled: boolean): Promise<void> {
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({
|
||||
key: SCANNER_CLEANUP_ENABLED_KEY,
|
||||
value: enabled ? 'true' : 'false'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EXTERNAL STACK PATHS
|
||||
// =============================================================================
|
||||
@@ -4721,55 +4580,6 @@ export async function setStackEnvVars(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of secret key names for a stack.
|
||||
* Used to mask secret values in container inspect responses.
|
||||
*/
|
||||
export async function getSecretKeyNames(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Set<string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of env var keys that should be masked in container inspect responses.
|
||||
* Handles two cases:
|
||||
* 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS})
|
||||
* 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret})
|
||||
* Detected by parsing the compose file for ${variable} references in environment: sections.
|
||||
*
|
||||
* @param composeContent - Optional compose file content. If provided, interpolation
|
||||
* references are parsed to detect secrets injected under different key names.
|
||||
*/
|
||||
export async function getSecretKeysToMask(
|
||||
stackName: string,
|
||||
environmentId?: number | null,
|
||||
composeContent?: string | null
|
||||
): Promise<Set<string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
|
||||
if (secretKeyNames.size === 0) return secretKeyNames;
|
||||
|
||||
// If we have compose content, parse interpolation references to find
|
||||
// container env keys that map to secret interpolation variables.
|
||||
// e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too.
|
||||
if (composeContent) {
|
||||
const interpolated = parseEnvInterpolation(composeContent);
|
||||
for (const [containerKey, varName] of interpolated) {
|
||||
if (secretKeyNames.has(varName)) {
|
||||
secretKeyNames.add(containerKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return secretKeyNames;
|
||||
}
|
||||
|
||||
export { parseEnvInterpolation } from './env-interpolation';
|
||||
|
||||
/**
|
||||
* Get count of environment variables for a stack.
|
||||
* @param stackName - Name of the stack
|
||||
|
||||
@@ -335,8 +335,7 @@ const REQUIRED_TABLES = [
|
||||
'audit_logs',
|
||||
'container_events',
|
||||
'schedule_executions',
|
||||
'user_preferences',
|
||||
'api_tokens'
|
||||
'user_preferences'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -769,7 +768,7 @@ async function seedDatabase(): Promise<void> {
|
||||
license: ['manage'],
|
||||
audit_logs: ['view'],
|
||||
activity: ['view'],
|
||||
schedules: ['view', 'edit', 'run']
|
||||
schedules: ['view']
|
||||
});
|
||||
|
||||
const operatorPermissions = JSON.stringify({
|
||||
@@ -788,7 +787,7 @@ async function seedDatabase(): Promise<void> {
|
||||
license: [],
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view', 'edit', 'run']
|
||||
schedules: ['view']
|
||||
});
|
||||
|
||||
const viewerPermissions = JSON.stringify({
|
||||
@@ -899,7 +898,6 @@ export const userPreferences = schemaProxy.userPreferences;
|
||||
export const scheduleExecutions = schemaProxy.scheduleExecutions;
|
||||
export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables;
|
||||
export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates;
|
||||
export const apiTokens = schemaProxy.apiTokens;
|
||||
|
||||
// Re-export types from SQLite schema (they're compatible with PostgreSQL)
|
||||
export type {
|
||||
@@ -958,9 +956,7 @@ export type {
|
||||
StackEnvironmentVariable,
|
||||
NewStackEnvironmentVariable,
|
||||
PendingContainerUpdate,
|
||||
NewPendingContainerUpdate,
|
||||
ApiToken,
|
||||
NewApiToken
|
||||
NewPendingContainerUpdate
|
||||
} from './schema/index.js';
|
||||
|
||||
export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
|
||||
|
||||
@@ -315,9 +315,7 @@ export const gitStacks = sqliteTable('git_stacks', {
|
||||
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
||||
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
|
||||
webhookSecret: text('webhook_secret'),
|
||||
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
|
||||
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
|
||||
noBuildCache: integer('no_build_cache', { mode: 'boolean' }).default(false),
|
||||
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
|
||||
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
|
||||
lastSync: text('last_sync'),
|
||||
@@ -469,25 +467,6 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
|
||||
envContainerUnique: unique().on(table.environmentId, table.containerId)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// API TOKENS TABLE
|
||||
// =============================================================================
|
||||
|
||||
export const apiTokens = sqliteTable('api_tokens', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
tokenPrefix: text('token_prefix').notNull(),
|
||||
lastUsed: text('last_used'),
|
||||
expiresAt: text('expires_at'),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
|
||||
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// USER PREFERENCES TABLE (unified key-value store)
|
||||
// =============================================================================
|
||||
@@ -591,6 +570,3 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe
|
||||
|
||||
export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect;
|
||||
export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert;
|
||||
|
||||
export type ApiToken = typeof apiTokens.$inferSelect;
|
||||
export type NewApiToken = typeof apiTokens.$inferInsert;
|
||||
|
||||
@@ -318,9 +318,7 @@ export const gitStacks = pgTable('git_stacks', {
|
||||
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
|
||||
webhookEnabled: boolean('webhook_enabled').default(false),
|
||||
webhookSecret: text('webhook_secret'),
|
||||
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
|
||||
buildOnDeploy: boolean('build_on_deploy').default(false),
|
||||
noBuildCache: boolean('no_build_cache').default(false),
|
||||
repullImages: boolean('repull_images').default(false),
|
||||
forceRedeploy: boolean('force_redeploy').default(false),
|
||||
lastSync: timestamp('last_sync', { mode: 'string' }),
|
||||
@@ -472,25 +470,6 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
|
||||
envContainerUnique: unique().on(table.environmentId, table.containerId)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// API TOKENS TABLE
|
||||
// =============================================================================
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
tokenPrefix: text('token_prefix').notNull(),
|
||||
lastUsed: timestamp('last_used', { mode: 'string' }),
|
||||
expiresAt: timestamp('expires_at', { mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
|
||||
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// USER PREFERENCES TABLE (unified key-value store)
|
||||
// =============================================================================
|
||||
|
||||
+53
-179
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { mergeImageEnvVars } from './container-env-merge';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
@@ -15,7 +14,6 @@ import * as tls from 'node:tls';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Environment } from './db';
|
||||
import { getStackEnvVarsAsRecord } from './db';
|
||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
||||
import { isSystemContainer } from './scheduler/tasks/update-utils';
|
||||
import { deepDiff } from '../utils/diff.js';
|
||||
|
||||
@@ -417,8 +415,7 @@ export function httpsAgentRequest(
|
||||
if (!streaming) {
|
||||
const isComposeOperation = path === '/_hawser/compose';
|
||||
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
|
||||
const isPrune = path.endsWith('/prune');
|
||||
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000;
|
||||
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : 30000;
|
||||
}
|
||||
|
||||
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping)
|
||||
@@ -887,7 +884,7 @@ export async function dockerFetch(
|
||||
body,
|
||||
headers,
|
||||
streaming || false,
|
||||
(streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal
|
||||
(streaming || path === '/_hawser/compose') ? 300000 : 30000, // 5 min for streaming/compose, 30s for normal
|
||||
isBinary,
|
||||
fetchOptions.signal ?? undefined
|
||||
);
|
||||
@@ -963,8 +960,7 @@ export async function dockerFetch(
|
||||
if (!streaming && !finalOptions.signal) {
|
||||
const isComposeOperation = path === '/_hawser/compose';
|
||||
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
|
||||
const isPrune = path.endsWith('/prune');
|
||||
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000);
|
||||
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : 30000);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1047,7 +1043,6 @@ export interface ContainerInfo {
|
||||
networks: { [networkName: string]: { ipAddress: string } };
|
||||
health?: string;
|
||||
restartCount: number;
|
||||
exitCode?: number;
|
||||
mounts: Array<{ type: string; source: string; destination: string; mode: string; rw: boolean }>;
|
||||
labels: { [key: string]: string };
|
||||
command: string;
|
||||
@@ -1083,39 +1078,6 @@ export async function listContainers(all = true, envId?: number | null): Promise
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch health status via inspect for containers with healthchecks where Status
|
||||
// string doesn't include health info (Podman compat API omits it from the list endpoint).
|
||||
// Skip entirely if any container already has health in Status (Docker includes it natively).
|
||||
const healthStatuses = new Map<string, string>();
|
||||
const healthRegex = /\((healthy|unhealthy|health:\s*starting)\)/i;
|
||||
const anyHasHealthInStatus = containers.some(c => healthRegex.test(c.Status || ''));
|
||||
if (!anyHasHealthInStatus) {
|
||||
// Single API call to find which containers have healthchecks configured
|
||||
try {
|
||||
const healthFiltered = await dockerJsonRequest<any[]>(
|
||||
'/containers/json?filters=' + encodeURIComponent(JSON.stringify({ health: ['healthy', 'unhealthy', 'starting'] })),
|
||||
{}, envId
|
||||
);
|
||||
const healthIds = new Set((healthFiltered || []).map((c: any) => c.Id));
|
||||
// Only inspect those specific containers (typically very few)
|
||||
if (healthIds.size > 0) {
|
||||
await Promise.all(
|
||||
[...healthIds].map(async (id) => {
|
||||
try {
|
||||
const inspect = await inspectContainer(id, envId);
|
||||
const h = inspect.State?.Health?.Status;
|
||||
if (h) healthStatuses.set(id, h);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore - health filter may not be supported
|
||||
}
|
||||
}
|
||||
|
||||
return containers.map((container) => {
|
||||
// Extract network info with IP addresses
|
||||
const networks: { [networkName: string]: { ipAddress: string } } = {};
|
||||
@@ -1138,25 +1100,14 @@ export async function listContainers(all = true, envId?: number | null): Promise
|
||||
|
||||
// Extract health status from Status string
|
||||
// Docker formats: "(healthy)", "(unhealthy)", "(health: starting)"
|
||||
// Podman compat API omits health from Status string, so fall back to inspect data
|
||||
let health: string | undefined;
|
||||
const healthMatch = container.Status?.match(/\((healthy|unhealthy|health:\s*starting)\)/i);
|
||||
if (healthMatch) {
|
||||
const matched = healthMatch[1].toLowerCase();
|
||||
// Normalize "health: starting" to just "starting"
|
||||
health = matched.includes('starting') ? 'starting' : matched;
|
||||
} else {
|
||||
const inspectHealth = healthStatuses.get(container.Id);
|
||||
if (inspectHealth) {
|
||||
health = inspectHealth === 'health: starting' ? 'starting' : inspectHealth;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse exit code from Status string (e.g., "Exited (0) 5 minutes ago")
|
||||
const exitCode = container.State === 'exited'
|
||||
? parseInt(container.Status?.match(/\((\d+)\)/)?.[1] ?? '-1')
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: container.Id,
|
||||
name: container.Names[0]?.replace(/^\//, '') || 'unnamed',
|
||||
@@ -1168,7 +1119,6 @@ export async function listContainers(all = true, envId?: number | null): Promise
|
||||
networks,
|
||||
health,
|
||||
restartCount: restartCounts.get(container.Id) || 0,
|
||||
exitCode,
|
||||
mounts,
|
||||
labels: container.Labels || {},
|
||||
command: container.Command || '',
|
||||
@@ -1216,26 +1166,13 @@ export async function renameContainer(id: string, newName: string, envId?: numbe
|
||||
await assertDockerResponse(response);
|
||||
}
|
||||
|
||||
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
|
||||
export async function getContainerLogs(id: string, tail = 100, envId?: number | null): Promise<string> {
|
||||
// Check if container has TTY enabled
|
||||
const info = await inspectContainer(id, envId);
|
||||
const hasTty = info.Config?.Tty ?? false;
|
||||
|
||||
let query = `stdout=true&stderr=true×tamps=true`;
|
||||
if (tail === 'all') {
|
||||
query += `&tail=all`;
|
||||
} else {
|
||||
query += `&tail=${tail}`;
|
||||
}
|
||||
if (since) {
|
||||
query += `&since=${since}`;
|
||||
}
|
||||
if (until) {
|
||||
query += `&until=${until}`;
|
||||
}
|
||||
|
||||
const response = await dockerFetch(
|
||||
`/containers/${id}/logs?${query}`,
|
||||
`/containers/${id}/logs?stdout=true&stderr=true&tail=${tail}×tamps=true`,
|
||||
{},
|
||||
envId
|
||||
);
|
||||
@@ -1288,9 +1225,9 @@ export interface DeviceRequest {
|
||||
export interface CreateContainerOptions {
|
||||
name: string;
|
||||
image: string;
|
||||
ports?: { [key: string]: { HostIp?: string; HostPort: string } } | null;
|
||||
ports?: { [key: string]: { HostIp?: string; HostPort: string } };
|
||||
volumes?: { [key: string]: {} };
|
||||
volumeBinds?: string[] | null;
|
||||
volumeBinds?: string[];
|
||||
env?: string[];
|
||||
labels?: { [key: string]: string };
|
||||
cmd?: string[];
|
||||
@@ -1308,15 +1245,9 @@ export interface CreateContainerOptions {
|
||||
networkIpv6Address?: string;
|
||||
/** Gateway priority for the primary network (Docker Engine 28+) */
|
||||
networkGwPriority?: number;
|
||||
/** Per-network endpoint configuration (IPv4, IPv6, aliases) */
|
||||
networkConfigs?: Record<string, {
|
||||
ipv4Address?: string;
|
||||
ipv6Address?: string;
|
||||
aliases?: string[];
|
||||
}>;
|
||||
user?: string | null;
|
||||
privileged?: boolean;
|
||||
healthcheck?: HealthcheckConfig | null;
|
||||
healthcheck?: HealthcheckConfig;
|
||||
memory?: number;
|
||||
memoryReservation?: number;
|
||||
memorySwap?: number;
|
||||
@@ -1407,10 +1338,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
containerConfig.User = options.user ?? '';
|
||||
}
|
||||
|
||||
if (options.healthcheck === null) {
|
||||
// Explicitly disable healthcheck (user cleared it)
|
||||
containerConfig.Healthcheck = { Test: ["NONE"] };
|
||||
} else if (options.healthcheck) {
|
||||
if (options.healthcheck) {
|
||||
containerConfig.Healthcheck = {};
|
||||
if (options.healthcheck.test && options.healthcheck.test.length > 0) {
|
||||
containerConfig.Healthcheck.Test = options.healthcheck.test;
|
||||
@@ -1429,11 +1357,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ports === null) {
|
||||
// Explicitly clear ports (user removed all mappings)
|
||||
containerConfig.ExposedPorts = {};
|
||||
containerConfig.HostConfig.PortBindings = {};
|
||||
} else if (options.ports) {
|
||||
if (options.ports) {
|
||||
containerConfig.ExposedPorts = {};
|
||||
containerConfig.HostConfig.PortBindings = {};
|
||||
|
||||
@@ -1443,10 +1367,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
}
|
||||
}
|
||||
|
||||
if (options.volumeBinds === null) {
|
||||
// Explicitly clear volume binds (user removed all)
|
||||
containerConfig.HostConfig.Binds = [];
|
||||
} else if (options.volumeBinds && options.volumeBinds.length > 0) {
|
||||
if (options.volumeBinds && options.volumeBinds.length > 0) {
|
||||
containerConfig.HostConfig.Binds = options.volumeBinds;
|
||||
}
|
||||
|
||||
@@ -1497,25 +1418,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
|
||||
for (const network of options.networks) {
|
||||
const isFirstNetwork = network === options.networks[0];
|
||||
const netCfg = options.networkConfigs?.[network];
|
||||
const endpointConfig: any = {};
|
||||
|
||||
// Per-network config from networkConfigs (takes precedence)
|
||||
if (netCfg) {
|
||||
if (netCfg.aliases && netCfg.aliases.length > 0) {
|
||||
endpointConfig.Aliases = netCfg.aliases;
|
||||
}
|
||||
if (netCfg.ipv4Address || netCfg.ipv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (netCfg.ipv4Address) {
|
||||
endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
|
||||
}
|
||||
if (netCfg.ipv6Address) {
|
||||
endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
|
||||
}
|
||||
}
|
||||
} else if (isFirstNetwork) {
|
||||
// Backward compat: apply flat fields to first network if no networkConfigs
|
||||
// Apply aliases, static IP, and gateway priority only to the first (primary) network
|
||||
if (isFirstNetwork) {
|
||||
if (options.networkAliases && options.networkAliases.length > 0) {
|
||||
endpointConfig.Aliases = options.networkAliases;
|
||||
}
|
||||
@@ -1698,22 +1604,9 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
containerConfig.StopTimeout = options.stopTimeout;
|
||||
}
|
||||
|
||||
// MAC address — set both top-level (API <1.44) and endpoint config (API 1.44+)
|
||||
// MAC address
|
||||
if (options.macAddress) {
|
||||
containerConfig.MacAddress = options.macAddress;
|
||||
|
||||
// For Docker API 1.44+, MacAddress must be in EndpointConfig
|
||||
const primaryNetwork = options.networks?.[0] || options.networkMode || 'bridge';
|
||||
if (containerConfig.NetworkingConfig?.EndpointsConfig?.[primaryNetwork]) {
|
||||
containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork].MacAddress = options.macAddress;
|
||||
} else {
|
||||
containerConfig.NetworkingConfig = containerConfig.NetworkingConfig || { EndpointsConfig: {} };
|
||||
containerConfig.NetworkingConfig.EndpointsConfig = containerConfig.NetworkingConfig.EndpointsConfig || {};
|
||||
containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork] = {
|
||||
...containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork],
|
||||
MacAddress: options.macAddress
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extra hosts (/etc/hosts entries)
|
||||
@@ -1901,17 +1794,15 @@ export async function recreateContainerFromInspect(
|
||||
HostConfig: hostConfig
|
||||
};
|
||||
|
||||
// 4a. Update image-embedded labels and env vars to match the new image.
|
||||
// Docker's create API uses exactly the labels/env you pass, ignoring the new image's
|
||||
// embedded values. We inspect both old and new images to distinguish image-origin
|
||||
// values from user-set values, then merge accordingly.
|
||||
// 4a. Update image-embedded labels to match the new image.
|
||||
// Docker's create API uses exactly the labels you pass, ignoring the new image's
|
||||
// embedded labels. We inspect both old and new images to distinguish image-origin
|
||||
// labels from user-set labels, then merge accordingly.
|
||||
try {
|
||||
const [oldImageInspect, newImageInspect] = await Promise.all([
|
||||
inspectImage(config.Image, envId),
|
||||
inspectImage(newImage, envId)
|
||||
]);
|
||||
|
||||
// Merge labels
|
||||
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
|
||||
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
|
||||
const containerLabels: Record<string, string> = createConfig.Labels || {};
|
||||
@@ -1932,17 +1823,9 @@ export async function recreateContainerFromInspect(
|
||||
|
||||
createConfig.Labels = mergedLabels;
|
||||
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
|
||||
|
||||
// Merge env vars (same logic: image-baked vars get updated, user-set vars preserved)
|
||||
const oldImageEnv: string[] = (oldImageInspect as any)?.Config?.Env || [];
|
||||
const newImageEnv: string[] = (newImageInspect as any)?.Config?.Env || [];
|
||||
const containerEnv: string[] = createConfig.Env || [];
|
||||
|
||||
createConfig.Env = mergeImageEnvVars(containerEnv, oldImageEnv, newImageEnv);
|
||||
log?.(`Updated image env vars: ${newImageEnv.length} from new image, ${createConfig.Env.length} total`);
|
||||
} catch (e) {
|
||||
log?.(`Warning: could not update image labels/env: ${e}`);
|
||||
// Fall through with old values — non-fatal
|
||||
log?.(`Warning: could not update image labels: ${e}`);
|
||||
// Fall through with old labels — non-fatal
|
||||
}
|
||||
|
||||
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
|
||||
@@ -2022,7 +1905,20 @@ export async function recreateContainerFromInspect(
|
||||
}
|
||||
}
|
||||
|
||||
const additionalBinds = getAdditionalVolumeBinds(hostConfig, inspectData.Mounts || []);
|
||||
// Preserve anonymous volumes from Mounts not in HostConfig.Binds
|
||||
const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => {
|
||||
const parts = b.split(':');
|
||||
return parts.length >= 2 ? parts[1] : parts[0];
|
||||
}));
|
||||
const mounts = inspectData.Mounts || [];
|
||||
const additionalBinds: string[] = [];
|
||||
for (const mount of mounts) {
|
||||
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
|
||||
if (!existingBinds.has(mount.Destination)) {
|
||||
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (additionalBinds.length > 0) {
|
||||
createConfig.HostConfig = {
|
||||
...hostConfig,
|
||||
@@ -2429,15 +2325,11 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
||||
const mergedOptions: CreateContainerOptions = {
|
||||
...existingOptions,
|
||||
...options,
|
||||
// Replace labels, but preserve Docker internal labels (com.docker.*)
|
||||
labels: options.labels !== undefined
|
||||
? {
|
||||
...Object.fromEntries(
|
||||
Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.'))
|
||||
),
|
||||
...options.labels
|
||||
}
|
||||
: existingOptions.labels
|
||||
// Special handling for labels - merge instead of replace to preserve Docker internal labels
|
||||
labels: {
|
||||
...existingOptions.labels,
|
||||
...options.labels
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Stop old container
|
||||
@@ -2501,7 +2393,7 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
||||
}
|
||||
|
||||
// 5. Start if needed
|
||||
if (startAfterUpdate) {
|
||||
if (startAfterUpdate || wasRunning) {
|
||||
try {
|
||||
await newContainer.start();
|
||||
} catch (startError) {
|
||||
@@ -2733,9 +2625,7 @@ function parseImageReference(imageName: string): { registry: string; repo: strin
|
||||
* 'ghcr.io' -> { host: 'ghcr.io', path: '', fullRegistry: 'ghcr.io' }
|
||||
* 'registry.example.com:5000/myorg' -> { host: 'registry.example.com:5000', path: '/myorg', fullRegistry: 'registry.example.com:5000/myorg' }
|
||||
*/
|
||||
export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string; protocol: string } {
|
||||
// Detect protocol (default to https)
|
||||
const protocol = url.startsWith('http://') ? 'http' : 'https';
|
||||
export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string } {
|
||||
// Remove protocol
|
||||
const withoutProtocol = url.replace(/^https?:\/\//, '');
|
||||
// Remove trailing slash
|
||||
@@ -2743,11 +2633,11 @@ export function parseRegistryUrl(url: string): { host: string; path: string; ful
|
||||
// Split on first slash (after port if present)
|
||||
const slashIndex = trimmed.indexOf('/');
|
||||
if (slashIndex === -1) {
|
||||
return { host: trimmed, path: '', fullRegistry: trimmed, protocol };
|
||||
return { host: trimmed, path: '', fullRegistry: trimmed };
|
||||
}
|
||||
const host = trimmed.substring(0, slashIndex);
|
||||
const path = trimmed.substring(slashIndex); // includes leading /
|
||||
return { host, path, fullRegistry: trimmed, protocol };
|
||||
return { host, path, fullRegistry: trimmed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2932,7 +2822,7 @@ export async function getRegistryAuthHeader(
|
||||
try {
|
||||
// Parse URL to extract host (V2 API is always at the host root)
|
||||
const parsed = parseRegistryUrl(registryUrl);
|
||||
const apiBaseUrl = `${parsed.protocol}://${parsed.host}`;
|
||||
const apiBaseUrl = `https://${parsed.host}`;
|
||||
|
||||
// Step 1: Challenge request to /v2/ (always at registry root, not under org path)
|
||||
const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, {
|
||||
@@ -3041,7 +2931,7 @@ export async function getRegistryAuth(
|
||||
const parsed = parseRegistryUrl(registry.url);
|
||||
|
||||
// V2 API endpoints are always at the registry host root
|
||||
const baseUrl = `${parsed.protocol}://${parsed.host}`;
|
||||
const baseUrl = `https://${parsed.host}`;
|
||||
|
||||
// Get auth header using proper token flow
|
||||
const credentials = registry.username && registry.password
|
||||
@@ -3952,25 +3842,19 @@ export async function getContainerTop(id: string, envId?: number | null): Promis
|
||||
export async function execInContainer(
|
||||
containerId: string,
|
||||
cmd: string[],
|
||||
envId?: number | null,
|
||||
user?: string | null
|
||||
envId?: number | null
|
||||
): Promise<string> {
|
||||
const execBody: any = {
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: false
|
||||
};
|
||||
|
||||
if (user) {
|
||||
execBody.User = user;
|
||||
}
|
||||
|
||||
// Create exec instance
|
||||
const execCreate = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/${containerId}/exec`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(execBody)
|
||||
body: JSON.stringify({
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: false
|
||||
})
|
||||
},
|
||||
envId
|
||||
);
|
||||
@@ -4066,7 +3950,6 @@ export async function runContainer(options: {
|
||||
cmd: string[];
|
||||
binds?: string[];
|
||||
env?: string[];
|
||||
extraHosts?: string[];
|
||||
name?: string;
|
||||
envId?: number | null;
|
||||
}): Promise<{ stdout: string; stderr: string }> {
|
||||
@@ -4088,10 +3971,6 @@ export async function runContainer(options: {
|
||||
}
|
||||
};
|
||||
|
||||
if (options.extraHosts && options.extraHosts.length > 0) {
|
||||
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
|
||||
}
|
||||
|
||||
const createResult = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
||||
{
|
||||
@@ -4151,7 +4030,6 @@ export async function runContainerWithStreaming(options: {
|
||||
cmd: string[];
|
||||
binds?: string[];
|
||||
env?: string[];
|
||||
extraHosts?: string[];
|
||||
name?: string;
|
||||
user?: string;
|
||||
envId?: number | null;
|
||||
@@ -4179,10 +4057,6 @@ export async function runContainerWithStreaming(options: {
|
||||
}
|
||||
};
|
||||
|
||||
if (options.extraHosts && options.extraHosts.length > 0) {
|
||||
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
|
||||
}
|
||||
|
||||
// Set user if specified (needed for rootless Docker socket access)
|
||||
if (options.user) {
|
||||
containerConfig.User = options.user;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Parse compose YAML to extract environment variable interpolation mappings.
|
||||
* Returns pairs of [containerEnvKey, interpolationVariable].
|
||||
*
|
||||
* Handles patterns:
|
||||
* - VAR=${ref}
|
||||
* - VAR=${ref:-default}
|
||||
* - VAR=${ref:+alt}
|
||||
* - VAR=${ref?error}
|
||||
*
|
||||
* Only extracts from `environment:` sections (list format: `- KEY=value`).
|
||||
*/
|
||||
export function parseEnvInterpolation(composeContent: string): Array<[string, string]> {
|
||||
const results: Array<[string, string]> = [];
|
||||
|
||||
// Step 1: Find lines matching `- ENV_KEY=...${...}...`
|
||||
const linePattern = /^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)/gm;
|
||||
let lineMatch;
|
||||
while ((lineMatch = linePattern.exec(composeContent)) !== null) {
|
||||
const containerKey = lineMatch[1];
|
||||
const valueStr = lineMatch[2];
|
||||
|
||||
// Step 2: Extract all ${VAR} references from the value
|
||||
const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:\-\+\?][^}]*)?\}/g;
|
||||
let varMatch;
|
||||
while ((varMatch = varPattern.exec(valueStr)) !== null) {
|
||||
const varName = varMatch[1];
|
||||
// Only add if names differ — same-name case handled by direct key matching
|
||||
if (containerKey !== varName) {
|
||||
results.push([containerKey, varName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Parse .env file content into key-value pairs.
|
||||
* Preserves values exactly as written — no quote stripping.
|
||||
* Docker Compose handles its own quote interpretation at runtime.
|
||||
*/
|
||||
export function parseEnvVars(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
const value = trimmed.substring(eqIndex + 1).trim();
|
||||
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+56
-160
@@ -16,70 +16,6 @@ import {
|
||||
} from './db';
|
||||
import { deployStack, getStackDir } from './stacks';
|
||||
|
||||
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
|
||||
let mergedCaBundleReady = false;
|
||||
|
||||
/**
|
||||
* Create a merged CA bundle combining system CAs with the custom cert from
|
||||
* NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without
|
||||
* merging, public CAs (GitHub, GitLab) break.
|
||||
*/
|
||||
function getMergedCaBundlePath(): string {
|
||||
if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) {
|
||||
console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`);
|
||||
return MERGED_CA_BUNDLE_PATH;
|
||||
}
|
||||
|
||||
const customCertPath = process.env.NODE_EXTRA_CA_CERTS!;
|
||||
console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`);
|
||||
|
||||
const systemCaPaths = [
|
||||
process.env.SSL_CERT_FILE,
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||
'/etc/ssl/cert.pem'
|
||||
];
|
||||
|
||||
let systemCaContent = '';
|
||||
let systemCaSource = '';
|
||||
for (const caPath of systemCaPaths) {
|
||||
if (caPath && existsSync(caPath)) {
|
||||
try {
|
||||
systemCaContent = readFileSync(caPath, 'utf-8');
|
||||
systemCaSource = caPath;
|
||||
console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!systemCaSource) {
|
||||
console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const customCaContent = readFileSync(customCertPath, 'utf-8');
|
||||
const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1;
|
||||
console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`);
|
||||
|
||||
const merged = systemCaContent
|
||||
? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n'
|
||||
: customCaContent;
|
||||
writeFileSync(MERGED_CA_BUNDLE_PATH, merged);
|
||||
mergedCaBundleReady = true;
|
||||
|
||||
const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1;
|
||||
console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`);
|
||||
} catch (err) {
|
||||
console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err);
|
||||
return customCertPath;
|
||||
}
|
||||
|
||||
return MERGED_CA_BUNDLE_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect stdout, stderr and exit code from a spawned process.
|
||||
*/
|
||||
@@ -109,19 +45,22 @@ if (!existsSync(GIT_REPOS_DIR)) {
|
||||
mkdirSync(GIT_REPOS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function getGitReposDir(): string {
|
||||
return GIT_REPOS_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact all env var values for safe logging. Only key names are preserved.
|
||||
* Mask sensitive values in environment variables for safe logging.
|
||||
*/
|
||||
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const key of Object.keys(vars)) {
|
||||
redacted[key] = '***';
|
||||
function maskSecrets(vars: Record<string, string>): Record<string, string> {
|
||||
const masked: Record<string, string> = {};
|
||||
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (secretPatterns.test(key)) {
|
||||
masked[key] = '***';
|
||||
} else if (value.length > 50) {
|
||||
masked[key] = value.substring(0, 10) + '...(truncated)';
|
||||
} else {
|
||||
masked[key] = value;
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
return masked;
|
||||
}
|
||||
|
||||
function getRepoPath(repoId: number): string {
|
||||
@@ -214,30 +153,14 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
SSH_AUTH_SOCK: ''
|
||||
};
|
||||
|
||||
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js).
|
||||
// GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the
|
||||
// custom cert so both self-signed repos and public repos (GitHub etc.) work (#967).
|
||||
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
|
||||
if (process.env.NODE_EXTRA_CA_CERTS) {
|
||||
env.GIT_SSL_CAINFO = getMergedCaBundlePath();
|
||||
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
|
||||
}
|
||||
|
||||
// Ensure current UID is resolvable for SSH/git operations
|
||||
await ensurePasswdEntry(env);
|
||||
|
||||
// For HTTPS password/token auth, inject credentials via http.extraHeader env vars
|
||||
// instead of embedding them in the URL (which leaks via /proc/<pid>/cmdline, #1081).
|
||||
// Uses GIT_CONFIG_COUNT mechanism (git >= 2.31) to set Authorization header.
|
||||
if (credential?.authType === 'password' && (credential.username || credential.password)) {
|
||||
const token = credential.password || '';
|
||||
const username = credential.username || '';
|
||||
// Use Basic auth (base64 of username:password) — works with GitHub PATs,
|
||||
// GitLab tokens, Gitea tokens, and standard username/password combos.
|
||||
const basicAuth = Buffer.from(`${username}:${token}`).toString('base64');
|
||||
env.GIT_CONFIG_COUNT = '1';
|
||||
env.GIT_CONFIG_KEY_0 = 'http.extraHeader';
|
||||
env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basicAuth}`;
|
||||
}
|
||||
|
||||
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
|
||||
// Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS,
|
||||
// NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670).
|
||||
@@ -292,20 +215,24 @@ function cleanupSshKey(credential: GitCredential | null): void {
|
||||
}
|
||||
|
||||
function buildRepoUrl(url: string, credential: GitCredential | null): string {
|
||||
// Never embed credentials in the URL — they leak via /proc/<pid>/cmdline (see #1081).
|
||||
// HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv().
|
||||
// Strip any existing credentials from the URL for safety.
|
||||
if (credential?.authType === 'password' && !url.startsWith('git@')) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
// For SSH URLs or no auth, return as-is
|
||||
if (!credential || credential.authType !== 'password' || url.startsWith('git@')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// For HTTPS with password auth, embed credentials
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (credential.username) {
|
||||
parsed.username = credential.username;
|
||||
}
|
||||
if (credential.password) {
|
||||
parsed.password = credential.password;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
@@ -850,15 +777,15 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
// (e.g., config files, scripts, additional env files)
|
||||
let changedFiles: string[] = [];
|
||||
if (commitChanged) {
|
||||
// Use contextDir if set, otherwise fall back to compose file's directory
|
||||
const diffDirRelative = gitStack.contextDir || dirname(gitStack.composePath);
|
||||
console.log(`${logPrefix} Checking for changes in directory: ${diffDirRelative || '(root)'}`);
|
||||
// Get the directory containing the compose file (relative to repo root)
|
||||
const composeDirRelative = dirname(gitStack.composePath);
|
||||
console.log(`${logPrefix} Checking for changes in directory: ${composeDirRelative || '(root)'}`);
|
||||
|
||||
const diffResult = await getChangedFilesInDir(
|
||||
repoPath,
|
||||
previousCommit,
|
||||
newCommit,
|
||||
diffDirRelative || '.',
|
||||
composeDirRelative || '.',
|
||||
env
|
||||
);
|
||||
|
||||
@@ -900,29 +827,10 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
console.log(`${logPrefix} Compose content:`);
|
||||
console.log(composeContent);
|
||||
|
||||
// Determine the source directory and compose filename
|
||||
// If contextDir is set, use it as the source directory (relative to repo root)
|
||||
// and compute composeFileName as relative path from contextDir to compose file
|
||||
let composeDir: string;
|
||||
let composeFileName: string;
|
||||
if (gitStack.contextDir) {
|
||||
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
|
||||
// Validate: context dir must be within repo
|
||||
if (!contextDirAbsolute.startsWith(repoPath)) {
|
||||
throw new Error('Context directory must be within the repository');
|
||||
}
|
||||
// Validate: compose file must be within context directory
|
||||
const relCompose = relative(contextDirAbsolute, composePath);
|
||||
if (relCompose.startsWith('..')) {
|
||||
throw new Error('Compose file must be within the context directory');
|
||||
}
|
||||
composeDir = contextDirAbsolute;
|
||||
composeFileName = relCompose; // e.g., "apps/myapp/compose.yaml"
|
||||
} else {
|
||||
composeDir = dirname(composePath);
|
||||
composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
|
||||
}
|
||||
console.log(`${logPrefix} Source directory (composeDir):`, composeDir);
|
||||
// Determine the compose directory and filename (for copying all files)
|
||||
const composeDir = dirname(composePath);
|
||||
const composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
|
||||
console.log(`${logPrefix} Compose directory:`, composeDir);
|
||||
console.log(`${logPrefix} Compose filename:`, composeFileName);
|
||||
|
||||
// Read env file if configured (optional - don't fail if missing)
|
||||
@@ -1024,7 +932,7 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0);
|
||||
if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) {
|
||||
console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', '));
|
||||
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(redactEnvVarsForLog(syncResult.envFileVars), null, 2));
|
||||
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(syncResult.envFileVars), null, 2));
|
||||
}
|
||||
|
||||
// Check if there are changes - skip redeploy if no changes and not forced
|
||||
@@ -1064,7 +972,6 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
||||
forceRecreate,
|
||||
build: gitStack.buildOnDeploy,
|
||||
noBuildCache: gitStack.noBuildCache,
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined
|
||||
});
|
||||
|
||||
@@ -1241,15 +1148,15 @@ export async function deployGitStackWithProgress(
|
||||
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
|
||||
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
|
||||
|
||||
// Check if any files in the context/compose directory have changed
|
||||
// Check if any files in the compose file's directory have changed
|
||||
// (for consistency with syncGitStack, though this function always deploys)
|
||||
if (commitChanged) {
|
||||
const diffDir = gitStack.contextDir || dirname(gitStack.composePath);
|
||||
const composeDir = dirname(gitStack.composePath);
|
||||
const diffResult = await getChangedFilesInDir(
|
||||
repoPath,
|
||||
previousCommit,
|
||||
newCommit,
|
||||
diffDir || '.',
|
||||
composeDir || '.',
|
||||
env
|
||||
);
|
||||
updated = diffResult.changed;
|
||||
@@ -1270,24 +1177,8 @@ export async function deployGitStackWithProgress(
|
||||
|
||||
const composeContent = readFileSync(composePath, 'utf-8');
|
||||
|
||||
// Determine the source directory and compose filename
|
||||
let composeDir: string;
|
||||
let progressComposeFileName: string;
|
||||
if (gitStack.contextDir) {
|
||||
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
|
||||
if (!contextDirAbsolute.startsWith(repoPath)) {
|
||||
throw new Error('Context directory must be within the repository');
|
||||
}
|
||||
const relCompose = relative(contextDirAbsolute, composePath);
|
||||
if (relCompose.startsWith('..')) {
|
||||
throw new Error('Compose file must be within the context directory');
|
||||
}
|
||||
composeDir = contextDirAbsolute;
|
||||
progressComposeFileName = relCompose;
|
||||
} else {
|
||||
composeDir = dirname(composePath);
|
||||
progressComposeFileName = basename(gitStack.composePath);
|
||||
}
|
||||
// Determine the compose directory (for copying all files)
|
||||
const composeDir = dirname(composePath);
|
||||
|
||||
// Read env file if configured (optional - don't fail if missing)
|
||||
let envFileVars: Record<string, string> | undefined;
|
||||
@@ -1334,17 +1225,16 @@ export async function deployGitStackWithProgress(
|
||||
compose: composeContent,
|
||||
envId: gitStack.environmentId,
|
||||
sourceDir: composeDir, // Copy entire directory from git repo
|
||||
composeFileName: progressComposeFileName, // Compose filename relative to source dir
|
||||
composeFileName: basename(gitStack.composePath), // Use original compose filename from repo
|
||||
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
||||
build: gitStack.buildOnDeploy,
|
||||
noBuildCache: gitStack.noBuildCache,
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Record the stack source with resolved compose path for consistency
|
||||
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
|
||||
const resolvedComposePath = join(stackDir, progressComposeFileName);
|
||||
const resolvedComposePath = join(stackDir, basename(gitStack.composePath));
|
||||
|
||||
await upsertStackSource({
|
||||
stackName: gitStack.stackName,
|
||||
@@ -1457,7 +1347,13 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
|
||||
}
|
||||
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
const value = trimmed.substring(eqIndex + 1).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
|
||||
// Handle quoted values
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Only add if key is valid env var name
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||
@@ -1469,7 +1365,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
|
||||
|
||||
console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length);
|
||||
console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', '));
|
||||
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(redactEnvVarsForLog(result), null, 2));
|
||||
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(maskSecrets(result), null, 2));
|
||||
if (skippedLines.length > 0) {
|
||||
console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; '));
|
||||
}
|
||||
|
||||
+17
-20
@@ -9,7 +9,6 @@ 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 { isNotifyDisabledByLabel } from './container-labels.js';
|
||||
import { pushMetric } from './metrics-store.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
@@ -192,26 +191,24 @@ export async function handleEdgeContainerEvent(
|
||||
// Broadcast to SSE clients
|
||||
containerEventEmitter.emit('event', savedEvent);
|
||||
|
||||
// Check dockhand.notify label before sending notification
|
||||
// Docker includes container labels in actorAttributes
|
||||
if (!isNotifyDisabledByLabel(event.actorAttributes)) {
|
||||
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
|
||||
const containerLabel = event.containerName || event.containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
|
||||
? 'error'
|
||||
: event.action === 'stop'
|
||||
? 'warning'
|
||||
: event.action === 'start'
|
||||
? 'success'
|
||||
: 'info';
|
||||
// Prepare notification
|
||||
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
|
||||
const containerLabel = event.containerName || event.containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
|
||||
? 'error'
|
||||
: event.action === 'stop'
|
||||
? 'warning'
|
||||
: event.action === 'start'
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
|
||||
type: notificationType as 'success' | 'error' | 'warning' | 'info'
|
||||
}, event.image);
|
||||
}
|
||||
// Send notification
|
||||
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
|
||||
type: notificationType as 'success' | 'error' | 'warning' | 'info'
|
||||
}, event.image);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Hawser] Error handling container event:', errorMsg);
|
||||
|
||||
@@ -34,7 +34,6 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
|
||||
// Used by scanner to replicate how Dockhand connects to Docker
|
||||
let cachedOwnDockerHost: string | null = null;
|
||||
let cachedOwnNetworkMode: string | null = null;
|
||||
let cachedOwnExtraHosts: string[] | null = null;
|
||||
|
||||
/**
|
||||
* Get our own container ID
|
||||
@@ -86,11 +85,12 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
if (process.env.HOST_DATA_DIR) {
|
||||
cachedHostDataDir = process.env.HOST_DATA_DIR;
|
||||
console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`);
|
||||
return cachedHostDataDir;
|
||||
}
|
||||
|
||||
const containerId = getOwnContainerId();
|
||||
if (!containerId) {
|
||||
console.warn('[HostPath] Running in Docker but could not detect container ID; ExtraHosts will not be mirrored to sidecars');
|
||||
console.warn('[HostPath] Running in Docker but could not detect container ID');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -140,9 +140,6 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
Config?: {
|
||||
Env?: string[];
|
||||
};
|
||||
HostConfig?: {
|
||||
ExtraHosts?: string[];
|
||||
};
|
||||
NetworkSettings?: {
|
||||
Networks?: Record<string, unknown>;
|
||||
};
|
||||
@@ -179,19 +176,6 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
cachedOwnExtraHosts = containerInfo.HostConfig?.ExtraHosts?.length
|
||||
? [...containerInfo.HostConfig.ExtraHosts]
|
||||
: null;
|
||||
if (cachedOwnExtraHosts) {
|
||||
console.log(`[HostPath] Detected own ExtraHosts: ${cachedOwnExtraHosts.join(', ')}`);
|
||||
}
|
||||
|
||||
// Explicit override wins for DATA_DIR path, but we still inspect to populate
|
||||
// mounts/network/DOCKER_HOST/ExtraHosts caches for sibling sidecars.
|
||||
if (cachedHostDataDir) {
|
||||
return cachedHostDataDir;
|
||||
}
|
||||
|
||||
// Find the mount for our DATA_DIR
|
||||
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
|
||||
|
||||
@@ -245,15 +229,6 @@ export function getOwnNetworkMode(): string | null {
|
||||
return cachedOwnNetworkMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ExtraHosts entries configured on Dockhand itself.
|
||||
* Used to mirror host aliases into sibling sidecar containers.
|
||||
* Populated by detectHostDataDir() at startup.
|
||||
*/
|
||||
export function getOwnExtraHosts(): string[] | null {
|
||||
return cachedOwnExtraHosts ? [...cachedOwnExtraHosts] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a container path to host path
|
||||
*
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
type HostConfigLike = {
|
||||
Binds?: string[] | null;
|
||||
Mounts?: Array<{ Target?: string | null }> | null;
|
||||
};
|
||||
|
||||
type InspectMountLike = {
|
||||
Type?: string | null;
|
||||
Name?: string | null;
|
||||
Destination?: string | null;
|
||||
};
|
||||
|
||||
/** Build extra bind strings for volume mounts missing from HostConfig. */
|
||||
export function getAdditionalVolumeBinds(
|
||||
hostConfig: HostConfigLike,
|
||||
mounts: InspectMountLike[]
|
||||
): string[] {
|
||||
const existingMountTargets = new Set((hostConfig.Binds || []).map((bind: string) => {
|
||||
const parts = bind.split(':');
|
||||
return parts.length >= 2 ? parts[1] : parts[0];
|
||||
}));
|
||||
|
||||
for (const mount of hostConfig.Mounts || []) {
|
||||
if (mount?.Target) existingMountTargets.add(mount.Target);
|
||||
}
|
||||
|
||||
const additionalBinds: string[] = [];
|
||||
for (const mount of mounts || []) {
|
||||
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
|
||||
if (!existingMountTargets.has(mount.Destination)) {
|
||||
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return additionalBinds;
|
||||
}
|
||||
+44
-114
@@ -9,7 +9,17 @@ import {
|
||||
type NotificationEventType
|
||||
} from './db';
|
||||
|
||||
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
|
||||
// Escape special characters for Telegram Markdown
|
||||
function escapeTelegramMarkdown(text: string): string {
|
||||
// Escape characters that have special meaning in Telegram Markdown
|
||||
return text
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/_/g, '\\_') // Underscore (italic)
|
||||
.replace(/\*/g, '\\*') // Asterisk (bold)
|
||||
.replace(/\[/g, '\\[') // Opening bracket (link)
|
||||
.replace(/\]/g, '\\]') // Closing bracket (link)
|
||||
.replace(/`/g, '\\`'); // Backtick (code)
|
||||
}
|
||||
|
||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||
async function drainResponse(response: Response): Promise<void> {
|
||||
@@ -134,8 +144,6 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom
|
||||
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}` };
|
||||
}
|
||||
@@ -269,18 +277,19 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
|
||||
|
||||
// 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' };
|
||||
// tgram://bot_token/chat_id
|
||||
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id' };
|
||||
}
|
||||
|
||||
const { botToken, chatId, topicId } = parsed;
|
||||
const [, botToken, chatId] = match;
|
||||
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)}]` : '';
|
||||
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@@ -289,11 +298,7 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
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
|
||||
}
|
||||
parse_mode: 'Markdown'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -311,22 +316,29 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
|
||||
// Gotify
|
||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = buildGotifyUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
// gotify://hostname/token or gotifys://hostname/token
|
||||
// gotify://hostname/subpath/token (subpath support)
|
||||
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
|
||||
const [, hostname, pathPart] = match;
|
||||
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
|
||||
// Token is always the last path segment; anything before it is a subpath
|
||||
const lastSlash = pathPart.lastIndexOf('/');
|
||||
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
|
||||
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
|
||||
const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(parsed.url, {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: titleWithEnv,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
priority: parsed.priority ?? defaultPriority
|
||||
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
|
||||
})
|
||||
});
|
||||
|
||||
@@ -346,10 +358,7 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
|
||||
// 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
|
||||
// ntfy://user:pass@host/topic (custom server with auth)
|
||||
// ntfys:// variants for HTTPS
|
||||
const isSecure = appriseUrl.startsWith('ntfys');
|
||||
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
|
||||
@@ -357,60 +366,36 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
|
||||
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(/^([^:]+):([^@]+)@(.+)$/);
|
||||
const basicMatch = path.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('/')) {
|
||||
} else if (path.includes('@') && path.includes('/')) {
|
||||
// token@host/topic -> Bearer token auth
|
||||
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
|
||||
const tokenMatch = path.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}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${path}`;
|
||||
}
|
||||
} else if (cleanPath.includes('/')) {
|
||||
} else if (path.includes('/')) {
|
||||
// Custom server without auth
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${path}`;
|
||||
} else {
|
||||
// Default ntfy.sh
|
||||
url = `https://ntfy.sh/${cleanPath}`;
|
||||
url = `https://ntfy.sh/${path}`;
|
||||
}
|
||||
|
||||
// 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
|
||||
'Title': payload.title,
|
||||
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
|
||||
'Tags': payload.type || 'info'
|
||||
};
|
||||
|
||||
if (authHeader) {
|
||||
@@ -445,7 +430,6 @@ async function sendPushover(appriseUrl: string, payload: NotificationPayload): P
|
||||
|
||||
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, {
|
||||
@@ -454,7 +438,7 @@ async function sendPushover(appriseUrl: string, payload: NotificationPayload): P
|
||||
body: JSON.stringify({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title: titleWithEnv,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 1 : 0
|
||||
})
|
||||
@@ -484,7 +468,6 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
type: payload.type || 'info',
|
||||
environment: payload.environmentName || null,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
@@ -499,59 +482,6 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
|
||||
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 }[] }> {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Request-scoped context using AsyncLocalStorage.
|
||||
*
|
||||
* The hook sets the authenticated user (from cookie or Bearer token)
|
||||
* and wraps resolve() in requestContext.run(). Downstream code like
|
||||
* authorize() reads the pre-resolved user from here instead of
|
||||
* re-validating the session.
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { AuthenticatedUser } from './auth';
|
||||
|
||||
export interface RequestContext {
|
||||
user: AuthenticatedUser | null;
|
||||
authEnabled: boolean;
|
||||
authMethod: 'cookie' | 'bearer' | 'none';
|
||||
}
|
||||
|
||||
export const requestContext = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
export function getRequestContext(): RequestContext | undefined {
|
||||
return requestContext.getStore();
|
||||
}
|
||||
@@ -16,14 +16,7 @@ import {
|
||||
} from './docker';
|
||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import {
|
||||
getHostDockerSocket,
|
||||
getHostDataDir,
|
||||
extractUidFromSocketPath,
|
||||
getOwnDockerHost,
|
||||
getOwnExtraHosts,
|
||||
getOwnNetworkMode
|
||||
} from './host-path';
|
||||
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdir, chown, rm } from 'node:fs/promises';
|
||||
|
||||
@@ -632,7 +625,6 @@ async function runScannerContainerCore(
|
||||
let rootlessUid: string | undefined;
|
||||
let scannerNetworkMode: string | undefined;
|
||||
let scannerDockerHost: string | undefined;
|
||||
const scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
|
||||
|
||||
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
|
||||
// Detected at startup from Dockhand's own container inspect data.
|
||||
@@ -644,12 +636,7 @@ async function runScannerContainerCore(
|
||||
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
|
||||
scannerDockerHost = ownDockerHost;
|
||||
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
|
||||
console.log(
|
||||
`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`
|
||||
);
|
||||
if (scannerExtraHosts?.length) {
|
||||
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
|
||||
}
|
||||
console.log(`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`);
|
||||
} else if (isHawser) {
|
||||
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
@@ -666,10 +653,6 @@ async function runScannerContainerCore(
|
||||
console.log(`[Scanner] Rootless Docker detected (UID ${rootlessUid})`);
|
||||
console.log(`[Scanner] Scanner will run as root inside container (maps to UID ${rootlessUid} on host via user namespace)`);
|
||||
}
|
||||
|
||||
if (scannerExtraHosts?.length) {
|
||||
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine cache storage strategy based on environment
|
||||
@@ -739,7 +722,6 @@ async function runScannerContainerCore(
|
||||
cmd,
|
||||
binds,
|
||||
env: envVars,
|
||||
extraHosts: scannerExtraHosts,
|
||||
name: `dockhand-${scannerType}-${Date.now()}`,
|
||||
envId,
|
||||
networkMode: scannerNetworkMode,
|
||||
|
||||
@@ -17,12 +17,10 @@ import {
|
||||
getGitStack,
|
||||
getScheduleCleanupCron,
|
||||
getEventCleanupCron,
|
||||
getScannerCleanupCron,
|
||||
getScheduleRetentionDays,
|
||||
getEventRetentionDays,
|
||||
getScheduleCleanupEnabled,
|
||||
getEventCleanupEnabled,
|
||||
getScannerCleanupEnabled,
|
||||
getEnvironments,
|
||||
getEnvUpdateCheckSettings,
|
||||
getAllEnvUpdateCheckSettings,
|
||||
@@ -66,31 +64,6 @@ let scannerCacheCleanupJob: Cron | null = null;
|
||||
// Scheduler state
|
||||
let isRunning = false;
|
||||
|
||||
/**
|
||||
* Scanner cache cleanup function that cleans local and all remote environments.
|
||||
* Shared between cron job, timezone refresh, and manual trigger.
|
||||
*/
|
||||
async function scannerCleanupAllEnvs(): Promise<{ volumes: string[]; dirs: string[] }> {
|
||||
const { cleanupScannerCache } = await import('../scanner');
|
||||
const envs = await getEnvironments();
|
||||
|
||||
// Clean local cache (volumes + bind mount dirs)
|
||||
const localResult = await cleanupScannerCache();
|
||||
|
||||
// Clean remote environment volumes
|
||||
for (const env of envs) {
|
||||
try {
|
||||
const envResult = await cleanupScannerCache(env.id);
|
||||
localResult.volumes.push(...envResult.volumes);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return localResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale 'syncing' states from git stacks.
|
||||
* Called on startup to recover from crashes during sync operations.
|
||||
@@ -134,7 +107,6 @@ export async function startScheduler(): Promise<void> {
|
||||
// Get cron expressions and default timezone from database
|
||||
const scheduleCleanupCron = await getScheduleCleanupCron();
|
||||
const eventCleanupCron = await getEventCleanupCron();
|
||||
const scannerCleanupCron = await getScannerCleanupCron();
|
||||
const defaultTimezone = await getDefaultTimezone();
|
||||
|
||||
// Start system cleanup jobs (static schedules with default timezone)
|
||||
@@ -162,18 +134,35 @@ export async function startScheduler(): Promise<void> {
|
||||
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
|
||||
});
|
||||
|
||||
// Scanner cache cleanup to prevent DB volume bloat (configurable schedule)
|
||||
const scannerCleanupEnabled = await getScannerCleanupEnabled();
|
||||
if (scannerCleanupEnabled) {
|
||||
scannerCacheCleanupJob = new Cron(scannerCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
|
||||
await runScannerCacheCleanupJob('cron', scannerCleanupAllEnvs);
|
||||
});
|
||||
}
|
||||
// Scanner cache cleanup runs weekly (Sunday 3am) to prevent DB volume bloat
|
||||
const scannerCleanupFn = async () => {
|
||||
const { cleanupScannerCache } = await import('../scanner');
|
||||
const envs = await getEnvironments();
|
||||
|
||||
// Clean local cache (volumes + bind mount dirs)
|
||||
const localResult = await cleanupScannerCache();
|
||||
|
||||
// Clean remote environment volumes
|
||||
for (const env of envs) {
|
||||
try {
|
||||
const envResult = await cleanupScannerCache(env.id);
|
||||
localResult.volumes.push(...envResult.volumes);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return localResult;
|
||||
};
|
||||
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
|
||||
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
|
||||
});
|
||||
|
||||
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
||||
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
||||
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
|
||||
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
|
||||
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
|
||||
|
||||
// Register all dynamic schedules from database
|
||||
await refreshAllSchedules();
|
||||
@@ -508,8 +497,6 @@ export async function refreshSystemJobs(): Promise<void> {
|
||||
// Get current settings
|
||||
const scheduleCleanupCron = await getScheduleCleanupCron();
|
||||
const eventCleanupCron = await getEventCleanupCron();
|
||||
const scannerCleanupCron = await getScannerCleanupCron();
|
||||
const scannerCleanupEnabled = await getScannerCleanupEnabled();
|
||||
const defaultTimezone = await getDefaultTimezone();
|
||||
|
||||
// Cleanup functions to pass to the job
|
||||
@@ -549,16 +536,18 @@ export async function refreshSystemJobs(): Promise<void> {
|
||||
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
|
||||
});
|
||||
|
||||
if (scannerCleanupEnabled) {
|
||||
scannerCacheCleanupJob = new Cron(scannerCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
|
||||
await runScannerCacheCleanupJob('cron', scannerCleanupAllEnvs);
|
||||
});
|
||||
}
|
||||
const scannerCleanupFn = async () => {
|
||||
const { cleanupScannerCache } = await import('../scanner');
|
||||
return cleanupScannerCache();
|
||||
};
|
||||
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
|
||||
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
|
||||
});
|
||||
|
||||
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
|
||||
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
|
||||
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
|
||||
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
|
||||
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -693,7 +682,11 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
|
||||
});
|
||||
return { success: true };
|
||||
} else if (jobId === String(SYSTEM_SCANNER_CLEANUP_ID) || jobId === 'scanner-cache-cleanup') {
|
||||
runScannerCacheCleanupJob('manual', scannerCleanupAllEnvs);
|
||||
const scannerCleanupFn = async () => {
|
||||
const { cleanupScannerCache } = await import('../scanner');
|
||||
return cleanupScannerCache();
|
||||
};
|
||||
runScannerCacheCleanupJob('manual', scannerCleanupFn);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: 'Unknown system job ID' };
|
||||
@@ -719,10 +712,8 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
|
||||
const eventRetention = await getEventRetentionDays();
|
||||
const scheduleCleanupCron = await getScheduleCleanupCron();
|
||||
const eventCleanupCron = await getEventCleanupCron();
|
||||
const scannerCleanupCron = await getScannerCleanupCron();
|
||||
const scheduleCleanupEnabled = await getScheduleCleanupEnabled();
|
||||
const eventCleanupEnabled = await getEventCleanupEnabled();
|
||||
const scannerCleanupEnabled = await getScannerCleanupEnabled();
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -760,10 +751,10 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
|
||||
type: 'system_cleanup' as const,
|
||||
name: 'Scanner cache cleanup',
|
||||
description: 'Removes scanner vulnerability database cache to reclaim disk space',
|
||||
cronExpression: scannerCleanupCron,
|
||||
nextRun: scannerCleanupEnabled ? getNextRun(scannerCleanupCron)?.toISOString() ?? null : null,
|
||||
cronExpression: '0 3 * * 0',
|
||||
nextRun: getNextRun('0 3 * * 0')?.toISOString() ?? null,
|
||||
isSystem: true,
|
||||
enabled: scannerCleanupEnabled
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ 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';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -370,18 +369,6 @@ export async function runContainerUpdate(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check dockhand.update label (label wins over DB settings)
|
||||
if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) {
|
||||
log(`Skipping - dockhand.update=false label set on container`);
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'skipped',
|
||||
completedAt: new Date().toISOString(),
|
||||
duration: Date.now() - startTime,
|
||||
details: { reason: 'Skipped by dockhand.update=false 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,6 @@ 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 { recreateContainer } from './container-update';
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -130,12 +129,6 @@ export async function runEnvUpdateCheckJob(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check dockhand.update label (label wins over DB settings)
|
||||
if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) {
|
||||
await log(` [${container.name}] Skipping - dockhand.update=false label`);
|
||||
continue;
|
||||
}
|
||||
|
||||
checkedCount++;
|
||||
await log(` Checking: ${container.name} (${imageName})`);
|
||||
|
||||
@@ -204,7 +197,7 @@ export async function runEnvUpdateCheckJob(
|
||||
.map(u => {
|
||||
const currentShort = u.currentDigest?.substring(0, 12) || 'unknown';
|
||||
const newShort = u.newDigest?.substring(0, 12) || 'unknown';
|
||||
return `- ${u.containerName} (${u.imageName}): ${currentShort} → ${newShort}`;
|
||||
return `- ${u.containerName} (${u.imageName})\n ${currentShort}... -> ${newShort}...`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ export async function runImagePrune(
|
||||
// Send success notification only when something was actually cleaned up
|
||||
if (imagesRemoved > 0) {
|
||||
await sendEventNotification('image_prune_success', {
|
||||
title: `Image prune completed — ${env.name}`,
|
||||
title: 'Image prune completed',
|
||||
message: `${imagesRemoved} unused images removed, ${formatBytes(spaceReclaimed)} disk space reclaimed`,
|
||||
type: 'success'
|
||||
}, envId);
|
||||
@@ -142,7 +142,7 @@ export async function runImagePrune(
|
||||
|
||||
// Send failure notification
|
||||
await sendEventNotification('image_prune_failed', {
|
||||
title: `Image prune failed — ${env.name}`,
|
||||
title: 'Image prune failed',
|
||||
message: `Failed to prune images: ${error.message}`,
|
||||
type: 'error'
|
||||
}, envId);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getEventRetentionDays,
|
||||
getScheduleCleanupEnabled,
|
||||
getEventCleanupEnabled,
|
||||
getScannerCleanupEnabled,
|
||||
createScheduleExecution,
|
||||
updateScheduleExecution,
|
||||
appendScheduleExecutionLog
|
||||
@@ -211,14 +210,6 @@ export async function runScannerCacheCleanupJob(
|
||||
triggeredBy: ScheduleTrigger = 'cron',
|
||||
cleanupFn?: () => Promise<{ volumes: string[]; dirs: string[] }>
|
||||
): Promise<void> {
|
||||
// Check if cleanup is enabled (skip check if manually triggered)
|
||||
if (triggeredBy === 'cron') {
|
||||
const enabled = await getScannerCleanupEnabled();
|
||||
if (!enabled) {
|
||||
return; // Skip execution if disabled
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const execution = await createScheduleExecution({
|
||||
|
||||
@@ -60,20 +60,19 @@ async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse compose file metadata: top-level `name` property and service count.
|
||||
* The `name` property (if present) should be used as the stack name instead of the directory name,
|
||||
* matching Docker Compose's behavior with `com.docker.compose.project`.
|
||||
* Count the number of services defined in a compose file
|
||||
* Parses YAML to reliably count top-level keys under 'services:' section
|
||||
*/
|
||||
function parseComposeMetadata(filePath: string): { name: string | null; serviceCount: number } {
|
||||
async function countServices(filePath: string): Promise<number> {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const doc = yaml.load(content) as Record<string, unknown> | null;
|
||||
const name = typeof doc?.name === 'string' ? doc.name.trim() : null;
|
||||
const serviceCount = doc?.services && typeof doc.services === 'object'
|
||||
? Object.keys(doc.services).length : 0;
|
||||
return { name, serviceCount };
|
||||
if (doc?.services && typeof doc.services === 'object') {
|
||||
return Object.keys(doc.services).length;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return { name: null, serviceCount: 0 };
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +122,13 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
|
||||
for (const pattern of COMPOSE_PATTERNS) {
|
||||
const composePath = join(currentPath, pattern);
|
||||
if (existsSync(composePath)) {
|
||||
// Found a stack! Use compose name property if defined, otherwise directory name
|
||||
const { name: composeName, serviceCount } = parseComposeMetadata(composePath);
|
||||
const stackName = normalizeStackName(composeName || basename(currentPath));
|
||||
// Found a stack! Stack name = directory name
|
||||
const stackName = normalizeStackName(basename(currentPath));
|
||||
if (stackName) {
|
||||
// Check for .env file
|
||||
const envPath = join(currentPath, '.env');
|
||||
// Count services in compose file
|
||||
const serviceCount = await countServices(composePath);
|
||||
discovered.push({
|
||||
name: stackName,
|
||||
composePath,
|
||||
@@ -166,13 +166,14 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
|
||||
if (lowerName.endsWith('.yml') || lowerName.endsWith('.yaml')) {
|
||||
// Validate it's actually a compose file
|
||||
if (await isComposeFile(entryPath)) {
|
||||
const { name: composeName, serviceCount } = parseComposeMetadata(entryPath);
|
||||
const stackName = normalizeStackName(
|
||||
composeName || entry.name.replace(/\.(yml|yaml)$/i, '')
|
||||
entry.name.replace(/\.(yml|yaml)$/i, '')
|
||||
);
|
||||
if (stackName) {
|
||||
// Check for .env file in same directory
|
||||
const envPath = join(currentPath, '.env');
|
||||
// Count services in compose file
|
||||
const serviceCount = await countServices(entryPath);
|
||||
discovered.push({
|
||||
name: stackName,
|
||||
composePath: entryPath,
|
||||
@@ -213,18 +214,8 @@ export async function adoptStack(
|
||||
return { success: false, error: 'Already adopted' };
|
||||
}
|
||||
|
||||
// If the compose file has a top-level `name:` property, prefer it over the passed name.
|
||||
// This ensures Docker's project name (from the label) matches Dockhand's stack name.
|
||||
let stackNameSource = stack.name;
|
||||
if (stack.composePath && existsSync(stack.composePath)) {
|
||||
const { name: composeName } = parseComposeMetadata(stack.composePath);
|
||||
if (composeName) {
|
||||
stackNameSource = composeName;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for name conflict within the same environment
|
||||
let finalName = normalizeStackName(stackNameSource);
|
||||
let finalName = normalizeStackName(stack.name);
|
||||
const existingNames = new Set(
|
||||
existingSources
|
||||
.filter((s) => s.environmentId === environmentId)
|
||||
@@ -233,12 +224,11 @@ export async function adoptStack(
|
||||
|
||||
if (existingNames.has(finalName)) {
|
||||
// Append suffix to make unique
|
||||
const baseName = finalName;
|
||||
let suffix = 1;
|
||||
while (existingNames.has(`${baseName}-${suffix}`)) {
|
||||
while (existingNames.has(`${stack.name}-${suffix}`)) {
|
||||
suffix++;
|
||||
}
|
||||
finalName = `${baseName}-${suffix}`;
|
||||
finalName = `${stack.name}-${suffix}`;
|
||||
}
|
||||
|
||||
// Create stack source record - use 'internal' since we know the file paths
|
||||
|
||||
+32
-87
@@ -31,7 +31,6 @@ import { unregisterSchedule } from './scheduler';
|
||||
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { rewriteComposeVolumePaths, getHostDataDir } from './host-path';
|
||||
import { getOrderValue } from './container-labels';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -78,9 +77,7 @@ export interface ContainerDetail {
|
||||
networks: Array<{ name: string; ipAddress: string }>;
|
||||
volumeCount: number;
|
||||
restartCount: number;
|
||||
exitCode?: number;
|
||||
created: number;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,7 +102,6 @@ export interface DeployStackOptions {
|
||||
sourceDir?: string; // Directory to copy all files from (for git stacks)
|
||||
forceRecreate?: boolean;
|
||||
build?: boolean; // Build images before starting (--build)
|
||||
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
|
||||
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
|
||||
composePath?: string; // Custom compose file path (for adopted/imported stacks)
|
||||
envPath?: string; // Custom env file path (for adopted/imported stacks)
|
||||
@@ -230,14 +226,8 @@ function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout:
|
||||
* Used to send files to Hawser for remote deployments.
|
||||
* Binary files are base64-encoded with a "base64:" prefix to preserve all bytes.
|
||||
*/
|
||||
// Max file size: 10 MB per file, 256 MB total payload
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
const MAX_TOTAL_SIZE = 256 * 1024 * 1024;
|
||||
|
||||
async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
let totalSize = 0;
|
||||
const skipped: string[] = [];
|
||||
|
||||
async function scanDir(currentPath: string, relativePath: string = ''): Promise<void> {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
@@ -250,21 +240,7 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
|
||||
if (entry.name === '.git') continue;
|
||||
await scanDir(fullPath, relPath);
|
||||
} else if (entry.isFile()) {
|
||||
const fileSize = statSync(fullPath).size;
|
||||
|
||||
if (fileSize > MAX_FILE_SIZE) {
|
||||
skipped.push(`${relPath} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (totalSize + fileSize > MAX_TOTAL_SIZE) {
|
||||
skipped.push(`${relPath} (would exceed ${MAX_TOTAL_SIZE / 1024 / 1024} MB total limit)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = readFileSync(fullPath);
|
||||
totalSize += fileSize;
|
||||
|
||||
if (isBinaryContent(bytes)) {
|
||||
files[relPath] = `base64:${bytes.toString('base64')}`;
|
||||
} else {
|
||||
@@ -275,11 +251,6 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
|
||||
}
|
||||
|
||||
await scanDir(dirPath);
|
||||
|
||||
if (skipped.length > 0) {
|
||||
console.log(`[readDirFilesAsMap] Skipped ${skipped.length} file(s) exceeding size limits: ${skipped.join(', ')}`);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -288,14 +259,23 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Redact all env var values for safe logging. Only key names are preserved.
|
||||
* Mask sensitive values in environment variables for safe logging.
|
||||
* Masks values for keys containing common secret patterns and truncates long values.
|
||||
*/
|
||||
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const key of Object.keys(vars)) {
|
||||
redacted[key] = '***';
|
||||
function maskSecrets(vars: Record<string, string>): Record<string, string> {
|
||||
const masked: Record<string, string> = {};
|
||||
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (secretPatterns.test(key)) {
|
||||
masked[key] = '***';
|
||||
} else if (value.length > 50) {
|
||||
// Truncate long values that might be secrets
|
||||
masked[key] = value.substring(0, 10) + '...(truncated)';
|
||||
} else {
|
||||
masked[key] = value;
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
return masked;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -772,10 +752,9 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract registry host from URL (parseRegistryUrl handles bare hostnames like 'ghcr.io')
|
||||
const { parseRegistryUrl } = await import('./docker.js');
|
||||
const { host } = parseRegistryUrl(reg.url);
|
||||
const registryHost = host;
|
||||
// Extract registry host from URL
|
||||
const url = new URL(reg.url);
|
||||
const registryHost = url.host;
|
||||
|
||||
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
|
||||
|
||||
@@ -814,7 +793,6 @@ interface ComposeCommandOptions {
|
||||
envId?: number | null;
|
||||
forceRecreate?: boolean;
|
||||
build?: boolean; // Build images before starting (--build)
|
||||
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
|
||||
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
|
||||
removeVolumes?: boolean;
|
||||
stackFiles?: Record<string, string>; // All files to send to Hawser
|
||||
@@ -878,7 +856,6 @@ async function executeLocalCompose(
|
||||
useOverrideFile?: boolean,
|
||||
serviceName?: string,
|
||||
build?: boolean,
|
||||
noBuildCache?: boolean,
|
||||
pullPolicy?: string
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
@@ -1072,7 +1049,6 @@ async function executeLocalCompose(
|
||||
args.push('up', '-d', '--remove-orphans');
|
||||
if (forceRecreate) args.push('--force-recreate');
|
||||
if (build) args.push('--build');
|
||||
if (build && noBuildCache) args.push('--no-cache');
|
||||
if (pullPolicy) args.push('--pull', pullPolicy);
|
||||
// If targeting a specific service, only update that service
|
||||
if (serviceName) {
|
||||
@@ -1117,7 +1093,7 @@ async function executeLocalCompose(
|
||||
console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
|
||||
console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0);
|
||||
if (envVars && Object.keys(envVars).length > 0) {
|
||||
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(redactEnvVarsForLog(envVars), null, 2));
|
||||
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
|
||||
}
|
||||
|
||||
// Login to registries before pulling images
|
||||
@@ -1249,7 +1225,6 @@ async function executeComposeViaHawser(
|
||||
serviceName?: string,
|
||||
composeFileName?: string,
|
||||
build?: boolean,
|
||||
noBuildCache?: boolean,
|
||||
pullPolicy?: string
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
@@ -1273,7 +1248,7 @@ async function executeComposeViaHawser(
|
||||
console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0);
|
||||
console.log(`${logPrefix} Secret env vars count:`, secretCount);
|
||||
if (allEnvVars && Object.keys(allEnvVars).length > 0) {
|
||||
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(redactEnvVarsForLog(allEnvVars), null, 2));
|
||||
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(maskSecrets(allEnvVars), null, 2));
|
||||
}
|
||||
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
|
||||
console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0);
|
||||
@@ -1324,7 +1299,6 @@ async function executeComposeViaHawser(
|
||||
forceRecreate: forceRecreate || false,
|
||||
removeVolumes: removeVolumes || false,
|
||||
build: build || false,
|
||||
noBuildCache: (build && noBuildCache) || false,
|
||||
pullPolicy: pullPolicy || '',
|
||||
registries, // Registry credentials for docker login
|
||||
serviceName // Target specific service only (with --no-deps)
|
||||
@@ -1372,13 +1346,10 @@ async function executeComposeViaHawser(
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`${logPrefix} EXCEPTION in executeComposeViaHawser:`, err.message);
|
||||
const isStringLength = err.message?.includes('Invalid string length');
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: isStringLength
|
||||
? `Stack files too large to send via Hawser. The repository may contain large binary files. Consider using a .dockerignore or moving large files out of the compose directory.`
|
||||
: `Failed to ${operation} via Hawser: ${err.message}`
|
||||
error: `Failed to ${operation} via Hawser: ${err.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1396,7 +1367,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, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
|
||||
|
||||
// Get environment configuration
|
||||
const env = envId ? await getEnvironment(envId) : null;
|
||||
@@ -1420,7 +1391,6 @@ async function executeComposeCommand(
|
||||
useOverrideFile,
|
||||
serviceName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
@@ -1485,7 +1455,6 @@ async function executeComposeCommand(
|
||||
serviceName,
|
||||
composeFileName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
@@ -1519,7 +1488,6 @@ async function executeComposeCommand(
|
||||
useOverrideFile,
|
||||
serviceName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
@@ -1543,7 +1511,6 @@ async function executeComposeCommand(
|
||||
useOverrideFile,
|
||||
serviceName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
@@ -1576,12 +1543,6 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
|
||||
const result: ComposeStackInfo[] = Array.from(stacks.entries()).map(([name, containerIds]) => {
|
||||
const stackContainers = containers.filter((c) => containerIds.has(c.id));
|
||||
const runningCount = stackContainers.filter((c) => c.state === 'running').length;
|
||||
// Containers that exited with code 0 are "completed" (e.g., init/migration containers)
|
||||
// and should not count against stack health
|
||||
const completedCount = stackContainers.filter((c) =>
|
||||
c.state === 'exited' && c.exitCode === 0
|
||||
).length;
|
||||
const activeTotal = stackContainers.length - completedCount;
|
||||
|
||||
const containerDetails: ContainerDetail[] = stackContainers
|
||||
.map((c) => {
|
||||
@@ -1617,30 +1578,21 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
|
||||
networks,
|
||||
volumeCount,
|
||||
restartCount: c.restartCount || 0,
|
||||
exitCode: c.exitCode,
|
||||
created: c.created,
|
||||
labels: c.labels || {}
|
||||
created: c.created
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const orderA = getOrderValue(a.labels);
|
||||
const orderB = getOrderValue(b.labels);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.service.localeCompare(b.service);
|
||||
});
|
||||
.sort((a, b) => a.service.localeCompare(b.service));
|
||||
|
||||
return {
|
||||
name,
|
||||
containers: Array.from(containerIds),
|
||||
containerDetails,
|
||||
status:
|
||||
activeTotal === 0
|
||||
? 'stopped'
|
||||
: runningCount >= activeTotal
|
||||
? 'running'
|
||||
: runningCount === 0
|
||||
? 'stopped'
|
||||
: 'partial'
|
||||
runningCount === stackContainers.length
|
||||
? 'running'
|
||||
: runningCount === 0
|
||||
? 'stopped'
|
||||
: 'partial'
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2029,8 +1981,7 @@ export async function downStack(
|
||||
export async function removeStack(
|
||||
stackName: string,
|
||||
envId?: number | null,
|
||||
force = false,
|
||||
removeVolumes = false
|
||||
force = false
|
||||
): Promise<StackOperationResult> {
|
||||
return withStackLock(stackName, async () => {
|
||||
// Get compose file (may not exist for external stacks)
|
||||
@@ -2048,7 +1999,6 @@ export async function removeStack(
|
||||
{
|
||||
stackName,
|
||||
envId,
|
||||
removeVolumes,
|
||||
workingDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath ?? undefined,
|
||||
envPath: composeResult.envPath ?? undefined
|
||||
@@ -2222,7 +2172,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, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
|
||||
const logPrefix = `[Stack:${name}]`;
|
||||
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
@@ -2300,11 +2250,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
// and would be destroyed, causing data loss (#831).
|
||||
console.log(`${logPrefix} Copying source directory to stack directory...`);
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
cpSync(sourceDir, workingDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
|
||||
});
|
||||
cpSync(sourceDir, workingDir, { recursive: true, force: true });
|
||||
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
|
||||
} else {
|
||||
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
|
||||
@@ -2369,7 +2315,6 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
envId,
|
||||
forceRecreate,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy,
|
||||
stackFiles,
|
||||
workingDir,
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
type ContainerEventAction
|
||||
} from './db';
|
||||
import { sendEnvironmentNotification, sendEventNotification } from './notifications';
|
||||
import { isNotifyDisabledByLabel } from './container-labels';
|
||||
import { rssBeforeOp, rssAfterOp } from './rss-tracker';
|
||||
import { pushMetric } from './metrics-store';
|
||||
|
||||
@@ -286,28 +285,24 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
|
||||
|
||||
// Sub-category: notification
|
||||
const notifBefore = rssBeforeOp();
|
||||
const actionLabel = action.startsWith('health_status')
|
||||
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
|
||||
: action.charAt(0).toUpperCase() + action.slice(1);
|
||||
const containerLabel = containerName || containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
|
||||
? 'error'
|
||||
: action === 'stop'
|
||||
? 'warning'
|
||||
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
// Check dockhand.notify label — Docker includes container labels in event Actor.Attributes
|
||||
if (!isNotifyDisabledByLabel(event.Actor?.Attributes)) {
|
||||
const actionLabel = action.startsWith('health_status')
|
||||
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
|
||||
: action.charAt(0).toUpperCase() + action.slice(1);
|
||||
const containerLabel = containerName || containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
|
||||
? 'error'
|
||||
: action === 'stop'
|
||||
? 'warning'
|
||||
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
sendEnvironmentNotification(msg.envId, action, {
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
|
||||
type: notificationType
|
||||
}, image).catch(() => {});
|
||||
}
|
||||
sendEnvironmentNotification(msg.envId, action, {
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
|
||||
type: notificationType
|
||||
}, image).catch(() => {});
|
||||
rssAfterOp('events_notif', notifBefore);
|
||||
rssAfterOp('events', before);
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* API Token Cache — LRU with TTL
|
||||
*
|
||||
* In-memory cache for validated API tokens. Without this, every Bearer
|
||||
* request would run Argon2id verification (~64MB RAM, ~100ms CPU per call).
|
||||
*
|
||||
* Cache key is SHA-256(fullToken) — safe to hold in memory (not reversible).
|
||||
* TTL is 60s by default, capped at the token's expiry time if sooner.
|
||||
*
|
||||
* Uses a bounded LRU (max 1024 entries) to cap memory usage. On overflow
|
||||
* the least-recently-used entry is evicted. Expired entries are also pruned
|
||||
* every 5 minutes.
|
||||
*
|
||||
* Separated from api-tokens.ts to avoid circular dependencies
|
||||
* (auth.ts ↔ api-tokens.ts).
|
||||
*/
|
||||
|
||||
export interface TokenCacheEntry {
|
||||
user: { id: number; [key: string]: any };
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const MAX_CACHE_SIZE = 1024;
|
||||
|
||||
/**
|
||||
* Simple LRU cache backed by a Map.
|
||||
* Map iteration order is insertion order, so we delete-and-re-set on every
|
||||
* access to move the entry to the "newest" position. The oldest entry
|
||||
* (Map.keys().next()) is evicted when the cache exceeds MAX_CACHE_SIZE.
|
||||
*/
|
||||
class LruTokenCache {
|
||||
private map = new Map<string, TokenCacheEntry>();
|
||||
|
||||
get(key: string): TokenCacheEntry | undefined {
|
||||
const entry = this.map.get(key);
|
||||
if (!entry) return undefined;
|
||||
// Move to end (most-recently-used)
|
||||
this.map.delete(key);
|
||||
this.map.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
set(key: string, entry: TokenCacheEntry): void {
|
||||
// If key already exists, delete first so re-insert moves it to end
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, entry);
|
||||
// Evict oldest if over capacity
|
||||
if (this.map.size > MAX_CACHE_SIZE) {
|
||||
const oldest = this.map.keys().next().value!;
|
||||
this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[string, TokenCacheEntry]> {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenCache = new LruTokenCache();
|
||||
|
||||
/**
|
||||
* Invalidate all cached tokens for a specific user.
|
||||
* Call when user permissions change, roles are updated, or user is deleted/deactivated.
|
||||
*/
|
||||
export function invalidateTokenCacheForUser(userId: number): void {
|
||||
for (const [key, entry] of tokenCache.entries()) {
|
||||
if (entry.user.id === userId) {
|
||||
tokenCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire token cache.
|
||||
* Called on revocation, role permission edits, and license state changes.
|
||||
*/
|
||||
export function clearTokenCache(): void {
|
||||
tokenCache.clear();
|
||||
}
|
||||
|
||||
// Periodic cleanup every 5 minutes
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export function ensureCleanupInterval(): void {
|
||||
if (cleanupInterval) return;
|
||||
cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of tokenCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
tokenCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
if (cleanupInterval.unref) cleanupInterval.unref();
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { ContainerInfo, ContainerStats } from '$lib/types';
|
||||
import { appendEnvParam, clearStaleEnvironment, environments } from '$lib/stores/environment';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
export interface AutoUpdateSetting {
|
||||
@@ -71,16 +70,9 @@ function createContainerStore() {
|
||||
const [min, hr, , , dow] = parts;
|
||||
const hourNum = parseInt(hr);
|
||||
const minNum = parseInt(min);
|
||||
const is12Hour = get(appSettings).timeFormat === '12h';
|
||||
|
||||
let timeStr: string;
|
||||
if (is12Hour) {
|
||||
const ampm = hourNum >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||
timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
|
||||
} else {
|
||||
timeStr = `${hourNum.toString().padStart(2, '0')}:${minNum.toString().padStart(2, '0')}`;
|
||||
}
|
||||
const ampm = hourNum >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
|
||||
const timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
|
||||
|
||||
if (scheduleType === 'daily' || dow === '*') {
|
||||
return { label: 'daily', tooltip: `Daily at ${timeStr}` };
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const store = writable<Record<string, string>>({});
|
||||
let loaded = false;
|
||||
|
||||
async function load() {
|
||||
if (!browser || loaded) return;
|
||||
loaded = true;
|
||||
try {
|
||||
const res = await fetch('/api/labels');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
store.set(data.colors || {});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const labelColorOverrides = {
|
||||
subscribe: store.subscribe,
|
||||
load,
|
||||
reload: async () => {
|
||||
loaded = false;
|
||||
await load();
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,6 @@ export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
export type DownloadFormat = 'tar' | 'tar.gz';
|
||||
export type EventCollectionMode = 'stream' | 'poll';
|
||||
export type LabelFilterMode = 'any' | 'all';
|
||||
|
||||
export interface AppSettings {
|
||||
confirmDestructive: boolean;
|
||||
@@ -22,22 +21,17 @@ export interface AppSettings {
|
||||
eventCleanupCron: string;
|
||||
scheduleCleanupEnabled: boolean;
|
||||
eventCleanupEnabled: boolean;
|
||||
scannerCleanupCron: string;
|
||||
scannerCleanupEnabled: boolean;
|
||||
logBufferSizeKb: number;
|
||||
defaultTimezone: string;
|
||||
eventCollectionMode: EventCollectionMode;
|
||||
eventPollInterval: number;
|
||||
metricsCollectionInterval: number;
|
||||
compactPorts: boolean;
|
||||
showExposedPorts: boolean;
|
||||
formatLogTimestamps: boolean;
|
||||
externalStackPaths: string[];
|
||||
primaryStackLocation: string | null;
|
||||
defaultGrypeImage: string;
|
||||
defaultTrivyImage: string;
|
||||
defaultComposeTemplate: string;
|
||||
labelFilterMode: LabelFilterMode;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -55,39 +49,17 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
eventCleanupCron: '30 3 * * *',
|
||||
scheduleCleanupEnabled: true,
|
||||
eventCleanupEnabled: true,
|
||||
scannerCleanupCron: '0 3 * * 0',
|
||||
scannerCleanupEnabled: true,
|
||||
logBufferSizeKb: 500,
|
||||
defaultTimezone: 'UTC',
|
||||
eventCollectionMode: 'stream',
|
||||
eventPollInterval: 60000,
|
||||
metricsCollectionInterval: 30000,
|
||||
compactPorts: false,
|
||||
showExposedPorts: false,
|
||||
formatLogTimestamps: false,
|
||||
externalStackPaths: [],
|
||||
primaryStackLocation: null,
|
||||
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
||||
defaultTrivyImage: 'aquasec/trivy:0.69.3',
|
||||
labelFilterMode: 'any',
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- APP_ENV=\${APP_ENV:-production}
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# Add more services as needed
|
||||
# networks:
|
||||
# default:
|
||||
# driver: bridge
|
||||
`
|
||||
defaultTrivyImage: 'aquasec/trivy:0.69.3'
|
||||
};
|
||||
|
||||
// Create a writable store for app settings
|
||||
@@ -119,22 +91,17 @@ function createSettingsStore() {
|
||||
eventCleanupCron: settings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron,
|
||||
scheduleCleanupEnabled: settings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
|
||||
eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
||||
scannerCleanupCron: settings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
|
||||
scannerCleanupEnabled: settings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
|
||||
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
|
||||
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
compactPorts: settings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
||||
showExposedPorts: settings.showExposedPorts ?? DEFAULT_SETTINGS.showExposedPorts,
|
||||
formatLogTimestamps: settings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
||||
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
|
||||
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -169,22 +136,17 @@ function createSettingsStore() {
|
||||
eventCleanupCron: updatedSettings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron,
|
||||
scheduleCleanupEnabled: updatedSettings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
|
||||
eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
|
||||
scannerCleanupCron: updatedSettings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
|
||||
scannerCleanupEnabled: updatedSettings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
|
||||
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
|
||||
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
compactPorts: updatedSettings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
||||
showExposedPorts: updatedSettings.showExposedPorts ?? DEFAULT_SETTINGS.showExposedPorts,
|
||||
formatLogTimestamps: updatedSettings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
||||
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
|
||||
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -309,20 +271,6 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setScannerCleanupCron: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, scannerCleanupCron: value };
|
||||
saveSettings({ scannerCleanupCron: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setScannerCleanupEnabled: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, scannerCleanupEnabled: value };
|
||||
saveSettings({ scannerCleanupEnabled: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setLogBufferSizeKb: (value: number) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, logBufferSizeKb: value };
|
||||
@@ -365,13 +313,6 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setShowExposedPorts: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, showExposedPorts: value };
|
||||
saveSettings({ showExposedPorts: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setFormatLogTimestamps: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, formatLogTimestamps: value };
|
||||
@@ -407,25 +348,8 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultComposeTemplate: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultComposeTemplate: value };
|
||||
saveSettings({ defaultComposeTemplate: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setLabelFilterMode: (value: LabelFilterMode) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, labelFilterMode: value };
|
||||
saveSettings({ labelFilterMode: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
// Manual refresh from database
|
||||
refresh: () => {
|
||||
initialized = false;
|
||||
return loadSettings();
|
||||
}
|
||||
refresh: loadSettings
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface ContainerInfo {
|
||||
* - null/undefined: Regular container
|
||||
*/
|
||||
systemContainer?: SystemContainerType | null;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
export interface ImageInfo {
|
||||
@@ -129,7 +128,6 @@ export interface StackContainer {
|
||||
volumeCount: number;
|
||||
restartCount: number;
|
||||
created: number;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ComposeStackInfo {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export interface ParsedCustomUrl {
|
||||
url: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a custom URL value that may be in markdown link format.
|
||||
* Supports:
|
||||
* - Plain URL: "https://example.com" → { url: "https://example.com" }
|
||||
* - Markdown link: "[My App](https://example.com)" → { url: "https://example.com", name: "My App" }
|
||||
* Returns null if value is empty/missing.
|
||||
*/
|
||||
export function parseCustomUrl(value: string | undefined | null): ParsedCustomUrl | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const match = trimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
if (match) {
|
||||
return { name: match[1].trim(), url: ensureProtocol(match[2].trim()) };
|
||||
}
|
||||
|
||||
return { url: ensureProtocol(trimmed) };
|
||||
}
|
||||
|
||||
function ensureProtocol(url: string): string {
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
return `https://${url}`;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function formatBytes(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : decimals)} ${units[i]}`;
|
||||
}
|
||||
@@ -50,21 +50,10 @@ function hashString(str: string): number {
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hex color to rgba with alpha for backgrounds
|
||||
*/
|
||||
export function hexToRgba(hex: string, alpha: number = 0.15): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary color for a label (for text/borders)
|
||||
*/
|
||||
export function getLabelColor(label: string, customColors?: Record<string, string>): string {
|
||||
if (customColors?.[label]) return customColors[label];
|
||||
export function getLabelColor(label: string): string {
|
||||
const index = hashString(label) % LABEL_COLORS.length;
|
||||
return LABEL_COLORS[index];
|
||||
}
|
||||
@@ -72,8 +61,7 @@ export function getLabelColor(label: string, customColors?: Record<string, strin
|
||||
/**
|
||||
* Get the background color for a label (lighter, with transparency)
|
||||
*/
|
||||
export function getLabelBgColor(label: string, customColors?: Record<string, string>): string {
|
||||
if (customColors?.[label]) return hexToRgba(customColors[label]);
|
||||
export function getLabelBgColor(label: string): string {
|
||||
const index = hashString(label) % LABEL_BG_COLORS.length;
|
||||
return LABEL_BG_COLORS[index];
|
||||
}
|
||||
@@ -81,10 +69,7 @@ export function getLabelBgColor(label: string, customColors?: Record<string, str
|
||||
/**
|
||||
* Get both colors for a label as an object
|
||||
*/
|
||||
export function getLabelColors(label: string, customColors?: Record<string, string>): { color: string; bgColor: string } {
|
||||
if (customColors?.[label]) {
|
||||
return { color: customColors[label], bgColor: hexToRgba(customColors[label]) };
|
||||
}
|
||||
export function getLabelColors(label: string): { color: string; bgColor: string } {
|
||||
const index = hashString(label) % LABEL_COLORS.length;
|
||||
return {
|
||||
color: LABEL_COLORS[index],
|
||||
@@ -92,24 +77,6 @@ export function getLabelColors(label: string, customColors?: Record<string, stri
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The available color palette for the color picker (6 columns x 6 rows)
|
||||
*/
|
||||
export const COLOR_PALETTE = [
|
||||
// Row 1: 400 (lighter)
|
||||
'#f87171', '#fb923c', '#facc15', '#4ade80', '#2dd4bf', '#60a5fa',
|
||||
// Row 2: 500 (standard)
|
||||
'#ef4444', '#f97316', '#eab308', '#22c55e', '#14b8a6', '#3b82f6',
|
||||
// Row 3: 600 (darker)
|
||||
'#dc2626', '#ea580c', '#ca8a04', '#16a34a', '#0d9488', '#2563eb',
|
||||
// Row 4: violet/pink/cyan/lime/indigo/fuchsia 400
|
||||
'#a78bfa', '#f472b6', '#22d3ee', '#a3e635', '#818cf8', '#e879f9',
|
||||
// Row 5: violet/pink/cyan/lime/indigo/fuchsia 500
|
||||
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', '#6366f1', '#d946ef',
|
||||
// Row 6: neutral tones
|
||||
'#78716c', '#a1a1aa', '#94a3b8', '#737373', '#57534e', '#44403c'
|
||||
];
|
||||
|
||||
/**
|
||||
* Maximum number of labels allowed per environment
|
||||
*/
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export function wrapHtmlLines(html: string): string {
|
||||
return html
|
||||
.split('\n')
|
||||
.map((line) => `<div class="log-line">${line || ' '}</div>`)
|
||||
.join('');
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Pure parsing/building functions for notification providers.
|
||||
// Extracted from notifications.ts so unit tests can import without pulling in DB deps.
|
||||
|
||||
// --- Telegram ---
|
||||
|
||||
// Escape special characters for Telegram legacy Markdown (parse_mode: 'Markdown')
|
||||
// Only _ * ` [ need escaping — ] and other chars are not special in legacy mode
|
||||
export function escapeTelegramMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/_/g, '\\_') // Underscore (italic)
|
||||
.replace(/\*/g, '\\*') // Asterisk (bold)
|
||||
.replace(/`/g, '\\`') // Backtick (code)
|
||||
.replace(/\[/g, '\\['); // Opening bracket (link)
|
||||
}
|
||||
|
||||
export function parseTelegramUrl(url: string): { botToken: string; chatId: string; topicId?: number } | null {
|
||||
const match = url.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
|
||||
if (!match) return null;
|
||||
const [, botToken, chatId, topicIdStr] = match;
|
||||
return { botToken, chatId, topicId: topicIdStr ? parseInt(topicIdStr, 10) : undefined };
|
||||
}
|
||||
|
||||
// --- Gotify ---
|
||||
|
||||
export function buildGotifyUrl(appriseUrl: string): { url: string; priority?: number } | null {
|
||||
// Strip query params before parsing path
|
||||
const qIdx = appriseUrl.indexOf('?');
|
||||
const baseUrl = qIdx >= 0 ? appriseUrl.substring(0, qIdx) : appriseUrl;
|
||||
const queryStr = qIdx >= 0 ? appriseUrl.substring(qIdx + 1) : '';
|
||||
|
||||
const match = baseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) return null;
|
||||
const [, hostname, pathPart] = match;
|
||||
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
|
||||
const lastSlash = pathPart.lastIndexOf('/');
|
||||
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
|
||||
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
|
||||
|
||||
// Parse priority from query params
|
||||
let priority: number | undefined;
|
||||
if (queryStr) {
|
||||
const params = new URLSearchParams(queryStr);
|
||||
const p = params.get('priority');
|
||||
if (p) {
|
||||
const num = parseInt(p);
|
||||
if (!isNaN(num) && num >= 0 && num <= 10) priority = num;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`,
|
||||
priority
|
||||
};
|
||||
}
|
||||
|
||||
// --- Workflows (Microsoft Power Automate) ---
|
||||
|
||||
export function parseWorkflowsUrl(appriseUrl: string): { hostname: string; workflow: string; signature: string } | null {
|
||||
const match = appriseUrl.match(/^workflows?:\/\/([^/]+)\/(.+)\/(.+)/);
|
||||
if (!match) return null;
|
||||
const [, hostname, workflow, signature] = match;
|
||||
return { hostname, workflow, signature };
|
||||
}
|
||||
|
||||
export function buildWorkflowsHttpUrl(hostname: string, workflow: string, signature: string): string {
|
||||
return `https://${hostname}/powerautomate/automations/direct/workflows/${workflow}/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=${signature}`;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
export interface PortMapping {
|
||||
publicPort: number;
|
||||
privatePort: number;
|
||||
display: string;
|
||||
isRange?: boolean;
|
||||
exposed?: boolean;
|
||||
}
|
||||
|
||||
interface PortInfo {
|
||||
PublicPort?: number;
|
||||
PrivatePort?: number;
|
||||
publicPort?: number;
|
||||
privatePort?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Docker port mappings, collapsing consecutive ranges of 3+ ports.
|
||||
* Accepts both Docker API format (PublicPort/PrivatePort) and camelCase (publicPort/privatePort).
|
||||
* e.g. 8080:8080, 8081:8081, 8082:8082 → 8080-8082:8080-8082
|
||||
* But 80:80, 81:81 stay as individual ports (only 2 consecutive).
|
||||
*/
|
||||
export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[] {
|
||||
if (!ports || ports.length === 0) return [];
|
||||
const seen = new Set<string>();
|
||||
const individual = ports
|
||||
.filter(p => (p.PublicPort || p.publicPort))
|
||||
.map(p => ({
|
||||
publicPort: p.PublicPort || p.publicPort!,
|
||||
privatePort: p.PrivatePort || p.privatePort!,
|
||||
display: `${p.PublicPort || p.publicPort}:${p.PrivatePort || p.privatePort}`
|
||||
}))
|
||||
.filter(p => {
|
||||
const key = p.display;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.publicPort - b.publicPort);
|
||||
|
||||
// Collapse consecutive port ranges (3+ ports only)
|
||||
if (individual.length <= 1) return individual;
|
||||
|
||||
const result: PortMapping[] = [];
|
||||
let rangeStart = 0;
|
||||
let rangeEnd = 0;
|
||||
|
||||
for (let i = 1; i < individual.length; i++) {
|
||||
const curr = individual[i];
|
||||
const start = individual[rangeStart];
|
||||
const prev = individual[rangeEnd];
|
||||
const offset = curr.publicPort - start.publicPort;
|
||||
const expectedPrivate = start.privatePort + offset;
|
||||
if (curr.publicPort === prev.publicPort + 1 && curr.privatePort === expectedPrivate) {
|
||||
rangeEnd = i;
|
||||
} else {
|
||||
flushRange(individual, rangeStart, rangeEnd, result);
|
||||
rangeStart = i;
|
||||
rangeEnd = i;
|
||||
}
|
||||
}
|
||||
flushRange(individual, rangeStart, rangeEnd, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract exposed-only (internal) ports that have no published host mapping.
|
||||
* These are ports with PrivatePort but no PublicPort in Docker's port list.
|
||||
*/
|
||||
export function formatExposedPorts(ports: PortInfo[] | undefined | null): PortMapping[] {
|
||||
if (!ports || ports.length === 0) return [];
|
||||
|
||||
// Collect published private ports to exclude them
|
||||
const publishedPrivate = new Set<number>();
|
||||
for (const p of ports) {
|
||||
if (p.PublicPort || p.publicPort) {
|
||||
publishedPrivate.add(p.PrivatePort || p.privatePort || 0);
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<number>();
|
||||
return ports
|
||||
.filter(p => {
|
||||
const priv = p.PrivatePort || p.privatePort;
|
||||
const pub = p.PublicPort || p.publicPort;
|
||||
// Only exposed (no public mapping) and not already shown as published
|
||||
return priv && !pub && !publishedPrivate.has(priv);
|
||||
})
|
||||
.map(p => {
|
||||
const priv = (p.PrivatePort || p.privatePort)!;
|
||||
return { publicPort: 0, privatePort: priv, display: String(priv), exposed: true };
|
||||
})
|
||||
.filter(p => {
|
||||
if (seen.has(p.privatePort)) return false;
|
||||
seen.add(p.privatePort);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.privatePort - b.privatePort);
|
||||
}
|
||||
|
||||
function flushRange(items: PortMapping[], start: number, end: number, result: PortMapping[]) {
|
||||
const rangeLen = end - start + 1;
|
||||
if (rangeLen >= 3) {
|
||||
// Collapse into range
|
||||
result.push({
|
||||
publicPort: items[start].publicPort,
|
||||
privatePort: items[start].privatePort,
|
||||
display: `${items[start].publicPort}-${items[end].publicPort}:${items[start].privatePort}-${items[end].privatePort}`,
|
||||
isRange: true
|
||||
});
|
||||
} else {
|
||||
// Keep as individual ports
|
||||
for (let i = start; i <= end; i++) {
|
||||
result.push(items[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Parse Docker port mapping syntax from UI inputs.
|
||||
*
|
||||
* Supported host port formats:
|
||||
* "8080" -> { hostIp: '', hostPort: '8080' }
|
||||
* "127.0.0.1:8080" -> { hostIp: '127.0.0.1', hostPort: '8080' }
|
||||
* "::1:8080" -> { hostIp: '::1', hostPort: '8080' }
|
||||
* "[::1]:8080" -> { hostIp: '::1', hostPort: '8080' }
|
||||
* "" -> { hostIp: '', hostPort: '' } (empty = random)
|
||||
*
|
||||
* Supported container port formats:
|
||||
* "8080" -> single port
|
||||
* "8000-8005" -> port range
|
||||
*/
|
||||
|
||||
export interface ParsedHostPort {
|
||||
hostIp: string;
|
||||
hostPort: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a host port string that may include an IP binding.
|
||||
* Returns the IP and port separately.
|
||||
*/
|
||||
export function parseHostPort(input: string): ParsedHostPort {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { hostIp: '', hostPort: '' };
|
||||
|
||||
// Check for bracketed IPv6: [::1]:8080
|
||||
const bracketMatch = trimmed.match(/^\[([^\]]+)\]:(.+)$/);
|
||||
if (bracketMatch) {
|
||||
return { hostIp: bracketMatch[1], hostPort: bracketMatch[2] };
|
||||
}
|
||||
|
||||
// Count colons to distinguish IPv4:port from IPv6
|
||||
const colons = (trimmed.match(/:/g) || []).length;
|
||||
|
||||
if (colons === 0) {
|
||||
// Just a port number or range: "8080" or "8000-8005"
|
||||
return { hostIp: '', hostPort: trimmed };
|
||||
}
|
||||
|
||||
if (colons === 1) {
|
||||
// IPv4:port -> "127.0.0.1:8080"
|
||||
const lastColon = trimmed.lastIndexOf(':');
|
||||
return {
|
||||
hostIp: trimmed.substring(0, lastColon),
|
||||
hostPort: trimmed.substring(lastColon + 1)
|
||||
};
|
||||
}
|
||||
|
||||
// Multiple colons — ambiguous between IPv6 address and IPv6:port.
|
||||
// Use bracket notation [::1]:8080 for IPv6 with port.
|
||||
// Bare multi-colon input is treated as IPv6 address with no port.
|
||||
return { hostIp: trimmed, hostPort: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a port number or range string.
|
||||
* Returns error message or empty string if valid.
|
||||
*/
|
||||
export function validatePort(port: string): string {
|
||||
if (!port) return ''; // Empty is valid (means random allocation)
|
||||
|
||||
// Port range: "8000-8005"
|
||||
const rangeMatch = port.match(/^(\d+)-(\d+)$/);
|
||||
if (rangeMatch) {
|
||||
const start = parseInt(rangeMatch[1]);
|
||||
const end = parseInt(rangeMatch[2]);
|
||||
if (start < 1 || start > 65535) return `Port ${start} out of range (1-65535)`;
|
||||
if (end < 1 || end > 65535) return `Port ${end} out of range (1-65535)`;
|
||||
if (start >= end) return 'Range start must be less than end';
|
||||
return '';
|
||||
}
|
||||
|
||||
// Single port
|
||||
if (!/^\d+$/.test(port)) return 'Invalid port number';
|
||||
const num = parseInt(port);
|
||||
if (num < 1 || num > 65535) return 'Port out of range (1-65535)';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an IP address (IPv4 or IPv6).
|
||||
* Returns error message or empty string if valid.
|
||||
*/
|
||||
export function validateIp(ip: string): string {
|
||||
if (!ip) return ''; // Empty is valid (bind to all interfaces)
|
||||
|
||||
// Basic IPv4 check
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts.every(p => p >= 0 && p <= 255)) return '';
|
||||
return 'Invalid IPv4 address';
|
||||
}
|
||||
|
||||
// IPv6 (simplified check — accept common forms)
|
||||
if (/^[0-9a-fA-F:]+$/.test(ip)) return '';
|
||||
|
||||
return 'Invalid IP address';
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand port range mappings into individual Docker API port bindings.
|
||||
*
|
||||
* Input: hostPort="8000-8005", containerPort="9000-9005", protocol="tcp", hostIp=""
|
||||
* Output: { "9000/tcp": { HostPort: "8000" }, "9001/tcp": { HostPort: "8001" }, ... }
|
||||
*/
|
||||
export function expandPortBindings(
|
||||
hostPort: string,
|
||||
containerPort: string,
|
||||
protocol: string,
|
||||
hostIp: string
|
||||
): Record<string, { HostIp?: string; HostPort: string }> {
|
||||
const result: Record<string, { HostIp?: string; HostPort: string }> = {};
|
||||
|
||||
const hostRange = parseRange(hostPort);
|
||||
const containerRange = parseRange(containerPort);
|
||||
|
||||
if (hostRange && containerRange) {
|
||||
// Both are ranges — must be same length
|
||||
const len = Math.min(hostRange.length, containerRange.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const key = `${containerRange[i]}/${protocol}`;
|
||||
const binding: { HostIp?: string; HostPort: string } = { HostPort: String(hostRange[i]) };
|
||||
if (hostIp) binding.HostIp = hostIp;
|
||||
result[key] = binding;
|
||||
}
|
||||
} else if (containerRange) {
|
||||
// Container is range, host is single port or empty
|
||||
for (const cp of containerRange) {
|
||||
const key = `${cp}/${protocol}`;
|
||||
const binding: { HostIp?: string; HostPort: string } = { HostPort: hostPort || '0' };
|
||||
if (hostIp) binding.HostIp = hostIp;
|
||||
result[key] = binding;
|
||||
}
|
||||
} else {
|
||||
// Single port mapping
|
||||
const key = `${containerPort}/${protocol}`;
|
||||
const binding: { HostIp?: string; HostPort: string } = { HostPort: hostPort || '0' };
|
||||
if (hostIp) binding.HostIp = hostIp;
|
||||
result[key] = binding;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseRange(port: string): number[] | null {
|
||||
const match = port.match(/^(\d+)-(\d+)$/);
|
||||
if (!match) return null;
|
||||
const start = parseInt(match[1]);
|
||||
const end = parseInt(match[2]);
|
||||
const result: number[] = [];
|
||||
for (let i = start; i <= end; i++) result.push(i);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a parsed host port back to display string.
|
||||
*/
|
||||
export function formatHostPort(hostIp: string, hostPort: string): string {
|
||||
if (!hostIp) return hostPort;
|
||||
// IPv6 needs brackets
|
||||
if (hostIp.includes(':')) return `[${hostIp}]:${hostPort}`;
|
||||
return `${hostIp}:${hostPort}`;
|
||||
}
|
||||
+9
-20
@@ -20,10 +20,8 @@
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
import type { EnvironmentStats } from './api/dashboard/stats/+server';
|
||||
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
|
||||
import { labelColorOverrides } from '$lib/stores/label-colors';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
|
||||
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
|
||||
|
||||
@@ -212,10 +210,10 @@
|
||||
if (filterLabels.length === 0) {
|
||||
return tiles;
|
||||
}
|
||||
const matchFn = $appSettings.labelFilterMode === 'all'
|
||||
? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label))
|
||||
: (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label));
|
||||
return tiles.filter(t => matchFn(t.stats?.labels || []));
|
||||
return tiles.filter(t => {
|
||||
const tileLabels = t.stats?.labels || [];
|
||||
return tileLabels.some(label => filterLabels.includes(label));
|
||||
});
|
||||
});
|
||||
|
||||
// Filter grid items based on selected labels
|
||||
@@ -223,12 +221,11 @@
|
||||
if (filterLabels.length === 0) {
|
||||
return gridItems;
|
||||
}
|
||||
const matchFn = $appSettings.labelFilterMode === 'all'
|
||||
? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label))
|
||||
: (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label));
|
||||
// Filter to only show tiles whose environments have at least one matching label
|
||||
return gridItems.filter(item => {
|
||||
const tile = tiles.find(t => t.id === item.id);
|
||||
return matchFn(tile?.stats?.labels || []);
|
||||
const tileLabels = tile?.stats?.labels || [];
|
||||
return tileLabels.some(label => filterLabels.includes(label));
|
||||
});
|
||||
});
|
||||
const orderedGridItems = $derived.by(() => {
|
||||
@@ -359,18 +356,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
let statsStreamFetching = false;
|
||||
|
||||
async function fetchStatsStreaming(isRefresh = false) {
|
||||
// Skip if previous fetch is still in-flight
|
||||
if (statsStreamFetching) return;
|
||||
|
||||
// Abort any previous streaming request
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
abortController = new AbortController();
|
||||
statsStreamFetching = true;
|
||||
|
||||
// Set up connection timeout
|
||||
const timeoutController = new AbortController();
|
||||
@@ -629,7 +620,6 @@
|
||||
} finally {
|
||||
initialLoading = false;
|
||||
refreshing = false;
|
||||
statsStreamFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,9 +929,8 @@
|
||||
// Chrome 77+ Page Lifecycle API - fires when frozen tab is resumed
|
||||
document.addEventListener('resume', handleVisibilityChange);
|
||||
|
||||
// Load label filter and custom label colors
|
||||
// Load label filter from localStorage
|
||||
loadLabelFilter();
|
||||
labelColorOverrides.load();
|
||||
|
||||
// Load preferences first
|
||||
await dashboardPreferences.load();
|
||||
@@ -1030,7 +1019,7 @@
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-colors border"
|
||||
style={isSelected
|
||||
? `background-color: ${getLabelBgColor(label, $labelColorOverrides)}; border-color: ${getLabelColor(label, $labelColorOverrides)}; color: ${getLabelColor(label, $labelColorOverrides)};`
|
||||
? `background-color: ${getLabelBgColor(label)}; border-color: ${getLabelColor(label)}; color: ${getLabelColor(label)};`
|
||||
: `background-color: transparent; border-color: hsl(var(--border)); color: hsl(var(--muted-foreground));`}
|
||||
onclick={() => toggleLabel(label)}
|
||||
>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { generateApiToken, listUserTokens } from '$lib/server/api-tokens';
|
||||
import { isAuthEnabled, verifyPassword } from '$lib/server/auth';
|
||||
import { getUser } from '$lib/server/db';
|
||||
import { audit } from '$lib/server/audit';
|
||||
import { getRequestContext } from '$lib/server/request-context';
|
||||
|
||||
// Password confirmation rate limiting (per userId)
|
||||
const pwFailCounts = new Map<number, { count: number; firstFail: number }>();
|
||||
const pwCooldowns = new Map<number, number>();
|
||||
const PW_FAIL_WINDOW = 60_000; // 1-minute sliding window
|
||||
const PW_FAIL_MAX = 5; // max failures per window
|
||||
const PW_COOLDOWN = 5 * 60 * 1000; // 5-minute cooldown
|
||||
|
||||
const MAX_TOKENS_PER_USER = 25;
|
||||
|
||||
// Periodic cleanup
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, until] of pwCooldowns) {
|
||||
if (now > until) pwCooldowns.delete(id);
|
||||
}
|
||||
for (const [id, entry] of pwFailCounts) {
|
||||
if (now - entry.firstFail > PW_FAIL_WINDOW) pwFailCounts.delete(id);
|
||||
}
|
||||
}, PW_COOLDOWN).unref?.();
|
||||
|
||||
function isPwRateLimited(userId: number): boolean {
|
||||
const until = pwCooldowns.get(userId);
|
||||
if (!until) return false;
|
||||
if (Date.now() > until) {
|
||||
pwCooldowns.delete(userId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordPwFailure(userId: number): void {
|
||||
const now = Date.now();
|
||||
const entry = pwFailCounts.get(userId);
|
||||
if (!entry || now - entry.firstFail > PW_FAIL_WINDOW) {
|
||||
pwFailCounts.set(userId, { count: 1, firstFail: now });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count >= PW_FAIL_MAX) {
|
||||
pwCooldowns.set(userId, now + PW_COOLDOWN);
|
||||
pwFailCounts.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/tokens - List the current user's API tokens
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokens = await listUserTokens(auth.user.id);
|
||||
return json(tokens);
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/auth/tokens - Create a new API token
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { cookies, request } = event;
|
||||
|
||||
const authEnabled = await isAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Token creation requires a cookie session — a Bearer token cannot create new tokens
|
||||
const reqCtx = getRequestContext();
|
||||
if (reqCtx?.authMethod === 'bearer') {
|
||||
return json({ error: 'Token creation requires a session login, not a Bearer token' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
const { name, expiresAt, password } = body;
|
||||
|
||||
// Local users must confirm their password to create tokens
|
||||
// SSO/OIDC and LDAP users skip this (they authenticated via their IdP)
|
||||
if (auth.user.provider === 'local') {
|
||||
if (isPwRateLimited(auth.user.id)) {
|
||||
return json({ error: 'Too many failed password attempts. Try again later.' }, { status: 429 });
|
||||
}
|
||||
if (!password || typeof password !== 'string') {
|
||||
return json({ error: 'Password is required to create an API token' }, { status: 400 });
|
||||
}
|
||||
const dbUser = await getUser(auth.user.id);
|
||||
if (!dbUser) {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
const valid = await verifyPassword(password, dbUser.passwordHash);
|
||||
if (!valid) {
|
||||
recordPwFailure(auth.user.id);
|
||||
return json({ error: 'Invalid password' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// L2: Per-user token count limit
|
||||
const existingTokens = await listUserTokens(auth.user.id);
|
||||
if (existingTokens.length >= MAX_TOKENS_PER_USER) {
|
||||
return json({ error: `Maximum of ${MAX_TOKENS_PER_USER} API tokens per user` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return json({ error: 'Token name is required' }, { status: 400 });
|
||||
}
|
||||
if (name.length > 255) {
|
||||
return json({ error: 'Token name must be 255 characters or less' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
if (expiresAt) {
|
||||
const expiryDate = new Date(expiresAt);
|
||||
if (isNaN(expiryDate.getTime())) {
|
||||
return json({ error: 'Invalid expiration date' }, { status: 400 });
|
||||
}
|
||||
if (expiryDate <= new Date()) {
|
||||
return json({ error: 'Expiration date must be in the future' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await generateApiToken(
|
||||
auth.user.id,
|
||||
name.trim(),
|
||||
expiresAt || null
|
||||
);
|
||||
|
||||
await audit(event, 'create', 'api_token', {
|
||||
entityId: String(result.id),
|
||||
entityName: name.trim(),
|
||||
description: `API token "${name.trim()}" created`
|
||||
});
|
||||
|
||||
return json({
|
||||
id: result.id,
|
||||
token: result.token,
|
||||
tokenPrefix: result.tokenPrefix
|
||||
}, { status: 201, headers: { 'Cache-Control': 'no-store' } });
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { revokeApiToken } from '$lib/server/api-tokens';
|
||||
import { isAuthEnabled } from '$lib/server/auth';
|
||||
import { getRequestContext } from '$lib/server/request-context';
|
||||
import { audit } from '$lib/server/audit';
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/tokens/[id] - Revoke an API token
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { cookies, params } = event;
|
||||
|
||||
const authEnabled = await isAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Bearer tokens cannot manage tokens (prevent leaked token from revoking others)
|
||||
const reqCtx = getRequestContext();
|
||||
if (reqCtx?.authMethod === 'bearer') {
|
||||
return json({ error: 'Token management requires a cookie session' }, { status: 403 });
|
||||
}
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenId = parseInt(params.id);
|
||||
if (isNaN(tokenId)) {
|
||||
return json({ error: 'Invalid token ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const success = await revokeApiToken(tokenId, auth.user.id, auth.isAdmin);
|
||||
if (!success) {
|
||||
return json({ error: 'Token not found or access denied' }, { status: 404 });
|
||||
}
|
||||
|
||||
await audit(event, 'delete', 'api_token', {
|
||||
entityId: params.id,
|
||||
description: `API token revoked`
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
deleteAutoUpdateSchedule
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
try {
|
||||
@@ -39,12 +38,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, request }) => {
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
@@ -66,7 +60,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
|
||||
let scheduleType: 'daily' | 'weekly' | 'custom' = 'custom';
|
||||
if (cronExpression) {
|
||||
const parts = cronExpression.split(' ');
|
||||
if (parts.length === 5) {
|
||||
if (parts.length >= 5) {
|
||||
const [, , day, month, dow] = parts;
|
||||
if (dow !== '*' && day === '*' && month === '*') {
|
||||
scheduleType = 'weekly';
|
||||
@@ -107,12 +101,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url }) => {
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
|
||||
@@ -3,7 +3,6 @@ import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, D
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import { isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
@@ -35,9 +34,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
try {
|
||||
const containers = await listContainers(all, envIdNum);
|
||||
// Filter out containers with dockhand.hidden=true label
|
||||
const visible = containers.filter(c => !isHiddenByLabel(c.labels));
|
||||
return json(visible);
|
||||
return json(containers);
|
||||
} catch (error: any) {
|
||||
// Return 404 for missing environment so frontend can clear stale localStorage
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
|
||||
@@ -4,8 +4,7 @@ import {
|
||||
removeContainer,
|
||||
getContainerLogs
|
||||
} from '$lib/server/docker';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeysToMask, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { getStackComposeFile } from '$lib/server/stacks';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
@@ -34,26 +33,6 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
try {
|
||||
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
|
||||
// Mask secret env vars for containers belonging to a Compose stack.
|
||||
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
|
||||
const stackName = details.Config?.Labels?.['com.docker.compose.project'];
|
||||
if (stackName && Array.isArray(details.Config?.Env)) {
|
||||
const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null);
|
||||
const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content);
|
||||
if (secretKeys.size > 0) {
|
||||
details.Config.Env = details.Config.Env.map((entry: string) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) return entry;
|
||||
const key = entry.substring(0, eqIdx);
|
||||
if (secretKeys.has(key)) {
|
||||
return `${key}=***`;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json(details);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { putContainerArchive, inspectContainer, execInContainer } from '$lib/server/docker';
|
||||
import { putContainerArchive } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -111,15 +111,6 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
|
||||
return json({ error: 'No files provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// We'll inspect the container once to determine its default user
|
||||
let defaultUser: string | undefined;
|
||||
try {
|
||||
const inspectData = await inspectContainer(params.id, envIdNum);
|
||||
defaultUser = inspectData.Config.User || undefined;
|
||||
} catch (e) {
|
||||
console.warn('Failed to inspect container for user info', e);
|
||||
}
|
||||
|
||||
// For simplicity, we'll upload files one at a time
|
||||
// A more sophisticated implementation could pack multiple files into one tar
|
||||
const uploaded: string[] = [];
|
||||
@@ -137,22 +128,6 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
|
||||
envId ? parseInt(envId) : undefined
|
||||
);
|
||||
|
||||
// chown the uploaded file
|
||||
if (defaultUser) {
|
||||
const targetPath = path.endsWith('/') ? `${path}${file.name}` : `${path}/${file.name}`;
|
||||
const ownerGroup = defaultUser.includes(':') ? defaultUser : `${defaultUser}:${defaultUser}`;
|
||||
try {
|
||||
await execInContainer(
|
||||
params.id,
|
||||
['chown', '-R', ownerGroup, targetPath],
|
||||
envId ? parseInt(envId) : undefined,
|
||||
'root'
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Failed to set ownership on', targetPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
uploaded.push(file.name);
|
||||
} catch (err: any) {
|
||||
errors.push(`${file.name}: ${err.message}`);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectContainer } from '$lib/server/docker';
|
||||
import { getSecretKeysToMask } from '$lib/server/db';
|
||||
import { getStackComposeFile } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
@@ -22,26 +20,6 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const containerData = await inspectContainer(params.id, envIdNum);
|
||||
|
||||
// Mask secret env vars for containers belonging to a Compose stack.
|
||||
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
|
||||
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
|
||||
if (stackName && Array.isArray(containerData.Config?.Env)) {
|
||||
const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null);
|
||||
const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content);
|
||||
if (secretKeys.size > 0) {
|
||||
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) return entry;
|
||||
const key = entry.substring(0, eqIdx);
|
||||
if (secretKeys.has(key)) {
|
||||
return `${key}=***`;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json(containerData);
|
||||
} catch (error) {
|
||||
console.error('Failed to inspect container:', error);
|
||||
|
||||
@@ -10,9 +10,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const tail = url.searchParams.get('tail') || '100';
|
||||
const since = url.searchParams.get('since') || undefined;
|
||||
const until = url.searchParams.get('until') || undefined;
|
||||
const tail = parseInt(url.searchParams.get('tail') || '100');
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
@@ -22,7 +20,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await getContainerLogs(params.id, tail === 'all' ? 'all' : parseInt(tail), envIdNum, since, until);
|
||||
const logs = await getContainerLogs(params.id, tail, envIdNum);
|
||||
return json({ logs });
|
||||
} catch (error: any) {
|
||||
console.error('Error getting container logs:', error?.message || error, error?.stack);
|
||||
|
||||
@@ -90,7 +90,7 @@ function parseDockerFrame(buffer: Buffer, offset: number): { type: number; size:
|
||||
/**
|
||||
* Handle logs streaming for Hawser Edge connections
|
||||
*/
|
||||
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number, since?: string, until?: string): Promise<Response> {
|
||||
async function handleEdgeLogsStream(containerId: string, tail: string, environmentId: number): Promise<Response> {
|
||||
// Check if edge agent is connected
|
||||
if (!isEdgeConnected(environmentId)) {
|
||||
return new Response(JSON.stringify({ error: 'Edge agent not connected' }), {
|
||||
@@ -115,7 +115,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
// Ignore - default to demux mode
|
||||
}
|
||||
|
||||
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true×tamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`;
|
||||
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`;
|
||||
|
||||
let controllerClosed = false;
|
||||
let cancelStream: (() => void) | null = null;
|
||||
@@ -262,8 +262,6 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const containerId = params.id;
|
||||
const tail = url.searchParams.get('tail') || '100';
|
||||
const since = url.searchParams.get('since') || '';
|
||||
const until = url.searchParams.get('until') || '';
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
@@ -279,7 +277,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
// Handle Hawser Edge mode separately
|
||||
if (config.type === 'hawser-edge') {
|
||||
return handleEdgeLogsStream(containerId, tail, config.environmentId!, since, until);
|
||||
return handleEdgeLogsStream(containerId, tail, config.environmentId!);
|
||||
}
|
||||
|
||||
// First, check if container has TTY enabled and get container name
|
||||
@@ -313,7 +311,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
|
||||
// Build the logs URL with follow=true for streaming
|
||||
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true×tamps=true&tail=${tail}${since ? `&since=${since}` : ''}${until ? `&until=${until}` : ''}`;
|
||||
const logsPath = `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true&tail=${tail}×tamps=true`;
|
||||
|
||||
let controllerClosed = false;
|
||||
let abortController: AbortController | null = new AbortController();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { inspectContainer, pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { removePendingContainerUpdate } from '$lib/server/db';
|
||||
@@ -25,29 +25,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
const body = await request.json();
|
||||
const { startAfterUpdate, repullImage, ...options } = body;
|
||||
|
||||
// Resolve masked secret values (***) back to real values from the current container.
|
||||
// The GET endpoint masks secrets in Config.Env, so the edit modal sends *** for unchanged secrets.
|
||||
if (Array.isArray(options.env) && options.env.some((e: string) => e.endsWith('=***'))) {
|
||||
const currentData = await inspectContainer(params.id, envIdNum);
|
||||
const currentEnvMap = new Map<string, string>();
|
||||
for (const entry of currentData.Config?.Env || []) {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx !== -1) {
|
||||
currentEnvMap.set(entry.substring(0, eqIdx), entry.substring(eqIdx + 1));
|
||||
}
|
||||
}
|
||||
options.env = options.env.map((entry: string) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) return entry;
|
||||
const key = entry.substring(0, eqIdx);
|
||||
const value = entry.substring(eqIdx + 1);
|
||||
if (value === '***' && currentEnvMap.has(key)) {
|
||||
return `${key}=${currentEnvMap.get(key)}`;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
if (repullImage) {
|
||||
console.log(`Pulling image...`);
|
||||
try {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { auditContainer } from '$lib/server/audit';
|
||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
|
||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
@@ -174,22 +173,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip containers with dockhand.update=false label
|
||||
if (isUpdateDisabledByLabel(config.Labels)) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - dockhand.update=false label`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageName)) {
|
||||
sendData({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
|
||||
|
||||
export interface BatchUpdateResult {
|
||||
containerId: string;
|
||||
@@ -63,17 +62,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
const imageName = config.Image;
|
||||
const containerName = container.name;
|
||||
|
||||
// Skip containers with dockhand.update=false label
|
||||
if (isUpdateDisabledByLabel(config.Labels)) {
|
||||
results.push({
|
||||
containerId,
|
||||
containerName,
|
||||
success: true,
|
||||
error: 'Skipped - dockhand.update=false label'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pull latest image first
|
||||
try {
|
||||
await pullImage(imageName, undefined, envIdNum);
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
@@ -17,7 +16,6 @@ export interface UpdateCheckResult {
|
||||
error?: string;
|
||||
isLocalImage?: boolean;
|
||||
systemContainer?: 'dockhand' | 'hawser' | null;
|
||||
updateDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +64,6 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
}
|
||||
|
||||
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||
const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels);
|
||||
|
||||
return {
|
||||
containerId: container.id,
|
||||
@@ -77,8 +74,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
newDigest: result.registryDigest,
|
||||
error: result.error,
|
||||
isLocalImage: result.isLocalImage,
|
||||
systemContainer: isSystemContainer(imageName) || null,
|
||||
updateDisabled
|
||||
systemContainer: isSystemContainer(imageName) || null
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -106,12 +102,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
|
||||
|
||||
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer && !r.updateDisabled).length;
|
||||
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
if (envIdNum) {
|
||||
for (const result of results) {
|
||||
if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) {
|
||||
if (result.hasUpdate && !result.systemContainer) {
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getPendingContainerUpdates, removePendingContainerUpdate, clearPendingContainerUpdates } from '$lib/server/db';
|
||||
import { getPendingContainerUpdates, removePendingContainerUpdate } from '$lib/server/db';
|
||||
|
||||
/**
|
||||
* Get pending container updates for an environment.
|
||||
@@ -48,8 +48,8 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
const containerId = url.searchParams.get('containerId');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (!envIdNum) {
|
||||
return json({ error: 'Environment ID required' }, { status: 400 });
|
||||
if (!envIdNum || !containerId) {
|
||||
return json({ error: 'Environment ID and container ID required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Need manage permission to delete
|
||||
@@ -58,11 +58,7 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (containerId) {
|
||||
await removePendingContainerUpdate(envIdNum, containerId);
|
||||
} else {
|
||||
await clearPendingContainerUpdates(envIdNum);
|
||||
}
|
||||
await removePendingContainerUpdate(envIdNum, containerId);
|
||||
return json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error removing pending update:', error);
|
||||
|
||||
@@ -303,7 +303,7 @@ async function getEnvironmentStatsProgressive(
|
||||
|
||||
envStats.containers.total = containers.length;
|
||||
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
|
||||
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited' && c.exitCode !== 0).length;
|
||||
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
|
||||
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
|
||||
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
|
||||
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { RequestHandler } from './$types';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
|
||||
// Track startup time and initial RSS for growth rate calculation
|
||||
const startupTime = Date.now();
|
||||
@@ -100,6 +99,16 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
});
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const sign = bytes < 0 ? '-' : '';
|
||||
const abs = Math.abs(bytes);
|
||||
if (abs < 1024) return `${sign}${abs} B`;
|
||||
if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)} KB`;
|
||||
if (abs < 1024 * 1024 * 1024) return `${sign}${(abs / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${sign}${(abs / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { RequestHandler } from './$types';
|
||||
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditEnvironment } from '$lib/server/audit';
|
||||
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';
|
||||
@@ -131,7 +130,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
const adminRole = await getRoleByName('Admin');
|
||||
if (adminRole) {
|
||||
await assignUserRole(user.id, adminRole.id, env.id);
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
}
|
||||
} catch (roleError) {
|
||||
// Log but don't fail - environment was created successfully
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { join } from 'path';
|
||||
import { existsSync, rmSync } 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';
|
||||
import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git';
|
||||
import { getStacksDir } from '$lib/server/stacks';
|
||||
import { deleteGitStackFiles } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditEnvironment } from '$lib/server/audit';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
@@ -138,9 +135,6 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id) || id <= 0) {
|
||||
return json({ error: 'Invalid environment ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get environment name before deletion for audit log
|
||||
const env = await getEnvironment(id);
|
||||
@@ -148,11 +142,6 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Safety: never delete directories if env name is empty/whitespace
|
||||
if (!env.name?.trim()) {
|
||||
return json({ error: 'Cannot delete environment with empty name' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Close Edge connection if this is a Hawser Edge environment
|
||||
// This rejects any pending requests and closes the WebSocket
|
||||
closeEdgeConnection(id);
|
||||
@@ -193,29 +182,6 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
await deleteImagePruneSettings(id);
|
||||
unregisterSchedule(id, 'image_prune');
|
||||
|
||||
// Clean up stack directory for this environment
|
||||
// Safety: only delete subdirectory named after the env, never the parent
|
||||
try {
|
||||
const stacksDir = getStacksDir();
|
||||
const envStackDir = join(stacksDir, env.name);
|
||||
if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) {
|
||||
rmSync(envStackDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to clean up stack directory for environment "${env.name}":`, err);
|
||||
}
|
||||
|
||||
// Clean up git-repos directory for this environment
|
||||
try {
|
||||
const gitReposDir = getGitReposDir();
|
||||
const envGitDir = join(gitReposDir, env.name);
|
||||
if (envGitDir !== gitReposDir && envGitDir.startsWith(gitReposDir) && existsSync(envGitDir)) {
|
||||
rmSync(envGitDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to clean up git-repos directory for environment "${env.name}":`, err);
|
||||
}
|
||||
|
||||
// Notify event collectors to stop collecting from deleted environment
|
||||
refreshSubprocessEnvironments();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user