1.0.7
This commit is contained in:
1 parent
bd7b832394
commit
7643807717
86 files changed
+3644
-1385
No files matched your search
@@ -285,11 +285,19 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
inspectResponse = await fetch(inspectUrl, {
|
||||
headers: inspectHeaders,
|
||||
// @ts-ignore
|
||||
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
|
||||
});
|
||||
const fetchOpts: any = { headers: inspectHeaders };
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0, // Disable TLS session caching for mTLS
|
||||
servername: config.host,
|
||||
rejectUnauthorized: true
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
}
|
||||
inspectResponse = await fetch(inspectUrl, fetchOpts);
|
||||
}
|
||||
|
||||
if (inspectResponse.ok) {
|
||||
@@ -341,12 +349,22 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
|
||||
const logsHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
response = await fetch(logsUrl, {
|
||||
const fetchOpts: any = {
|
||||
headers: logsHeaders,
|
||||
signal: abortController?.signal,
|
||||
// @ts-ignore
|
||||
tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
|
||||
});
|
||||
signal: abortController?.signal
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0, // Disable TLS session caching for mTLS
|
||||
servername: config.host,
|
||||
rejectUnauthorized: true
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
}
|
||||
response = await fetch(logsUrl, fetchOpts);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getContainerStats } from '$lib/server/docker';
|
||||
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
|
||||
@@ -47,6 +47,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } {
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate memory usage the same way Docker CLI does.
|
||||
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
|
||||
* - cgroup v2: subtract inactive_file from stats
|
||||
* - cgroup v1: subtract total_inactive_file from stats
|
||||
* See: https://docs.docker.com/engine/containers/runmetrics/
|
||||
*
|
||||
* Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache }
|
||||
*/
|
||||
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
|
||||
const raw = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
|
||||
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
|
||||
// Only subtract cache if it's less than raw usage (sanity check)
|
||||
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
|
||||
|
||||
return { usage, raw, cache };
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -67,15 +89,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const stats = await getContainerStats(params.id, envIdNum) as any;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
const memoryUsage = stats.memory_stats?.usage || 0;
|
||||
const memory = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
||||
const memoryPercent = (memory.usage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
return json({
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage,
|
||||
memoryUsage: memory.usage,
|
||||
memoryRaw: memory.raw,
|
||||
memoryCache: memory.cache,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
@@ -85,6 +109,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Return 404 for deleted environments so client can clear stale cache
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Failed to get container stats:', error);
|
||||
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listContainers, getContainerStats } from '$lib/server/docker';
|
||||
import { listContainers, getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { ContainerStats } from '$lib/types';
|
||||
@@ -48,6 +48,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } {
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate memory usage the same way Docker CLI does.
|
||||
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
|
||||
* - cgroup v2: subtract inactive_file from stats
|
||||
* - cgroup v1: subtract total_inactive_file from stats
|
||||
* See: https://docs.docker.com/engine/containers/runmetrics/
|
||||
*
|
||||
* Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache }
|
||||
*/
|
||||
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
|
||||
const raw = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
|
||||
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
|
||||
// Only subtract cache if it's less than raw usage (sanity check)
|
||||
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
|
||||
|
||||
return { usage, raw, cache };
|
||||
}
|
||||
|
||||
// Helper to add timeout to promises
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
@@ -112,10 +134,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (!stats) return null;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
// Use raw memory usage (total memory attributed to container)
|
||||
const memoryUsage = stats.memory_stats?.usage || 0;
|
||||
// Calculate memory usage the same way Docker CLI does (excludes cache)
|
||||
const memory = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
||||
const memoryPercent = (memory.usage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
@@ -123,7 +145,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage,
|
||||
memoryUsage: memory.usage,
|
||||
memoryRaw: memory.raw,
|
||||
memoryCache: memory.cache,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
@@ -142,6 +166,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
return json(validStats);
|
||||
} catch (error: any) {
|
||||
// Return 404 for deleted environments so client can clear stale cache
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Failed to get container stats:', error);
|
||||
return json([], { status: 200 }); // Return empty array instead of error
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface EnvironmentStats {
|
||||
updateCheckAutoUpdate: boolean;
|
||||
labels?: string[];
|
||||
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
||||
online: boolean;
|
||||
online?: boolean; // undefined = connecting, false = offline, true = online
|
||||
error?: string;
|
||||
containers: {
|
||||
total: number;
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
listContainers,
|
||||
listImages,
|
||||
listNetworks,
|
||||
getDockerInfo,
|
||||
getContainerStats,
|
||||
getDiskUsage
|
||||
} from '$lib/server/docker';
|
||||
@@ -95,6 +94,28 @@ function calculateCpuPercent(stats: any): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate memory usage the same way Docker CLI does.
|
||||
* Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
|
||||
* - cgroup v2: subtract inactive_file from stats
|
||||
* - cgroup v1: subtract total_inactive_file from stats
|
||||
* See: https://docs.docker.com/engine/containers/runmetrics/
|
||||
*/
|
||||
function calculateMemoryUsage(memoryStats: any): number {
|
||||
const usage = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
|
||||
// cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
|
||||
// Only subtract cache if it's less than usage (sanity check)
|
||||
if (cache > 0 && cache < usage) {
|
||||
return usage - cache;
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
// Progressive stats loading - returns stats object and emits partial updates via callback
|
||||
async function getEnvironmentStatsProgressive(
|
||||
env: any,
|
||||
@@ -150,23 +171,9 @@ async function getEnvironmentStatsProgressive(
|
||||
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
|
||||
}
|
||||
|
||||
// Check if Docker is accessible (with 5 second timeout)
|
||||
const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
|
||||
if (!dockerInfo) {
|
||||
envStats.error = 'Connection timeout or Docker not accessible';
|
||||
envStats.loading = undefined; // Clear loading states on error
|
||||
// Send offline status to client
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: false,
|
||||
error: envStats.error,
|
||||
loading: undefined
|
||||
});
|
||||
return envStats;
|
||||
}
|
||||
envStats.online = true;
|
||||
|
||||
// Get all database stats in parallel for better performance
|
||||
// NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
|
||||
// Instead, we determine online status from whether listContainers succeeds
|
||||
const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([
|
||||
getLatestHostMetrics(env.id),
|
||||
getContainerEventStats(env.id),
|
||||
@@ -204,10 +211,9 @@ async function getEnvironmentStatsProgressive(
|
||||
}));
|
||||
}
|
||||
|
||||
// Send initial update with DB data and online status
|
||||
// Send initial update with DB data (online status determined later by Docker API success)
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: true,
|
||||
metrics: envStats.metrics,
|
||||
events: envStats.events,
|
||||
recentEvents: envStats.recentEvents,
|
||||
@@ -223,9 +229,22 @@ async function getEnvironmentStatsProgressive(
|
||||
return size && size > 0 ? size : 0;
|
||||
};
|
||||
|
||||
// PHASE 1: Containers (usually fast)
|
||||
const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, [])
|
||||
// Track if Docker API is accessible - determined by listContainers success
|
||||
let dockerApiAccessible = false;
|
||||
let dockerApiError: string | null = null;
|
||||
|
||||
// PHASE 1: Containers (usually fast) - this determines online status
|
||||
// Use 10s timeout - this is the critical path that determines if env is online
|
||||
const containersPromise = withTimeout(listContainers(true, env.id), 10000, null)
|
||||
.then(async (containers) => {
|
||||
// Timeout returns null
|
||||
if (containers === null) {
|
||||
throw new Error('Connection timeout');
|
||||
}
|
||||
// If we got here, Docker API is accessible
|
||||
dockerApiAccessible = true;
|
||||
envStats.online = true;
|
||||
|
||||
envStats.containers.total = containers.length;
|
||||
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
|
||||
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
|
||||
@@ -236,11 +255,39 @@ async function getEnvironmentStatsProgressive(
|
||||
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: true,
|
||||
containers: { ...envStats.containers },
|
||||
loading: { ...envStats.loading! }
|
||||
});
|
||||
|
||||
return containers;
|
||||
})
|
||||
.catch((error) => {
|
||||
// Docker API failed - mark as offline
|
||||
dockerApiAccessible = false;
|
||||
const errorStr = String(error);
|
||||
if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) {
|
||||
dockerApiError = 'Agent not connected';
|
||||
} else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
|
||||
dockerApiError = 'Docker socket not accessible';
|
||||
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
|
||||
dockerApiError = 'Connection lost';
|
||||
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
|
||||
dockerApiError = 'Connection timeout';
|
||||
} else {
|
||||
dockerApiError = 'Connection error';
|
||||
}
|
||||
envStats.error = dockerApiError;
|
||||
envStats.loading!.containers = false;
|
||||
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
online: false,
|
||||
error: dockerApiError,
|
||||
loading: { ...envStats.loading! }
|
||||
});
|
||||
|
||||
return [] as any[];
|
||||
});
|
||||
|
||||
// PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel
|
||||
@@ -339,7 +386,7 @@ async function getEnvironmentStatsProgressive(
|
||||
if (!stats) return null;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
const memoryUsage = stats.memory_stats?.usage || 0;
|
||||
const memoryUsage = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironments, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
|
||||
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -69,6 +70,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json({ error: 'Name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if environment with this name already exists
|
||||
const existing = await getEnvironmentByName(data.name);
|
||||
if (existing) {
|
||||
return json({ error: 'An environment with this name already exists' }, { status: 409 });
|
||||
}
|
||||
|
||||
// Host is required for direct and hawser-standard connections
|
||||
const connectionType = data.connectionType || 'socket';
|
||||
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
|
||||
@@ -83,9 +90,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
host: data.host,
|
||||
port: data.port || 2375,
|
||||
protocol: data.protocol || 'http',
|
||||
tlsCa: data.tlsCa,
|
||||
tlsCert: data.tlsCert,
|
||||
tlsKey: data.tlsKey,
|
||||
tlsCa: cleanPem(data.tlsCa),
|
||||
tlsCert: cleanPem(data.tlsCert),
|
||||
tlsKey: cleanPem(data.tlsKey),
|
||||
tlsSkipVerify: data.tlsSkipVerify || false,
|
||||
icon: data.icon || 'globe',
|
||||
socketPath: data.socketPath || '/var/run/docker.sock',
|
||||
@@ -124,7 +131,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json(env);
|
||||
} catch (error) {
|
||||
console.error('Failed to create environment:', error);
|
||||
const message = error instanceof Error ? error.message : 'Failed to create environment';
|
||||
return json({ error: message }, { status: 500 });
|
||||
return json({ error: 'Failed to create environment' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { deleteGitStackFiles } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { closeEdgeConnection } from '$lib/server/hawser';
|
||||
|
||||
@@ -62,9 +63,9 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
||||
host: data.host,
|
||||
port: data.port,
|
||||
protocol: data.protocol,
|
||||
tlsCa: data.tlsCa,
|
||||
tlsCert: data.tlsCert,
|
||||
tlsKey: data.tlsKey,
|
||||
tlsCa: cleanPem(data.tlsCa),
|
||||
tlsCert: cleanPem(data.tlsCert),
|
||||
tlsKey: cleanPem(data.tlsKey),
|
||||
tlsSkipVerify: data.tlsSkipVerify,
|
||||
icon: data.icon,
|
||||
socketPath: data.socketPath,
|
||||
|
||||
@@ -60,50 +60,54 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
headers['X-Hawser-Token'] = config.hawserToken;
|
||||
}
|
||||
|
||||
// For HTTPS with custom CA or skip verification, use subprocess to avoid Vite dev server TLS issues
|
||||
if (protocol === 'https' && (config.tlsCa || config.tlsSkipVerify)) {
|
||||
const fs = await import('node:fs');
|
||||
let tempCaPath = '';
|
||||
// For HTTPS with custom CA, client certs, or skip verification, use subprocess to avoid Vite dev server TLS issues
|
||||
if (protocol === 'https' && (config.tlsCa || config.tlsCert || config.tlsSkipVerify)) {
|
||||
// Clean PEM content (remove extra whitespace)
|
||||
const cleanPem = (pem: string) => pem
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
|
||||
// Clean the certificate - remove leading/trailing whitespace from each line
|
||||
let cleanedCa = '';
|
||||
if (config.tlsCa && !config.tlsSkipVerify) {
|
||||
cleanedCa = config.tlsCa
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
|
||||
tempCaPath = `/tmp/dockhand-ca-${Date.now()}.pem`;
|
||||
fs.writeFileSync(tempCaPath, cleanedCa);
|
||||
}
|
||||
|
||||
// Build Bun script that runs outside Vite's process (Vite interferes with TLS)
|
||||
const tlsConfig = config.tlsSkipVerify
|
||||
? `tls: { rejectUnauthorized: false }`
|
||||
: `tls: { ca: await Bun.file('${tempCaPath}').text() }`;
|
||||
// Pass config as base64-encoded JSON to avoid escaping issues
|
||||
const tlsConfig = {
|
||||
url: `https://${host}:${port}/info`,
|
||||
headers,
|
||||
tlsSkipVerify: config.tlsSkipVerify || false,
|
||||
ca: config.tlsCa && !config.tlsSkipVerify ? cleanPem(config.tlsCa) : null,
|
||||
cert: config.tlsCert ? cleanPem(config.tlsCert) : null,
|
||||
key: config.tlsKey ? cleanPem(config.tlsKey) : null,
|
||||
host
|
||||
};
|
||||
const configBase64 = Buffer.from(JSON.stringify(tlsConfig)).toString('base64');
|
||||
|
||||
// Inline script with config embedded (bun -e doesn't pass argv correctly)
|
||||
const scriptContent = `
|
||||
const response = await fetch('https://${host}:${port}/info', {
|
||||
headers: ${JSON.stringify(headers)},
|
||||
${tlsConfig}
|
||||
});
|
||||
const body = await response.text();
|
||||
console.log(JSON.stringify({ status: response.status, body }));
|
||||
const config = JSON.parse(Buffer.from('${configBase64}', 'base64').toString());
|
||||
try {
|
||||
const tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.tlsSkipVerify
|
||||
};
|
||||
if (config.ca) tls.ca = [config.ca];
|
||||
if (config.cert) tls.cert = [config.cert];
|
||||
if (config.key) tls.key = config.key;
|
||||
const response = await fetch(config.url, {
|
||||
headers: config.headers,
|
||||
tls,
|
||||
keepalive: false
|
||||
});
|
||||
const body = await response.text();
|
||||
console.log(JSON.stringify({ status: response.status, body }));
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify({ error: e.message }));
|
||||
}
|
||||
`;
|
||||
const scriptPath = `/tmp/dockhand-test-${Date.now()}.ts`;
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const proc = Bun.spawn(['bun', '-e', scriptContent], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
|
||||
// Cleanup temp files
|
||||
if (tempCaPath) {
|
||||
try { fs.unlinkSync(tempCaPath); } catch {}
|
||||
}
|
||||
try { fs.unlinkSync(scriptPath); } catch {}
|
||||
|
||||
if (!output.trim()) {
|
||||
throw new Error(stderr || 'Empty response from TLS test subprocess');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDockerEvents } from '$lib/server/docker';
|
||||
import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
@@ -118,8 +118,13 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
processEvents();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to connect to Docker events:', error);
|
||||
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
// Expected error when environment doesn't exist - don't spam logs
|
||||
sendEvent('error', { message: 'Environment not found' });
|
||||
} else {
|
||||
console.error('Failed to connect to Docker events:', error);
|
||||
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
|
||||
}
|
||||
clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule } from '$lib/server/scheduler';
|
||||
import crypto from 'node:crypto';
|
||||
import { secureRandomBytes } from '$lib/server/crypto-fallback';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -94,7 +94,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
// Generate webhook secret if webhook is enabled
|
||||
let webhookSecret = data.webhookSecret;
|
||||
if (data.webhookEnabled && !webhookSecret) {
|
||||
webhookSecret = crypto.randomBytes(32).toString('hex');
|
||||
webhookSecret = secureRandomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
const gitStack = await createGitStack({
|
||||
|
||||
@@ -16,7 +16,16 @@ function isDockerHub(url: string): boolean {
|
||||
lower.includes('registry.hub.docker.com');
|
||||
}
|
||||
|
||||
async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
|
||||
interface PaginatedTags {
|
||||
tags: TagInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
}
|
||||
|
||||
async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize: number = 20): Promise<PaginatedTags> {
|
||||
// Docker Hub uses a different API
|
||||
// For official images: https://hub.docker.com/v2/repositories/library/<image>/tags
|
||||
// For user images: https://hub.docker.com/v2/repositories/<user>/<image>/tags
|
||||
@@ -27,7 +36,7 @@ async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
|
||||
repoPath = `library/${imageName}`;
|
||||
}
|
||||
|
||||
const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=100&ordering=last_updated`;
|
||||
const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=${pageSize}&page=${page}&ordering=last_updated`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
@@ -45,12 +54,21 @@ async function fetchDockerHubTags(imageName: string): Promise<TagInfo[]> {
|
||||
const data = await response.json();
|
||||
const results = data.results || [];
|
||||
|
||||
return results.map((tag: any) => ({
|
||||
const tags = results.map((tag: any) => ({
|
||||
name: tag.name,
|
||||
size: tag.full_size || tag.images?.[0]?.size,
|
||||
lastUpdated: tag.last_updated || tag.tag_last_pushed,
|
||||
digest: tag.images?.[0]?.digest
|
||||
}));
|
||||
|
||||
return {
|
||||
tags,
|
||||
total: data.count || 0,
|
||||
page,
|
||||
pageSize,
|
||||
hasNext: !!data.next,
|
||||
hasPrev: !!data.previous
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
|
||||
@@ -104,16 +122,18 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const registryId = url.searchParams.get('registry');
|
||||
const imageName = url.searchParams.get('image');
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(url.searchParams.get('pageSize') || '20');
|
||||
|
||||
if (!imageName) {
|
||||
return json({ error: 'Image name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let tags: TagInfo[];
|
||||
let result: PaginatedTags;
|
||||
|
||||
if (!registryId) {
|
||||
// No registry specified, assume Docker Hub
|
||||
tags = await fetchDockerHubTags(imageName);
|
||||
result = await fetchDockerHubTags(imageName, page, pageSize);
|
||||
} else {
|
||||
const registry = await getRegistry(parseInt(registryId));
|
||||
if (!registry) {
|
||||
@@ -121,13 +141,22 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
if (isDockerHub(registry.url)) {
|
||||
tags = await fetchDockerHubTags(imageName);
|
||||
result = await fetchDockerHubTags(imageName, page, pageSize);
|
||||
} else {
|
||||
tags = await fetchRegistryTags(registry, imageName);
|
||||
// V2 registries don't support pagination well, return all tags
|
||||
const tags = await fetchRegistryTags(registry, imageName);
|
||||
result = {
|
||||
tags,
|
||||
total: tags.length,
|
||||
page: 1,
|
||||
pageSize: tags.length,
|
||||
hasNext: false,
|
||||
hasPrev: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return json(tags);
|
||||
return json(result);
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching tags:', error);
|
||||
|
||||
|
||||
@@ -15,14 +15,26 @@ import {
|
||||
getEventCleanupEnabled,
|
||||
setEventCleanupEnabled,
|
||||
getDefaultTimezone,
|
||||
setDefaultTimezone
|
||||
setDefaultTimezone,
|
||||
getEventCollectionMode,
|
||||
setEventCollectionMode,
|
||||
getEventPollInterval,
|
||||
setEventPollInterval,
|
||||
getMetricsCollectionInterval,
|
||||
setMetricsCollectionInterval,
|
||||
getExternalStackPaths,
|
||||
setExternalStackPaths,
|
||||
getPrimaryStackLocation,
|
||||
setPrimaryStackLocation
|
||||
} from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSystemJobs } from '$lib/server/scheduler';
|
||||
import { sendToEventSubprocess, sendToMetricsSubprocess, type UpdateIntervalCommand } from '$lib/server/subprocess-manager';
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
export type DownloadFormat = 'tar' | 'tar.gz';
|
||||
export type EventCollectionMode = 'stream' | 'poll';
|
||||
|
||||
export interface GeneralSettings {
|
||||
confirmDestructive: boolean;
|
||||
@@ -41,6 +53,10 @@ export interface GeneralSettings {
|
||||
eventCleanupEnabled: boolean;
|
||||
logBufferSizeKb: number;
|
||||
defaultTimezone: string;
|
||||
// Background monitoring settings
|
||||
eventCollectionMode: EventCollectionMode;
|
||||
eventPollInterval: number;
|
||||
metricsCollectionInterval: number;
|
||||
// Theme settings (for when auth is disabled)
|
||||
lightTheme: string;
|
||||
darkTheme: string;
|
||||
@@ -48,6 +64,10 @@ export interface GeneralSettings {
|
||||
fontSize: string;
|
||||
gridFontSize: string;
|
||||
terminalFont: string;
|
||||
// External stack paths
|
||||
externalStackPaths: string[];
|
||||
// Primary stack location
|
||||
primaryStackLocation: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
|
||||
@@ -61,6 +81,9 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
defaultTrivyArgs: 'image --format json {image}',
|
||||
logBufferSizeKb: 500,
|
||||
defaultTimezone: 'UTC',
|
||||
eventCollectionMode: 'stream',
|
||||
eventPollInterval: 60000,
|
||||
metricsCollectionInterval: 30000,
|
||||
lightTheme: 'default',
|
||||
darkTheme: 'default',
|
||||
font: 'system',
|
||||
@@ -104,12 +127,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
eventCleanupEnabled,
|
||||
logBufferSizeKb,
|
||||
defaultTimezone,
|
||||
eventCollectionMode,
|
||||
eventPollInterval,
|
||||
metricsCollectionInterval,
|
||||
lightTheme,
|
||||
darkTheme,
|
||||
font,
|
||||
fontSize,
|
||||
gridFontSize,
|
||||
terminalFont
|
||||
terminalFont,
|
||||
externalStackPaths,
|
||||
primaryStackLocation
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -127,12 +155,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getEventCleanupEnabled(),
|
||||
getSetting('log_buffer_size_kb'),
|
||||
getDefaultTimezone(),
|
||||
getEventCollectionMode(),
|
||||
getEventPollInterval(),
|
||||
getMetricsCollectionInterval(),
|
||||
getSetting('theme_light'),
|
||||
getSetting('theme_dark'),
|
||||
getSetting('theme_font'),
|
||||
getSetting('theme_font_size'),
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font')
|
||||
getSetting('theme_terminal_font'),
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation()
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -152,12 +185,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
eventCleanupEnabled,
|
||||
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
|
||||
eventPollInterval: eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
lightTheme: lightTheme ?? DEFAULT_SETTINGS.lightTheme,
|
||||
darkTheme: darkTheme ?? DEFAULT_SETTINGS.darkTheme,
|
||||
font: font ?? DEFAULT_SETTINGS.font,
|
||||
fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize,
|
||||
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
|
||||
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont
|
||||
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
|
||||
externalStackPaths,
|
||||
primaryStackLocation
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
@@ -175,7 +213,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, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont } = 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, externalStackPaths, primaryStackLocation } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -228,6 +266,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
// Refresh system jobs to use the new timezone
|
||||
await refreshSystemJobs();
|
||||
}
|
||||
if (eventCollectionMode !== undefined && (eventCollectionMode === 'stream' || eventCollectionMode === 'poll')) {
|
||||
await setEventCollectionMode(eventCollectionMode);
|
||||
// Notify event subprocess to refresh collectors with new mode
|
||||
sendToEventSubprocess({ type: 'refresh_environments' });
|
||||
}
|
||||
if (eventPollInterval !== undefined && typeof eventPollInterval === 'number') {
|
||||
// Validate: 30s - 300s (30 seconds to 5 minutes)
|
||||
const validatedInterval = Math.max(30000, Math.min(300000, eventPollInterval));
|
||||
await setEventPollInterval(validatedInterval);
|
||||
// Notify event subprocess to refresh collectors with new interval
|
||||
sendToEventSubprocess({ type: 'refresh_environments' });
|
||||
}
|
||||
if (metricsCollectionInterval !== undefined && typeof metricsCollectionInterval === 'number') {
|
||||
// Validate: 10s - 300s (10 seconds to 5 minutes)
|
||||
const validatedInterval = Math.max(10000, Math.min(300000, metricsCollectionInterval));
|
||||
await setMetricsCollectionInterval(validatedInterval);
|
||||
// Notify metrics subprocess to update its collection interval
|
||||
sendToMetricsSubprocess({ type: 'update_interval', intervalMs: validatedInterval });
|
||||
}
|
||||
if (lightTheme !== undefined && VALID_LIGHT_THEMES.includes(lightTheme)) {
|
||||
await setSetting('theme_light', lightTheme);
|
||||
}
|
||||
@@ -246,6 +303,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) {
|
||||
await setSetting('theme_terminal_font', terminalFont);
|
||||
}
|
||||
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
|
||||
// Filter to valid non-empty strings
|
||||
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
|
||||
await setExternalStackPaths(validPaths);
|
||||
}
|
||||
if (primaryStackLocation !== undefined) {
|
||||
// Accept string or null
|
||||
if (primaryStackLocation === null || (typeof primaryStackLocation === 'string' && primaryStackLocation.trim())) {
|
||||
await setPrimaryStackLocation(primaryStackLocation);
|
||||
} else if (primaryStackLocation === '') {
|
||||
// Empty string means clear the setting
|
||||
await setPrimaryStackLocation(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all settings in parallel for the response
|
||||
const [
|
||||
@@ -265,12 +336,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
eventCleanupEnabledVal,
|
||||
logBufferSizeKbVal,
|
||||
defaultTimezoneVal,
|
||||
eventCollectionModeVal,
|
||||
eventPollIntervalVal,
|
||||
metricsCollectionIntervalVal,
|
||||
lightThemeVal,
|
||||
darkThemeVal,
|
||||
fontVal,
|
||||
fontSizeVal,
|
||||
gridFontSizeVal,
|
||||
terminalFontVal
|
||||
terminalFontVal,
|
||||
externalStackPathsVal,
|
||||
primaryStackLocationVal
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -288,12 +364,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getEventCleanupEnabled(),
|
||||
getSetting('log_buffer_size_kb'),
|
||||
getDefaultTimezone(),
|
||||
getEventCollectionMode(),
|
||||
getEventPollInterval(),
|
||||
getMetricsCollectionInterval(),
|
||||
getSetting('theme_light'),
|
||||
getSetting('theme_dark'),
|
||||
getSetting('theme_font'),
|
||||
getSetting('theme_font_size'),
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font')
|
||||
getSetting('theme_terminal_font'),
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation()
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -313,12 +394,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
eventCleanupEnabled: eventCleanupEnabledVal,
|
||||
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
|
||||
eventPollInterval: eventPollIntervalVal ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: metricsCollectionIntervalVal ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
lightTheme: lightThemeVal ?? DEFAULT_SETTINGS.lightTheme,
|
||||
darkTheme: darkThemeVal ?? DEFAULT_SETTINGS.darkTheme,
|
||||
font: fontVal ?? DEFAULT_SETTINGS.font,
|
||||
fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize,
|
||||
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
|
||||
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont
|
||||
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
|
||||
externalStackPaths: externalStackPathsVal,
|
||||
primaryStackLocation: primaryStackLocationVal
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
|
||||
@@ -35,11 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const existingNames = new Set(stacks.map((s) => s.name));
|
||||
|
||||
for (const source of stackSources) {
|
||||
// Only add internal/git stacks that aren't already in the list
|
||||
if (
|
||||
!existingNames.has(source.stackName) &&
|
||||
(source.sourceType === 'internal' || source.sourceType === 'git')
|
||||
) {
|
||||
// Add stacks from database that aren't already in the Docker list
|
||||
// This includes internal, git, and external (adopted) stacks that are currently down
|
||||
if (!existingNames.has(source.stackName)) {
|
||||
stacks.push({
|
||||
name: source.stackName,
|
||||
containers: [],
|
||||
@@ -78,7 +76,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, compose, start, envVars, rawEnvContent } = body;
|
||||
const { name, compose, start, envVars, rawEnvContent, composePath, envPath } = body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||
@@ -90,7 +88,10 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
|
||||
// If start is false, only create the compose file without deploying
|
||||
if (start === false) {
|
||||
const result = await saveStackComposeFile(name, compose, true);
|
||||
const result = await saveStackComposeFile(name, compose, true, envIdNum, {
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
if (!result.success) {
|
||||
return json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
@@ -100,7 +101,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
|
||||
if (rawEnvContent) {
|
||||
// Write raw content to .env file (should NOT contain secrets)
|
||||
await writeRawStackEnvFile(name, rawEnvContent);
|
||||
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
|
||||
}
|
||||
// Save ALL vars to DB (secrets for shell injection at runtime)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
@@ -108,14 +109,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
}
|
||||
// Fallback: if no rawEnvContent, generate .env from non-secret vars
|
||||
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVars(name, envVars, envIdNum);
|
||||
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
|
||||
}
|
||||
|
||||
// Record the stack as internally created
|
||||
// Record the stack as internally created with custom paths if provided
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envIdNum,
|
||||
sourceType: 'internal'
|
||||
sourceType: 'internal',
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
return json({ success: true, started: false });
|
||||
@@ -124,13 +127,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
// Save environment variables BEFORE deploying so they're available during start
|
||||
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
|
||||
// First ensure the stack directory exists by saving compose file
|
||||
await saveStackComposeFile(name, compose, true);
|
||||
await saveStackComposeFile(name, compose, true, envIdNum, {
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
// - rawEnvContent: non-secret vars with comments → .env file
|
||||
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
|
||||
if (rawEnvContent) {
|
||||
// Write raw content to .env file (should NOT contain secrets)
|
||||
await writeRawStackEnvFile(name, rawEnvContent);
|
||||
await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
|
||||
}
|
||||
// Save ALL vars to DB (secrets for shell injection at runtime)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
@@ -138,7 +144,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
}
|
||||
// Fallback: if no rawEnvContent, generate .env from non-secret vars
|
||||
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVars(name, envVars, envIdNum);
|
||||
await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,18 +152,22 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
const result = await deployStack({
|
||||
name,
|
||||
compose,
|
||||
envId: envIdNum
|
||||
envId: envIdNum,
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error, output: result.output }, { status: 400 });
|
||||
}
|
||||
|
||||
// Record the stack as internally created
|
||||
// Record the stack as internally created with custom paths if provided
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
environmentId: envIdNum,
|
||||
sourceType: 'internal'
|
||||
sourceType: 'internal',
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
|
||||
return json({ success: true, started: true, output: result.output });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { removeStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { removeStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -34,9 +34,6 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -4,22 +4,36 @@ import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/ser
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
// GET /api/stacks/[name]/compose - Get compose file content
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'view'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = params;
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
try {
|
||||
const result = await getStackComposeFile(name);
|
||||
const result = await getStackComposeFile(name, envIdNum);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ error: result.error }, { status: 404 });
|
||||
// Return info about what's needed - unified response for all missing compose files
|
||||
return json({
|
||||
error: result.error,
|
||||
needsFileLocation: result.needsFileLocation || false,
|
||||
composePath: result.composePath,
|
||||
envPath: result.envPath
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
return json({ content: result.content, stackDir: result.stackDir });
|
||||
return json({
|
||||
content: result.content,
|
||||
stackDir: result.stackDir,
|
||||
composePath: result.composePath,
|
||||
envPath: result.envPath,
|
||||
suggestedEnvPath: result.suggestedEnvPath
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`Error getting compose file for stack ${name}:`, error);
|
||||
return json({ error: error.message || 'Failed to get compose file' }, { status: 500 });
|
||||
@@ -41,16 +55,29 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { content, restart = false } = body;
|
||||
const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } = body;
|
||||
|
||||
if (!content || typeof content !== 'string') {
|
||||
return json({ error: 'Compose file content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build options object for custom paths, move operation, and file renames
|
||||
const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath)
|
||||
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
|
||||
: undefined;
|
||||
|
||||
let result;
|
||||
if (restart) {
|
||||
// Deploy with docker compose up -d --force-recreate
|
||||
// Force recreate ensures env var changes are applied
|
||||
// Note: deployStack uses requireComposeFile which will use saved paths
|
||||
// Save paths first if provided
|
||||
if (pathOptions) {
|
||||
const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||
if (!saveResult.success) {
|
||||
return json({ error: saveResult.error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
result = await deployStack({
|
||||
name,
|
||||
compose: content,
|
||||
@@ -58,8 +85,8 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
|
||||
forceRecreate: true
|
||||
});
|
||||
} else {
|
||||
// Just save the file without restarting
|
||||
result = await saveStackComposeFile(name, content);
|
||||
// Just save the file without restarting (update operation, not create)
|
||||
result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { downStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { downStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -42,9 +42,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
+32
-7
@@ -1,9 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
|
||||
import { getStacksDir } from '$lib/server/stacks';
|
||||
import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db';
|
||||
import { findStackDir } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
@@ -61,12 +61,37 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
|
||||
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
|
||||
|
||||
// Try to read .env file from stack directory (only contains non-secrets)
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
// Check if this stack has a custom compose path configured
|
||||
const source = await getStackSource(stackName, envIdNum);
|
||||
|
||||
// Determine the env file path based on path resolution rules:
|
||||
// - envPath = '' (empty string) → explicitly no env file
|
||||
// - envPath = '/path/.env' → use custom path
|
||||
// - envPath = null with composePath → suggest .env next to compose (but don't auto-load)
|
||||
// - envPath = null without composePath → use default location
|
||||
let envFilePath: string | null = null;
|
||||
|
||||
if (source?.envPath === '') {
|
||||
// Empty string = explicitly no env file
|
||||
envFilePath = null;
|
||||
} else if (source?.envPath) {
|
||||
// Custom env path specified
|
||||
envFilePath = source.envPath;
|
||||
} else if (source?.composePath) {
|
||||
// Custom compose path but no env path - suggest .env next to compose
|
||||
// For loading, check if it exists (but don't fail if it doesn't)
|
||||
envFilePath = join(dirname(source.composePath), '.env');
|
||||
} else {
|
||||
// Default location - .env in stack directory
|
||||
const stackDir = await findStackDir(stackName, envIdNum);
|
||||
if (stackDir) {
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
}
|
||||
|
||||
let fileVars: Record<string, string> = {};
|
||||
|
||||
if (existsSync(envFilePath)) {
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
fileVars = parseEnvFile(content);
|
||||
|
||||
+59
-10
@@ -1,8 +1,9 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getStacksDir } from '$lib/server/stacks';
|
||||
import { findStackDir, getStackDir } from '$lib/server/stacks';
|
||||
import { getStackSource } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
@@ -26,11 +27,36 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
|
||||
// Check if this stack has custom paths configured
|
||||
const source = await getStackSource(stackName, envIdNum);
|
||||
|
||||
// Determine the env file path based on path resolution rules:
|
||||
// - envPath = '' (empty string) → explicitly no env file
|
||||
// - envPath = '/path/.env' → use custom path
|
||||
// - envPath = null with composePath → suggest .env next to compose
|
||||
// - envPath = null without composePath → use default location
|
||||
let envFilePath: string | null = null;
|
||||
|
||||
if (source?.envPath === '') {
|
||||
// Empty string = explicitly no env file
|
||||
return json({ content: '', noEnvFile: true });
|
||||
} else if (source?.envPath) {
|
||||
// Custom env path specified
|
||||
envFilePath = source.envPath;
|
||||
} else if (source?.composePath) {
|
||||
// Custom compose path but no env path - suggest .env next to compose
|
||||
envFilePath = join(dirname(source.composePath), '.env');
|
||||
} else {
|
||||
// Default location - .env in stack directory
|
||||
const stackDir = await findStackDir(stackName, envIdNum);
|
||||
if (stackDir) {
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
}
|
||||
|
||||
let content = '';
|
||||
if (existsSync(envFilePath)) {
|
||||
if (envFilePath && existsSync(envFilePath)) {
|
||||
try {
|
||||
content = await Bun.file(envFilePath).text();
|
||||
} catch {
|
||||
@@ -73,12 +99,35 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
return json({ error: 'Invalid request body: content string required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
// Check if this stack has custom paths configured
|
||||
const source = await getStackSource(stackName, envIdNum);
|
||||
|
||||
// Only write if stack directory exists
|
||||
if (!existsSync(stackDir)) {
|
||||
// Determine the env file path based on path resolution rules:
|
||||
// - envPath = '' (empty string) → explicitly no env file, don't write
|
||||
// - envPath = '/path/.env' → use custom path
|
||||
// - envPath = null with composePath → suggest .env next to compose
|
||||
// - envPath = null without composePath → use default location
|
||||
let envFilePath: string | null = null;
|
||||
|
||||
if (source?.envPath === '') {
|
||||
// Empty string = explicitly no env file - don't allow writes
|
||||
return json({ success: true, noEnvFile: true });
|
||||
} else if (source?.envPath) {
|
||||
// Custom env path specified
|
||||
envFilePath = source.envPath;
|
||||
} else if (source?.composePath) {
|
||||
// Custom compose path but no env path - suggest .env next to compose
|
||||
envFilePath = join(dirname(source.composePath), '.env');
|
||||
} else {
|
||||
// Default location - .env in stack directory
|
||||
const stackDir = await findStackDir(stackName, envIdNum);
|
||||
if (stackDir) {
|
||||
envFilePath = join(stackDir, '.env');
|
||||
}
|
||||
}
|
||||
|
||||
// Only write if we have a valid path
|
||||
if (!envFilePath) {
|
||||
return json({ error: 'Stack directory not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { restartStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { restartStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { startStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { startStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { stopStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { stopStack, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
return json({ success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ExternalStackError) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
return json({ error: error.message }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const sources = await getStackSources(envIdNum);
|
||||
|
||||
// Convert to a map for easier lookup in the frontend
|
||||
const sourceMap: Record<string, { sourceType: string; repository?: any }> = {};
|
||||
const sourceMap: Record<string, { sourceType: string; composePath?: string | null; repository?: any }> = {};
|
||||
for (const source of sources) {
|
||||
sourceMap[source.stackName] = {
|
||||
sourceType: source.sourceType,
|
||||
composePath: source.composePath,
|
||||
repository: source.repository
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
nodeVersion: process.version,
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
kernel: os.release(),
|
||||
memory: bunInfo.memory,
|
||||
container: containerRuntime,
|
||||
ownContainer
|
||||
|
||||
Reference in new issue
Block a user