1.0.33
This commit is contained in:
85 files changed
+10118
-1046
No files matched your search
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* POST /api/containers/[id]/update-runtime
|
||||
*
|
||||
* In-place update of a running container's restart policy, CPU/memory limits,
|
||||
* blkio weights, and pids limit — the only properties Docker can change
|
||||
* without recreating the container. The body must contain ONLY fields from
|
||||
* IN_PLACE_UPDATE_FIELDS (see docker.ts); any unknown fields are silently
|
||||
* dropped so a confused or malicious caller can't sneak a recreate-only
|
||||
* field (image, env, ports, etc.) through this path.
|
||||
*
|
||||
* Returns Docker's response — typically `{ Warnings: string[] | null }`.
|
||||
*/
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { inspectContainer, updateContainerRuntime, IN_PLACE_UPDATE_FIELDS, type InPlaceUpdateField } from '$lib/server/docker';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Same permission as the recreate-style update — anyone who could edit
|
||||
// the container the slow way should be able to edit it the fast way.
|
||||
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sanity-check: at least one allowed field must be present. Empty/all-unknown
|
||||
// payloads get a clear error rather than silently calling Docker with {}.
|
||||
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
|
||||
const filtered: Partial<Record<InPlaceUpdateField, unknown>> = {};
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (allowed.has(k) && v !== undefined) filtered[k as InPlaceUpdateField] = v;
|
||||
}
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
return json({
|
||||
error: 'No supported fields provided',
|
||||
supportedFields: Array.from(allowed)
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateContainerRuntime(params.id, filtered, envIdNum);
|
||||
|
||||
// Audit log — include which fields were touched, not their values
|
||||
// (CPU/memory numbers are non-sensitive, but the audit row stays
|
||||
// cleaner if we just record the keys).
|
||||
let containerName = params.id;
|
||||
try {
|
||||
const inspect = await inspectContainer(params.id, envIdNum);
|
||||
containerName = inspect.Name?.replace(/^\//, '') || params.id;
|
||||
} catch { /* fallback to id */ }
|
||||
await auditContainer(event, 'update', params.id, containerName, envIdNum, {
|
||||
inPlace: true,
|
||||
fields: Object.keys(filtered),
|
||||
warnings: result.Warnings ?? []
|
||||
});
|
||||
|
||||
return json({ success: true, warnings: result.Warnings ?? [] });
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: 'Container not found' }, { status: 404 });
|
||||
}
|
||||
return json({ error: error?.message || 'Update failed' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
@@ -42,7 +42,9 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
}
|
||||
|
||||
const allContainers = await listContainers(true, envIdNum);
|
||||
const containers = allContainers;
|
||||
// Containers labeled dockhand.hidden=true are excluded from update checks —
|
||||
// they're invisible to the user, so we won't alert on updates for them either (#1083).
|
||||
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
|
||||
|
||||
send('progress', { checked: 0, total: containers.length });
|
||||
|
||||
@@ -65,8 +67,19 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
};
|
||||
}
|
||||
|
||||
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||
const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels);
|
||||
if (updateDisabled) {
|
||||
return {
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
imageName,
|
||||
hasUpdate: false,
|
||||
systemContainer: isSystemContainer(imageName) || null,
|
||||
updateDisabled: true
|
||||
};
|
||||
}
|
||||
|
||||
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||
|
||||
return {
|
||||
containerId: container.id,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { validateEnvName } from '$lib/utils/env-name';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -71,8 +72,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
if (!data.name) {
|
||||
return json({ error: 'Name is required' }, { status: 400 });
|
||||
const nameCheck = validateEnvName(data.name);
|
||||
if (!nameCheck.ok) {
|
||||
return json({ error: nameCheck.reason }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if environment with this name already exists
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { join } from 'path';
|
||||
import { existsSync, rmSync } from 'fs';
|
||||
import { existsSync, rmSync, renameSync } from 'fs';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
|
||||
import { clearDockerClientCache } from '$lib/server/docker';
|
||||
@@ -11,6 +11,7 @@ import { auditEnvironment } from '$lib/server/audit';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { validateEnvName } from '$lib/utils/env-name';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { closeEdgeConnection } from '$lib/server/hawser';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
@@ -64,6 +65,65 @@ export const PUT: RequestHandler = async (event) => {
|
||||
|
||||
const data = await request.json();
|
||||
|
||||
// #1179: validate name if it's being changed. Existing invalid names are
|
||||
// not auto-corrected — only writes go through this check.
|
||||
const isRename = data.name !== undefined && data.name !== oldEnv.name;
|
||||
if (isRename) {
|
||||
const nameCheck = validateEnvName(data.name);
|
||||
if (!nameCheck.ok) {
|
||||
return json({ error: nameCheck.reason }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Rename on-disk directories BEFORE the DB write. If the fs rename
|
||||
// fails (cross-mount EXDEV, perm error, target exists), we surface a
|
||||
// 409 and leave the DB untouched — better than the previous behavior
|
||||
// of silently orphaning stacks under the old name.
|
||||
//
|
||||
// Applies to ALL connection types. For socket/direct envs the staging
|
||||
// dir IS the deployed dir, so containers need a redeploy after rename
|
||||
// (see client warning). For Hawser envs the agent owns the deployed
|
||||
// dir on the remote host and isn't affected, but Dockhand still keeps
|
||||
// a local staging copy under stacks/<envName>/<stackName>/ (for the
|
||||
// in-app editor) and ALL git stacks clone to git-repos/<envName>/
|
||||
// regardless of where they ultimately deploy — so the rename matters
|
||||
// locally for every env type.
|
||||
if (isRename) {
|
||||
const stacksDir = getStacksDir();
|
||||
const gitReposDir = getGitReposDir();
|
||||
const oldStacks = join(stacksDir, oldEnv.name);
|
||||
const newStacks = join(stacksDir, data.name);
|
||||
const oldRepos = join(gitReposDir, oldEnv.name);
|
||||
const newRepos = join(gitReposDir, data.name);
|
||||
|
||||
// Refuse to overwrite a target dir that already holds someone
|
||||
// else's data.
|
||||
if (existsSync(oldStacks) && existsSync(newStacks)) {
|
||||
return json({
|
||||
error: `Cannot rename: ${newStacks} already exists. Pick a different name or move that directory out of the way.`
|
||||
}, { status: 409 });
|
||||
}
|
||||
if (existsSync(oldRepos) && existsSync(newRepos)) {
|
||||
return json({
|
||||
error: `Cannot rename: ${newRepos} already exists. Pick a different name or move that directory out of the way.`
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsSync(oldStacks)) renameSync(oldStacks, newStacks);
|
||||
if (existsSync(oldRepos)) renameSync(oldRepos, newRepos);
|
||||
} catch (err: any) {
|
||||
// Best-effort rollback if the second rename failed after the first
|
||||
// succeeded. Avoids leaving the filesystem in a split state.
|
||||
try { if (existsSync(newStacks) && !existsSync(oldStacks)) renameSync(newStacks, oldStacks); } catch {}
|
||||
try { if (existsSync(newRepos) && !existsSync(oldRepos)) renameSync(newRepos, oldRepos); } catch {}
|
||||
const code = err?.code === 'EXDEV'
|
||||
? 'EXDEV: stacks dir is on a different filesystem from the rename target. Move it back to the same filesystem to rename this environment.'
|
||||
: (err?.message || 'Rename failed');
|
||||
return json({ error: code }, { status: 409 });
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached Docker client before updating
|
||||
clearDockerClientCache(id);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectImage, tagImage, pushImage, parseRegistryUrl } from '$lib/server/docker';
|
||||
import { encodeRegistryAuth } from '$lib/server/registry-auth';
|
||||
import { getRegistry, getEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditImage } from '$lib/server/audit';
|
||||
@@ -110,8 +111,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
const authServerAddress = isDockerHub ? 'https://index.docker.io/v1/' : registryHost;
|
||||
const authConfig = registry.username && registry.password
|
||||
? {
|
||||
username: registry.username,
|
||||
password: registry.password,
|
||||
// Trim to neutralize stray whitespace in legacy stored credentials (#1105)
|
||||
username: registry.username.trim(),
|
||||
password: registry.password.trim(),
|
||||
serveraddress: authServerAddress
|
||||
}
|
||||
: {
|
||||
@@ -148,7 +150,18 @@ export const POST: RequestHandler = async (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
const authHeader = encodeRegistryAuth(authConfig);
|
||||
{
|
||||
const u = (authConfig as { username?: string }).username ?? '';
|
||||
const p = (authConfig as { password?: string }).password ?? '';
|
||||
const userLast = u.length ? u.charCodeAt(u.length - 1).toString(16) : 'na';
|
||||
const pwLast = p.length ? p.charCodeAt(p.length - 1).toString(16) : 'na';
|
||||
console.log(
|
||||
`[Push/Edge] auth: registry=${registryHost} user(len=${u.length},last=0x${userLast}) ` +
|
||||
`pw(len=${p.length},last=0x${pwLast}) serveraddress=${authConfig.serveraddress} ` +
|
||||
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sendEdgeStreamRequest(
|
||||
|
||||
@@ -63,7 +63,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
} else if (type === 'apprise') {
|
||||
const appriseConfig = config as AppriseConfig;
|
||||
if (!appriseConfig.urls?.length) {
|
||||
return json({ error: 'Apprise config requires at least one URL' }, { status: 400 });
|
||||
return json({ error: 'Webhook config requires at least one URL' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
} else if (existing.type === 'apprise') {
|
||||
const appriseConfig = config as AppriseConfig;
|
||||
if (!appriseConfig.urls?.length) {
|
||||
return json({ error: 'Apprise config requires at least one URL' }, { status: 400 });
|
||||
return json({ error: 'Webhook config requires at least one URL' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (data.type === 'apprise') {
|
||||
const config = data.config;
|
||||
if (!config.urls?.length) {
|
||||
return json({ error: 'At least one Apprise URL is required' }, { status: 400 });
|
||||
return json({ error: 'At least one webhook URL is required' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
const validTerminalFontIds = monospaceFonts.map(f => f.id);
|
||||
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
|
||||
|
||||
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string } = {};
|
||||
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {};
|
||||
|
||||
if (data.lightTheme !== undefined) {
|
||||
if (!validLightThemeIds.includes(data.lightTheme)) {
|
||||
@@ -96,6 +96,13 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
updates.editorFont = data.editorFont;
|
||||
}
|
||||
|
||||
if (data.animateIcons !== undefined) {
|
||||
if (typeof data.animateIcons !== 'boolean') {
|
||||
return json({ error: 'Invalid animateIcons' }, { status: 400 });
|
||||
}
|
||||
updates.animateIcons = data.animateIcons;
|
||||
}
|
||||
|
||||
await setUserThemePreferences(currentUser.id, updates);
|
||||
|
||||
// Return updated preferences
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditRegistry } from '$lib/server/audit';
|
||||
import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -38,11 +39,21 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Name and URL are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Trim username/password to prevent stray whitespace from copy-paste corrupting
|
||||
// the X-Registry-Auth / Authorization headers (#1105).
|
||||
const trimmedUsername = typeof data.username === 'string' ? data.username.trim() : undefined;
|
||||
const trimmedPassword = typeof data.password === 'string' ? data.password.trim() : undefined;
|
||||
|
||||
// Diagnostic logging (#1105) — never logs the plaintext credential
|
||||
const { host: normalizedHost } = parseRegistryUrl(data.url);
|
||||
const hubTag = DOCKER_HUB_HOSTS.has(normalizedHost) ? ' (docker-hub)' : '';
|
||||
console.log(`[Registry] create: url=${data.url} normalized=${normalizedHost}${hubTag} user(len=${trimmedUsername?.length ?? 0}) pw(len=${trimmedPassword?.length ?? 0})`);
|
||||
|
||||
const registry = await createRegistry({
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
username: data.username || undefined,
|
||||
password: data.password || undefined,
|
||||
username: trimmedUsername || undefined,
|
||||
password: trimmedPassword || undefined,
|
||||
isDefault: data.isDefault || false
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getRegistry, updateRegistry, deleteRegistry, setDefaultRegistry } from
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditRegistry } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -51,11 +52,22 @@ export const PUT: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
// Trim username/password to prevent stray whitespace from copy-paste corrupting
|
||||
// the X-Registry-Auth / Authorization headers (#1105).
|
||||
const trimmedUsername = typeof data.username === 'string' ? data.username.trim() : data.username;
|
||||
const trimmedPassword = typeof data.password === 'string' ? data.password.trim() : data.password;
|
||||
|
||||
// Diagnostic logging (#1105) — never logs the plaintext credential
|
||||
const userLen = typeof trimmedUsername === 'string' ? trimmedUsername.length : 0;
|
||||
const pwLen = typeof trimmedPassword === 'string' ? trimmedPassword.length : 0;
|
||||
const { host: normalizedHost } = parseRegistryUrl(data.url);
|
||||
const hubTag = DOCKER_HUB_HOSTS.has(normalizedHost) ? ' (docker-hub)' : '';
|
||||
console.log(`[Registry] update id=${id}: url=${data.url} normalized=${normalizedHost}${hubTag} user(len=${userLen}) pw(len=${pwLen})`);
|
||||
const registry = await updateRegistry(id, {
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
username: trimmedUsername,
|
||||
password: trimmedPassword,
|
||||
isDefault: data.isDefault
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { parseRegistryUrl, getRegistryAuthHeader, DOCKER_HUB_HOSTS } from '$lib/server/docker';
|
||||
import { getRegistry } from '$lib/server/db';
|
||||
|
||||
/**
|
||||
* Test registry connectivity and credentials.
|
||||
*
|
||||
* Accepts either inline credentials (from the modal form) or a registry ID
|
||||
* (to test an already-saved registry using stored credentials).
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('registries', 'view')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
let url: string;
|
||||
let username: string | undefined;
|
||||
let password: string | undefined;
|
||||
|
||||
if (data.registryId) {
|
||||
// Test a saved registry — fetch credentials from DB
|
||||
const reg = await getRegistry(data.registryId);
|
||||
if (!reg) return json({ error: 'Registry not found' }, { status: 404 });
|
||||
url = reg.url;
|
||||
username = reg.username || undefined;
|
||||
password = reg.password || undefined;
|
||||
} else {
|
||||
url = data.url;
|
||||
username = data.username?.trim() || undefined;
|
||||
password = data.password?.trim() || undefined;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = parseRegistryUrl(url);
|
||||
const apiBase = `${parsed.protocol}://${parsed.host}`;
|
||||
|
||||
try {
|
||||
// Step 1: connectivity — can we reach /v2/ at all?
|
||||
const pingResp = await fetch(`${apiBase}/v2/`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Dockhand/1.0' },
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
});
|
||||
|
||||
const hasAuth = pingResp.status === 401;
|
||||
const isOpen = pingResp.ok;
|
||||
|
||||
if (!isOpen && !hasAuth) {
|
||||
return json({
|
||||
success: false,
|
||||
connectivity: false,
|
||||
message: `Registry returned HTTP ${pingResp.status}`
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: if credentials provided, test authentication.
|
||||
// Empty scope — we only care whether the registry accepts the login.
|
||||
// Asking for a privileged scope like registry:catalog:* causes Docker
|
||||
// Hub to reject the request with HTTP 400 for non-admin users even
|
||||
// when their credentials are valid.
|
||||
if (username && password) {
|
||||
const authHeader = await getRegistryAuthHeader(url, '', { username, password });
|
||||
|
||||
if (!authHeader) {
|
||||
return json({
|
||||
success: false,
|
||||
connectivity: true,
|
||||
authenticated: false,
|
||||
message: 'Authentication failed — check username and password'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the token works
|
||||
const authResp = await fetch(`${apiBase}/v2/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Dockhand/1.0',
|
||||
'Authorization': authHeader
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
});
|
||||
|
||||
if (!authResp.ok) {
|
||||
return json({
|
||||
success: false,
|
||||
connectivity: true,
|
||||
authenticated: false,
|
||||
message: `Authentication token rejected (HTTP ${authResp.status})`
|
||||
});
|
||||
}
|
||||
|
||||
const isHub = DOCKER_HUB_HOSTS.has(parsed.host);
|
||||
return json({
|
||||
success: true,
|
||||
connectivity: true,
|
||||
authenticated: true,
|
||||
message: isHub
|
||||
? `Connected to Docker Hub as ${username}`
|
||||
: `Connected and authenticated as ${username}`
|
||||
});
|
||||
}
|
||||
|
||||
// No credentials — just report connectivity
|
||||
const isHub = DOCKER_HUB_HOSTS.has(parsed.host);
|
||||
return json({
|
||||
success: true,
|
||||
connectivity: true,
|
||||
authenticated: null,
|
||||
message: isHub
|
||||
? 'Docker Hub is reachable (no credentials to test)'
|
||||
: isOpen
|
||||
? 'Registry is reachable (no auth required)'
|
||||
: 'Registry is reachable (requires authentication)'
|
||||
});
|
||||
} catch (e: any) {
|
||||
const msg = e?.cause?.code || e?.message || String(e);
|
||||
return json({
|
||||
success: false,
|
||||
connectivity: false,
|
||||
message: `Connection failed: ${msg}`
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -90,6 +90,8 @@ export interface GeneralSettings {
|
||||
defaultComposeTemplate: string;
|
||||
// Label filter mode
|
||||
labelFilterMode: 'any' | 'all';
|
||||
// Whether spinning icons (animate-spin etc.) are animated (#1169)
|
||||
animateIcons: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
|
||||
@@ -122,6 +124,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
|
||||
labelFilterMode: 'any' as const,
|
||||
animateIcons: true,
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -199,7 +202,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultGrypeImage,
|
||||
defaultTrivyImage,
|
||||
defaultComposeTemplate,
|
||||
labelFilterMode
|
||||
labelFilterMode,
|
||||
animateIcons
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -238,7 +242,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode')
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('animate_icons')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -281,7 +286,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
@@ -299,7 +305,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, animateIcons } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -439,6 +445,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
|
||||
await setSetting('label_filter_mode', labelFilterMode);
|
||||
}
|
||||
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
|
||||
await setSetting('animate_icons', animateIcons);
|
||||
}
|
||||
|
||||
// Fetch all settings in parallel for the response
|
||||
const [
|
||||
@@ -479,7 +488,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultGrypeImageVal,
|
||||
defaultTrivyImageVal,
|
||||
defaultComposeTemplateVal,
|
||||
labelFilterModeVal
|
||||
labelFilterModeVal,
|
||||
animateIconsVal
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -518,7 +528,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_compose_template'),
|
||||
getSetting('label_filter_mode')
|
||||
getSetting('label_filter_mode'),
|
||||
getSetting('animate_icons')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -561,7 +572,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
|
||||
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
|
||||
@@ -13,7 +13,8 @@ const DEFAULT_THEME_SETTINGS = {
|
||||
fontSize: 'normal',
|
||||
gridFontSize: 'normal',
|
||||
terminalFont: 'system-mono',
|
||||
editorFont: 'system-mono'
|
||||
editorFont: 'system-mono',
|
||||
animateIcons: true
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
@@ -25,7 +26,8 @@ export const GET: RequestHandler = async () => {
|
||||
fontSize,
|
||||
gridFontSize,
|
||||
terminalFont,
|
||||
editorFont
|
||||
editorFont,
|
||||
animateIcons
|
||||
] = await Promise.all([
|
||||
getSetting('theme_light'),
|
||||
getSetting('theme_dark'),
|
||||
@@ -33,7 +35,8 @@ export const GET: RequestHandler = async () => {
|
||||
getSetting('theme_font_size'),
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font'),
|
||||
getSetting('theme_editor_font')
|
||||
getSetting('theme_editor_font'),
|
||||
getSetting('animate_icons')
|
||||
]);
|
||||
|
||||
return json({
|
||||
@@ -43,7 +46,8 @@ export const GET: RequestHandler = async () => {
|
||||
fontSize: fontSize ?? DEFAULT_THEME_SETTINGS.fontSize,
|
||||
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
|
||||
terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont,
|
||||
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont
|
||||
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont,
|
||||
animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get theme settings:', error);
|
||||
|
||||
+3
-2
@@ -158,8 +158,9 @@ export const POST: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
defined = envVars.map(v => v.key).sort();
|
||||
}
|
||||
|
||||
// Calculate missing and unused
|
||||
const missing = required.filter(v => !defined.includes(v));
|
||||
// Calculate missing and unused. Built-in Docker/Compose vars are provided implicitly
|
||||
// by the runtime, so they're never "missing" even if not in the user's env panel.
|
||||
const missing = required.filter(v => !defined.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v));
|
||||
const unused = defined.filter(v => !required.includes(v) && !optional.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v));
|
||||
|
||||
const result: ValidationResult = {
|
||||
|
||||
Reference in new issue
Block a user