mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.27
This commit is contained in:
@@ -10,7 +10,7 @@ PGID=${PGID:-1001}
|
|||||||
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
|
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
|
||||||
|
|
||||||
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
|
# 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"
|
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
|
||||||
if [ "$MEMORY_MONITOR" = "true" ]; then
|
if [ "$MEMORY_MONITOR" = "true" ]; then
|
||||||
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
|
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dockhand",
|
"name": "dockhand",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.26",
|
"version": "1.0.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx vite dev",
|
"dev": "npx vite dev",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const containerColumns: ColumnConfig[] = [
|
|||||||
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
|
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
|
||||||
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
|
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
|
||||||
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' },
|
{ 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: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' },
|
||||||
{ id: 'diskIO', label: 'Disk 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 },
|
{ id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 },
|
||||||
|
|||||||
@@ -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",
|
"version": "1.0.26",
|
||||||
"date": "2026-04-19",
|
"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": "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": "scheduled image prune notifications missing environment name (#770)" },
|
||||||
{ "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" },
|
{ "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"
|
"imageTag": "fnsys/dockhand:v1.0.26"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r
|
|||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true, // Prevents XSS attacks from reading cookie
|
httpOnly: true, // Prevents XSS attacks from reading cookie
|
||||||
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
|
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
|
maxAge: maxAge // Session timeout in seconds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -78,6 +78,7 @@ import {
|
|||||||
|
|
||||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||||
import { encrypt, decrypt } from './encryption.js';
|
import { encrypt, decrypt } from './encryption.js';
|
||||||
|
import { parseEnvInterpolation } from './env-interpolation';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export { db, isPostgres, isSqlite };
|
export { db, isPostgres, isSqlite };
|
||||||
@@ -2066,6 +2067,7 @@ export async function getGitStacksByRepositoryId(repositoryId: number): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGitRepository(id: number): Promise<boolean> {
|
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));
|
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2522,6 +2524,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGitStack(id: number): Promise<boolean> {
|
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));
|
await db.delete(gitStacks).where(eq(gitStacks.id, id));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2781,11 +2784,21 @@ export async function upsertStackSource(data: {
|
|||||||
const existing = await getStackSource(data.stackName, data.environmentId);
|
const existing = await getStackSource(data.stackName, data.environmentId);
|
||||||
|
|
||||||
if (existing) {
|
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)
|
await db.update(stackSources)
|
||||||
.set({
|
.set({
|
||||||
sourceType: data.sourceType,
|
sourceType: data.sourceType,
|
||||||
gitRepositoryId: data.gitRepositoryId || null,
|
gitRepositoryId: newRepoId,
|
||||||
gitStackId: data.gitStackId || null,
|
gitStackId: newStackId,
|
||||||
composePath: data.composePath ?? null,
|
composePath: data.composePath ?? null,
|
||||||
envPath: data.envPath ?? null,
|
envPath: data.envPath ?? null,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
@@ -2793,6 +2806,7 @@ export async function upsertStackSource(data: {
|
|||||||
.where(eq(stackSources.id, existing.id));
|
.where(eq(stackSources.id, existing.id));
|
||||||
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
||||||
} else {
|
} 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({
|
await db.insert(stackSources).values({
|
||||||
stackName: data.stackName,
|
stackName: data.stackName,
|
||||||
environmentId: data.environmentId ?? null,
|
environmentId: data.environmentId ?? null,
|
||||||
@@ -2826,6 +2840,7 @@ export async function updateStackSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
|
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)
|
// Delete matching record (either with specific envId or NULL)
|
||||||
await db.delete(stackSources)
|
await db.delete(stackSources)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -3193,14 +3208,16 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<Audit
|
|||||||
// Labels filter - find environments with matching labels first
|
// Labels filter - find environments with matching labels first
|
||||||
let labelFilteredEnvIds: number[] | undefined;
|
let labelFilteredEnvIds: number[] | undefined;
|
||||||
if (filters.labels && filters.labels.length > 0) {
|
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);
|
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
|
||||||
labelFilteredEnvIds = allEnvs
|
labelFilteredEnvIds = allEnvs
|
||||||
.filter(env => {
|
.filter(env => {
|
||||||
if (!env.labels) return false;
|
if (!env.labels) return false;
|
||||||
try {
|
try {
|
||||||
const envLabels = JSON.parse(env.labels) as string[];
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -3408,14 +3425,16 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P
|
|||||||
// Labels filter - find environments with matching labels first
|
// Labels filter - find environments with matching labels first
|
||||||
let labelFilteredEnvIds: number[] | undefined;
|
let labelFilteredEnvIds: number[] | undefined;
|
||||||
if (filters.labels && filters.labels.length > 0) {
|
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);
|
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
|
||||||
labelFilteredEnvIds = allEnvs
|
labelFilteredEnvIds = allEnvs
|
||||||
.filter(env => {
|
.filter(env => {
|
||||||
if (!env.labels) return false;
|
if (!env.labels) return false;
|
||||||
try {
|
try {
|
||||||
const envLabels = JSON.parse(env.labels) as string[];
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -4629,6 +4648,43 @@ export async function getSecretKeyNames(
|
|||||||
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
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.
|
* Get count of environment variables for a stack.
|
||||||
* @param stackName - Name of the stack
|
* @param stackName - Name of the stack
|
||||||
|
|||||||
@@ -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
@@ -16,6 +16,70 @@ import {
|
|||||||
} from './db';
|
} from './db';
|
||||||
import { deployStack, getStackDir } from './stacks';
|
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.
|
* 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: ''
|
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) {
|
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
|
// Ensure current UID is resolvable for SSH/git operations
|
||||||
|
|||||||
+20
-17
@@ -9,6 +9,7 @@ import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
|
|||||||
import { logContainerEvent, type ContainerEventAction } from './db.js';
|
import { logContainerEvent, type ContainerEventAction } from './db.js';
|
||||||
import { containerEventEmitter } from './event-collector.js';
|
import { containerEventEmitter } from './event-collector.js';
|
||||||
import { sendEnvironmentNotification } from './notifications.js';
|
import { sendEnvironmentNotification } from './notifications.js';
|
||||||
|
import { isNotifyDisabledByLabel } from './container-labels.js';
|
||||||
import { pushMetric } from './metrics-store.js';
|
import { pushMetric } from './metrics-store.js';
|
||||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||||
import { hashPassword, verifyPassword } from './auth.js';
|
import { hashPassword, verifyPassword } from './auth.js';
|
||||||
@@ -191,24 +192,26 @@ export async function handleEdgeContainerEvent(
|
|||||||
// Broadcast to SSE clients
|
// Broadcast to SSE clients
|
||||||
containerEventEmitter.emit('event', savedEvent);
|
containerEventEmitter.emit('event', savedEvent);
|
||||||
|
|
||||||
// Prepare notification
|
// Check dockhand.notify label before sending notification
|
||||||
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
|
// Docker includes container labels in actorAttributes
|
||||||
const containerLabel = event.containerName || event.containerId.substring(0, 12);
|
if (!isNotifyDisabledByLabel(event.actorAttributes)) {
|
||||||
const notificationType =
|
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
|
||||||
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
|
const containerLabel = event.containerName || event.containerId.substring(0, 12);
|
||||||
? 'error'
|
const notificationType =
|
||||||
: event.action === 'stop'
|
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
|
||||||
? 'warning'
|
? 'error'
|
||||||
: event.action === 'start'
|
: event.action === 'stop'
|
||||||
? 'success'
|
? 'warning'
|
||||||
: 'info';
|
: event.action === 'start'
|
||||||
|
? 'success'
|
||||||
|
: 'info';
|
||||||
|
|
||||||
// Send notification
|
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
|
||||||
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
|
title: `Container ${actionLabel}`,
|
||||||
title: `Container ${actionLabel}`,
|
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
|
||||||
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
|
type: notificationType as 'success' | 'error' | 'warning' | 'info'
|
||||||
type: notificationType as 'success' | 'error' | 'warning' | 'info'
|
}, event.image);
|
||||||
}, event.image);
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
console.error('[Hawser] Error handling container event:', errorMsg);
|
console.error('[Hawser] Error handling container event:', errorMsg);
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { describe, it } from 'node:test';
|
|
||||||
|
|
||||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
|
||||||
|
|
||||||
describe('getAdditionalVolumeBinds', () => {
|
|
||||||
it('skips volume mounts when the target already exists in HostConfig.Mounts', () => {
|
|
||||||
const additionalBinds = getAdditionalVolumeBinds(
|
|
||||||
{
|
|
||||||
Binds: ['/volume1/backups:/backup'],
|
|
||||||
Mounts: [{ Target: '/data' }]
|
|
||||||
},
|
|
||||||
[
|
|
||||||
{ Type: 'volume', Name: 'docsight_docsis_data', Destination: '/data' },
|
|
||||||
{ Type: 'bind', Name: 'ignored', Destination: '/backup' }
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(additionalBinds, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds volume mounts that are missing from HostConfig', () => {
|
|
||||||
const additionalBinds = getAdditionalVolumeBinds(
|
|
||||||
{
|
|
||||||
Binds: ['/volume1/backups:/backup'],
|
|
||||||
Mounts: []
|
|
||||||
},
|
|
||||||
[{ Type: 'volume', Name: 'docsight_docsis_data', Destination: '/data' }]
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(additionalBinds, ['docsight_docsis_data:/data']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,17 +9,7 @@ import {
|
|||||||
type NotificationEventType
|
type NotificationEventType
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
// Escape special characters for Telegram Markdown
|
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
|
||||||
function escapeTelegramMarkdown(text: string): string {
|
|
||||||
// Escape characters that have special meaning in Telegram Markdown
|
|
||||||
return text
|
|
||||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
|
||||||
.replace(/_/g, '\\_') // Underscore (italic)
|
|
||||||
.replace(/\*/g, '\\*') // Asterisk (bold)
|
|
||||||
.replace(/\[/g, '\\[') // Opening bracket (link)
|
|
||||||
.replace(/\]/g, '\\]') // Closing bracket (link)
|
|
||||||
.replace(/`/g, '\\`'); // Backtick (code)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||||
async function drainResponse(response: Response): Promise<void> {
|
async function drainResponse(response: Response): Promise<void> {
|
||||||
@@ -279,21 +269,18 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
|
|||||||
|
|
||||||
// Telegram
|
// Telegram
|
||||||
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||||
// tgram://bot_token/chat_id:topic_id?
|
const parsed = parseTelegramUrl(appriseUrl);
|
||||||
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
|
if (!parsed) {
|
||||||
if (!match) {
|
|
||||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
|
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`;
|
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||||
|
|
||||||
// Escape markdown special characters in title and message
|
// Escape markdown special characters in title and message
|
||||||
const escapedTitle = escapeTelegramMarkdown(payload.title);
|
const escapedTitle = escapeTelegramMarkdown(payload.title);
|
||||||
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
||||||
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
|
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
|
||||||
|
|
||||||
const topicId = topicIdStr ? parseInt(topicIdStr, 10) : undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -324,21 +311,11 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
|||||||
|
|
||||||
// Gotify
|
// Gotify
|
||||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||||
// gotify://hostname/token or gotifys://hostname/token
|
const url = buildGotifyUrl(appriseUrl);
|
||||||
// gotify://hostname/subpath/token (subpath support)
|
if (!url) {
|
||||||
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
|
|
||||||
if (!match) {
|
|
||||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
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;
|
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -496,47 +473,57 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
|
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
|
||||||
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||||
// workflows://hostname/workflow/signature
|
const parsed = parseWorkflowsUrl(appriseUrl);
|
||||||
const match = appriseUrl.match(/^workflows?:\/\/([^/]+)\/(.+)\/(.+)/);
|
if (!parsed) {
|
||||||
if (!match) return false;
|
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
|
||||||
|
}
|
||||||
|
|
||||||
const [, hostname, workflow, signature] = match;
|
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
|
||||||
const url = `https://${hostname}/powerautomate/automations/direct/workflows/${workflow}/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=${signature}`;
|
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
try {
|
||||||
method: 'POST',
|
const response = await fetch(url, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
headers: { 'Content-Type': 'application/json' },
|
||||||
type: 'message',
|
body: JSON.stringify({
|
||||||
attachments: [
|
type: 'message',
|
||||||
{
|
attachments: [
|
||||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
{
|
||||||
content: {
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||||
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
|
content: {
|
||||||
type: 'AdaptiveCard',
|
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
|
||||||
version: '1.2',
|
type: 'AdaptiveCard',
|
||||||
body: [
|
version: '1.2',
|
||||||
{
|
body: [
|
||||||
type: 'TextBlock',
|
{
|
||||||
style: 'heading',
|
type: 'TextBlock',
|
||||||
wrap: true,
|
style: 'heading',
|
||||||
text: payload.title
|
wrap: true,
|
||||||
},
|
text: titleWithEnv
|
||||||
{
|
},
|
||||||
type: 'TextBlock',
|
{
|
||||||
style: 'default',
|
type: 'TextBlock',
|
||||||
wrap: true,
|
style: 'default',
|
||||||
text: payload.message
|
wrap: true,
|
||||||
}
|
text: payload.message
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
]
|
})
|
||||||
})
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
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
|
// Send notification to all enabled channels
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
||||||
import { sendEventNotification } from '../../notifications';
|
import { sendEventNotification } from '../../notifications';
|
||||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||||
|
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -369,6 +370,18 @@ export async function runContainerUpdate(
|
|||||||
return;
|
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
|
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||||
if (isDigestBasedImage(imageNameFromConfig)) {
|
if (isDigestBasedImage(imageNameFromConfig)) {
|
||||||
log(`Skipping ${containerName} - image pinned to specific digest`);
|
log(`Skipping ${containerName} - image pinned to specific digest`);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { sendEventNotification } from '../../notifications';
|
import { sendEventNotification } from '../../notifications';
|
||||||
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
||||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||||
|
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||||
import { recreateContainer } from './container-update';
|
import { recreateContainer } from './container-update';
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
@@ -129,6 +130,12 @@ export async function runEnvUpdateCheckJob(
|
|||||||
continue;
|
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++;
|
checkedCount++;
|
||||||
await log(` Checking: ${container.name} (${imageName})`);
|
await log(` Checking: ${container.name} (${imageName})`);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type ContainerEventAction
|
type ContainerEventAction
|
||||||
} from './db';
|
} from './db';
|
||||||
import { sendEnvironmentNotification, sendEventNotification } from './notifications';
|
import { sendEnvironmentNotification, sendEventNotification } from './notifications';
|
||||||
|
import { isNotifyDisabledByLabel } from './container-labels';
|
||||||
import { rssBeforeOp, rssAfterOp } from './rss-tracker';
|
import { rssBeforeOp, rssAfterOp } from './rss-tracker';
|
||||||
import { pushMetric } from './metrics-store';
|
import { pushMetric } from './metrics-store';
|
||||||
|
|
||||||
@@ -285,24 +286,28 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
|
|||||||
|
|
||||||
// Sub-category: notification
|
// Sub-category: notification
|
||||||
const notifBefore = rssBeforeOp();
|
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, {
|
// Check dockhand.notify label — Docker includes container labels in event Actor.Attributes
|
||||||
title: `Container ${actionLabel}`,
|
if (!isNotifyDisabledByLabel(event.Actor?.Attributes)) {
|
||||||
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
|
const actionLabel = action.startsWith('health_status')
|
||||||
type: notificationType
|
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
|
||||||
}, image).catch(() => {});
|
: 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_notif', notifBefore);
|
||||||
rssAfterOp('events', before);
|
rssAfterOp('events', before);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||||
export type DownloadFormat = 'tar' | 'tar.gz';
|
export type DownloadFormat = 'tar' | 'tar.gz';
|
||||||
export type EventCollectionMode = 'stream' | 'poll';
|
export type EventCollectionMode = 'stream' | 'poll';
|
||||||
|
export type LabelFilterMode = 'any' | 'all';
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
confirmDestructive: boolean;
|
confirmDestructive: boolean;
|
||||||
@@ -32,6 +33,8 @@ export interface AppSettings {
|
|||||||
primaryStackLocation: string | null;
|
primaryStackLocation: string | null;
|
||||||
defaultGrypeImage: string;
|
defaultGrypeImage: string;
|
||||||
defaultTrivyImage: string;
|
defaultTrivyImage: string;
|
||||||
|
defaultComposeTemplate: string;
|
||||||
|
labelFilterMode: LabelFilterMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: AppSettings = {
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
@@ -59,7 +62,26 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
externalStackPaths: [],
|
externalStackPaths: [],
|
||||||
primaryStackLocation: null,
|
primaryStackLocation: null,
|
||||||
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
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
|
// Create a writable store for app settings
|
||||||
@@ -101,7 +123,9 @@ function createSettingsStore() {
|
|||||||
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||||
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
||||||
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
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 {
|
} catch {
|
||||||
@@ -146,7 +170,9 @@ function createSettingsStore() {
|
|||||||
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||||
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
||||||
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
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) {
|
} catch (error) {
|
||||||
@@ -348,6 +374,20 @@ function createSettingsStore() {
|
|||||||
return newSettings;
|
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
|
// Manual refresh from database
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
initialized = false;
|
initialized = false;
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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).
|
* Accepts both Docker API format (PublicPort/PrivatePort) and camelCase (publicPort/privatePort).
|
||||||
* e.g. 8080:8080, 8081:8081, 8082:8082 → 8080-8082:8080-8082
|
* 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[] {
|
export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[] {
|
||||||
if (!ports || ports.length === 0) return [];
|
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);
|
.sort((a, b) => a.publicPort - b.publicPort);
|
||||||
|
|
||||||
// Collapse consecutive port ranges
|
// Collapse consecutive port ranges (3+ ports only)
|
||||||
if (individual.length <= 1) return individual;
|
if (individual.length <= 1) return individual;
|
||||||
|
|
||||||
const result: PortMapping[] = [];
|
const result: PortMapping[] = [];
|
||||||
let rangeStart = individual[0];
|
let rangeStart = 0;
|
||||||
let rangeEnd = individual[0];
|
let rangeEnd = 0;
|
||||||
|
|
||||||
for (let i = 1; i < individual.length; i++) {
|
for (let i = 1; i < individual.length; i++) {
|
||||||
const curr = individual[i];
|
const curr = individual[i];
|
||||||
const offset = curr.publicPort - rangeStart.publicPort;
|
const start = individual[rangeStart];
|
||||||
const expectedPrivate = rangeStart.privatePort + offset;
|
const prev = individual[rangeEnd];
|
||||||
if (curr.publicPort === rangeEnd.publicPort + 1 && curr.privatePort === expectedPrivate) {
|
const offset = curr.publicPort - start.publicPort;
|
||||||
rangeEnd = curr;
|
const expectedPrivate = start.privatePort + offset;
|
||||||
|
if (curr.publicPort === prev.publicPort + 1 && curr.privatePort === expectedPrivate) {
|
||||||
|
rangeEnd = i;
|
||||||
} else {
|
} else {
|
||||||
result.push(rangeStart.publicPort === rangeEnd.publicPort
|
flushRange(individual, rangeStart, rangeEnd, result);
|
||||||
? rangeStart
|
rangeStart = i;
|
||||||
: { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true });
|
rangeEnd = i;
|
||||||
rangeStart = curr;
|
|
||||||
rangeEnd = curr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.push(rangeStart.publicPort === rangeEnd.publicPort
|
flushRange(individual, rangeStart, rangeEnd, result);
|
||||||
? rangeStart
|
|
||||||
: { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true });
|
|
||||||
|
|
||||||
return 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
|
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||||
|
import { appSettings } from '$lib/stores/settings';
|
||||||
|
|
||||||
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
|
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
|
||||||
|
|
||||||
@@ -210,10 +211,10 @@
|
|||||||
if (filterLabels.length === 0) {
|
if (filterLabels.length === 0) {
|
||||||
return tiles;
|
return tiles;
|
||||||
}
|
}
|
||||||
return tiles.filter(t => {
|
const matchFn = $appSettings.labelFilterMode === 'all'
|
||||||
const tileLabels = t.stats?.labels || [];
|
? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label))
|
||||||
return tileLabels.some(label => filterLabels.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
|
// Filter grid items based on selected labels
|
||||||
@@ -221,11 +222,12 @@
|
|||||||
if (filterLabels.length === 0) {
|
if (filterLabels.length === 0) {
|
||||||
return gridItems;
|
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 => {
|
return gridItems.filter(item => {
|
||||||
const tile = tiles.find(t => t.id === item.id);
|
const tile = tiles.find(t => t.id === item.id);
|
||||||
const tileLabels = tile?.stats?.labels || [];
|
return matchFn(tile?.stats?.labels || []);
|
||||||
return tileLabels.some(label => filterLabels.includes(label));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const orderedGridItems = $derived.by(() => {
|
const orderedGridItems = $derived.by(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, D
|
|||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
import { hasEnvironments } from '$lib/server/db';
|
import { hasEnvironments } from '$lib/server/db';
|
||||||
|
import { isHiddenByLabel } from '$lib/server/container-labels';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
@@ -34,7 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const containers = await listContainers(all, envIdNum);
|
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) {
|
} catch (error: any) {
|
||||||
// Return 404 for missing environment so frontend can clear stale localStorage
|
// Return 404 for missing environment so frontend can clear stale localStorage
|
||||||
if (error instanceof EnvironmentNotFoundError) {
|
if (error instanceof EnvironmentNotFoundError) {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
removeContainer,
|
removeContainer,
|
||||||
getContainerLogs
|
getContainerLogs
|
||||||
} from '$lib/server/docker';
|
} 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 { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
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);
|
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'];
|
const stackName = details.Config?.Labels?.['com.docker.compose.project'];
|
||||||
if (stackName && Array.isArray(details.Config?.Env)) {
|
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) {
|
if (secretKeys.size > 0) {
|
||||||
details.Config.Env = details.Config.Env.map((entry: string) => {
|
details.Config.Env = details.Config.Env.map((entry: string) => {
|
||||||
const eqIdx = entry.indexOf('=');
|
const eqIdx = entry.indexOf('=');
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { inspectContainer } from '$lib/server/docker';
|
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 { authorize } from '$lib/server/authorize';
|
||||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
@@ -22,10 +23,12 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
try {
|
try {
|
||||||
const containerData = await inspectContainer(params.id, envIdNum);
|
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'];
|
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
|
||||||
if (stackName && Array.isArray(containerData.Config?.Env)) {
|
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) {
|
if (secretKeys.size > 0) {
|
||||||
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
|
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
|
||||||
const eqIdx = entry.indexOf('=');
|
const eqIdx = entry.indexOf('=');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { auditContainer } from '$lib/server/audit';
|
|||||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||||
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
||||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
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 { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||||
|
|
||||||
@@ -173,6 +174,22 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
continue;
|
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
|
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||||
if (isDigestBasedImage(imageName)) {
|
if (isDigestBasedImage(imageName)) {
|
||||||
sendData({
|
sendData({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
|
|||||||
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
|
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||||
|
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
|
||||||
|
|
||||||
export interface BatchUpdateResult {
|
export interface BatchUpdateResult {
|
||||||
containerId: string;
|
containerId: string;
|
||||||
@@ -62,6 +63,17 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
const imageName = config.Image;
|
const imageName = config.Image;
|
||||||
const containerName = container.name;
|
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
|
// Pull latest image first
|
||||||
try {
|
try {
|
||||||
await pullImage(imageName, undefined, envIdNum);
|
await pullImage(imageName, undefined, envIdNum);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
|
|||||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||||
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||||
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||||
|
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
|
||||||
import { createJobResponse } from '$lib/server/sse';
|
import { createJobResponse } from '$lib/server/sse';
|
||||||
|
|
||||||
export interface UpdateCheckResult {
|
export interface UpdateCheckResult {
|
||||||
@@ -16,6 +17,7 @@ export interface UpdateCheckResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
isLocalImage?: boolean;
|
isLocalImage?: boolean;
|
||||||
systemContainer?: 'dockhand' | 'hawser' | null;
|
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 result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||||
|
const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
@@ -74,7 +77,8 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
|||||||
newDigest: result.registryDigest,
|
newDigest: result.registryDigest,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
isLocalImage: result.isLocalImage,
|
isLocalImage: result.isLocalImage,
|
||||||
systemContainer: isSystemContainer(imageName) || null
|
systemContainer: isSystemContainer(imageName) || null,
|
||||||
|
updateDisabled
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
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()));
|
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
|
// Save containers with updates to the database for persistence
|
||||||
if (envIdNum) {
|
if (envIdNum) {
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.hasUpdate && !result.systemContainer) {
|
if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) {
|
||||||
await addPendingContainerUpdate(
|
await addPendingContainerUpdate(
|
||||||
envIdNum,
|
envIdNum,
|
||||||
result.containerId,
|
result.containerId,
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export const DELETE: RequestHandler = async (event) => {
|
|||||||
|
|
||||||
// Delete git stack clone directories before cascade deletes the DB rows
|
// Delete git stack clone directories before cascade deletes the DB rows
|
||||||
const stacks = await getGitStacksByRepositoryId(id);
|
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) {
|
for (const stack of stacks) {
|
||||||
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
|
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ export interface GeneralSettings {
|
|||||||
// Scanner images
|
// Scanner images
|
||||||
defaultGrypeImage: string;
|
defaultGrypeImage: string;
|
||||||
defaultTrivyImage: string;
|
defaultTrivyImage: string;
|
||||||
|
// Compose template
|
||||||
|
defaultComposeTemplate: string;
|
||||||
|
// Label filter mode
|
||||||
|
labelFilterMode: 'any' | 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
|
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
|
||||||
@@ -105,7 +109,26 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
|||||||
externalStackPaths: [],
|
externalStackPaths: [],
|
||||||
primaryStackLocation: null,
|
primaryStackLocation: null,
|
||||||
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
|
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'];
|
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,
|
externalStackPaths,
|
||||||
primaryStackLocation,
|
primaryStackLocation,
|
||||||
defaultGrypeImage,
|
defaultGrypeImage,
|
||||||
defaultTrivyImage
|
defaultTrivyImage,
|
||||||
|
defaultComposeTemplate,
|
||||||
|
labelFilterMode
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getSetting('confirm_destructive'),
|
getSetting('confirm_destructive'),
|
||||||
getSetting('show_stopped_containers'),
|
getSetting('show_stopped_containers'),
|
||||||
@@ -192,7 +217,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
getExternalStackPaths(),
|
getExternalStackPaths(),
|
||||||
getPrimaryStackLocation(),
|
getPrimaryStackLocation(),
|
||||||
getSetting('default_grype_image'),
|
getSetting('default_grype_image'),
|
||||||
getSetting('default_trivy_image')
|
getSetting('default_trivy_image'),
|
||||||
|
getSetting('default_compose_template'),
|
||||||
|
getSetting('label_filter_mode')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const settings: GeneralSettings = {
|
const settings: GeneralSettings = {
|
||||||
@@ -227,7 +254,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
externalStackPaths,
|
externalStackPaths,
|
||||||
primaryStackLocation,
|
primaryStackLocation,
|
||||||
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
|
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
|
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||||
|
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||||
|
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
||||||
};
|
};
|
||||||
|
|
||||||
return json(settings);
|
return json(settings);
|
||||||
@@ -245,7 +274,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, 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) {
|
if (confirmDestructive !== undefined) {
|
||||||
await setSetting('confirm_destructive', confirmDestructive);
|
await setSetting('confirm_destructive', confirmDestructive);
|
||||||
@@ -364,6 +393,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
|
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
|
||||||
await setSetting('default_trivy_image', defaultTrivyImage);
|
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
|
// Fetch all settings in parallel for the response
|
||||||
const [
|
const [
|
||||||
@@ -398,7 +433,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
externalStackPathsVal,
|
externalStackPathsVal,
|
||||||
primaryStackLocationVal,
|
primaryStackLocationVal,
|
||||||
defaultGrypeImageVal,
|
defaultGrypeImageVal,
|
||||||
defaultTrivyImageVal
|
defaultTrivyImageVal,
|
||||||
|
defaultComposeTemplateVal,
|
||||||
|
labelFilterModeVal
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getSetting('confirm_destructive'),
|
getSetting('confirm_destructive'),
|
||||||
getSetting('show_stopped_containers'),
|
getSetting('show_stopped_containers'),
|
||||||
@@ -431,7 +468,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
getExternalStackPaths(),
|
getExternalStackPaths(),
|
||||||
getPrimaryStackLocation(),
|
getPrimaryStackLocation(),
|
||||||
getSetting('default_grype_image'),
|
getSetting('default_grype_image'),
|
||||||
getSetting('default_trivy_image')
|
getSetting('default_trivy_image'),
|
||||||
|
getSetting('default_compose_template'),
|
||||||
|
getSetting('label_filter_mode')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const settings: GeneralSettings = {
|
const settings: GeneralSettings = {
|
||||||
@@ -466,7 +505,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
externalStackPaths: externalStackPathsVal,
|
externalStackPaths: externalStackPathsVal,
|
||||||
primaryStackLocation: primaryStackLocationVal,
|
primaryStackLocation: primaryStackLocationVal,
|
||||||
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
|
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
|
||||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
|
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
|
||||||
|
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||||
|
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode
|
||||||
};
|
};
|
||||||
|
|
||||||
return json(settings);
|
return json(settings);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
|
import { formatPorts, type PortMapping } from '$lib/utils/port-format';
|
||||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
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 {
|
function extractHostFromUrl(urlString: string): string | null {
|
||||||
if (!urlString) return null;
|
if (!urlString) return null;
|
||||||
|
|
||||||
@@ -1861,7 +1860,7 @@
|
|||||||
{@const remainingCount = ports.length - 1}
|
{@const remainingCount = ports.length - 1}
|
||||||
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
|
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
|
||||||
{#each displayPorts as port}
|
{#each displayPorts as port}
|
||||||
{@const url = !port.isRange && currentEnvDetails ? getPortUrl(port.publicPort) : null}
|
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
|
||||||
{#if url}
|
{#if url}
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte';
|
import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte';
|
||||||
import { appendEnvParam } from '$lib/stores/environment';
|
import { appendEnvParam } from '$lib/stores/environment';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import type { VulnerabilityCriteria } from '$lib/server/db';
|
import type { VulnerabilityCriteria } from '$lib/server/db';
|
||||||
import type { StepType } from '$lib/utils/update-steps';
|
import type { StepType } from '$lib/utils/update-steps';
|
||||||
import { getStepLabel, getStepIcon, getStepColor } from '$lib/utils/update-steps';
|
import { getStepLabel, getStepIcon, getStepColor } from '$lib/utils/update-steps';
|
||||||
@@ -78,11 +79,33 @@
|
|||||||
let progress = $state<ContainerProgress[]>([]);
|
let progress = $state<ContainerProgress[]>([]);
|
||||||
let progressListEl = $state<HTMLDivElement | null>(null);
|
let progressListEl = $state<HTMLDivElement | null>(null);
|
||||||
let scrollTick = $state(0);
|
let scrollTick = $state(0);
|
||||||
|
let userScrolledUp = $state(false);
|
||||||
let currentIndex = $state(0);
|
let currentIndex = $state(0);
|
||||||
let totalCount = $state(0);
|
let totalCount = $state(0);
|
||||||
let summary = $state<{ total: number; success: number; failed: number; blocked: number } | null>(null);
|
let summary = $state<{ total: number; success: number; failed: number; blocked: number } | null>(null);
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let forceUpdating = $state<Set<string>>(new Set()); // Track containers being force-updated
|
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 {
|
function formatPullLog(entry: PullLogEntry): string {
|
||||||
// Clarify potentially confusing Docker messages
|
// Clarify potentially confusing Docker messages
|
||||||
@@ -241,6 +264,9 @@
|
|||||||
} else if (data.type === 'complete') {
|
} else if (data.type === 'complete') {
|
||||||
status = 'complete';
|
status = 'complete';
|
||||||
summary = data.summary;
|
summary = data.summary;
|
||||||
|
for (const item of progress) {
|
||||||
|
item.showLogs = false;
|
||||||
|
}
|
||||||
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
|
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
|
||||||
} else if (data.type === 'error') {
|
} else if (data.type === 'error') {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
@@ -266,6 +292,7 @@
|
|||||||
currentIndex = 0;
|
currentIndex = 0;
|
||||||
summary = null;
|
summary = null;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
filterMode = 'updated';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenChange(isOpen: boolean) {
|
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(() => {
|
$effect(() => {
|
||||||
scrollTick;
|
scrollTick;
|
||||||
if (progressListEl) {
|
if (progressListEl && !userScrolledUp) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
progressListEl?.scrollTo({ top: progressListEl.scrollHeight, behavior: 'smooth' });
|
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" />
|
<Progress value={progressPercentage} class="h-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Container list with status - scrollable area -->
|
<!-- Filter toggle + Container list with status - scrollable area -->
|
||||||
{#if progress.length > 0}
|
{#if progress.length > 0}
|
||||||
<div bind:this={progressListEl} class="border rounded-lg divide-y flex-1 min-h-0 overflow-auto">
|
{#if summary && (summary.failed > 0 || summary.blocked > 0) && summary.success > 0}
|
||||||
{#each progress as item (item.containerId)}
|
<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 StepIcon = getStepIcon(item.step)}
|
||||||
{@const isActive = item.step !== 'done' && item.step !== 'failed' && item.step !== 'blocked'}
|
{@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)}
|
{@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0 || (item.vulnerabilities && item.vulnerabilities.length > 0)}
|
||||||
|
|||||||
@@ -162,20 +162,37 @@
|
|||||||
// Push colliding items down (returns new array)
|
// Push colliding items down (returns new array)
|
||||||
function pushCollidingItems(movedItem: GridItemLayout, sourceItems: GridItemLayout[]): GridItemLayout[] {
|
function pushCollidingItems(movedItem: GridItemLayout, sourceItems: GridItemLayout[]): GridItemLayout[] {
|
||||||
const newItems = sourceItems.map(item => ({ ...item }));
|
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 changed = true;
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
const maxIterations = 100; // Prevent infinite loops
|
while (changed && iterations < 100) {
|
||||||
|
|
||||||
while (changed && iterations < maxIterations) {
|
|
||||||
changed = false;
|
changed = false;
|
||||||
iterations++;
|
iterations++;
|
||||||
for (const item of newItems) {
|
const sorted = newItems
|
||||||
if (item.id === movedItem.id) continue;
|
.filter(i => i.id !== movedItem.id)
|
||||||
|
.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||||
|
|
||||||
if (hasCollision(item, movedItem)) {
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
// Push this item down
|
for (let j = i + 1; j < sorted.length; j++) {
|
||||||
item.y = movedItem.y + movedItem.h;
|
const upper = sorted[i];
|
||||||
changed = true;
|
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
@@ -10,13 +10,15 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
|
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 { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
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 { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||||
import type { ContainerInfo } from '$lib/types';
|
import type { ContainerInfo } from '$lib/types';
|
||||||
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
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 { NoEnvironment } from '$lib/components/ui/empty-state';
|
||||||
import { AnsiUp } from 'ansi_up';
|
import { AnsiUp } from 'ansi_up';
|
||||||
const ansiUp = new AnsiUp();
|
const ansiUp = new AnsiUp();
|
||||||
@@ -306,12 +308,94 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
// Log search state
|
// Log search state
|
||||||
let logSearchActive = $state(false);
|
let logSearchActive = $state(false);
|
||||||
let logSearchQuery = $state('');
|
let logSearchQuery = $state('');
|
||||||
|
let logSearchFilterMode = $state(false);
|
||||||
let currentMatchIndex = $state(0);
|
let currentMatchIndex = $state(0);
|
||||||
let matchCount = $state(0);
|
let matchCount = $state(0);
|
||||||
let logSearchInputRef: HTMLInputElement | undefined;
|
let logSearchInputRef: HTMLInputElement | undefined;
|
||||||
|
|
||||||
const fontSizeOptions = [10, 12, 14, 16];
|
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
|
// Subscribe to environment changes - restore state and fetch data
|
||||||
const unsubscribeEnv = currentEnvironment.subscribe(async (env) => {
|
const unsubscribeEnv = currentEnvironment.subscribe(async (env) => {
|
||||||
envId = env?.id ?? null;
|
envId = env?.id ?? null;
|
||||||
@@ -769,6 +853,10 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
return `[${data.containerName}] ${line}`;
|
return `[${data.containerName}] ${line}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
|
// Format timestamps if enabled
|
||||||
|
if ($appSettings.formatLogTimestamps) {
|
||||||
|
text = formatLogTimestamps(text);
|
||||||
|
}
|
||||||
// Buffer text and schedule flush
|
// Buffer text and schedule flush
|
||||||
pendingText += text;
|
pendingText += text;
|
||||||
if (!flushTimer) {
|
if (!flushTimer) {
|
||||||
@@ -953,12 +1041,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
if (data.text) {
|
if (data.text) {
|
||||||
// Use consistent color based on position in all selected containers
|
// Use consistent color based on position in all selected containers
|
||||||
const color = getContainerColor(data.containerId);
|
const color = getContainerColor(data.containerId);
|
||||||
|
const logText = $appSettings.formatLogTimestamps ? formatLogTimestamps(data.text) : data.text;
|
||||||
// Add to pending batch instead of updating state immediately
|
// Add to pending batch instead of updating state immediately
|
||||||
pendingLogs.push({
|
pendingLogs.push({
|
||||||
containerId: data.containerId,
|
containerId: data.containerId,
|
||||||
containerName: data.containerName,
|
containerName: data.containerName,
|
||||||
color,
|
color,
|
||||||
text: data.text,
|
text: logText,
|
||||||
timestamp: data.timestamp,
|
timestamp: data.timestamp,
|
||||||
stream: data.stream
|
stream: data.stream
|
||||||
});
|
});
|
||||||
@@ -1134,6 +1223,11 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
// Stop any existing stream
|
// Stop any existing stream
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
|
|
||||||
|
// Close terminal when switching containers
|
||||||
|
if (terminalOpen && terminalContainerId !== container.id) {
|
||||||
|
closeTerminal();
|
||||||
|
}
|
||||||
|
|
||||||
selectedContainer = container;
|
selectedContainer = container;
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
dropdownOpen = false;
|
dropdownOpen = false;
|
||||||
@@ -1314,10 +1408,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
function closeLogSearch() {
|
function closeLogSearch() {
|
||||||
logSearchActive = false;
|
logSearchActive = false;
|
||||||
logSearchQuery = '';
|
logSearchQuery = '';
|
||||||
|
logSearchFilterMode = false;
|
||||||
currentMatchIndex = 0;
|
currentMatchIndex = 0;
|
||||||
matchCount = 0;
|
matchCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSearchFilterMode() {
|
||||||
|
logSearchFilterMode = !logSearchFilterMode;
|
||||||
|
}
|
||||||
|
|
||||||
function navigateMatch(direction: 'prev' | 'next') {
|
function navigateMatch(direction: 'prev' | 'next') {
|
||||||
if (!logsRef || matchCount === 0) return;
|
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)
|
// Highlighted logs with search matches and ANSI color support (single container mode)
|
||||||
let highlightedLogs = $derived(() => {
|
let highlightedLogs = $derived(() => {
|
||||||
// First convert ANSI codes to HTML
|
let text = logs || '';
|
||||||
const withAnsi = ansiToHtml(logs || '');
|
const query = logSearchQuery.trim();
|
||||||
if (!logSearchQuery.trim()) return withAnsi;
|
|
||||||
|
|
||||||
// For search, we need to highlight matches while preserving HTML tags
|
// Filter lines before ANSI conversion (plain text matching)
|
||||||
// We'll only highlight text outside of HTML tags
|
if (logSearchFilterMode && query) {
|
||||||
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedForRegex = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const escapedQuery = escapeHtml(query);
|
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 parts = withAnsi.split(/(<[^>]*>)/);
|
||||||
const highlighted = parts.map(part => {
|
return parts.map(part => {
|
||||||
// Skip HTML tags
|
|
||||||
if (part.startsWith('<')) return part;
|
if (part.startsWith('<')) return part;
|
||||||
// Highlight matches in text
|
|
||||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||||
return part.replace(regex, '<mark class="search-match">$1</mark>');
|
return part.replace(regex, '<mark class="search-match">$1</mark>');
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return highlighted;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Format merged logs HTML — uses pre-built mergedHtml string, only applies search highlighting when needed
|
// Format merged logs HTML — uses pre-built mergedHtml string, only applies search highlighting when needed
|
||||||
let formattedMergedHtml = $derived(() => {
|
let formattedMergedHtml = $derived(() => {
|
||||||
if (!mergedHtml) return '';
|
if (!mergedHtml) return '';
|
||||||
if (!logSearchQuery.trim()) return mergedHtml;
|
const query = logSearchQuery.trim();
|
||||||
|
|
||||||
// Apply search highlighting (same approach as single mode's highlightedLogs)
|
// Filter mode: remove non-matching lines from HTML
|
||||||
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
let html = mergedHtml;
|
||||||
const escapedQuery = escapeHtml(query);
|
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 searchRegex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||||
const parts = mergedHtml.split(/(<[^>]*>)/);
|
const parts = html.split(/(<[^>]*>)/);
|
||||||
return parts.map(part => {
|
return parts.map(part => {
|
||||||
if (part.startsWith('<')) return part;
|
if (part.startsWith('<')) return part;
|
||||||
return part.replace(searchRegex, '<mark class="search-match">$1</mark>');
|
return part.replace(searchRegex, '<mark class="search-match">$1</mark>');
|
||||||
@@ -1430,6 +1545,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
loadTerminalSettings();
|
||||||
// All initialization is handled in currentEnvironment.subscribe
|
// All initialization is handled in currentEnvironment.subscribe
|
||||||
// This just sets up the refresh interval
|
// This just sets up the refresh interval
|
||||||
containerInterval = setInterval(fetchContainers, 10000);
|
containerInterval = setInterval(fetchContainers, 10000);
|
||||||
@@ -1437,6 +1553,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
document.removeEventListener('mousemove', handleTerminalResize);
|
||||||
|
document.removeEventListener('mouseup', stopTerminalResize);
|
||||||
unsubscribeEnv();
|
unsubscribeEnv();
|
||||||
if (containerInterval) {
|
if (containerInterval) {
|
||||||
clearInterval(containerInterval);
|
clearInterval(containerInterval);
|
||||||
@@ -1831,8 +1949,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Logs panel -->
|
<!-- Logs + Terminal split -->
|
||||||
<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'}">
|
<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 layoutMode === 'grouped'}
|
||||||
{#if selectedContainerIds.size === 0}
|
{#if selectedContainerIds.size === 0}
|
||||||
<div class="flex items-center justify-center h-full text-muted-foreground">
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Header bar for grouped mode -->
|
<!-- 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 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 min-w-[100px]">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
{#if streamingEnabled}
|
{#if streamingEnabled}
|
||||||
{#if isConnected}
|
{#if isConnected}
|
||||||
<div class="flex items-center gap-1.5" title="Connected - Live streaming">
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2 flex-wrap ml-auto">
|
||||||
<button
|
<button
|
||||||
onclick={toggleStreaming}
|
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'}"
|
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}
|
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'}"
|
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}
|
{#if matchCount > 0}
|
||||||
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
|
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
|
||||||
{:else if logSearchQuery}
|
{:else if logSearchQuery}
|
||||||
@@ -1993,8 +2119,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Header bar inside black area -->
|
<!-- 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 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 min-w-[100px]">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<!-- Connection status indicator -->
|
<!-- Connection status indicator -->
|
||||||
{#if streamingEnabled}
|
{#if streamingEnabled}
|
||||||
{#if isConnected}
|
{#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>
|
<span class="text-xs {darkMode ? 'text-zinc-500' : 'text-gray-400'}">Paused</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Container name -->
|
<!-- Container name + terminal toggles -->
|
||||||
{#if selectedContainer}
|
{#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>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2 flex-wrap ml-auto">
|
||||||
<!-- Streaming toggle -->
|
<!-- Streaming toggle -->
|
||||||
<button
|
<button
|
||||||
onclick={toggleStreaming}
|
onclick={toggleStreaming}
|
||||||
@@ -2099,6 +2245,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
onkeydown={handleLogSearchKeydown}
|
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'}"
|
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}
|
{#if matchCount > 0}
|
||||||
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
|
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
|
||||||
{:else if logSearchQuery}
|
{:else if logSearchQuery}
|
||||||
@@ -2177,6 +2330,31 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
// Search state
|
// Search state
|
||||||
let logSearchActive = $state(false);
|
let logSearchActive = $state(false);
|
||||||
let logSearchQuery = $state('');
|
let logSearchQuery = $state('');
|
||||||
|
let logSearchFilterMode = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-filter-mode') === 'true');
|
||||||
let currentMatchIndex = $state(0);
|
let currentMatchIndex = $state(0);
|
||||||
let matchCount = $state(0);
|
let matchCount = $state(0);
|
||||||
let logSearchInputRef: HTMLInputElement;
|
let logSearchInputRef: HTMLInputElement;
|
||||||
@@ -107,10 +108,16 @@
|
|||||||
function closeLogSearch() {
|
function closeLogSearch() {
|
||||||
logSearchActive = false;
|
logSearchActive = false;
|
||||||
logSearchQuery = '';
|
logSearchQuery = '';
|
||||||
|
logSearchFilterMode = false;
|
||||||
currentMatchIndex = 0;
|
currentMatchIndex = 0;
|
||||||
matchCount = 0;
|
matchCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSearchFilterMode() {
|
||||||
|
logSearchFilterMode = !logSearchFilterMode;
|
||||||
|
localStorage.setItem('dockhand-log-filter-mode', String(logSearchFilterMode));
|
||||||
|
}
|
||||||
|
|
||||||
function navigateMatch(direction: 'prev' | 'next') {
|
function navigateMatch(direction: 'prev' | 'next') {
|
||||||
if (!logsRef || matchCount === 0) return;
|
if (!logsRef || matchCount === 0) return;
|
||||||
|
|
||||||
@@ -151,11 +158,22 @@
|
|||||||
if ($appSettings.formatLogTimestamps) {
|
if ($appSettings.formatLogTimestamps) {
|
||||||
text = formatLogTimestamps(text);
|
text = formatLogTimestamps(text);
|
||||||
}
|
}
|
||||||
const withAnsi = ansiUp.ansi_to_html(text);
|
|
||||||
if (!logSearchQuery.trim()) return withAnsi;
|
|
||||||
|
|
||||||
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const query = logSearchQuery.trim();
|
||||||
const escapedQuery = query.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
// Split by HTML tags and only process text parts
|
// Split by HTML tags and only process text parts
|
||||||
const parts = withAnsi.split(/(<[^>]*>)/);
|
const parts = withAnsi.split(/(<[^>]*>)/);
|
||||||
@@ -246,6 +264,13 @@
|
|||||||
onkeydown={handleLogSearchKeydown}
|
onkeydown={handleLogSearchKeydown}
|
||||||
class="bg-transparent border-none outline-none text-xs text-zinc-200 w-20 placeholder:text-zinc-500"
|
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}
|
{#if matchCount > 0}
|
||||||
<span class="text-xs text-zinc-400">{currentMatchIndex + 1}/{matchCount}</span>
|
<span class="text-xs text-zinc-400">{currentMatchIndex + 1}/{matchCount}</span>
|
||||||
{:else if logSearchQuery}
|
{:else if logSearchQuery}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
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 { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
// Search state
|
// Search state
|
||||||
let logSearchActive = $state(false);
|
let logSearchActive = $state(false);
|
||||||
let logSearchQuery = $state('');
|
let logSearchQuery = $state('');
|
||||||
|
let logSearchFilterMode = $state(false);
|
||||||
let currentMatchIndex = $state(0);
|
let currentMatchIndex = $state(0);
|
||||||
let matchCount = $state(0);
|
let matchCount = $state(0);
|
||||||
let logSearchInputRef: HTMLInputElement | undefined;
|
let logSearchInputRef: HTMLInputElement | undefined;
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
if (settings.fontSize !== undefined) fontSize = settings.fontSize;
|
if (settings.fontSize !== undefined) fontSize = settings.fontSize;
|
||||||
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
|
if (settings.autoScroll !== undefined) autoScroll = settings.autoScroll;
|
||||||
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
|
if (settings.streamingEnabled !== undefined) streamingEnabled = settings.streamingEnabled;
|
||||||
|
if (settings.logSearchFilterMode !== undefined) logSearchFilterMode = settings.logSearchFilterMode;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
@@ -112,7 +114,8 @@
|
|||||||
wordWrap,
|
wordWrap,
|
||||||
fontSize,
|
fontSize,
|
||||||
autoScroll,
|
autoScroll,
|
||||||
streamingEnabled
|
streamingEnabled,
|
||||||
|
logSearchFilterMode
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,10 +493,16 @@
|
|||||||
function closeLogSearch() {
|
function closeLogSearch() {
|
||||||
logSearchActive = false;
|
logSearchActive = false;
|
||||||
logSearchQuery = '';
|
logSearchQuery = '';
|
||||||
|
logSearchFilterMode = false;
|
||||||
currentMatchIndex = 0;
|
currentMatchIndex = 0;
|
||||||
matchCount = 0;
|
matchCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSearchFilterMode() {
|
||||||
|
logSearchFilterMode = !logSearchFilterMode;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
function navigateMatch(direction: 'prev' | 'next') {
|
function navigateMatch(direction: 'prev' | 'next') {
|
||||||
if (!logsRef || matchCount === 0) return;
|
if (!logsRef || matchCount === 0) return;
|
||||||
|
|
||||||
@@ -534,11 +543,22 @@
|
|||||||
if ($appSettings.formatLogTimestamps) {
|
if ($appSettings.formatLogTimestamps) {
|
||||||
text = formatLogTimestamps(text);
|
text = formatLogTimestamps(text);
|
||||||
}
|
}
|
||||||
const withAnsi = ansiUp.ansi_to_html(text);
|
|
||||||
if (!logSearchQuery.trim()) return withAnsi;
|
|
||||||
|
|
||||||
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const query = logSearchQuery.trim();
|
||||||
const escapedQuery = query.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
// Split by HTML tags and only process text parts
|
// Split by HTML tags and only process text parts
|
||||||
const parts = withAnsi.split(/(<[^>]*>)/);
|
const parts = withAnsi.split(/(<[^>]*>)/);
|
||||||
@@ -734,6 +754,13 @@
|
|||||||
onkeydown={handleLogSearchKeydown}
|
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'}"
|
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}
|
{#if matchCount > 0}
|
||||||
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
|
<span class="text-xs {darkMode ? 'text-zinc-400' : 'text-gray-500'}">{currentMatchIndex + 1}/{matchCount}</span>
|
||||||
{:else if logSearchQuery}
|
{:else if logSearchQuery}
|
||||||
|
|||||||
@@ -560,12 +560,12 @@
|
|||||||
<RefreshCw class="w-3.5 h-3.5" />
|
<RefreshCw class="w-3.5 h-3.5" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" onclick={openGraphModal}>
|
<Button size="sm" variant="outline" onclick={openGraphModal}>
|
||||||
<GitGraph class="w-3.5 h-3.5" />
|
<GitGraph class="w-3.5 h-3.5" />
|
||||||
View Graph
|
View Graph
|
||||||
</Button>
|
</Button>
|
||||||
{#if $canAccess('networks', 'create')}
|
{#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" />
|
<Plus class="w-3.5 h-3.5" />
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
|
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
|
||||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||||
import TimezoneSelector from '$lib/components/TimezoneSelector.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 { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags } from 'lucide-svelte';
|
||||||
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode } from '$lib/stores/settings';
|
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 { canAccess, authStore } from '$lib/stores/auth';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
|
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
|
||||||
@@ -27,6 +28,46 @@
|
|||||||
let defaultTrivyArgs = $derived($appSettings.defaultTrivyArgs);
|
let defaultTrivyArgs = $derived($appSettings.defaultTrivyArgs);
|
||||||
let defaultGrypeImage = $derived($appSettings.defaultGrypeImage);
|
let defaultGrypeImage = $derived($appSettings.defaultGrypeImage);
|
||||||
let defaultTrivyImage = $derived($appSettings.defaultTrivyImage);
|
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 scheduleRetentionDays = $derived($appSettings.scheduleRetentionDays);
|
||||||
let eventRetentionDays = $derived($appSettings.eventRetentionDays);
|
let eventRetentionDays = $derived($appSettings.eventRetentionDays);
|
||||||
let scheduleCleanupCron = $derived($appSettings.scheduleCleanupCron);
|
let scheduleCleanupCron = $derived($appSettings.scheduleCleanupCron);
|
||||||
@@ -425,6 +466,39 @@
|
|||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Right column -->
|
<!-- Right column -->
|
||||||
@@ -686,6 +760,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2019,7 +2019,7 @@
|
|||||||
{#if container.ports.length > 0}
|
{#if container.ports.length > 0}
|
||||||
{@const mappedPorts = formatPorts(container.ports)}
|
{@const mappedPorts = formatPorts(container.ports)}
|
||||||
{#each mappedPorts as port}
|
{#each mappedPorts as port}
|
||||||
{@const url = !port.isRange ? getPortUrl(port.publicPort) : null}
|
{@const url = getPortUrl(port.publicPort)}
|
||||||
{#if url}
|
{#if url}
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
|
|||||||
@@ -564,24 +564,7 @@
|
|||||||
// Debounce timer for validation
|
// Debounce timer for validation
|
||||||
let validateTimer: ReturnType<typeof setTimeout> | null = null;
|
let validateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const defaultCompose = `version: "3.8"
|
const defaultCompose = $appSettings.defaultComposeTemplate;
|
||||||
|
|
||||||
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
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Count of defined environment variables (with non-empty keys)
|
// Count of defined environment variables (with non-empty keys)
|
||||||
const envVarCount = $derived(envVars.filter(v => v.key.trim()).length);
|
const envVarCount = $derived(envVars.filter(v => v.key.trim()).length);
|
||||||
|
|||||||
Reference in New Issue
Block a user