v1.0.21
This commit is contained in:
1 parent
790ce092ee
commit
55f3101a19
51 files changed
+763
-599
No files matched your search
@@ -30,8 +30,11 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
return json(details);
|
||||
} catch (error) {
|
||||
console.error('Error inspecting container:', error);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error inspecting container:', error?.message || error);
|
||||
return json({ error: 'Failed to inspect container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -96,8 +99,11 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing container:', error);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error removing container:', error?.message || error);
|
||||
return json({ error: 'Failed to remove container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -26,7 +26,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Error listing container directory:', error);
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error listing container directory:', error?.message || error);
|
||||
return json({ error: error.message || 'Failed to list directory' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -36,7 +36,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
return json({ content, path });
|
||||
} catch (error: any) {
|
||||
console.error('Error reading container file:', error);
|
||||
if (error?.statusCode === 404 && !error.message?.includes('No such file')) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error reading container file:', error?.message || error);
|
||||
const msg = error.message || String(error);
|
||||
|
||||
if (msg.includes('No such file or directory')) {
|
||||
@@ -92,7 +95,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
|
||||
return json({ success: true, path });
|
||||
} catch (error: any) {
|
||||
console.error('Error writing container file:', error);
|
||||
console.error('Error writing container file:', error?.message || error);
|
||||
const msg = error.message || String(error);
|
||||
|
||||
if (msg.includes('Permission denied')) {
|
||||
|
||||
@@ -76,7 +76,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
return new Response(body, { headers });
|
||||
} catch (error: any) {
|
||||
console.error('Error downloading container file:', error);
|
||||
console.error('Error downloading container file:', error?.message || error);
|
||||
|
||||
if (error.message?.includes('No such file or directory')) {
|
||||
return new Response(JSON.stringify({ error: 'File not found' }), {
|
||||
|
||||
@@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading to container:', error);
|
||||
console.error('Error uploading to container:', error?.message || error);
|
||||
|
||||
if (error.message?.includes('Permission denied')) {
|
||||
return json({ error: 'Permission denied to write to this path' }, { status: 403 });
|
||||
|
||||
@@ -46,8 +46,11 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error renaming container:', error);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error renaming container:', error?.message || error);
|
||||
return json({ error: 'Failed to rename container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -38,8 +38,11 @@ export const POST: RequestHandler = async (event) => {
|
||||
await auditContainer(event, 'restart', params.id, containerName, envIdNum);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error restarting container:', error);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error restarting container:', error?.message || error);
|
||||
return json({ error: 'Failed to restart container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -31,8 +31,11 @@ export const POST: RequestHandler = async (event) => {
|
||||
await auditContainer(event, 'start', params.id, containerName, envIdNum);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error starting container:', error);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error starting container:', error?.message || error);
|
||||
return json({ error: 'Failed to start container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -31,8 +31,11 @@ export const POST: RequestHandler = async (event) => {
|
||||
await auditContainer(event, 'stop', params.id, containerName, envIdNum);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error stopping container:', error);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error stopping container:', error?.message || error);
|
||||
return json({ error: 'Failed to stop container' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -47,8 +47,11 @@ export const POST: RequestHandler = async (event) => {
|
||||
await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate });
|
||||
|
||||
return json({ success: true, id: container.id });
|
||||
} catch (error) {
|
||||
console.error('Error updating container:', error);
|
||||
return json({ error: 'Failed to update container', details: String(error) }, { status: 500 });
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error updating container:', error?.message || error);
|
||||
return json({ error: 'Failed to update container', details: error?.message || String(error) }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
|
||||
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
containerId: string;
|
||||
@@ -19,9 +20,9 @@ export interface UpdateCheckResult {
|
||||
|
||||
/**
|
||||
* Check all containers for available image updates.
|
||||
* Returns all results at once after checking in parallel.
|
||||
* Returns progress events during checking, final result when done.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -32,21 +33,21 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
return createJobResponse(async (send) => {
|
||||
// Clear existing pending updates for this environment before checking
|
||||
if (envIdNum) {
|
||||
await clearPendingContainerUpdates(envIdNum);
|
||||
}
|
||||
|
||||
const allContainers = await listContainers(true, envIdNum);
|
||||
|
||||
// Include all containers (system containers get flagged, not filtered)
|
||||
const containers = allContainers;
|
||||
|
||||
send('progress', { checked: 0, total: containers.length });
|
||||
|
||||
// Check container for updates
|
||||
let checked = 0;
|
||||
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
|
||||
try {
|
||||
// Get container's image name from config
|
||||
const inspectData = await inspectContainer(container.id, envIdNum) as any;
|
||||
const imageName = inspectData.Config?.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
@@ -62,7 +63,6 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Use shared update detection function
|
||||
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||
|
||||
return {
|
||||
@@ -88,13 +88,23 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Check all containers in parallel
|
||||
const results = await Promise.all(containers.map(checkContainer));
|
||||
// Sliding window concurrency limit to avoid DNS threadpool saturation (#676).
|
||||
const CONCURRENCY = 20;
|
||||
const results: UpdateCheckResult[] = new Array(containers.length);
|
||||
let next = 0;
|
||||
async function runNext(): Promise<void> {
|
||||
while (next < containers.length) {
|
||||
const idx = next++;
|
||||
results[idx] = await checkContainer(containers[idx]);
|
||||
checked++;
|
||||
send('progress', { checked, total: containers.length });
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
|
||||
|
||||
const updatesFound = results.filter(r => r.hasUpdate).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
// Skip system containers (Dockhand/Hawser) - they use their own update paths
|
||||
if (envIdNum) {
|
||||
for (const result of results) {
|
||||
if (result.hasUpdate && !result.systemContainer) {
|
||||
@@ -108,13 +118,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
send('result', {
|
||||
total: containers.length,
|
||||
updatesFound,
|
||||
results
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error checking for updates:', error);
|
||||
return json({ error: 'Failed to check for updates', details: error.message }, { status: 500 });
|
||||
}
|
||||
}, request);
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { prefersJSON, sseToJSON } from '$lib/server/sse';
|
||||
import type { EnvironmentStats } from '../+server';
|
||||
import { parseLabels } from '$lib/utils/label-colors';
|
||||
import { isEdgeConnected } from '$lib/server/hawser';
|
||||
|
||||
|
||||
// Skip disk usage collection (Synology NAS performance fix)
|
||||
@@ -249,6 +250,22 @@ async function getEnvironmentStatsProgressive(
|
||||
loading: { ...envStats.loading }
|
||||
});
|
||||
|
||||
// For edge envs with no connected agent, skip the 5s ping and fail immediately.
|
||||
// On restart, agents take 30-70s to reconnect — without this check, every open
|
||||
// dashboard tab fires a 5s ping per edge env simultaneously, creating a flood.
|
||||
if (env.connectionType === 'hawser-edge' && !isEdgeConnected(env.id)) {
|
||||
envStats.online = false;
|
||||
envStats.error = 'Agent not connected';
|
||||
envStats.loading = undefined;
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: false,
|
||||
error: 'Agent not connected',
|
||||
loading: undefined
|
||||
});
|
||||
return envStats;
|
||||
}
|
||||
|
||||
// Quick reachability check — if ping fails, skip all expensive Docker API calls
|
||||
if (!await dockerPing(env.id)) {
|
||||
envStats.online = false;
|
||||
|
||||
@@ -80,8 +80,14 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'An environment with this name already exists' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Host is required for direct and hawser-standard connections
|
||||
// Validate connection type
|
||||
const validConnectionTypes = ['socket', 'direct', 'hawser-standard', 'hawser-edge'];
|
||||
const connectionType = data.connectionType || 'socket';
|
||||
if (!validConnectionTypes.includes(connectionType)) {
|
||||
return json({ error: `Invalid connection type: ${connectionType}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Host is required for direct and hawser-standard connections
|
||||
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
|
||||
return json({ error: 'Host is required for this connection type' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
return json(hostInfo);
|
||||
} catch (error) {
|
||||
console.error('Failed to get host info:', error);
|
||||
console.error('Failed to get host info:', (error as Error)?.message ?? error);
|
||||
return json({ error: 'Failed to get host info' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -49,7 +49,9 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
|
||||
if (response.status === 404) {
|
||||
throw new Error('Image not found on Docker Hub');
|
||||
}
|
||||
throw new Error(`Docker Hub returned error: ${response.status}`);
|
||||
const err = new Error(`Docker Hub returned error: ${response.status}`) as any;
|
||||
err.statusCode = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -162,6 +164,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return json({ error: 'Registry host not found' }, { status: 503 });
|
||||
}
|
||||
if (error.statusCode) {
|
||||
return json({ error: error.message || 'Failed to fetch tags' }, { status: error.statusCode });
|
||||
}
|
||||
|
||||
return json({ error: error.message || 'Failed to fetch tags' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId, getHostDockerSocket } from '$lib/server/host-path';
|
||||
import { getOwnContainerId, getHostDockerSocket, getOwnDockerHost, getOwnNetworkMode } from '$lib/server/host-path';
|
||||
import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prefersJSON, sseToJSON } from '$lib/server/sse';
|
||||
|
||||
const UPDATER_IMAGE = 'fnsys/dockhand-updater:latest';
|
||||
const UPDATER_LABEL = 'dockhand.updater';
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/** Fetch from the local Docker socket (buffered). */
|
||||
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path, options);
|
||||
/** Get TCP Docker host if configured, null otherwise. */
|
||||
function getDockerTcpHost(): string | null {
|
||||
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
|
||||
return dockerHost?.startsWith('tcp://') ? dockerHost : null;
|
||||
}
|
||||
|
||||
/** Fetch from the local Docker socket (streaming body for pull progress). */
|
||||
/** Fetch from the local Docker (buffered). Supports TCP and Unix socket. */
|
||||
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const tcpHost = getDockerTcpHost();
|
||||
if (tcpHost) {
|
||||
return fetch(tcpHost.replace('tcp://', 'http://') + path, options);
|
||||
}
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return unixSocketRequest(socketPath, path, options);
|
||||
}
|
||||
|
||||
/** Fetch from the local Docker (streaming body for pull progress). */
|
||||
function localDockerStreamFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketStreamRequest(DOCKER_SOCKET, path, options);
|
||||
const tcpHost = getDockerTcpHost();
|
||||
if (tcpHost) {
|
||||
return fetch(tcpHost.replace('tcp://', 'http://') + path, options);
|
||||
}
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return unixSocketStreamRequest(socketPath, path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an image via local Docker socket, streaming progress via callback.
|
||||
* Pull an image via local Docker, streaming progress via callback.
|
||||
*/
|
||||
async function pullImageLocal(imageName: string, onProgress?: (line: string) => void): Promise<void> {
|
||||
let fromImage = imageName;
|
||||
@@ -79,9 +94,14 @@ async function pullImageLocal(imageName: string, onProgress?: (line: string) =>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker socket is mounted read-write
|
||||
* Check if Docker access allows write operations.
|
||||
* TCP connections always allow writes (no RO mount concept).
|
||||
* Socket connections check if the mount is read-write.
|
||||
*/
|
||||
async function isDockerSocketWritable(containerId: string): Promise<boolean> {
|
||||
async function isDockerWritable(containerId: string): Promise<boolean> {
|
||||
// TCP connections don't have mount-level RO/RW — access implies full control
|
||||
if (getDockerTcpHost()) return true;
|
||||
|
||||
const response = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!response.ok) return false;
|
||||
|
||||
@@ -235,7 +255,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json({ error: 'Not running in Docker' }, { status: 400 });
|
||||
}
|
||||
|
||||
const writable = await isDockerSocketWritable(containerId);
|
||||
const writable = await isDockerWritable(containerId);
|
||||
if (!writable) {
|
||||
return json({
|
||||
error: 'Docker socket is mounted read-only. Self-update requires read-write Docker socket access.'
|
||||
@@ -252,8 +272,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json({ error: 'Failed to determine container name' }, { status: 500 });
|
||||
}
|
||||
|
||||
const socketHostPath = getHostDockerSocket();
|
||||
|
||||
// Start SSE stream for preparation progress
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
@@ -353,6 +371,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
...networkEnvVars
|
||||
];
|
||||
|
||||
// Configure updater's Docker access based on connection type
|
||||
const tcpHost = getDockerTcpHost();
|
||||
const updaterHostConfig: Record<string, unknown> = { AutoRemove: true };
|
||||
|
||||
if (tcpHost) {
|
||||
// TCP: pass DOCKER_HOST so docker CLI in sidecar uses TCP
|
||||
updaterEnv.push(`DOCKER_HOST=${tcpHost}`);
|
||||
// Put sidecar on same network so it can reach the Docker TCP endpoint
|
||||
const network = getOwnNetworkMode();
|
||||
if (network) {
|
||||
updaterHostConfig.NetworkMode = network;
|
||||
}
|
||||
send('log', { message: `Updater using TCP: ${tcpHost}` });
|
||||
} else {
|
||||
// Socket: bind-mount the host Docker socket
|
||||
const socketHostPath = getHostDockerSocket();
|
||||
updaterHostConfig.Binds = [`${socketHostPath}:/var/run/docker.sock`];
|
||||
}
|
||||
|
||||
console.log('[SelfUpdate] Creating updater container...');
|
||||
const updaterResponse = await localDockerFetch('/containers/create?name=dockhand-updater', {
|
||||
method: 'POST',
|
||||
@@ -363,12 +400,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
Labels: {
|
||||
[UPDATER_LABEL]: 'true'
|
||||
},
|
||||
HostConfig: {
|
||||
AutoRemove: true,
|
||||
Binds: [
|
||||
`${socketHostPath}:/var/run/docker.sock`
|
||||
]
|
||||
}
|
||||
HostConfig: updaterHostConfig
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnContainerId } from '$lib/server/host-path';
|
||||
import { getOwnContainerId, getOwnDockerHost } from '$lib/server/host-path';
|
||||
import { getRegistryManifestDigest, unixSocketRequest } from '$lib/server/docker';
|
||||
import { compareVersions } from '$lib/utils/version';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/** Fetch from the local Docker socket directly (not through environment routing) */
|
||||
/** Fetch from the local Docker directly (not through environment routing) */
|
||||
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path, options);
|
||||
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
|
||||
|
||||
if (dockerHost?.startsWith('tcp://')) {
|
||||
// TCP connection (socat proxy, socket-proxy, remote Docker)
|
||||
const url = dockerHost.replace('tcp://', 'http://') + path;
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
// Unix socket (default)
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return unixSocketRequest(socketPath, path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getOwnDockerHost } from '$lib/server/host-path';
|
||||
import { unixSocketRequest } from '$lib/server/docker';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
/** Fetch from the local Docker socket directly */
|
||||
/** Fetch from the local Docker directly. Supports TCP and Unix socket. */
|
||||
function localDockerFetch(path: string): Promise<Response> {
|
||||
return unixSocketRequest(DOCKER_SOCKET, path);
|
||||
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
|
||||
if (dockerHost?.startsWith('tcp://')) {
|
||||
return fetch(dockerHost.replace('tcp://', 'http://') + path);
|
||||
}
|
||||
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
return unixSocketRequest(socketPath, path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface GeneralSettings {
|
||||
gridFontSize: string;
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
// Compact ports
|
||||
compactPorts: boolean;
|
||||
// External stack paths
|
||||
externalStackPaths: string[];
|
||||
// Primary stack location
|
||||
@@ -85,6 +87,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
eventCollectionMode: 'stream',
|
||||
eventPollInterval: 60000,
|
||||
metricsCollectionInterval: 30000,
|
||||
compactPorts: false,
|
||||
lightTheme: 'default',
|
||||
darkTheme: 'default',
|
||||
font: 'system',
|
||||
@@ -140,6 +143,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
gridFontSize,
|
||||
terminalFont,
|
||||
editorFont,
|
||||
compactPorts,
|
||||
externalStackPaths,
|
||||
primaryStackLocation
|
||||
] = await Promise.all([
|
||||
@@ -169,6 +173,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font'),
|
||||
getSetting('theme_editor_font'),
|
||||
getSetting('compact_ports'),
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation()
|
||||
]);
|
||||
@@ -200,6 +205,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
|
||||
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
|
||||
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
|
||||
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
||||
externalStackPaths,
|
||||
primaryStackLocation
|
||||
};
|
||||
@@ -219,7 +225,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, externalStackPaths, primaryStackLocation } = 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, externalStackPaths, primaryStackLocation } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -312,6 +318,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (editorFont !== undefined && VALID_EDITOR_FONTS.includes(editorFont)) {
|
||||
await setSetting('theme_editor_font', editorFont);
|
||||
}
|
||||
if (compactPorts !== undefined) {
|
||||
await setSetting('compact_ports', compactPorts);
|
||||
}
|
||||
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
|
||||
// Filter to valid non-empty strings
|
||||
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
|
||||
@@ -355,6 +364,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
gridFontSizeVal,
|
||||
terminalFontVal,
|
||||
editorFontVal,
|
||||
compactPortsVal,
|
||||
externalStackPathsVal,
|
||||
primaryStackLocationVal
|
||||
] = await Promise.all([
|
||||
@@ -384,6 +394,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font'),
|
||||
getSetting('theme_editor_font'),
|
||||
getSetting('compact_ports'),
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation()
|
||||
]);
|
||||
@@ -415,6 +426,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
|
||||
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
|
||||
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
|
||||
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
|
||||
externalStackPaths: externalStackPathsVal,
|
||||
primaryStackLocation: primaryStackLocationVal
|
||||
};
|
||||
|
||||
@@ -24,9 +24,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const volume = await inspectVolume(params.name, envIdNum);
|
||||
return json(volume);
|
||||
} catch (error) {
|
||||
console.error('Failed to inspect volume:', error);
|
||||
return json({ error: 'Failed to inspect volume' }, { status: 500 });
|
||||
} catch (error: any) {
|
||||
const status = error.statusCode ?? 500;
|
||||
console.error(`Failed to inspect volume ${params.name}: ${error.message}`);
|
||||
return json({ error: 'Failed to inspect volume' }, { status });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,7 +58,12 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to remove volume:', error);
|
||||
return json({ error: 'Failed to remove volume', details: error.message }, { status: 500 });
|
||||
const status = error.statusCode ?? 500;
|
||||
if (status === 404) {
|
||||
console.warn(`Failed to remove volume ${params.name}: ${error.message}`);
|
||||
} else {
|
||||
console.error(`Failed to remove volume ${params.name}: ${error.message}`);
|
||||
}
|
||||
return json({ error: 'Failed to remove volume', details: error.message }, { status });
|
||||
}
|
||||
};
|
||||
@@ -81,6 +81,7 @@
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import { ipToNumber } from '$lib/utils/ip';
|
||||
import { formatHostPortUrl } from '$lib/utils/url';
|
||||
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection';
|
||||
@@ -262,6 +263,8 @@
|
||||
|
||||
// Update check state
|
||||
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
|
||||
let updateCheckProgress = $state({ checked: 0, total: 0 });
|
||||
let updateCheckBtnEl = $state<HTMLButtonElement | null>(null);
|
||||
let showBatchUpdateModal = $state(false);
|
||||
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
|
||||
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
|
||||
@@ -423,6 +426,13 @@
|
||||
|
||||
async function checkForUpdates() {
|
||||
updateCheckStatus = 'checking';
|
||||
updateCheckProgress = { checked: 0, total: 0 };
|
||||
|
||||
// Lock button width to prevent layout shift
|
||||
if (updateCheckBtnEl) {
|
||||
updateCheckBtnEl.style.minWidth = `${updateCheckBtnEl.offsetWidth}px`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(appendEnvParam('/api/containers/check-updates', envId), {
|
||||
method: 'POST'
|
||||
@@ -430,9 +440,20 @@
|
||||
if (!response.ok) {
|
||||
updateCheckStatus = 'error';
|
||||
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
||||
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
const { jobId } = await response.json();
|
||||
|
||||
const data: any = await watchJob(jobId, (line) => {
|
||||
if (line.event === 'progress') {
|
||||
updateCheckProgress = line.data as { checked: number; total: number };
|
||||
}
|
||||
});
|
||||
|
||||
// Unlock button width
|
||||
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
|
||||
|
||||
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
|
||||
const failedChecks = data.results.filter((r: any) => r.error && !r.hasUpdate).length;
|
||||
const failedSuffix = failedChecks > 0 ? ` (${failedChecks} failed to check)` : '';
|
||||
@@ -459,6 +480,7 @@
|
||||
} catch (error) {
|
||||
updateCheckStatus = 'error';
|
||||
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
||||
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1369,22 +1391,35 @@
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
bind:ref={updateCheckBtnEl}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={checkForUpdates}
|
||||
disabled={updateCheckStatus === 'checking'}
|
||||
title="Check for available updates"
|
||||
class="relative overflow-hidden"
|
||||
>
|
||||
{#if updateCheckStatus === 'checking'}
|
||||
<CircleArrowUp class="w-3.5 h-3.5 mr-1 animate-spin" />
|
||||
<CircleArrowUp class="w-3.5 h-3.5 animate-spin" />
|
||||
{#if updateCheckProgress.total > 0}
|
||||
<span class="tabular-nums">Checking {String(updateCheckProgress.checked).padStart(String(updateCheckProgress.total).length, '\u2007')}/{updateCheckProgress.total}</span>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 h-px bg-foreground transition-[width] duration-150 ease-out"
|
||||
style="width: {(updateCheckProgress.checked / updateCheckProgress.total) * 100}%"
|
||||
></div>
|
||||
{:else}
|
||||
Check for updates
|
||||
{/if}
|
||||
{:else if updateCheckStatus === 'none' || updateCheckStatus === 'found'}
|
||||
<Check class="w-3.5 h-3.5 mr-1 text-green-600" />
|
||||
Check for updates
|
||||
{:else if updateCheckStatus === 'error'}
|
||||
<XCircle class="w-3.5 h-3.5 mr-1 text-destructive" />
|
||||
Check for updates
|
||||
{:else}
|
||||
<CircleArrowUp class="w-3.5 h-3.5" />
|
||||
Check for updates
|
||||
{/if}
|
||||
Check for updates
|
||||
</Button>
|
||||
{#if updatableContainersCount > 0}
|
||||
<Button
|
||||
@@ -1791,8 +1826,11 @@
|
||||
<code class="text-xs">{getContainerIp(container.networks)}</code>
|
||||
{:else if column.id === 'ports'}
|
||||
{#if ports.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each ports as port}
|
||||
{@const compactPorts = $appSettings.compactPorts}
|
||||
{@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports}
|
||||
{@const remainingCount = ports.length - 1}
|
||||
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
|
||||
{#each displayPorts as port}
|
||||
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
|
||||
{#if url}
|
||||
<a
|
||||
@@ -1800,16 +1838,22 @@
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="inline-flex items-center gap-0.5 text-xs bg-muted hover:bg-blue-500/20 hover:text-blue-500 px-1 py-0.5 rounded transition-colors"
|
||||
class="inline-flex items-center gap-0.5 text-xs bg-muted hover:bg-blue-500/20 hover:text-blue-500 px-1 py-0.5 rounded transition-colors shrink-0"
|
||||
title="Open {url} in new tab"
|
||||
>
|
||||
<code>{port.display}</code>
|
||||
<ExternalLink class="w-2.5 h-2.5 text-muted-foreground" />
|
||||
</a>
|
||||
{:else}
|
||||
<code class="text-xs bg-muted px-1 py-0.5 rounded">{port.display}</code>
|
||||
<code class="text-xs bg-muted px-1 py-0.5 rounded shrink-0">{port.display}</code>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if compactPorts && remainingCount > 0}
|
||||
<span
|
||||
class="text-xs bg-muted text-muted-foreground px-1 py-0.5 rounded cursor-default shrink-0"
|
||||
title={ports.map(p => p.display).join(', ')}
|
||||
>+{remainingCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
||||
|
||||
@@ -219,6 +219,10 @@
|
||||
// Store scan result, individual scanner results, and vulnerabilities
|
||||
const containerProgress = progress.find(p => p.containerId === data.containerId);
|
||||
if (containerProgress) {
|
||||
// Add combined summary log when multiple scanners were used
|
||||
if (data.message && data.scannerResults && data.scannerResults.length > 1) {
|
||||
containerProgress.scanLogs.push({ message: data.message });
|
||||
}
|
||||
containerProgress.scanResult = data.scanResult;
|
||||
containerProgress.scannerResults = data.scannerResults;
|
||||
containerProgress.vulnerabilities = data.vulnerabilities;
|
||||
@@ -583,6 +587,9 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
|
||||
{#each sortedVulns(item.vulnerabilities).slice(0, 50) as vuln}
|
||||
<tr class="border-b border-muted/50">
|
||||
<td class="py-1 pr-2 font-mono">
|
||||
{#if item.scannerResults && item.scannerResults.length > 1 && vuln.scanner}
|
||||
<Badge variant="outline" class="px-1 py-0 text-[9px] mr-1 {vuln.scanner === 'grype' ? 'border-blue-400 text-blue-500' : 'border-emerald-400 text-emerald-500'}">{vuln.scanner === 'grype' ? 'G' : 'T'}</Badge>
|
||||
{/if}
|
||||
{#if vuln.link}
|
||||
<a href={vuln.link} target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-0.5">
|
||||
{vuln.id}
|
||||
|
||||
@@ -913,8 +913,8 @@
|
||||
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
|
||||
startAfterUpdate,
|
||||
repullImage,
|
||||
user: containerUser.trim() || undefined,
|
||||
privileged: privilegedMode || undefined,
|
||||
user: containerUser.trim() || null,
|
||||
privileged: privilegedMode,
|
||||
healthcheck,
|
||||
memory: parseMemory(memoryLimit),
|
||||
memoryReservation: parseMemory(memoryReservation),
|
||||
@@ -926,7 +926,7 @@
|
||||
capDrop: capDrop.length > 0 ? capDrop : undefined,
|
||||
devices: devices.length > 0 ? devices : undefined,
|
||||
deviceRequests,
|
||||
runtime: runtime || undefined,
|
||||
runtime: runtime || null,
|
||||
dns: dnsServers.length > 0 ? dnsServers : undefined,
|
||||
dnsSearch: dnsSearch.length > 0 ? dnsSearch : undefined,
|
||||
dnsOptions: dnsOptions.length > 0 ? dnsOptions : undefined,
|
||||
|
||||
@@ -18,6 +18,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
import { AnsiUp } from 'ansi_up';
|
||||
const ansiUp = new AnsiUp();
|
||||
ansiUp.use_classes = true;
|
||||
|
||||
// Track if we've handled the initial container from URL
|
||||
let initialContainerHandled = $state(false);
|
||||
@@ -1347,94 +1350,11 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ANSI color code to CSS class mapping
|
||||
const ansiColorMap: Record<string, string> = {
|
||||
'30': 'ansi-black',
|
||||
'31': 'ansi-red',
|
||||
'32': 'ansi-green',
|
||||
'33': 'ansi-yellow',
|
||||
'34': 'ansi-blue',
|
||||
'35': 'ansi-magenta',
|
||||
'36': 'ansi-cyan',
|
||||
'37': 'ansi-white',
|
||||
'90': 'ansi-bright-black',
|
||||
'91': 'ansi-bright-red',
|
||||
'92': 'ansi-bright-green',
|
||||
'93': 'ansi-bright-yellow',
|
||||
'94': 'ansi-bright-blue',
|
||||
'95': 'ansi-bright-magenta',
|
||||
'96': 'ansi-bright-cyan',
|
||||
'97': 'ansi-bright-white',
|
||||
// Background colors
|
||||
'40': 'ansi-bg-black',
|
||||
'41': 'ansi-bg-red',
|
||||
'42': 'ansi-bg-green',
|
||||
'43': 'ansi-bg-yellow',
|
||||
'44': 'ansi-bg-blue',
|
||||
'45': 'ansi-bg-magenta',
|
||||
'46': 'ansi-bg-cyan',
|
||||
'47': 'ansi-bg-white',
|
||||
// Text styles
|
||||
'1': 'ansi-bold',
|
||||
'2': 'ansi-dim',
|
||||
'3': 'ansi-italic',
|
||||
'4': 'ansi-underline',
|
||||
};
|
||||
|
||||
// Convert ANSI escape codes to HTML spans with CSS classes
|
||||
function ansiToHtml(text: string): string {
|
||||
// Strip Docker log stream header bytes (control characters at start of lines)
|
||||
// These appear as bytes 0x00-0x02 followed by stream data
|
||||
let cleaned = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1A]/g, '');
|
||||
|
||||
// First escape HTML
|
||||
let escaped = escapeHtml(cleaned);
|
||||
|
||||
// Match ANSI escape sequences: ESC[ followed by codes and ending with m
|
||||
// ESC can be \x1b, \033, or \e
|
||||
const ansiRegex = /\x1b\[([0-9;]*)m/g;
|
||||
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let openSpans = 0;
|
||||
let match;
|
||||
|
||||
while ((match = ansiRegex.exec(escaped)) !== null) {
|
||||
// Add text before this match
|
||||
result += escaped.slice(lastIndex, match.index);
|
||||
lastIndex = ansiRegex.lastIndex;
|
||||
|
||||
const codes = match[1].split(';').filter(c => c !== '');
|
||||
|
||||
for (const code of codes) {
|
||||
if (code === '0' || code === '39' || code === '49' || code === '') {
|
||||
// Reset code - close all open spans
|
||||
while (openSpans > 0) {
|
||||
result += '</span>';
|
||||
openSpans--;
|
||||
}
|
||||
} else if (ansiColorMap[code]) {
|
||||
result += `<span class="${ansiColorMap[code]}">`;
|
||||
openSpans++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
result += escaped.slice(lastIndex);
|
||||
|
||||
// Close any remaining open spans
|
||||
while (openSpans > 0) {
|
||||
result += '</span>';
|
||||
openSpans--;
|
||||
}
|
||||
|
||||
return result;
|
||||
return ansiUp.ansi_to_html(text);
|
||||
}
|
||||
|
||||
// Highlighted logs with search matches and ANSI color support (single container mode)
|
||||
@@ -2258,39 +2178,4 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
outline: 2px solid rgb(250, 204, 21);
|
||||
}
|
||||
|
||||
/* ANSI color classes - foreground colors */
|
||||
:global(.ansi-black) { color: #3f3f46; }
|
||||
:global(.ansi-red) { color: #ef4444; }
|
||||
:global(.ansi-green) { color: #22c55e; }
|
||||
:global(.ansi-yellow) { color: #eab308; }
|
||||
:global(.ansi-blue) { color: #3b82f6; }
|
||||
:global(.ansi-magenta) { color: #d946ef; }
|
||||
:global(.ansi-cyan) { color: #06b6d4; }
|
||||
:global(.ansi-white) { color: #e4e4e7; }
|
||||
|
||||
/* Bright foreground colors */
|
||||
:global(.ansi-bright-black) { color: #71717a; }
|
||||
:global(.ansi-bright-red) { color: #f87171; }
|
||||
:global(.ansi-bright-green) { color: #4ade80; }
|
||||
:global(.ansi-bright-yellow) { color: #facc15; }
|
||||
:global(.ansi-bright-blue) { color: #60a5fa; }
|
||||
:global(.ansi-bright-magenta) { color: #e879f9; }
|
||||
:global(.ansi-bright-cyan) { color: #22d3ee; }
|
||||
:global(.ansi-bright-white) { color: #fafafa; }
|
||||
|
||||
/* Background colors */
|
||||
:global(.ansi-bg-black) { background-color: #18181b; }
|
||||
:global(.ansi-bg-red) { background-color: #dc2626; }
|
||||
:global(.ansi-bg-green) { background-color: #16a34a; }
|
||||
:global(.ansi-bg-yellow) { background-color: #ca8a04; }
|
||||
:global(.ansi-bg-blue) { background-color: #2563eb; }
|
||||
:global(.ansi-bg-magenta) { background-color: #c026d3; }
|
||||
:global(.ansi-bg-cyan) { background-color: #0891b2; }
|
||||
:global(.ansi-bg-white) { background-color: #d4d4d8; }
|
||||
|
||||
/* Text styles */
|
||||
:global(.ansi-bold) { font-weight: bold; }
|
||||
:global(.ansi-dim) { opacity: 0.7; }
|
||||
:global(.ansi-italic) { font-style: italic; }
|
||||
:global(.ansi-underline) { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -4,6 +4,9 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import { getMonospaceFont } from '$lib/themes';
|
||||
import { AnsiUp } from 'ansi_up';
|
||||
const ansiUp = new AnsiUp();
|
||||
ansiUp.use_classes = true;
|
||||
|
||||
interface Props {
|
||||
logs: string;
|
||||
@@ -139,25 +142,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Highlighted logs with search matches
|
||||
// Highlighted logs with search matches and ANSI color support
|
||||
let highlightedLogs = $derived(() => {
|
||||
const escaped = escapeHtml(logs || '');
|
||||
if (!logSearchQuery.trim()) return escaped;
|
||||
const withAnsi = ansiUp.ansi_to_html(logs || '');
|
||||
if (!logSearchQuery.trim()) return withAnsi;
|
||||
|
||||
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const escapedQuery = escapeHtml(query);
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
return escaped.replace(regex, '<mark class="search-match">$1</mark>');
|
||||
const escapedQuery = query.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// Split by HTML tags and only process text parts
|
||||
const parts = withAnsi.split(/(<[^>]*>)/);
|
||||
return parts.map(part => {
|
||||
if (part.startsWith('<')) return part;
|
||||
return part.replace(new RegExp(`(${escapedQuery})`, 'gi'), '<mark class="search-match">$1</mark>');
|
||||
}).join('');
|
||||
});
|
||||
|
||||
// Update match count after render
|
||||
@@ -322,4 +320,5 @@
|
||||
box-shadow: 0 0 8px rgba(234, 179, 8, 0.9), 0 0 16px rgba(234, 179, 8, 0.5);
|
||||
outline: 2px solid rgb(250, 204, 21);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -6,6 +6,9 @@
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import { getMonospaceFont } from '$lib/themes';
|
||||
import { AnsiUp } from 'ansi_up';
|
||||
const ansiUp = new AnsiUp();
|
||||
ansiUp.use_classes = true;
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
@@ -519,100 +522,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Escape HTML to prevent XSS
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ANSI color code to CSS class mapping
|
||||
const ansiColorMap: Record<string, string> = {
|
||||
'30': 'ansi-black',
|
||||
'31': 'ansi-red',
|
||||
'32': 'ansi-green',
|
||||
'33': 'ansi-yellow',
|
||||
'34': 'ansi-blue',
|
||||
'35': 'ansi-magenta',
|
||||
'36': 'ansi-cyan',
|
||||
'37': 'ansi-white',
|
||||
'90': 'ansi-bright-black',
|
||||
'91': 'ansi-bright-red',
|
||||
'92': 'ansi-bright-green',
|
||||
'93': 'ansi-bright-yellow',
|
||||
'94': 'ansi-bright-blue',
|
||||
'95': 'ansi-bright-magenta',
|
||||
'96': 'ansi-bright-cyan',
|
||||
'97': 'ansi-bright-white',
|
||||
'40': 'ansi-bg-black',
|
||||
'41': 'ansi-bg-red',
|
||||
'42': 'ansi-bg-green',
|
||||
'43': 'ansi-bg-yellow',
|
||||
'44': 'ansi-bg-blue',
|
||||
'45': 'ansi-bg-magenta',
|
||||
'46': 'ansi-bg-cyan',
|
||||
'47': 'ansi-bg-white',
|
||||
'1': 'ansi-bold',
|
||||
'2': 'ansi-dim',
|
||||
'3': 'ansi-italic',
|
||||
'4': 'ansi-underline',
|
||||
};
|
||||
|
||||
// Convert ANSI escape codes to HTML spans with CSS classes
|
||||
function ansiToHtml(text: string): string {
|
||||
// Strip control characters
|
||||
let cleaned = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1A]/g, '');
|
||||
|
||||
// Escape HTML
|
||||
let escaped = escapeHtml(cleaned);
|
||||
|
||||
// Match ANSI escape sequences
|
||||
const ansiRegex = /\x1b\[([0-9;]*)m/g;
|
||||
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let openSpans = 0;
|
||||
let match;
|
||||
|
||||
while ((match = ansiRegex.exec(escaped)) !== null) {
|
||||
result += escaped.slice(lastIndex, match.index);
|
||||
lastIndex = ansiRegex.lastIndex;
|
||||
|
||||
const codes = match[1].split(';').filter(c => c !== '');
|
||||
|
||||
for (const code of codes) {
|
||||
if (code === '0' || code === '39' || code === '49' || code === '') {
|
||||
while (openSpans > 0) {
|
||||
result += '</span>';
|
||||
openSpans--;
|
||||
}
|
||||
} else if (ansiColorMap[code]) {
|
||||
result += `<span class="${ansiColorMap[code]}">`;
|
||||
openSpans++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result += escaped.slice(lastIndex);
|
||||
|
||||
while (openSpans > 0) {
|
||||
result += '</span>';
|
||||
openSpans--;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Highlighted logs with search matches and ANSI color support
|
||||
let highlightedLogs = $derived(() => {
|
||||
const withAnsi = ansiToHtml(logs || '');
|
||||
const withAnsi = ansiUp.ansi_to_html(logs || '');
|
||||
if (!logSearchQuery.trim()) return withAnsi;
|
||||
|
||||
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const escapedQuery = escapeHtml(query);
|
||||
const escapedQuery = query.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// Split by HTML tags and only process text parts
|
||||
const parts = withAnsi.split(/(<[^>]*>)/);
|
||||
@@ -897,42 +813,6 @@
|
||||
outline: 2px solid rgb(250, 204, 21);
|
||||
}
|
||||
|
||||
/* ANSI color classes - foreground colors */
|
||||
:global(.ansi-black) { color: #3f3f46; }
|
||||
:global(.ansi-red) { color: #ef4444; }
|
||||
:global(.ansi-green) { color: #22c55e; }
|
||||
:global(.ansi-yellow) { color: #eab308; }
|
||||
:global(.ansi-blue) { color: #3b82f6; }
|
||||
:global(.ansi-magenta) { color: #d946ef; }
|
||||
:global(.ansi-cyan) { color: #06b6d4; }
|
||||
:global(.ansi-white) { color: #e4e4e7; }
|
||||
|
||||
/* Bright foreground colors */
|
||||
:global(.ansi-bright-black) { color: #71717a; }
|
||||
:global(.ansi-bright-red) { color: #f87171; }
|
||||
:global(.ansi-bright-green) { color: #4ade80; }
|
||||
:global(.ansi-bright-yellow) { color: #facc15; }
|
||||
:global(.ansi-bright-blue) { color: #60a5fa; }
|
||||
:global(.ansi-bright-magenta) { color: #e879f9; }
|
||||
:global(.ansi-bright-cyan) { color: #22d3ee; }
|
||||
:global(.ansi-bright-white) { color: #fafafa; }
|
||||
|
||||
/* Background colors */
|
||||
:global(.ansi-bg-black) { background-color: #18181b; }
|
||||
:global(.ansi-bg-red) { background-color: #dc2626; }
|
||||
:global(.ansi-bg-green) { background-color: #16a34a; }
|
||||
:global(.ansi-bg-yellow) { background-color: #ca8a04; }
|
||||
:global(.ansi-bg-blue) { background-color: #2563eb; }
|
||||
:global(.ansi-bg-magenta) { background-color: #c026d3; }
|
||||
:global(.ansi-bg-cyan) { background-color: #0891b2; }
|
||||
:global(.ansi-bg-white) { background-color: #d4d4d8; }
|
||||
|
||||
/* Text styles */
|
||||
:global(.ansi-bold) { font-weight: bold; }
|
||||
:global(.ansi-dim) { opacity: 0.7; }
|
||||
:global(.ansi-italic) { font-style: italic; }
|
||||
:global(.ansi-underline) { text-decoration: underline; }
|
||||
|
||||
/* Fade-in animation for logs */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
||||
@@ -307,7 +307,21 @@
|
||||
* Strip protocol and port from a host/IP string
|
||||
*/
|
||||
function stripHostProtocol(value: string): string {
|
||||
return value.replace(/^(?:\w+:\/\/)/, '').replace(/[:/].*$/, '');
|
||||
// Strip protocol prefix (e.g., tcp://, https://)
|
||||
const stripped = value.replace(/^(?:\w+:\/\/)/, '');
|
||||
|
||||
// Handle bracketed IPv6 with optional port: [::1]:port → ::1
|
||||
if (stripped.startsWith('[')) {
|
||||
return stripped.replace(/^\[([^\]]+)\].*$/, '$1');
|
||||
}
|
||||
|
||||
// Handle plain IPv6 (2+ colons = IPv6, not IPv4:port or hostname:port)
|
||||
if ((stripped.match(/:/g) || []).length > 1) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
// IPv4 or hostname: strip :port and path
|
||||
return stripped.replace(/[:/].*$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -215,28 +215,26 @@
|
||||
|
||||
testingAll = true;
|
||||
|
||||
// Process environments sequentially to avoid overwhelming the system
|
||||
// This is especially important for Edge environments that have longer timeouts
|
||||
for (const env of environments) {
|
||||
// Mark this environment as testing
|
||||
testingEnvs.add(env.id);
|
||||
testingEnvs = new Set(testingEnvs);
|
||||
// Show all spinners immediately, then test all envs in parallel.
|
||||
// Sequential testing was wrong for edge envs: 30s timeout × N envs = N×30s total wait.
|
||||
// Parallel: all timeouts run concurrently, total wait is max(individual timeouts) = 30s.
|
||||
environments.forEach(env => testingEnvs.add(env.id));
|
||||
testingEnvs = new Set(testingEnvs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/environments/${env.id}/test`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
testResults[env.id] = result;
|
||||
} catch (error) {
|
||||
testResults[env.id] = { success: false, error: 'Connection failed' };
|
||||
}
|
||||
testResults = { ...testResults };
|
||||
|
||||
// Mark this environment as done
|
||||
testingEnvs.delete(env.id);
|
||||
testingEnvs = new Set(testingEnvs);
|
||||
}
|
||||
await Promise.all(
|
||||
environments.map(async (env) => {
|
||||
try {
|
||||
const response = await fetch(`/api/environments/${env.id}/test`, { method: 'POST' });
|
||||
testResults[env.id] = await response.json();
|
||||
} catch {
|
||||
testResults[env.id] = { success: false, error: 'Connection failed' };
|
||||
} finally {
|
||||
testingEnvs.delete(env.id);
|
||||
testingEnvs = new Set(testingEnvs);
|
||||
}
|
||||
testResults = { ...testResults };
|
||||
})
|
||||
);
|
||||
|
||||
testingAll = false;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
let confirmDestructive = $derived($appSettings.confirmDestructive);
|
||||
let showStoppedContainers = $derived($appSettings.showStoppedContainers);
|
||||
let highlightUpdates = $derived($appSettings.highlightUpdates);
|
||||
let compactPorts = $derived($appSettings.compactPorts);
|
||||
let timeFormat = $derived($appSettings.timeFormat);
|
||||
let dateFormat = $derived($appSettings.dateFormat);
|
||||
let downloadFormat = $derived($appSettings.downloadFormat);
|
||||
@@ -178,6 +179,20 @@
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Highlight container rows in amber when updates are available</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>Compact port display</Label>
|
||||
<TogglePill
|
||||
checked={compactPorts}
|
||||
onchange={() => {
|
||||
appSettings.setCompactPorts(!compactPorts);
|
||||
toast.success(compactPorts ? 'Showing all ports' : 'Compact port display enabled');
|
||||
}}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Show first port with +N count instead of all ports</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>Time format</Label>
|
||||
|
||||
@@ -1797,7 +1797,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if $canAccess('stacks', 'stop')}
|
||||
{#if $canAccess('stacks', 'stop') && stack.status !== 'created' && stack.status !== 'not deployed'}
|
||||
<ConfirmPopover
|
||||
open={confirmDownName === stack.name}
|
||||
action="Down"
|
||||
|
||||
Reference in new issue
Block a user