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