This commit is contained in:
jarek committed 2026-05-09 09:55:18 +02:00
1 parent 91ef3e3c9b
commit 002d969a5d
46 files changed
+7202 -233

No files matched your search

@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getPendingContainerUpdates, removePendingContainerUpdate } from '$lib/server/db';
import { getPendingContainerUpdates, removePendingContainerUpdate, clearPendingContainerUpdates } from '$lib/server/db';
/**
* Get pending container updates for an environment.
@@ -48,8 +48,8 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
const containerId = url.searchParams.get('containerId');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!envIdNum || !containerId) {
return json({ error: 'Environment ID and container ID required' }, { status: 400 });
if (!envIdNum) {
return json({ error: 'Environment ID required' }, { status: 400 });
}
// Need manage permission to delete
@@ -58,7 +58,11 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
}
try {
await removePendingContainerUpdate(envIdNum, containerId);
if (containerId) {
await removePendingContainerUpdate(envIdNum, containerId);
} else {
await clearPendingContainerUpdates(envIdNum);
}
return json({ success: true });
} catch (error: any) {
console.error('Error removing pending update:', error);
+10 -1
View File
@@ -7,7 +7,8 @@ import {
getGitRepository,
createGitRepository,
upsertStackSource,
setStackEnvVars
setStackEnvVars,
getStackSource
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
@@ -61,6 +62,12 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
}
// Check for name conflicts with existing stacks (regular/external/git)
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
if (existing) {
return json({ error: 'A stack with this name already exists on this environment' }, { status: 409 });
}
// Either repositoryId or new repo details (url, branch) must be provided
let repositoryId = data.repositoryId;
@@ -120,7 +127,9 @@ export const POST: RequestHandler = async (event) => {
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret,
contextDir: data.contextDir || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
});
@@ -73,7 +73,9 @@ export const PUT: RequestHandler = async (event) => {
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret,
contextDir: data.contextDir,
buildOnDeploy: data.buildOnDeploy,
noBuildCache: data.noBuildCache,
repullImages: data.repullImages,
forceRedeploy: data.forceRedeploy
});
+2 -2
View File
@@ -46,8 +46,8 @@ export const GET: RequestHandler = async ({ url }) => {
});
if (!response.ok) {
if (response.status === 401) {
return json({ error: 'Authentication failed. Please check your credentials.' }, { status: 401 });
if (response.status === 401 || response.status === 403) {
return json({ error: 'Catalog listing not available. This registry may not support the _catalog endpoint (common with GitLab and Harbor). Try searching for images by name instead.' }, { status: response.status });
}
if (response.status === 404) {
return json({ error: 'Registry does not support V2 catalog API' }, { status: 404 });
+37 -15
View File
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
import { getRegistryAuth, parseRegistryUrl } from '$lib/server/docker';
interface SearchResult {
name: string;
@@ -46,28 +46,50 @@ async function searchDockerHub(term: string, limit: number): Promise<SearchResul
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
const results: string[] = [];
const { orgPath } = parseRegistryUrl(registry.url);
const orgPrefix = orgPath ? orgPath.replace(/^\//, '') : '';
// Strategy 1: If term looks like an image name (contains /), try direct lookup first
// This is much faster than iterating through catalog for large registries like ghcr.io
// Strategy 1: Direct image lookup — try the exact term and org-prefixed variants
// This uses per-repository auth scope which works on all V2 registries (GitLab, Harbor, etc.)
const directCandidates: string[] = [];
if (term.includes('/')) {
const directResult = await tryDirectImageLookup(registry, term);
if (directResult) {
results.push(term);
directCandidates.push(term);
}
// If registry URL has an org path (e.g., https://registry.example.com/group),
// try prepending it to the search term
if (orgPrefix && !term.startsWith(orgPrefix + '/')) {
directCandidates.push(`${orgPrefix}/${term}`);
}
for (const candidate of directCandidates) {
if (results.length >= limit) break;
const exists = await tryDirectImageLookup(registry, candidate);
if (exists && !results.includes(candidate)) {
results.push(candidate);
}
}
// Strategy 2: Fall back to catalog search for partial matches or if direct lookup failed
// Strategy 2: Fall back to catalog search for partial/fuzzy matches
// Some registries (GitLab, Harbor) don't support _catalog for deploy tokens,
// so catch errors gracefully and return whatever we have from direct lookup
if (results.length < limit) {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
// Add catalog results, avoiding duplicates
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
try {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
}
}
} catch (e: any) {
// Catalog not supported but we have direct lookup results — that's fine
if (results.length > 0) {
console.warn(`[Registry] Catalog search failed (using direct lookup results): ${e.message}`);
} else {
throw e;
}
}
}
// Return results in the same format as Docker Hub
return results.map((name: string) => ({
name,
description: '',
@@ -136,8 +158,8 @@ async function searchCatalog(registry: any, term: string, limit: number): Promis
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. This registry may not support catalog listing (common with GitLab and Harbor deploy tokens).');
}
throw new Error(`Registry returned error: ${response.status}`);
}
+2 -2
View File
@@ -93,8 +93,8 @@ async function fetchRegistryTags(registry: any, imageName: string): Promise<TagI
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. Please check your credentials and permissions.');
}
if (response.status === 404) {
throw new Error('Image not found in registry');
@@ -2,13 +2,17 @@ import { json, type RequestHandler } from '@sveltejs/kit';
import {
setScheduleCleanupEnabled,
setEventCleanupEnabled,
setScannerCleanupEnabled,
getScheduleCleanupEnabled,
getEventCleanupEnabled
getEventCleanupEnabled,
getScannerCleanupEnabled
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
const SYSTEM_SCHEDULE_CLEANUP_ID = 1;
const SYSTEM_EVENT_CLEANUP_ID = 2;
const SYSTEM_SCANNER_CLEANUP_ID = 4;
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -32,6 +36,11 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
const currentEnabled = await getEventCleanupEnabled();
await setEventCleanupEnabled(!currentEnabled);
return json({ success: true, enabled: !currentEnabled });
} else if (systemId === SYSTEM_SCANNER_CLEANUP_ID) {
const currentEnabled = await getScannerCleanupEnabled();
await setScannerCleanupEnabled(!currentEnabled);
await refreshSystemJobs();
return json({ success: true, enabled: !currentEnabled });
} else {
return json({ error: 'Unknown system schedule' }, { status: 400 });
}
+28 -2
View File
@@ -14,6 +14,10 @@ import {
setScheduleCleanupEnabled,
getEventCleanupEnabled,
setEventCleanupEnabled,
getScannerCleanupCron,
setScannerCleanupCron,
getScannerCleanupEnabled,
setScannerCleanupEnabled,
getDefaultTimezone,
setDefaultTimezone,
getEventCollectionMode,
@@ -52,6 +56,8 @@ export interface GeneralSettings {
eventCleanupCron: string;
scheduleCleanupEnabled: boolean;
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
// Background monitoring settings
@@ -83,7 +89,7 @@ export interface GeneralSettings {
labelFilterMode: 'any' | 'all';
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
confirmDestructive: true,
showStoppedContainers: true,
highlightUpdates: true,
@@ -165,6 +171,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupCron,
scheduleCleanupEnabled,
eventCleanupEnabled,
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb,
defaultTimezone,
eventCollectionMode,
@@ -200,6 +208,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getEventCleanupCron(),
getScheduleCleanupEnabled(),
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
@@ -237,6 +247,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupCron,
scheduleCleanupEnabled,
eventCleanupEnabled,
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
@@ -274,7 +286,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, 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, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -318,6 +330,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (eventCleanupEnabled !== undefined && typeof eventCleanupEnabled === 'boolean') {
await setEventCleanupEnabled(eventCleanupEnabled);
}
if (scannerCleanupCron !== undefined && typeof scannerCleanupCron === 'string') {
await setScannerCleanupCron(scannerCleanupCron);
await refreshSystemJobs();
}
if (scannerCleanupEnabled !== undefined && typeof scannerCleanupEnabled === 'boolean') {
await setScannerCleanupEnabled(scannerCleanupEnabled);
await refreshSystemJobs();
}
if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') {
// Clamp to reasonable range: 100KB - 5000KB (5MB)
await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb)));
@@ -416,6 +436,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupCronVal,
scheduleCleanupEnabledVal,
eventCleanupEnabledVal,
scannerCleanupCronVal,
scannerCleanupEnabledVal,
logBufferSizeKbVal,
defaultTimezoneVal,
eventCollectionModeVal,
@@ -451,6 +473,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getEventCleanupCron(),
getScheduleCleanupEnabled(),
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
@@ -488,6 +512,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupCron: eventCleanupCronVal,
scheduleCleanupEnabled: scheduleCleanupEnabledVal,
eventCleanupEnabled: eventCleanupEnabledVal,
scannerCleanupCron: scannerCleanupCronVal,
scannerCleanupEnabled: scannerCleanupEnabledVal,
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
@@ -1,7 +1,7 @@
import { gzipSync } from 'node:zlib';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVolumeArchive } from '$lib/server/docker';
import { getVolumeArchive, releaseVolumeHelperContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
@@ -36,15 +36,33 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
if (format === 'tar.gz') {
// Compress with gzip
// Compress with gzip — fully consumes the archive stream
const tarData = new Uint8Array(await response.arrayBuffer());
body = gzipSync(tarData);
contentType = 'application/gzip';
extension = '.tar.gz';
}
// Note: Helper container is cached and reused for subsequent requests.
// Cache TTL handles cleanup automatically.
// Data fully read, release helper container immediately
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
} else {
// For streaming tar, wrap the stream to release on completion
const reader = body.getReader();
body = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
} else {
controller.enqueue(value);
}
},
cancel() {
reader.cancel();
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
}
});
}
const headers: Record<string, string> = {
'Content-Type': contentType,
@@ -66,6 +84,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
} catch (error: any) {
console.error('Failed to export volume:', error);
// Best-effort cleanup on error
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
if (error.message?.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}