Compare commits

...

15 Commits

Author SHA1 Message Date
jarek 7e3797cbfe 1.0.27 2026-04-26 08:01:32 +02:00
Ivan Kara ccfda4c054 Adjust uploaded files permission 2026-04-19 18:56:01 +02:00
Sebastiaan Lokhorst 28a6211457 Add Apprise workflows:// notification format
For sending messages to e.g. Microsoft Teams
2026-04-19 16:44:41 +02:00
Dennis Braun 7c123833b5 fix: avoid duplicate volume binds during recreate 2026-04-19 16:26:56 +02:00
YewFence a1def17750 chore: delete the unnecessary functions called 2026-04-19 16:22:01 +02:00
YewFence 94657735fb feat: mirror Dockhand's ExtraHosts into scanner and self-update sidecar containers
Add `extraHosts` option to `runContainer` and `runContainerWithStreaming` so arbitrary `HostConfig.ExtraHosts` entries can be passed when spawning containers.

Expose `getOwnExtraHosts()` from `host-path.ts` and forward the cached entries into scanner and self-updater containers, ensuring custom host aliases (e.g. internal registry hostnames) are available inside those sidecars without additional user configuration.
2026-04-19 16:22:01 +02:00
Penlane 74741d2a01 fix: improve canvas resize 2026-04-19 16:18:48 +02:00
Penlane 94591fef48 feat: include NetworkGraph in Networks page 2026-04-19 16:18:48 +02:00
Penlane 44b06e8fc6 feat: add NetworkGraphModal 2026-04-19 16:18:48 +02:00
Penlane e35d485ae9 feat: add NetworkGraphViewer 2026-04-19 16:18:48 +02:00
FlyingT f27c0b066f Update README.md 2026-04-19 16:08:18 +02:00
FlyingT 4840ac024d Add files via upload 2026-04-19 16:08:18 +02:00
FlyingT d3aacfa94b Add files via upload 2026-04-19 16:08:18 +02:00
GiulioSavini 8671dfaf32 fix: allow 6-field cron expressions with seconds
The cron editor rejected sub-minute expressions like `*/30 * * * * *`
because validation required exactly 5 fields. Now accepts both 5-field
(standard) and 6-field (with seconds) cron expressions.

Also fixes schedule type auto-detection to correctly fall back to
'custom' for 6-field expressions instead of misinterpreting field
positions.

Fixes #819
2026-04-19 16:01:50 +02:00
Tim Huge d10f6dfd6d Fix: Remove Telegram link preview 2026-04-19 15:57:52 +02:00
53 changed files with 2287 additions and 261 deletions
+6
View File
@@ -36,6 +36,12 @@ 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
View File
@@ -1 +1 @@
v1.0.26
v1.0.27
+1 -1
View File
@@ -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)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs, git ops auto-merge with system 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.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.26",
"version": "1.0.27",
"type": "module",
"scripts": {
"dev": "npx vite dev",
+4 -12
View File
@@ -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,23 +137,15 @@
onchange(newValue);
}
// Validate cron expression
// Validate cron expression (supports 5-field and 6-field with seconds)
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [min, hr, day, month, dow] = parts;
if (parts.length !== 5 && parts.length !== 6) return false;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return (
cronFieldPattern.test(min) &&
cronFieldPattern.test(hr) &&
cronFieldPattern.test(day) &&
cronFieldPattern.test(month) &&
cronFieldPattern.test(dow)
);
return parts.every((part) => cronFieldPattern.test(part));
}
// Human-readable description using cronstrue
+1 -1
View File
@@ -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: 60, minWidth: 50, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, 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 },
+25 -1
View File
@@ -1,4 +1,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",
@@ -12,7 +36,7 @@
{ "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)" }
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
],
"imageTag": "fnsys/dockhand:v1.0.26"
},
+1 -1
View File
@@ -223,7 +223,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: 'strict', // CSRF protection
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
maxAge: maxAge // Session timeout in seconds
});
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 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
*
* 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',
} 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
}
/**
* 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;
} {
return {
updateDisabled: isUpdateDisabledByLabel(labels),
hidden: isHiddenByLabel(labels),
notifyDisabled: isNotifyDisabledByLabel(labels),
};
}
+62 -6
View File
@@ -78,6 +78,7 @@ 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 };
@@ -2066,6 +2067,7 @@ 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;
}
@@ -2522,6 +2524,7 @@ 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;
}
@@ -2781,11 +2784,21 @@ 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: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
gitRepositoryId: newRepoId,
gitStackId: newStackId,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString()
@@ -2793,6 +2806,7 @@ 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,
@@ -2826,6 +2840,7 @@ 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(
@@ -3193,14 +3208,16 @@ 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) {
// Get environments that have ANY of the specified labels
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
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 filters.labels!.some(label => envLabels.includes(label));
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3408,14 +3425,16 @@ 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) {
// Get environments that have ANY of the specified labels
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
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 filters.labels!.some(label => envLabels.includes(label));
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -4629,6 +4648,43 @@ export async function getSecretKeyNames(
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
+26 -22
View File
@@ -14,6 +14,7 @@ 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';
@@ -1917,20 +1918,7 @@ export async function recreateContainerFromInspect(
}
}
// 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}`);
}
}
}
const additionalBinds = getAdditionalVolumeBinds(hostConfig, inspectData.Mounts || []);
if (additionalBinds.length > 0) {
createConfig.HostConfig = {
...hostConfig,
@@ -3856,19 +3844,25 @@ export async function getContainerTop(id: string, envId?: number | null): Promis
export async function execInContainer(
containerId: string,
cmd: string[],
envId?: number | null
envId?: number | null,
user?: string | null
): Promise<string> {
// Create exec instance
const execBody: any = {
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
Tty: false
};
if (user) {
execBody.User = user;
}
const execCreate = await dockerJsonRequest<{ Id: string }>(
`/containers/${containerId}/exec`,
{
method: 'POST',
body: JSON.stringify({
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
Tty: false
})
body: JSON.stringify(execBody)
},
envId
);
@@ -3964,6 +3958,7 @@ export async function runContainer(options: {
cmd: string[];
binds?: string[];
env?: string[];
extraHosts?: string[];
name?: string;
envId?: number | null;
}): Promise<{ stdout: string; stderr: string }> {
@@ -3985,6 +3980,10 @@ 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)}`,
{
@@ -4044,6 +4043,7 @@ export async function runContainerWithStreaming(options: {
cmd: string[];
binds?: string[];
env?: string[];
extraHosts?: string[];
name?: string;
user?: string;
envId?: number | null;
@@ -4071,6 +4071,10 @@ 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;
+36
View File
@@ -0,0 +1,36 @@
/**
* 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;
}
+68 -2
View File
@@ -16,6 +16,70 @@ 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.
*/
@@ -153,9 +217,11 @@ 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)
// 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).
if (process.env.NODE_EXTRA_CA_CERTS) {
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
env.GIT_SSL_CAINFO = getMergedCaBundlePath();
}
// Ensure current UID is resolvable for SSH/git operations
+20 -17
View File
@@ -9,6 +9,7 @@ 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';
@@ -191,24 +192,26 @@ export async function handleEdgeContainerEvent(
// Broadcast to SSE clients
containerEventEmitter.emit('event', savedEvent);
// 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';
// 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';
// 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);
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);
+27 -2
View File
@@ -34,6 +34,7 @@ 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
@@ -85,12 +86,11 @@ 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');
console.warn('[HostPath] Running in Docker but could not detect container ID; ExtraHosts will not be mirrored to sidecars');
return null;
}
@@ -140,6 +140,9 @@ export async function detectHostDataDir(): Promise<string | null> {
Config?: {
Env?: string[];
};
HostConfig?: {
ExtraHosts?: string[];
};
NetworkSettings?: {
Networks?: Record<string, unknown>;
};
@@ -176,6 +179,19 @@ 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);
@@ -229,6 +245,15 @@ 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
*
+36
View File
@@ -0,0 +1,36 @@
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;
}
+66 -31
View File
@@ -9,17 +9,7 @@ import {
type NotificationEventType
} from './db';
// 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)
}
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
/** Drain a response body to release the underlying socket/TLS connection. */
async function drainResponse(response: Response): Promise<void> {
@@ -144,6 +134,8 @@ 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}` };
}
@@ -277,21 +269,18 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// tgram://bot_token/chat_id:topic_id?
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
if (!match) {
const parsed = parseTelegramUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
}
const [, botToken, chatId, topicIdStr] = match;
const { botToken, chatId, topicId } = parsed;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
// Escape markdown special characters in title and message
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
const topicId = topicIdStr ? parseInt(topicIdStr, 10) : undefined;
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
@@ -301,7 +290,10 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown'
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
})
});
@@ -319,21 +311,11 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// gotify://hostname/token or gotifys://hostname/token
// gotify://hostname/subpath/token (subpath support)
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
if (!match) {
const url = buildGotifyUrl(appriseUrl);
if (!url) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
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}`;
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
@@ -490,6 +472,59 @@ 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 }[] }> {
+20 -2
View File
@@ -16,7 +16,14 @@ import {
} from './docker';
import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
import {
getHostDockerSocket,
getHostDataDir,
extractUidFromSocketPath,
getOwnDockerHost,
getOwnExtraHosts,
getOwnNetworkMode
} from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown, rm } from 'node:fs/promises';
@@ -625,6 +632,7 @@ 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.
@@ -636,7 +644,12 @@ 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'}`);
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(', ')}`);
}
} else if (isHawser) {
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
hostSocketPath = '/var/run/docker.sock';
@@ -653,6 +666,10 @@ 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
@@ -722,6 +739,7 @@ async function runScannerContainerCore(
cmd,
binds,
env: envVars,
extraHosts: scannerExtraHosts,
name: `dockhand-${scannerType}-${Date.now()}`,
envId,
networkMode: scannerNetworkMode,
@@ -38,6 +38,7 @@ import {
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels';
// =============================================================================
// TYPES
@@ -369,6 +370,18 @@ 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,6 +31,7 @@ import {
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
interface UpdateInfo {
@@ -129,6 +130,12 @@ 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})`);
+22 -17
View File
@@ -24,6 +24,7 @@ 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';
@@ -285,24 +286,28 @@ 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';
sendEnvironmentNotification(msg.envId, action, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
type: notificationType
}, image).catch(() => {});
// 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(() => {});
}
rssAfterOp('events_notif', notifBefore);
rssAfterOp('events', before);
}
+43 -3
View File
@@ -5,6 +5,7 @@ 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;
@@ -32,6 +33,8 @@ export interface AppSettings {
primaryStackLocation: string | null;
defaultGrypeImage: string;
defaultTrivyImage: string;
defaultComposeTemplate: string;
labelFilterMode: LabelFilterMode;
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -59,7 +62,26 @@ const DEFAULT_SETTINGS: AppSettings = {
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: 'anchore/grype:v0.110.0',
defaultTrivyImage: 'aquasec/trivy:0.69.3'
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
`
};
// Create a writable store for app settings
@@ -101,7 +123,9 @@ function createSettingsStore() {
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
});
}
} catch {
@@ -146,7 +170,9 @@ function createSettingsStore() {
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
});
}
} catch (error) {
@@ -348,6 +374,20 @@ 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;
+47
View File
@@ -0,0 +1,47 @@
// 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): string | null {
const match = appriseUrl.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;
return `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
}
// --- 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}`;
}
+33 -16
View File
@@ -13,9 +13,10 @@ interface PortInfo {
}
/**
* Format Docker port mappings, collapsing consecutive ranges.
* 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 [];
@@ -35,30 +36,46 @@ export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[]
})
.sort((a, b) => a.publicPort - b.publicPort);
// Collapse consecutive port ranges
// Collapse consecutive port ranges (3+ ports only)
if (individual.length <= 1) return individual;
const result: PortMapping[] = [];
let rangeStart = individual[0];
let rangeEnd = individual[0];
let rangeStart = 0;
let rangeEnd = 0;
for (let i = 1; i < individual.length; i++) {
const curr = individual[i];
const offset = curr.publicPort - rangeStart.publicPort;
const expectedPrivate = rangeStart.privatePort + offset;
if (curr.publicPort === rangeEnd.publicPort + 1 && curr.privatePort === expectedPrivate) {
rangeEnd = curr;
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 {
result.push(rangeStart.publicPort === rangeEnd.publicPort
? rangeStart
: { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true });
rangeStart = curr;
rangeEnd = curr;
flushRange(individual, rangeStart, rangeEnd, result);
rangeStart = i;
rangeEnd = i;
}
}
result.push(rangeStart.publicPort === rangeEnd.publicPort
? rangeStart
: { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true });
flushRange(individual, rangeStart, rangeEnd, result);
return result;
}
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]);
}
}
}
+9 -7
View File
@@ -22,6 +22,7 @@
import { getLabelColor, getLabelBgColor } from '$lib/utils/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';
@@ -210,10 +211,10 @@
if (filterLabels.length === 0) {
return tiles;
}
return tiles.filter(t => {
const tileLabels = t.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
});
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 || []));
});
// Filter grid items based on selected labels
@@ -221,11 +222,12 @@
if (filterLabels.length === 0) {
return gridItems;
}
// Filter to only show tiles whose environments have at least one matching label
const matchFn = $appSettings.labelFilterMode === 'all'
? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label))
: (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label));
return gridItems.filter(item => {
const tile = tiles.find(t => t.id === item.id);
const tileLabels = tile?.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
return matchFn(tile?.stats?.labels || []);
});
});
const orderedGridItems = $derived.by(() => {
@@ -66,7 +66,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';
+4 -1
View File
@@ -3,6 +3,7 @@ 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 }) => {
@@ -34,7 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
try {
const containers = await listContainers(all, envIdNum);
return json(containers);
// Filter out containers with dockhand.hidden=true label
const visible = containers.filter(c => !isHiddenByLabel(c.labels));
return json(visible);
} catch (error: any) {
// Return 404 for missing environment so frontend can clear stale localStorage
if (error instanceof EnvironmentNotFoundError) {
+6 -3
View File
@@ -4,7 +4,8 @@ import {
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeyNames, removePendingContainerUpdate } from '$lib/server/db';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeysToMask, removePendingContainerUpdate } from '$lib/server/db';
import { getStackComposeFile } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
@@ -34,10 +35,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const details = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack
// 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 secretKeys = await getSecretKeyNames(stackName, envIdNum);
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('=');
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive } from '$lib/server/docker';
import { putContainerArchive, inspectContainer, execInContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
@@ -111,6 +111,15 @@ 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[] = [];
@@ -128,6 +137,22 @@ 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,7 +1,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { getSecretKeyNames } from '$lib/server/db';
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,10 +23,12 @@ 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
// 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 secretKeys = await getSecretKeyNames(stackName, envIdNum);
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('=');
@@ -15,6 +15,7 @@ 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';
@@ -173,6 +174,22 @@ 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,6 +4,7 @@ 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;
@@ -62,6 +63,17 @@ 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,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
import { createJobResponse } from '$lib/server/sse';
export interface UpdateCheckResult {
@@ -16,6 +17,7 @@ export interface UpdateCheckResult {
error?: string;
isLocalImage?: boolean;
systemContainer?: 'dockhand' | 'hawser' | null;
updateDisabled?: boolean;
}
/**
@@ -64,6 +66,7 @@ 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,
@@ -74,7 +77,8 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
newDigest: result.registryDigest,
error: result.error,
isLocalImage: result.isLocalImage,
systemContainer: isSystemContainer(imageName) || null
systemContainer: isSystemContainer(imageName) || null,
updateDisabled
};
} catch (error: any) {
return {
@@ -102,12 +106,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).length;
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer && !r.updateDisabled).length;
// Save containers with updates to the database for persistence
if (envIdNum) {
for (const result of results) {
if (result.hasUpdate && !result.systemContainer) {
if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) {
await addPendingContainerUpdate(
envIdNum,
result.containerId,
@@ -115,6 +115,7 @@ export const DELETE: RequestHandler = async (event) => {
// Delete git stack clone directories before cascade deletes the DB rows
const stacks = await getGitStacksByRepositoryId(id);
console.log(`[GitStack] Repository "${repository.name}" (id=${id}) deletion affects ${stacks.length} stacks: ${stacks.map(s => s.stackName).join(', ')}`);
for (const stack of stacks) {
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
}
+15 -15
View File
@@ -1,6 +1,13 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnContainerId, getHostDockerSocket, getOwnDockerHost, getOwnNetworkMode } from '$lib/server/host-path';
import { getAdditionalVolumeBinds } from '$lib/server/mount-dedupe';
import {
getOwnContainerId,
getHostDockerSocket,
getOwnDockerHost,
getOwnExtraHosts,
getOwnNetworkMode
} from '$lib/server/host-path';
import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
import { prefersJSON, sseToJSON } from '$lib/server/sse';
@@ -160,20 +167,7 @@ function buildCreateConfig(inspectData: any, newImage: string): any {
// Otherwise the old container's hostname is inherited, breaking self-identification
delete createConfig.Hostname;
// Preserve anonymous volumes from Mounts not in HostConfig.Binds
const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => {
const parts = b.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
const mounts = inspectData.Mounts || [];
const additionalBinds: string[] = [];
for (const mount of mounts) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingBinds.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
const additionalBinds = getAdditionalVolumeBinds(hostConfig, inspectData.Mounts || []);
if (additionalBinds.length > 0) {
createConfig.HostConfig = {
...createConfig.HostConfig,
@@ -395,6 +389,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Configure updater's Docker access based on connection type
const tcpHost = getDockerTcpHost();
const updaterHostConfig: Record<string, unknown> = { AutoRemove: true };
const updaterExtraHosts = getOwnExtraHosts() ?? undefined;
if (updaterExtraHosts?.length) {
updaterHostConfig.ExtraHosts = updaterExtraHosts;
console.log(`[SelfUpdate] Reusing ExtraHosts for updater: ${updaterExtraHosts.join(', ')}`);
}
if (tcpHost) {
// TCP: pass DOCKER_HOST so docker CLI in sidecar uses TCP
+49 -8
View File
@@ -77,6 +77,10 @@ export interface GeneralSettings {
// Scanner images
defaultGrypeImage: string;
defaultTrivyImage: string;
// Compose template
defaultComposeTemplate: string;
// Label filter mode
labelFilterMode: 'any' | 'all';
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
@@ -105,7 +109,26 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
labelFilterMode: 'any' as const,
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
`
};
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
@@ -159,7 +182,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
externalStackPaths,
primaryStackLocation,
defaultGrypeImage,
defaultTrivyImage
defaultTrivyImage,
defaultComposeTemplate,
labelFilterMode
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -192,7 +217,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
getExternalStackPaths(),
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode')
]);
const settings: GeneralSettings = {
@@ -227,7 +254,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
externalStackPaths,
primaryStackLocation,
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
};
return json(settings);
@@ -245,7 +274,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -364,6 +393,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
await setSetting('default_trivy_image', defaultTrivyImage);
}
if (defaultComposeTemplate !== undefined && typeof defaultComposeTemplate === 'string') {
await setSetting('default_compose_template', defaultComposeTemplate);
}
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
await setSetting('label_filter_mode', labelFilterMode);
}
// Fetch all settings in parallel for the response
const [
@@ -398,7 +433,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
externalStackPathsVal,
primaryStackLocationVal,
defaultGrypeImageVal,
defaultTrivyImageVal
defaultTrivyImageVal,
defaultComposeTemplateVal,
labelFilterModeVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -431,7 +468,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getExternalStackPaths(),
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode')
]);
const settings: GeneralSettings = {
@@ -466,7 +505,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal,
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode
};
return json(settings);
+2 -3
View File
@@ -12,6 +12,7 @@
import * as Select from '$lib/components/ui/select';
import * as Tooltip from '$lib/components/ui/tooltip';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { formatPorts, type PortMapping } from '$lib/utils/port-format';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { Badge } from '$lib/components/ui/badge';
@@ -1149,8 +1150,6 @@
}
}
import { formatPorts, type PortMapping } from '$lib/utils/port-format';
function extractHostFromUrl(urlString: string): string | null {
if (!urlString) return null;
@@ -1861,7 +1860,7 @@
{@const remainingCount = ports.length - 1}
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
{#each displayPorts as port}
{@const url = !port.isRange && currentEnvDetails ? getPortUrl(port.publicPort) : null}
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
{#if url}
<a
href={url}
+63 -5
View File
@@ -6,6 +6,7 @@
import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { untrack } from 'svelte';
import type { VulnerabilityCriteria } from '$lib/server/db';
import type { StepType } from '$lib/utils/update-steps';
import { getStepLabel, getStepIcon, getStepColor } from '$lib/utils/update-steps';
@@ -78,11 +79,33 @@
let progress = $state<ContainerProgress[]>([]);
let progressListEl = $state<HTMLDivElement | null>(null);
let scrollTick = $state(0);
let userScrolledUp = $state(false);
let currentIndex = $state(0);
let totalCount = $state(0);
let summary = $state<{ total: number; success: number; failed: number; blocked: number } | null>(null);
let errorMessage = $state('');
let forceUpdating = $state<Set<string>>(new Set()); // Track containers being force-updated
let filterMode = $state<'updated' | 'failed'>('updated');
let filteredProgress = $derived(
!summary
? progress
: filterMode === 'failed'
? progress.filter(p => p.step === 'failed' || p.step === 'blocked')
: progress.filter(p => p.step === 'done' || p.success)
);
$effect(() => {
// Only track filterMode, not progress — avoid re-running on every SSE update
const mode = filterMode;
if (mode === 'updated') {
untrack(() => {
for (const item of progress) {
item.showLogs = false;
}
});
}
});
function formatPullLog(entry: PullLogEntry): string {
// Clarify potentially confusing Docker messages
@@ -241,6 +264,9 @@
} else if (data.type === 'complete') {
status = 'complete';
summary = data.summary;
for (const item of progress) {
item.showLogs = false;
}
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
} else if (data.type === 'error') {
status = 'error';
@@ -266,6 +292,7 @@
currentIndex = 0;
summary = null;
errorMessage = '';
filterMode = 'updated';
}
function handleOpenChange(isOpen: boolean) {
@@ -384,10 +411,18 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
}
});
// Auto-scroll progress list to bottom on SSE data (not UI toggles)
// Track whether user has scrolled up to read earlier output
function handleProgressScroll() {
if (!progressListEl) return;
const { scrollTop, scrollHeight, clientHeight } = progressListEl;
// Consider "at bottom" if within 50px of the end
userScrolledUp = scrollHeight - scrollTop - clientHeight > 50;
}
// Auto-scroll progress list to bottom on SSE data, but only if user hasn't scrolled up
$effect(() => {
scrollTick;
if (progressListEl) {
if (progressListEl && !userScrolledUp) {
requestAnimationFrame(() => {
progressListEl?.scrollTo({ top: progressListEl.scrollHeight, behavior: 'smooth' });
});
@@ -438,10 +473,33 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
<Progress value={progressPercentage} class="h-2" />
</div>
<!-- Container list with status - scrollable area -->
<!-- Filter toggle + Container list with status - scrollable area -->
{#if progress.length > 0}
<div bind:this={progressListEl} class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
{#each progress as item (item.containerId)}
{#if summary && (summary.failed > 0 || summary.blocked > 0) && summary.success > 0}
<div class="flex items-center gap-1 shrink-0">
<Button
variant={filterMode === 'updated' ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
onclick={() => filterMode = 'updated'}
>
<CheckCircle2 class="w-3 h-3 mr-1" />
Updated ({summary.success})
</Button>
<Button
variant={filterMode === 'failed' ? 'destructive' : 'outline'}
size="sm"
class="h-7 text-xs"
onclick={() => filterMode = 'failed'}
>
<XCircle class="w-3 h-3 mr-1" />
Failed ({summary.failed + summary.blocked})
</Button>
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={progressListEl} onscroll={handleProgressScroll} class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
{#each filteredProgress as item (item.containerId)}
{@const StepIcon = getStepIcon(item.step)}
{@const isActive = item.step !== 'done' && item.step !== 'failed' && item.step !== 'blocked'}
{@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0 || (item.vulnerabilities && item.vulnerabilities.length > 0)}
+26 -9
View File
@@ -162,20 +162,37 @@
// Push colliding items down (returns new array)
function pushCollidingItems(movedItem: GridItemLayout, sourceItems: GridItemLayout[]): GridItemLayout[] {
const newItems = sourceItems.map(item => ({ ...item }));
// Step 1: Push items that directly collide with the moved item
for (const item of newItems) {
if (item.id === movedItem.id) continue;
const overlaps = !(item.x + item.w <= movedItem.x || item.x >= movedItem.x + movedItem.w ||
item.y + item.h <= movedItem.y || item.y >= movedItem.y + movedItem.h);
if (overlaps) {
item.y = movedItem.y + movedItem.h;
}
}
// Step 2: Resolve cascading collisions by sorting top-to-bottom and pushing down
let changed = true;
let iterations = 0;
const maxIterations = 100; // Prevent infinite loops
while (changed && iterations < maxIterations) {
while (changed && iterations < 100) {
changed = false;
iterations++;
for (const item of newItems) {
if (item.id === movedItem.id) continue;
const sorted = newItems
.filter(i => i.id !== movedItem.id)
.sort((a, b) => a.y - b.y || a.x - b.x);
if (hasCollision(item, movedItem)) {
// Push this item down
item.y = movedItem.y + movedItem.h;
changed = true;
for (let i = 0; i < sorted.length; i++) {
for (let j = i + 1; j < sorted.length; j++) {
const upper = sorted[i];
const lower = sorted[j];
const overlaps = !(upper.x + upper.w <= lower.x || upper.x >= lower.x + lower.w ||
upper.y + upper.h <= lower.y || upper.y >= lower.y + lower.h);
if (overlaps) {
lower.y = upper.y + upper.h;
changed = true;
}
}
}
}
+209 -31
View File
@@ -10,13 +10,15 @@
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser } from 'lucide-svelte';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser, Filter, GripHorizontal, Terminal, ArrowDown, ArrowRight } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import PageHeader from '$lib/components/PageHeader.svelte';
import TerminalPanel from '../terminal/TerminalPanel.svelte';
import { detectShells, getBestShell, getSavedUser } from '$lib/utils/shell-detection';
import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
import type { ContainerInfo } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { AnsiUp } from 'ansi_up';
const ansiUp = new AnsiUp();
@@ -306,12 +308,94 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Log search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
let logSearchFilterMode = $state(false);
let currentMatchIndex = $state(0);
let matchCount = $state(0);
let logSearchInputRef: HTMLInputElement | undefined;
const fontSizeOptions = [10, 12, 14, 16];
// Terminal state
let terminalOpen = $state(false);
let terminalContainerId = $state<string | null>(null);
let terminalContainerName = $state('');
let terminalShell = $state('/bin/bash');
let terminalUser = $state('root');
let terminalLayout = $state<'below' | 'right'>('below');
let terminalSplitRatio = $state(0.5); // 0-1, ratio of logs panel
let isResizingTerminal = $state(false);
let terminalSplitRef: HTMLDivElement | undefined;
const TERMINAL_LAYOUT_KEY = 'dockhand-logs-terminal-layout';
const TERMINAL_SPLIT_KEY = 'dockhand-logs-terminal-split';
function loadTerminalSettings() {
if (typeof window === 'undefined') return;
const savedLayout = localStorage.getItem(TERMINAL_LAYOUT_KEY);
if (savedLayout === 'below' || savedLayout === 'right') terminalLayout = savedLayout;
const savedSplit = localStorage.getItem(TERMINAL_SPLIT_KEY);
if (savedSplit) {
const r = parseFloat(savedSplit);
if (!isNaN(r) && r >= 0.2 && r <= 0.8) terminalSplitRatio = r;
}
}
function saveTerminalSettings() {
if (typeof window === 'undefined') return;
localStorage.setItem(TERMINAL_LAYOUT_KEY, terminalLayout);
localStorage.setItem(TERMINAL_SPLIT_KEY, String(terminalSplitRatio));
}
async function openTerminal(containerId: string, containerName: string, layout?: 'below' | 'right') {
if (terminalOpen && terminalContainerId === containerId && (!layout || layout === terminalLayout)) {
closeTerminal();
return;
}
if (layout) {
terminalLayout = layout;
saveTerminalSettings();
}
terminalContainerId = containerId;
terminalContainerName = containerName;
const savedUser = getSavedUser(containerId);
if (savedUser) terminalUser = savedUser;
const result = await detectShells(containerId, envId);
const best = getBestShell(result, terminalShell);
if (best) terminalShell = best;
terminalOpen = true;
}
function closeTerminal() {
terminalOpen = false;
terminalContainerId = null;
}
function startTerminalResize(e: MouseEvent) {
e.preventDefault();
isResizingTerminal = true;
document.addEventListener('mousemove', handleTerminalResize);
document.addEventListener('mouseup', stopTerminalResize);
}
function handleTerminalResize(e: MouseEvent) {
if (!isResizingTerminal || !terminalSplitRef) return;
const rect = terminalSplitRef.getBoundingClientRect();
let ratio: number;
if (terminalLayout === 'below') {
ratio = (e.clientY - rect.top) / rect.height;
} else {
ratio = (e.clientX - rect.left) / rect.width;
}
terminalSplitRatio = Math.max(0.2, Math.min(0.8, ratio));
}
function stopTerminalResize() {
isResizingTerminal = false;
document.removeEventListener('mousemove', handleTerminalResize);
document.removeEventListener('mouseup', stopTerminalResize);
saveTerminalSettings();
}
// Subscribe to environment changes - restore state and fetch data
const unsubscribeEnv = currentEnvironment.subscribe(async (env) => {
envId = env?.id ?? null;
@@ -769,6 +853,10 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
return `[${data.containerName}] ${line}`;
}).join('\n');
}
// Format timestamps if enabled
if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
// Buffer text and schedule flush
pendingText += text;
if (!flushTimer) {
@@ -953,12 +1041,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
if (data.text) {
// Use consistent color based on position in all selected containers
const color = getContainerColor(data.containerId);
const logText = $appSettings.formatLogTimestamps ? formatLogTimestamps(data.text) : data.text;
// Add to pending batch instead of updating state immediately
pendingLogs.push({
containerId: data.containerId,
containerName: data.containerName,
color,
text: data.text,
text: logText,
timestamp: data.timestamp,
stream: data.stream
});
@@ -1134,6 +1223,11 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Stop any existing stream
stopStreaming();
// Close terminal when switching containers
if (terminalOpen && terminalContainerId !== container.id) {
closeTerminal();
}
selectedContainer = container;
searchQuery = '';
dropdownOpen = false;
@@ -1314,10 +1408,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
function closeLogSearch() {
logSearchActive = false;
logSearchQuery = '';
logSearchFilterMode = false;
currentMatchIndex = 0;
matchCount = 0;
}
function toggleSearchFilterMode() {
logSearchFilterMode = !logSearchFilterMode;
}
function navigateMatch(direction: 'prev' | 'next') {
if (!logsRef || matchCount === 0) return;
@@ -1368,38 +1467,54 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Highlighted logs with search matches and ANSI color support (single container mode)
let highlightedLogs = $derived(() => {
// First convert ANSI codes to HTML
const withAnsi = ansiToHtml(logs || '');
if (!logSearchQuery.trim()) return withAnsi;
let text = logs || '';
const query = logSearchQuery.trim();
// For search, we need to highlight matches while preserving HTML tags
// We'll only highlight text outside of HTML tags
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
// Filter lines before ANSI conversion (plain text matching)
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
text = text.split('\n').filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiToHtml(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(escapedForRegex);
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
const highlighted = parts.map(part => {
// Skip HTML tags
return parts.map(part => {
if (part.startsWith('<')) return part;
// Highlight matches in text
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return part.replace(regex, '<mark class="search-match">$1</mark>');
}).join('');
return highlighted;
});
// Format merged logs HTML — uses pre-built mergedHtml string, only applies search highlighting when needed
let formattedMergedHtml = $derived(() => {
if (!mergedHtml) return '';
if (!logSearchQuery.trim()) return mergedHtml;
const query = logSearchQuery.trim();
// Apply search highlighting (same approach as single mode's highlightedLogs)
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
// Filter mode: remove non-matching lines from HTML
let html = mergedHtml;
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
// Split by <br/> or newlines, filter lines (strip HTML for matching, keep original for display)
const lines = html.split(/\n/);
html = lines.filter(line => {
const plainText = line.replace(/<[^>]*>/g, '');
return filterRegex.test(plainText);
}).join('\n');
}
if (!query) return html;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(escapedForRegex);
const searchRegex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = mergedHtml.split(/(<[^>]*>)/);
const parts = html.split(/(<[^>]*>)/);
return parts.map(part => {
if (part.startsWith('<')) return part;
return part.replace(searchRegex, '<mark class="search-match">$1</mark>');
@@ -1430,6 +1545,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
onMount(() => {
loadTerminalSettings();
// All initialization is handled in currentEnvironment.subscribe
// This just sets up the refresh interval
containerInterval = setInterval(fetchContainers, 10000);
@@ -1437,6 +1553,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
});
onDestroy(() => {
document.removeEventListener('mousemove', handleTerminalResize);
document.removeEventListener('mouseup', stopTerminalResize);
unsubscribeEnv();
if (containerInterval) {
clearInterval(containerInterval);
@@ -1831,8 +1949,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</div>
{/if}
<!-- Logs panel -->
<div class="flex-1 min-h-0 border rounded-lg overflow-hidden flex flex-col transition-colors {darkMode ? 'bg-zinc-950 border-zinc-800' : 'bg-gray-50 border-gray-300'}">
<!-- Logs + Terminal split -->
<div bind:this={terminalSplitRef} class="flex-1 min-h-0 min-w-0 overflow-hidden flex {terminalOpen ? (terminalLayout === 'below' ? 'flex-col' : 'flex-row') : ''} gap-0">
<div class="{terminalOpen ? 'min-h-0 min-w-0' : 'flex-1'} border rounded-lg overflow-hidden flex flex-col transition-colors {darkMode ? 'bg-zinc-950 border-zinc-800' : 'bg-gray-50 border-gray-300'}" style="{terminalOpen ? (terminalLayout === 'below' ? `height: ${terminalSplitRatio * 100}%` : `width: ${terminalSplitRatio * 100}%`) : ''}">
{#if layoutMode === 'grouped'}
{#if selectedContainerIds.size === 0}
<div class="flex items-center justify-center h-full text-muted-foreground">
@@ -1840,8 +1959,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</div>
{:else}
<!-- Header bar for grouped mode -->
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0 transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex items-center flex-wrap gap-y-1 px-3 py-1.5 border-b shrink-0 transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
<div class="flex items-center gap-2 shrink-0">
{#if streamingEnabled}
{#if isConnected}
<div class="flex items-center gap-1.5" title="Connected - Live streaming">
@@ -1885,7 +2004,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{/if}
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-wrap ml-auto">
<button
onclick={toggleStreaming}
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors {streamingEnabled ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50 text-amber-400' : 'bg-amber-500/30 ring-1 ring-amber-600/50 text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-200'}"
@@ -1943,6 +2062,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs w-28 {darkMode ? 'text-zinc-200 placeholder:text-zinc-500' : 'text-gray-800 placeholder:text-gray-400'}"
/>
<button
onclick={toggleSearchFilterMode}
class="p-0.5 rounded transition-colors {logSearchFilterMode ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : darkMode ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}"
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
>
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</button>
{#if matchCount > 0}
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
@@ -1993,8 +2119,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
</div>
{:else}
<!-- Header bar inside black area -->
<div class="flex items-center justify-between px-3 py-1.5 border-b shrink-0 transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex items-center flex-wrap gap-y-1 px-3 py-1.5 border-b shrink-0 transition-colors {darkMode ? 'border-zinc-800 bg-zinc-900/50' : 'border-gray-300 bg-gray-100'}">
<div class="flex items-center gap-2 shrink-0">
<!-- Connection status indicator -->
{#if streamingEnabled}
{#if isConnected}
@@ -2028,14 +2154,34 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<span class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-400'}">Paused</span>
</div>
{/if}
<!-- Container name -->
<!-- Container name + terminal toggles -->
{#if selectedContainer}
<div class="flex items-center gap-1 ml-2">
<div class="flex items-center gap-1.5 ml-2">
<span class="text-xs font-medium {darkMode ? 'text-zinc-300' : 'text-gray-700'}">{selectedContainer.name}</span>
<button
onclick={() => openTerminal(selectedContainer!.id, selectedContainer!.name, 'below')}
class="p-0.5 rounded transition-colors {terminalOpen && terminalLayout === 'below' && terminalContainerId === selectedContainer.id ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title="Terminal below"
>
<span class="inline-flex items-center gap-px">
<Terminal class="w-3.5 h-3.5 {terminalOpen && terminalLayout === 'below' && terminalContainerId === selectedContainer.id ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
<ArrowDown class="w-2.5 h-2.5 {terminalOpen && terminalLayout === 'below' && terminalContainerId === selectedContainer.id ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-600' : 'text-gray-400'}" strokeWidth={2.5} />
</span>
</button>
<button
onclick={() => openTerminal(selectedContainer!.id, selectedContainer!.name, 'right')}
class="p-0.5 rounded transition-colors {terminalOpen && terminalLayout === 'right' && terminalContainerId === selectedContainer.id ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title="Terminal on side"
>
<span class="inline-flex items-center gap-px">
<Terminal class="w-3.5 h-3.5 {terminalOpen && terminalLayout === 'right' && terminalContainerId === selectedContainer.id ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
<ArrowRight class="w-2.5 h-2.5 {terminalOpen && terminalLayout === 'right' && terminalContainerId === selectedContainer.id ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-600' : 'text-gray-400'}" strokeWidth={2.5} />
</span>
</button>
</div>
{/if}
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 flex-wrap ml-auto">
<!-- Streaming toggle -->
<button
onclick={toggleStreaming}
@@ -2099,6 +2245,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs w-28 {darkMode ? 'text-zinc-200 placeholder:text-zinc-500' : 'text-gray-800 placeholder:text-gray-400'}"
/>
<button
onclick={toggleSearchFilterMode}
class="p-0.5 rounded transition-colors {logSearchFilterMode ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : darkMode ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}"
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
>
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</button>
{#if matchCount > 0}
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
@@ -2177,6 +2330,31 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
{/if}
{/if}
</div>
<!-- Terminal panel with resize handle -->
{#if terminalOpen && terminalContainerId}
<!-- Resize handle -->
<div
role="separator"
class="{terminalLayout === 'below' ? 'h-2 cursor-ns-resize w-full' : 'w-2 cursor-ew-resize h-full'} flex items-center justify-center hover:bg-muted/50 transition-colors {isResizingTerminal ? 'bg-muted/50' : ''}"
onmousedown={startTerminalResize}
>
<GripHorizontal class="{terminalLayout === 'below' ? 'w-8 h-4' : 'w-4 h-8 rotate-90'} text-zinc-600" />
</div>
<!-- Terminal -->
<div class="min-h-0 min-w-0 border rounded-lg overflow-hidden" style="{terminalLayout === 'below' ? `height: ${(1 - terminalSplitRatio) * 100}%` : `width: ${(1 - terminalSplitRatio) * 100}%`}">
<TerminalPanel
containerId={terminalContainerId}
containerName={terminalContainerName}
shell={terminalShell}
user={terminalUser}
visible={true}
envId={envId}
fillHeight={true}
onClose={closeTerminal}
/>
</div>
{/if}
</div>
</div>
</div>
{/if}
+30 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser } from 'lucide-svelte';
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
@@ -45,6 +45,7 @@
// Search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
let logSearchFilterMode = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-filter-mode') === 'true');
let currentMatchIndex = $state(0);
let matchCount = $state(0);
let logSearchInputRef: HTMLInputElement;
@@ -107,10 +108,16 @@
function closeLogSearch() {
logSearchActive = false;
logSearchQuery = '';
logSearchFilterMode = false;
currentMatchIndex = 0;
matchCount = 0;
}
function toggleSearchFilterMode() {
logSearchFilterMode = !logSearchFilterMode;
localStorage.setItem('dockhand-log-filter-mode', String(logSearchFilterMode));
}
function navigateMatch(direction: 'prev' | 'next') {
if (!logsRef || matchCount === 0) return;
@@ -151,11 +158,22 @@
if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!logSearchQuery.trim()) return withAnsi;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = query.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const query = logSearchQuery.trim();
// Filter lines before ANSI conversion (plain text matching)
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
const lines = text.split('\n');
text = lines.filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapedForRegex.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
@@ -246,6 +264,13 @@
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs text-zinc-200 w-20 placeholder:text-zinc-500"
/>
<button
onclick={toggleSearchFilterMode}
class="p-0.5 rounded transition-colors {logSearchFilterMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'hover:bg-zinc-700'}"
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
>
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? 'text-amber-400' : 'text-zinc-400'}" />
</button>
{#if matchCount > 0}
<span class="text-xs text-zinc-400">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
+33 -6
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser } from 'lucide-svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser, Filter } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
@@ -53,6 +53,7 @@
// Search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
let logSearchFilterMode = $state(false);
let currentMatchIndex = $state(0);
let matchCount = $state(0);
let logSearchInputRef: HTMLInputElement | undefined;
@@ -97,6 +98,7 @@
if (settings.fontSize !== undefined) fontSize = settings.fontSize;
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
if (settings.logSearchFilterMode !== undefined) logSearchFilterMode = settings.logSearchFilterMode;
} catch {
// Ignore parse errors
}
@@ -112,7 +114,8 @@
wordWrap,
fontSize,
autoScroll,
streamingEnabled
streamingEnabled,
logSearchFilterMode
}));
}
}
@@ -490,10 +493,16 @@
function closeLogSearch() {
logSearchActive = false;
logSearchQuery = '';
logSearchFilterMode = false;
currentMatchIndex = 0;
matchCount = 0;
}
function toggleSearchFilterMode() {
logSearchFilterMode = !logSearchFilterMode;
saveSettings();
}
function navigateMatch(direction: 'prev' | 'next') {
if (!logsRef || matchCount === 0) return;
@@ -534,11 +543,22 @@
if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!logSearchQuery.trim()) return withAnsi;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = query.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const query = logSearchQuery.trim();
// Filter lines before ANSI conversion (plain text matching)
if (logSearchFilterMode && query) {
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterRegex = new RegExp(escapedForRegex, 'i');
const lines = text.split('\n');
text = lines.filter(line => filterRegex.test(line)).join('\n');
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!query) return withAnsi;
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapedForRegex.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
@@ -734,6 +754,13 @@
onkeydown={handleLogSearchKeydown}
class="bg-transparent border-none outline-none text-xs w-20 {darkMode ? 'text-zinc-200 placeholder:text-zinc-500' : 'text-gray-800 placeholder:text-gray-400'}"
/>
<button
onclick={toggleSearchFilterMode}
class="p-0.5 rounded transition-colors {logSearchFilterMode ? (darkMode ? 'bg-amber-500/20 ring-1 ring-amber-500/50' : 'bg-amber-500/30 ring-1 ring-amber-600/50') : darkMode ? 'hover:bg-zinc-700' : 'hover:bg-gray-300'}"
title={logSearchFilterMode ? 'Show all lines (filter mode active)' : 'Hide non-matching lines'}
>
<Filter class="w-3 h-3 transition-colors {logSearchFilterMode ? (darkMode ? 'text-amber-400' : 'text-amber-700') : darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</button>
{#if matchCount > 0}
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
{:else if logSearchQuery}
+21 -2
View File
@@ -9,7 +9,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Trash2, Search, Plus, Eye, Check, XCircle, RefreshCw, Icon, AlertTriangle, X, Network, Link, Copy, CopyPlus, Share2, Server, Globe, MonitorSmartphone, Cpu, CircleOff } from 'lucide-svelte';
import { Trash2, Search, Plus, Eye, Check, XCircle, RefreshCw, Icon, AlertTriangle, X, Network, Link, Copy, CopyPlus, Share2, Server, Globe, MonitorSmartphone, Cpu, CircleOff, GitGraph } from 'lucide-svelte';
import { broom } from '@lucide/lab';
import { copyToClipboard } from '$lib/utils/clipboard';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
@@ -25,6 +25,7 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
import { ipToNumber } from '$lib/utils/ip';
import NetworkGraphModal from './NetworkGraphModal.svelte';
type SortField = 'name' | 'driver' | 'containers' | 'subnet' | 'gateway';
type SortDirection = 'asc' | 'desc';
@@ -83,6 +84,7 @@
let showCreateModal = $state(false);
let showInspectModal = $state(false);
let showConnectModal = $state(false);
let showGraphModal = $state(false);
let inspectNetworkId = $state('');
let inspectNetworkName = $state('');
let connectNetwork = $state<NetworkInfo | null>(null);
@@ -351,6 +353,10 @@
showConnectModal = true;
}
function openGraphModal() {
showGraphModal = true;
}
async function disconnectContainer(networkId: string, networkName: string, containerId: string, containerName: string) {
disconnectingContainerId = containerId;
try {
@@ -554,8 +560,12 @@
<RefreshCw class="w-3.5 h-3.5" />
Refresh
</Button>
<Button size="sm" variant="outline" onclick={openGraphModal}>
<GitGraph class="w-3.5 h-3.5" />
View Graph
</Button>
{#if $canAccess('networks', 'create')}
<Button size="sm" variant="secondary" onclick={() => showCreateModal = true}>
<Button size="sm" variant="outline" onclick={() => showCreateModal = true}>
<Plus class="w-3.5 h-3.5" />
Create
</Button>
@@ -742,3 +752,12 @@
onClose={() => showBatchOpModal = false}
onComplete={handleBatchComplete}
/>
<!-- Edit Stack Modal -->
<NetworkGraphModal
bind:open={showGraphModal}
networks={networks}
onClose={() => {
showGraphModal = false;
}}
/>
@@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from "svelte";
import * as Dialog from "$lib/components/ui/dialog";
import CodeEditor from "$lib/components/CodeEditor.svelte";
import { Layers, X } from "lucide-svelte";
import { focusFirstInput } from "$lib/utils";
import { ErrorDialog } from "$lib/components/ui/error-dialog";
import NetworkGraphViewer from "./NetworkGraphViewer.svelte";
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte";
import type { NetworkInfo } from "$lib/types";
// Get sidebar state to adjust modal positioning
const sidebar = useSidebar();
interface Props {
open: boolean;
networks: NetworkInfo[]; // Required for edit mode, optional for create
onClose: () => void;
}
let { open = $bindable(), networks: propNetworks, onClose }: Props = $props();
let networks = $state<NetworkInfo[]>([]);
// Form state
let saving = $state(false);
let editorTheme = $state<"light" | "dark">("dark");
// Error dialog state
let operationError = $state<{
title: string;
message: string;
details?: string;
} | null>(null);
// CodeEditor reference for explicit marker updates
let codeEditorRef: CodeEditor | null = $state(null);
// NetworkGraphViewer reference for resize on panel toggle
let graphViewerRef: NetworkGraphViewer | null = $state(null);
// Display title
const displayName = "DEMO";
onMount(() => {
// Load saved editor theme, or fall back to app theme / system preference
const savedEditorTheme = localStorage.getItem("dockhand-editor-theme");
if (savedEditorTheme === "dark" || savedEditorTheme === "light") {
editorTheme = savedEditorTheme;
} else {
const appTheme = localStorage.getItem("theme");
if (appTheme === "dark" || appTheme === "light") {
editorTheme = appTheme;
} else {
// Fallback to system preference
editorTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
}
});
function tryClose() {
handleClose();
}
let containerRef: HTMLDivElement | null = $state(null);
function handleClose() {
// Reset mode back to prop values
networks = propNetworks;
codeEditorRef = null;
operationError = null;
onClose();
}
// Initialize when dialog opens - ONLY ONCE per open
let hasInitialized = $state(false);
$effect(() => {
if (open && !hasInitialized) {
hasInitialized = true;
// Reset mode to prop values on each open
networks = propNetworks;
} else if (!open) {
hasInitialized = false; // Reset when modal closes
}
});
</script>
<Dialog.Root
bind:open
onOpenChange={(isOpen) => {
if (isOpen) {
focusFirstInput();
} else {
// No unsaved changes - reset state
handleClose();
}
}}
>
<Dialog.Content
class="max-w-none h-[95vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700 {sidebar.state === 'collapsed'
? 'w-[calc(100vw-6rem)] ml-[1.5rem]'
: 'w-[calc(100vw-12rem)] ml-[4.5rem]'}"
showCloseButton={false}
>
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
</div>
<div>
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">View network graph</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">View network connections between containers</Dialog.Description>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Close button -->
<button
onclick={tryClose}
class="p-1.5 rounded-md text-zinc-400 dark:text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
<X class="w-4 h-4" />
</button>
</div>
</div>
</Dialog.Header>
<!-- Content area -->
<div bind:this={containerRef} class="flex-1 min-h-0 flex flex-col">
<!-- Graph tab: Full width -->
<NetworkGraphViewer bind:this={graphViewerRef} {networks} class="h-full flex-1" />
</div>
<!-- Footer -->
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0" />
</Dialog.Content>
</Dialog.Root>
<!-- Error dialog for failed operations -->
{#if operationError}
{@const errorDialogOpen = true}
<ErrorDialog open={errorDialogOpen} title={operationError.title} message={operationError.message} details={operationError.details} onClose={() => (operationError = null)} />
{/if}
@@ -0,0 +1,832 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import cytoscape from "cytoscape";
import {
Box,
Database,
Network,
ZoomIn,
ZoomOut,
Maximize2,
RotateCcw,
X,
ChevronDown,
Sun,
Moon,
LayoutGrid,
GitBranch,
Circle,
Target,
Sparkles,
Share2,
Server,
Globe,
MonitorSmartphone,
Cpu,
CircleOff,
} from "lucide-svelte";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import type { NetworkInfo } from "$lib/types";
interface Props {
networks: NetworkInfo[];
class?: string;
}
let { networks, class: className = "" }: Props = $props();
let containerEl: HTMLDivElement | null = $state(null);
let cy: cytoscape.Core | null = null;
let graphInitialized = $state(false);
let selectedNode = $state<any>(null);
let selectedEdge = $state<any>(null);
// Theme state
let graphTheme = $state<"light" | "dark">("light");
// Layout state
type LayoutType = "breadthfirst" | "grid" | "circle" | "concentric" | "cose";
let currentLayout = $state<LayoutType>("breadthfirst");
let showLayoutMenu = $state(false);
const layoutOptions: { value: LayoutType; label: string; icon: string }[] = [
{ value: "breadthfirst", label: "Tree", icon: "tree" },
{ value: "grid", label: "Grid", icon: "grid" },
{ value: "circle", label: "Circle", icon: "circle" },
{ value: "concentric", label: "Radial", icon: "radial" },
{ value: "cose", label: "Force", icon: "force" },
];
function buildGraphElements(nets: NetworkInfo[]) {
interface ContainerResult {
containerId: string;
containerName: string;
networks: {
ipv4: string;
netName: string;
}[];
}
const elements: cytoscape.ElementDefinition[] = [];
const networks = nets;
// Derive services from networks
const serviceMap = networks.reduce<Record<string, ContainerResult>>((svcs, network) => {
Object.entries(network.containers).forEach(([id, config]) => {
if (!svcs[id]) {
svcs[id] = {
containerId: id,
containerName: config.name,
networks: [],
};
}
svcs[id].networks.push({
ipv4: config.ipv4Address,
netName: network.name,
});
});
return svcs;
}, {});
const services = Object.values(serviceMap);
// Add service nodes
services.forEach((service) => {
elements.push({
data: {
id: `service-${service.containerName}`,
label: service.containerName,
caption: '',
type: "service",
config: service,
},
});
});
// Add network nodes
networks.forEach((network) => {
const driver = network.driver;
elements.push({
data: {
id: `network-${network.name}`,
label: network.name,
caption: driver,
type: "network",
driver: driver,
external: !network.internal,
config: network,
},
});
});
// Connect services to networks
services.forEach((service) => {
const serviceNetworks = service.networks;
if (serviceNetworks) {
serviceNetworks.forEach((network) => {
const netName = network.netName;
const foundName = networks.find((network) => network.name === netName);
if (foundName || netName === "default") {
const targetId = foundName ? `network-${netName}` : "network-default";
const defaultNet = networks.find((network) => network.name === "default");
if (netName === "default" && !defaultNet) {
const defaultExists = elements.find((e) => e.data.id === "network-default");
if (!defaultExists) {
elements.push({
data: {
id: "network-default",
label: "default",
type: "network",
driver: "bridge",
external: false,
},
});
}
}
elements.push({
data: {
id: `net-${service.containerName}-${netName}`,
source: `service-${service.containerName}`,
target: targetId,
type: "network-connection",
},
});
}
});
}
});
return elements;
}
// SVG icons as data URLs for nodes
function getSvgIcon(type: string, color: string): string {
const icons: Record<string, string> = {
service: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`,
network: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/></svg>`,
};
const svg = icons[type] || icons.service;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function createGraph(useExistingData = false, skipLayout = false) {
if (!containerEl) return;
// Even if parsing failed, we get at least an empty structure to render
if (!networks) {
networks = [];
}
const elements = buildGraphElements(networks);
// If skipping layout, store current positions before destroying
let savedPositions: Map<string, { x: number; y: number }> | null = null;
if (skipLayout && cy) {
savedPositions = new Map();
cy.nodes().forEach((node) => {
const pos = node.position();
savedPositions!.set(node.id(), { x: pos.x, y: pos.y });
});
}
if (cy) {
cy.destroy();
}
// Theme-based colors
const isDark = graphTheme === "dark";
const colors = {
service: {
bg: isDark ? "#3b82f6" : "#dbeafe",
border: isDark ? "#2563eb" : "#93c5fd",
text: isDark ? "#ffffff" : "#1e3a5f",
icon: isDark ? "#ffffff" : "#2563eb",
},
network: {
bg: isDark ? "#8b5cf6" : "#ede9fe",
border: isDark ? "#7c3aed" : "#c4b5fd",
text: isDark ? "#ffffff" : "#3b1e5f",
icon: isDark ? "#ffffff" : "#7c3aed",
},
edge: isDark ? "#64748b" : "#94a3b8",
selected: isDark ? "#fbbf24" : "#18181b",
caption: isDark ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.5)",
};
cy = cytoscape({
container: containerEl,
elements,
style: [
// Service nodes
{
selector: 'node[type="service"]',
style: {
"background-color": colors.service.bg,
"border-color": colors.service.border,
"border-width": 2,
label: (ele: any) => `${ele.data("label")}\n${ele.data("caption") || ""}`,
color: colors.service.text,
"text-valign": "center",
"text-halign": "center",
"font-size": "10px",
"font-weight": 600,
width: 150,
height: 55,
shape: "roundrectangle",
"text-wrap": "wrap",
"text-max-width": "115px",
"text-overflow-wrap": "anywhere",
"line-height": 1.2,
"background-image": getSvgIcon("service", colors.service.icon),
"background-width": "16px",
"background-height": "16px",
"background-position-x": "8px",
"background-position-y": "50%",
"background-clip": "none",
"text-margin-x": 10,
},
},
// Network nodes
{
selector: 'node[type="network"]',
style: {
"background-color": colors.network.bg,
"border-color": colors.network.border,
"border-width": 2,
label: (ele: any) => `${ele.data("label")}\nnetwork: ${ele.data("caption") || "bridge"}`,
color: colors.network.text,
"text-valign": "center",
"text-halign": "center",
"font-size": "9px",
"font-weight": 600,
width: 120,
height: 46,
shape: "roundrectangle",
"text-wrap": "wrap",
"text-max-width": "90px",
"text-overflow-wrap": "anywhere",
"line-height": 1.2,
"background-image": getSvgIcon("network", colors.network.icon),
"background-width": "14px",
"background-height": "14px",
"background-position-x": "6px",
"background-position-y": "50%",
"background-clip": "none",
"text-margin-x": 8,
},
},
// Link edges
{
selector: 'edge[type="link"]',
style: {
width: 2,
"line-color": "#64748b",
"target-arrow-color": "#64748b",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
"line-style": "dashed",
},
},
// Network connection edges
{
selector: 'edge[type="network-connection"]',
style: {
width: 1.5,
"line-color": "#a78bfa",
"curve-style": "bezier",
"line-style": "dotted",
},
},
// Selected node
{
selector: "node:selected",
style: {
"border-width": 3,
"border-color": "#18181b",
"overlay-color": "#18181b",
"overlay-padding": 3,
"overlay-opacity": 0.15,
},
},
// Selected edge
{
selector: "edge:selected",
style: {
width: 3,
"line-color": "#f59e0b",
"target-arrow-color": "#f59e0b",
},
},
// Connection mode - highlight services
{
selector: "node.connection-source",
style: {
"border-width": 4,
"border-color": "#22c55e",
"overlay-color": "#22c55e",
"overlay-padding": 5,
"overlay-opacity": 0.3,
},
},
{
selector: "node.connection-target",
style: {
"border-color": "#3b82f6",
"border-width": 3,
"overlay-color": "#3b82f6",
"overlay-padding": 3,
"overlay-opacity": 0.2,
},
},
],
layout:
skipLayout && savedPositions
? { name: "preset" }
: {
name: "breadthfirst",
directed: true,
padding: 50,
spacingFactor: 1.5,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
},
wheelSensitivity: 0.3,
minZoom: 0.3,
maxZoom: 3,
});
// Restore saved positions if skipping layout
if (skipLayout && savedPositions) {
cy.nodes().forEach((node) => {
const savedPos = savedPositions!.get(node.id());
if (savedPos) {
node.position(savedPos);
}
});
}
// Handle node selection
cy.on("tap", "node", (evt) => {
const nodeData = evt.target.data();
console.log("Node tapped:", nodeData);
selectedNode = nodeData;
selectedEdge = null;
console.log("selectedNode set to:", selectedNode);
});
// Handle edge selection
cy.on("tap", "edge", (evt) => {
selectedEdge = evt.target.data();
selectedNode = null;
});
cy.on("tap", (evt) => {
if (evt.target === cy) {
selectedNode = null;
selectedEdge = null;
}
});
graphInitialized = true;
// Ensure the graph renders correctly after container is sized
setTimeout(() => {
if (cy) {
cy.resize();
cy.fit(undefined, 50);
}
}, 100);
}
function zoomIn() {
if (cy) cy.zoom(cy.zoom() * 1.2);
}
function zoomOut() {
if (cy) cy.zoom(cy.zoom() / 1.2);
}
function fitToScreen() {
if (cy) cy.fit(undefined, 50);
}
// Exported function to handle container resize
export function resize() {
if (cy && containerEl) {
// Cytoscape caches container dimensions aggressively
// We need to unmount and remount to the container
cy!.unmount();
// Wait for DOM to update
requestAnimationFrame(() => {
if (cy && containerEl) {
cy!.mount(containerEl);
cy!.resize();
cy!.fit(undefined, 50);
}
});
}
}
function getLayoutConfig(layoutName: LayoutType): cytoscape.LayoutOptions {
const baseConfig = {
padding: 50,
avoidOverlap: true,
nodeDimensionsIncludeLabels: true,
};
switch (layoutName) {
case "breadthfirst":
return {
...baseConfig,
name: "breadthfirst",
directed: true,
spacingFactor: 1.5,
};
case "grid":
return {
...baseConfig,
name: "grid",
rows: undefined,
cols: undefined,
};
case "circle":
return {
...baseConfig,
name: "circle",
spacingFactor: 1.2,
};
case "concentric":
return {
...baseConfig,
name: "concentric",
minNodeSpacing: 50,
concentric: (node: any) => {
// Services at center, resources around
return node.data("type") === "service" ? 2 : 1;
},
levelWidth: () => 1,
};
case "cose":
return {
...baseConfig,
name: "cose",
idealEdgeLength: () => 100,
nodeOverlap: 20,
animate: true,
animationDuration: 500,
};
default:
return { ...baseConfig, name: layoutName };
}
}
function applyLayout(layoutName: LayoutType) {
if (!cy) return;
currentLayout = layoutName;
showLayoutMenu = false;
cy.layout(getLayoutConfig(layoutName)).run();
cy.fit(undefined, 50);
}
function resetLayout() {
if (cy) {
cy.layout(getLayoutConfig(currentLayout)).run();
cy.fit(undefined, 50);
}
}
onMount(() => {
// Follow app theme from localStorage
const appTheme = localStorage.getItem("theme");
if (appTheme === "dark" || appTheme === "light") {
graphTheme = appTheme;
} else {
// Fallback to system preference
graphTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
});
// Create graph when container element becomes available
$effect(() => {
if (containerEl && networks && !graphInitialized) {
createGraph();
}
});
onDestroy(() => {
if (cy) {
cy.destroy();
cy = null;
}
});
function toggleGraphTheme() {
graphTheme = graphTheme === "light" ? "dark" : "light";
createGraph(true); // Recreate graph with new theme, preserve local edits
}
function getNodeIcon(type: string) {
switch (type) {
case "service":
return Box;
case "network":
return Network;
default:
return Database;
}
}
function getNodeColor(type: string) {
switch (type) {
case "service":
return "bg-blue-500";
case "network":
return "bg-violet-500";
default:
return "bg-slate-500";
}
}
</script>
<div class="flex flex-col h-full {className}">
<!-- Toolbar -->
<div class="flex items-center justify-between px-2 py-1.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 min-h-[40px]">
<div class="flex items-center gap-2 flex-wrap"></div>
<!-- Controls -->
<div class="flex items-center gap-0.5">
<!-- Layout selector -->
<div class="relative">
<button
onclick={() => (showLayoutMenu = !showLayoutMenu)}
class="h-6 px-2 flex items-center gap-1 rounded text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
title="Change layout"
>
{#if currentLayout === "breadthfirst"}
<GitBranch class="w-3 h-3" />
{:else if currentLayout === "grid"}
<LayoutGrid class="w-3 h-3" />
{:else if currentLayout === "circle"}
<Circle class="w-3 h-3" />
{:else if currentLayout === "concentric"}
<Target class="w-3 h-3" />
{:else}
<Sparkles class="w-3 h-3" />
{/if}
<ChevronDown class="w-3 h-3" />
</button>
{#if showLayoutMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 top-full mt-1 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 py-1 z-20 min-w-[120px]"
onmouseleave={() => (showLayoutMenu = false)}
>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'breadthfirst'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("breadthfirst")}
>
<GitBranch class="w-3.5 h-3.5" />
Tree
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'grid'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("grid")}
>
<LayoutGrid class="w-3.5 h-3.5" />
Grid
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'circle'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("circle")}
>
<Circle class="w-3.5 h-3.5" />
Circle
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'concentric'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("concentric")}
>
<Target class="w-3.5 h-3.5" />
Radial
</button>
<button
class="w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 hover:bg-zinc-100 dark:hover:bg-zinc-700 {currentLayout === 'cose'
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-zinc-700 dark:text-zinc-200'}"
onclick={() => applyLayout("cose")}
>
<Sparkles class="w-3.5 h-3.5" />
Force
</button>
</div>
{/if}
</div>
<div class="w-px h-4 bg-zinc-300 dark:bg-zinc-600 mx-1"></div>
<!-- Theme toggle -->
<button
onclick={toggleGraphTheme}
class="h-6 w-6 flex items-center justify-center rounded text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
title={graphTheme === "light" ? "Switch to dark theme" : "Switch to light theme"}
>
{#if graphTheme === "light"}
<Moon class="w-3.5 h-3.5" />
{:else}
<Sun class="w-3.5 h-3.5" />
{/if}
</button>
<div class="w-px h-4 bg-zinc-300 dark:bg-zinc-600 mx-1"></div>
<Button variant="ghost" size="sm" onclick={zoomOut} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<ZoomOut class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={zoomIn} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<ZoomIn class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={fitToScreen} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<Maximize2 class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onclick={resetLayout} class="h-6 w-6 p-0 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200">
<RotateCcw class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<div class="flex-1 flex min-h-0 h-full">
<!-- Graph container -->
<div class="flex-1 relative h-full min-w-0 {graphTheme === 'dark' ? 'bg-zinc-900' : 'bg-zinc-100'}">
<div bind:this={containerEl} class="w-full h-full"></div>
<!-- Footer: Legend -->
<div class="absolute bottom-2 left-2 pointer-events-none z-10">
<div
class="flex items-center gap-2 text-xs bg-white/80 dark:bg-zinc-800/80 backdrop-blur-sm rounded px-2 py-1 shadow-sm border border-zinc-200/50 dark:border-zinc-700/50 whitespace-nowrap"
>
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-blue-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Service</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0">
<div class="w-2 h-2 rounded-sm bg-violet-500 flex-shrink-0"></div>
<span class="text-zinc-600 dark:text-zinc-300">Network</span>
</div>
</div>
</div>
<!-- Details panel (overlay) -->
{#if selectedNode || selectedEdge}
<div class="absolute top-0 right-0 bottom-0 w-[420px] border-l border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95 shadow-lg z-20 flex flex-col">
<!-- Sticky header -->
{#if selectedNode}
{@const NodeIcon = getNodeIcon(selectedNode.type)}
<div class="sticky top-0 z-10 p-3 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded {getNodeColor(selectedNode.type)}">
<NodeIcon class="w-3.5 h-3.5 text-white" />
</div>
<div>
<h3 class="font-semibold text-sm text-zinc-800 dark:text-zinc-100">
{selectedNode.label}
</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400 capitalize">
{selectedNode.type}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700"
onclick={() => {
selectedNode = null;
selectedEdge = null;
}}
title="Close"
>
<X class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
{:else if selectedEdge}
<!-- Sticky header for edge -->
<div class="sticky top-0 z-10 p-3 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95">
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-sm text-zinc-800 dark:text-zinc-100 capitalize">
{selectedEdge.type.replace("-", " ")}
</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400">
{selectedEdge.source.replace(/^(service|network)-/, "")}
{selectedEdge.target.replace(/^(service|network)-/, "")}
</p>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-zinc-500 hover:text-zinc-600 hover:bg-zinc-100 dark:hover:bg-zinc-700"
onclick={() => {
selectedNode = null;
selectedEdge = null;
}}
title="Close"
>
<X class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
{/if}
<!-- Scrollable content -->
<div class="flex-1 overflow-y-auto p-3">
{#if selectedNode}
{#if selectedNode.type === "service"}
<div class="space-y-3 text-sm">
<!-- Container Id -->
<div class="space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Container Id</span>
</div>
<Input value={selectedNode.config.containerId} placeholder="containerId" class="h-8 text-xs" readonly />
</div>
</div>
{:else if selectedNode.type === "network"}
<div class="space-y-3 text-sm">
<!-- Driver -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">Driver</span>
<!-- Simulate the select element -->
<div class="flex items-center justify-between w-fit h-8 px-3 py-2 text-xs border rounded-md border-input bg-background shadow-sm dark:bg-input/30">
<span class="flex items-center gap-1.5">
{#if selectedNode.config.driver === "bridge"}
<Share2 class="w-3.5 h-3.5 text-emerald-500" />
{:else if selectedNode.config.driver === "host"}
<Server class="w-3.5 h-3.5 text-sky-500" />
{:else if selectedNode.config.driver === "overlay"}
<Globe class="w-3.5 h-3.5 text-violet-500" />
{:else if selectedNode.config.driver === "macvlan"}
<MonitorSmartphone class="w-3.5 h-3.5 text-amber-500" />
{:else if selectedNode.config.driver === "ipvlan"}
<Cpu class="w-3.5 h-3.5 text-orange-500" />
{:else}
<CircleOff class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
<span class="capitalize">{selectedNode.config.driver}</span>
</span>
</div>
</div>
<!-- IPAM Config -->
<div class="space-y-1.5">
<span class="text-xs font-medium text-zinc-600 dark:text-zinc-300">IPAM configuration</span>
<div class="space-y-4 pt-2">
<div class="relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Subnet</span>
<Input value={selectedNode.config.ipam?.config?.[0].subnet} placeholder="172.20.0.0/16" class="h-9 pt-3 text-xs" readonly />
</div>
<div class="relative">
<span class="absolute -top-2 left-2 text-[9px] text-zinc-400 bg-white dark:bg-zinc-800 px-1 z-10">Gateway</span>
<Input value={selectedNode.config.ipam?.config?.[0].gateway} placeholder="172.20.0.1" class="h-9 pt-3 text-xs" readonly />
</div>
</div>
</div>
<!-- Boolean flags -->
<div class="space-y-2 pointer-events-none select-none">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={selectedNode.config.external} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">External network</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={selectedNode.config.internal} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">Internal network</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" bind:checked={selectedNode.config.attachable} class="rounded border-zinc-300" />
<span class="text-xs text-zinc-600">Attachable</span>
</label>
</div>
</div>
{/if}
{:else if selectedEdge}
{#if selectedEdge.type === "network-connection"}
<p class="text-xs text-zinc-500 dark:text-zinc-400">Service connected to this network.</p>
{/if}
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
+113 -2
View File
@@ -8,8 +8,9 @@
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info } from 'lucide-svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode } from '$lib/stores/settings';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags } from 'lucide-svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
@@ -27,6 +28,46 @@
let defaultTrivyArgs = $derived($appSettings.defaultTrivyArgs);
let defaultGrypeImage = $derived($appSettings.defaultGrypeImage);
let defaultTrivyImage = $derived($appSettings.defaultTrivyImage);
let defaultComposeTemplate = $derived($appSettings.defaultComposeTemplate);
let labelFilterMode = $derived($appSettings.labelFilterMode);
let composeTemplateWIP = $state('');
let composeTemplateInitialized = false;
$effect(() => {
if (!composeTemplateInitialized && defaultComposeTemplate !== undefined) {
composeTemplateWIP = defaultComposeTemplate;
composeTemplateInitialized = true;
}
});
const builtinComposeTemplate = `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
`;
function saveComposeTemplate() {
appSettings.setDefaultComposeTemplate(composeTemplateWIP);
toast.success('Compose template updated');
}
function revertComposeTemplate() {
composeTemplateWIP = builtinComposeTemplate;
toast.info('Template reverted to default');
}
let scheduleRetentionDays = $derived($appSettings.scheduleRetentionDays);
let eventRetentionDays = $derived($appSettings.eventRetentionDays);
let scheduleCleanupCron = $derived($appSettings.scheduleCleanupCron);
@@ -425,6 +466,39 @@
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<FileText class="w-4 h-4" />
Compose template
</Card.Title>
<p class="text-xs text-muted-foreground">Default YAML content when creating a new stack.</p>
</Card.Header>
<Card.Content class="space-y-3">
<div class="h-64">
<CodeEditor
value={composeTemplateWIP}
onchange={(v) => { composeTemplateWIP = v; }}
language="yaml"
readonly={!$canAccess('settings', 'edit')}
class="h-full rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
/>
</div>
{#if $canAccess('settings', 'edit')}
<div class="flex gap-2">
<Button size="sm" variant="outline" onclick={saveComposeTemplate}>
<Save class="w-3.5 h-3.5" />
Save template
</Button>
<Button size="sm" variant="ghost" onclick={revertComposeTemplate}>
<RotateCcw class="w-3.5 h-3.5" />
Revert to default
</Button>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
<!-- Right column -->
@@ -686,6 +760,43 @@
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<LayoutDashboard class="w-4 h-4" />
Dashboard
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-3">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Label filter matching</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="w-80">
<p class="text-xs">
Controls how multiple selected labels filter environments on the dashboard.
<strong>"Any"</strong>: shows environments that have at least one of the selected labels.
<strong>"All"</strong>: shows only environments that have every selected label.
</p>
</Tooltip.Content>
</Tooltip.Root>
<ToggleSwitch
value={labelFilterMode}
leftValue="any"
rightValue="all"
onchange={(mode) => appSettings.setLabelFilterMode(mode as LabelFilterMode)}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
</div>
</div>
</div>
@@ -423,11 +423,12 @@ tgram://bot_token/chat_id
tgram://bot_token/chat_id:topic_id
ntfy://my-topic
pushover://user_key/api_token
workflows://hostname/workflow/signature
jsons://hostname/webhook/path"
class="flex min-h-[220px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
<p class="text-xs text-muted-foreground">
Supports Gotify (gotify:// or gotifys:// for HTTPS), Discord, Slack, Mattermost (mmost:// or mmosts://), Telegram, ntfy, Pushover, and generic JSON webhooks.
Supports Gotify (gotify:// or gotifys:// for HTTPS), Discord, Slack, Mattermost (mmost:// or mmosts://), Telegram, ntfy, Pushover, Workflows (for e.g. Microsoft Teams), and generic JSON webhooks.
</p>
</div>
</div>
+1 -1
View File
@@ -2019,7 +2019,7 @@
{#if container.ports.length > 0}
{@const mappedPorts = formatPorts(container.ports)}
{#each mappedPorts as port}
{@const url = !port.isRange ? getPortUrl(port.publicPort) : null}
{@const url = getPortUrl(port.publicPort)}
{#if url}
<a
href={url}
+1 -18
View File
@@ -564,24 +564,7 @@
// Debounce timer for validation
let validateTimer: ReturnType<typeof setTimeout> | null = null;
const defaultCompose = `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
`;
const defaultCompose = $appSettings.defaultComposeTemplate;
// Count of defined environment variables (with non-empty keys)
const envVarCount = $derived(envVars.filter(v => v.key.trim()).length);