This commit is contained in:
jarek committed 2026-05-17 08:02:31 +02:00
1 parent d9054ff347
commit e7100f8926
44 files changed
+1672 -226

No files matched your search

@@ -303,7 +303,7 @@ async function getEnvironmentStatsProgressive(
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;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited' && c.exitCode !== 0).length;
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
+26 -1
View File
@@ -1,8 +1,11 @@
import { json } from '@sveltejs/kit';
import { join } from 'path';
import { existsSync, rmSync } from 'fs';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker';
import { deleteGitStackFiles } from '$lib/server/git';
import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git';
import { getStacksDir } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
@@ -182,6 +185,28 @@ export const DELETE: RequestHandler = async (event) => {
await deleteImagePruneSettings(id);
unregisterSchedule(id, 'image_prune');
// Clean up stack directory for this environment
try {
const stacksDir = getStacksDir();
const envStackDir = join(stacksDir, env.name);
if (existsSync(envStackDir)) {
rmSync(envStackDir, { recursive: true, force: true });
}
} catch (err) {
console.error(`Failed to clean up stack directory for environment "${env.name}":`, err);
}
// Clean up git-repos directory for this environment
try {
const gitReposDir = getGitReposDir();
const envGitDir = join(gitReposDir, env.name);
if (existsSync(envGitDir)) {
rmSync(envGitDir, { recursive: true, force: true });
}
} catch (err) {
console.error(`Failed to clean up git-repos directory for environment "${env.name}":`, err);
}
// Notify event collectors to stop collecting from deleted environment
refreshSubprocessEnvironments();
+1 -8
View File
@@ -13,7 +13,6 @@ import {
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule } from '$lib/server/scheduler';
import { secureRandomBytes } from '$lib/server/crypto-fallback';
import { auditGitStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
@@ -110,12 +109,6 @@ export const POST: RequestHandler = async (event) => {
}
}
// Generate webhook secret if webhook is enabled
let webhookSecret = data.webhookSecret;
if (data.webhookEnabled && !webhookSecret) {
webhookSecret = secureRandomBytes(32).toString('hex');
}
const gitStack = await createGitStack({
stackName: trimmedStackName,
environmentId: data.environmentId || null,
@@ -126,7 +119,7 @@ export const POST: RequestHandler = async (event) => {
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret,
webhookSecret: data.webhookSecret || null,
contextDir: data.contextDir || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
+190
View File
@@ -0,0 +1,190 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironments, updateEnvironment, getSetting, setSetting } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { parseLabels, serializeLabels, MAX_LABELS } from '$lib/utils/label-colors';
async function getLabelColors(): Promise<Record<string, string>> {
const colors = await getSetting('label_colors');
return (colors && typeof colors === 'object' && !Array.isArray(colors)) ? colors : {};
}
/**
* GET /api/labels — returns all unique labels with usage counts, environment details, and custom colors
*/
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const environments = await getEnvironments();
const customColors = await getLabelColors();
const labelMap = new Map<string, { envId: number; envName: string }[]>();
for (const env of environments) {
const labels = parseLabels(env.labels as string | null);
for (const label of labels) {
if (!labelMap.has(label)) {
labelMap.set(label, []);
}
labelMap.get(label)!.push({ envId: env.id, envName: env.name });
}
}
const result = Array.from(labelMap.entries())
.map(([label, envs]) => ({
label,
environments: envs,
count: envs.length,
color: customColors[label] || null
}))
.sort((a, b) => a.label.localeCompare(b.label));
return json({ labels: result, colors: customColors });
} catch (error) {
console.error('Failed to get labels:', error);
return json({ error: 'Failed to get labels' }, { status: 500 });
}
};
/**
* POST /api/labels — bulk label operations
* Body: { action: 'rename', oldLabel: string, newLabel: string }
* | { action: 'delete', label: string }
* | { action: 'add', label: string, environmentIds: number[] }
* | { action: 'set-color', label: string, color: string | null }
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const { action } = body;
if (action === 'rename') {
const { oldLabel, newLabel } = body;
if (!oldLabel || !newLabel || typeof oldLabel !== 'string' || typeof newLabel !== 'string') {
return json({ error: 'oldLabel and newLabel are required' }, { status: 400 });
}
const trimmed = newLabel.trim();
if (!trimmed) {
return json({ error: 'New label cannot be empty' }, { status: 400 });
}
const environments = await getEnvironments();
let affected = 0;
for (const env of environments) {
const labels = parseLabels(env.labels as string | null);
const idx = labels.indexOf(oldLabel);
if (idx === -1) continue;
if (labels.includes(trimmed)) {
labels.splice(idx, 1);
} else {
labels[idx] = trimmed;
}
await updateEnvironment(env.id, { labels: serializeLabels(labels) });
affected++;
}
// Migrate custom color from old label to new
const customColors = await getLabelColors();
if (customColors[oldLabel]) {
if (!customColors[trimmed]) {
customColors[trimmed] = customColors[oldLabel];
}
delete customColors[oldLabel];
await setSetting('label_colors', customColors);
}
return json({ success: true, affected });
}
if (action === 'delete') {
const { label } = body;
if (!label || typeof label !== 'string') {
return json({ error: 'label is required' }, { status: 400 });
}
const environments = await getEnvironments();
let affected = 0;
for (const env of environments) {
const labels = parseLabels(env.labels as string | null);
const idx = labels.indexOf(label);
if (idx === -1) continue;
labels.splice(idx, 1);
await updateEnvironment(env.id, { labels: serializeLabels(labels) });
affected++;
}
// Remove custom color
const customColors = await getLabelColors();
if (customColors[label]) {
delete customColors[label];
await setSetting('label_colors', customColors);
}
return json({ success: true, affected });
}
if (action === 'add') {
const { label, environmentIds } = body;
if (!label || typeof label !== 'string' || !label.trim()) {
return json({ error: 'label is required' }, { status: 400 });
}
if (!Array.isArray(environmentIds) || environmentIds.length === 0) {
return json({ error: 'environmentIds array is required' }, { status: 400 });
}
const trimmed = label.trim();
const environments = await getEnvironments();
const targetIds = new Set(environmentIds.map(Number));
let affected = 0;
for (const env of environments) {
if (!targetIds.has(env.id)) continue;
const labels = parseLabels(env.labels as string | null);
if (labels.includes(trimmed)) continue;
if (labels.length >= MAX_LABELS) continue;
labels.push(trimmed);
await updateEnvironment(env.id, { labels: serializeLabels(labels) });
affected++;
}
return json({ success: true, affected });
}
if (action === 'set-color') {
const { label, color } = body;
if (!label || typeof label !== 'string') {
return json({ error: 'label is required' }, { status: 400 });
}
const customColors = await getLabelColors();
if (color && typeof color === 'string' && /^#[0-9a-fA-F]{6}$/.test(color)) {
customColors[label] = color;
} else {
delete customColors[label];
}
await setSetting('label_colors', customColors);
return json({ success: true });
}
return json({ error: 'Invalid action. Use "rename", "delete", "add", or "set-color"' }, { status: 400 });
} catch (error) {
console.error('Failed to manage labels:', error);
return json({ error: 'Failed to manage labels' }, { status: 500 });
}
};
+13 -1
View File
@@ -74,6 +74,8 @@ export interface GeneralSettings {
editorFont: string;
// Compact ports
compactPorts: boolean;
// Show exposed (internal) ports
showExposedPorts: boolean;
// Log timestamp formatting
formatLogTimestamps: boolean;
// External stack paths
@@ -104,6 +106,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
compactPorts: false,
showExposedPorts: false,
formatLogTimestamps: false,
lightTheme: 'default',
darkTheme: 'default',
@@ -186,6 +189,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
terminalFont,
editorFont,
compactPorts,
showExposedPorts,
formatLogTimestamps,
externalStackPaths,
primaryStackLocation,
@@ -223,6 +227,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getSetting('show_exposed_ports'),
getSetting('format_log_timestamps'),
getExternalStackPaths(),
getPrimaryStackLocation(),
@@ -262,6 +267,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
showExposedPorts: showExposedPorts ?? DEFAULT_SETTINGS.showExposedPorts,
formatLogTimestamps: formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths,
primaryStackLocation,
@@ -286,7 +292,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -390,6 +396,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (compactPorts !== undefined) {
await setSetting('compact_ports', compactPorts);
}
if (showExposedPorts !== undefined) {
await setSetting('show_exposed_ports', showExposedPorts);
}
if (formatLogTimestamps !== undefined) {
await setSetting('format_log_timestamps', formatLogTimestamps);
}
@@ -451,6 +460,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
terminalFontVal,
editorFontVal,
compactPortsVal,
showExposedPortsVal,
formatLogTimestampsVal,
externalStackPathsVal,
primaryStackLocationVal,
@@ -488,6 +498,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getSetting('show_exposed_ports'),
getSetting('format_log_timestamps'),
getExternalStackPaths(),
getPrimaryStackLocation(),
@@ -527,6 +538,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
showExposedPorts: showExposedPortsVal ?? DEFAULT_SETTINGS.showExposedPorts,
formatLogTimestamps: formatLogTimestampsVal ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal,
+2 -1
View File
@@ -60,7 +60,8 @@ function extractComposeVars(yaml: string): { required: string[]; optional: strin
// Also match $VAR_NAME (simple variable substitution)
// Use negative lookbehind (?<!\$) to skip escaped $$
const simpleRegex = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g;
// Also require $ to not be preceded by an alphanumeric char (avoids matching inside values like "1$abc")
const simpleRegex = /(?<![A-Za-z0-9\$])\$([A-Za-z_][A-Za-z0-9_]*)(?![{A-Za-z0-9_])/g;
while ((match = simpleRegex.exec(line)) !== null) {
const varName = match[1];
if (!required.includes(varName) && !optional.includes(varName)) {