1.0.29
This commit is contained in:
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user