This commit is contained in:
jarek committed 2026-01-17 15:06:14 +01:00
1 parent 62636426bf
commit c185d00dc3
60 files changed
+2830 -918

No files matched your search

@@ -0,0 +1,99 @@
import { json } from '@sveltejs/kit';
import { execInContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// Shell paths to check
const SHELLS_TO_CHECK = [
{ path: '/bin/bash', label: 'Bash' },
{ path: '/bin/sh', label: 'Shell (sh)' },
{ path: '/bin/zsh', label: 'Zsh' },
{ path: '/bin/ash', label: 'Ash (Alpine)' }
];
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check - need exec permission to detect shells
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const containerId = params.id;
const availableShells: string[] = [];
// Check each shell by testing if the file exists and is executable
// Use a single command to check all shells at once for efficiency
const checkCommand = SHELLS_TO_CHECK.map(s =>
`test -x ${s.path} && echo "${s.path}"`
).join('; ');
try {
const output = await execInContainer(
containerId,
['sh', '-c', checkCommand],
envIdNum
);
// Parse output - each line is an available shell path
const lines = output.trim().split('\n').filter(Boolean);
availableShells.push(...lines);
} catch {
// If even sh fails, try checking with test commands individually
// This handles edge cases where sh might not be available
for (const shell of SHELLS_TO_CHECK) {
try {
await execInContainer(
containerId,
['test', '-x', shell.path],
envIdNum
);
availableShells.push(shell.path);
} catch {
// Shell not available, continue to next
}
}
}
// Determine default shell - prefer bash, then sh, then first available
let defaultShell: string | null = null;
if (availableShells.includes('/bin/bash')) {
defaultShell = '/bin/bash';
} else if (availableShells.includes('/bin/sh')) {
defaultShell = '/bin/sh';
} else if (availableShells.length > 0) {
defaultShell = availableShells[0];
}
return json({
shells: availableShells,
defaultShell,
allShells: SHELLS_TO_CHECK.map(s => ({
path: s.path,
label: s.label,
available: availableShells.includes(s.path)
}))
});
} catch (error) {
console.error('Error detecting shells:', error);
return json({
error: 'Failed to detect shells',
shells: [],
defaultShell: null,
allShells: SHELLS_TO_CHECK.map(s => ({
path: s.path,
label: s.label,
available: false
}))
}, { status: 200 }); // Return 200 with empty results rather than 500
}
};
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
export interface UpdateCheckResult {
containerId: string;
@@ -36,7 +37,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
await clearPendingContainerUpdates(envIdNum);
}
const containers = await listContainers(true, envIdNum);
const allContainers = await listContainers(true, envIdNum);
// Filter out system containers (Dockhand, Hawser) - they cannot be updated from within Dockhand
const containers = allContainers.filter(c => !isSystemContainer(c.image));
// Check container for updates
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
+10 -5
View File
@@ -5,7 +5,8 @@ import {
getContainerEventStats,
getEnvSetting,
hasEnvironments,
getEnvUpdateCheckSettings
getEnvUpdateCheckSettings,
getPendingContainerUpdates
} from '$lib/server/db';
import {
listContainers,
@@ -61,6 +62,7 @@ export interface EnvironmentStats {
paused: number;
restarting: number;
unhealthy: number;
pendingUpdates: number;
};
images: {
total: number;
@@ -165,7 +167,7 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
@@ -252,10 +254,11 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
// Get latest metrics and event stats in parallel
const [latestMetrics, eventStats] = await Promise.all([
// Get latest metrics, event stats, and pending updates in parallel
const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id)
getContainerEventStats(env.id),
getPendingContainerUpdates(env.id)
]);
if (latestMetrics) {
@@ -272,6 +275,8 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
today: eventStats.today
};
envStats.containers.pendingUpdates = pendingUpdates.length;
} catch (error) {
// Convert technical error messages to user-friendly ones
const errorStr = String(error);
@@ -6,7 +6,8 @@ import {
getContainerEventStats,
getContainerEvents,
getEnvSetting,
getEnvUpdateCheckSettings
getEnvUpdateCheckSettings,
getPendingContainerUpdates
} from '$lib/server/db';
import {
listContainers,
@@ -136,7 +137,7 @@ async function getEnvironmentStatsProgressive(
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
@@ -174,11 +175,12 @@ async function getEnvironmentStatsProgressive(
// 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([
const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getContainerEvents({ environmentId: env.id, limit: 10 }),
getHostMetrics(30, env.id)
getHostMetrics(30, env.id),
getPendingContainerUpdates(env.id)
]);
if (latestMetrics) {
@@ -195,6 +197,8 @@ async function getEnvironmentStatsProgressive(
today: eventStats.today
};
envStats.containers.pendingUpdates = pendingUpdates.length;
if (recentEventsResult.events.length > 0) {
envStats.recentEvents = recentEventsResult.events.map(e => ({
container_name: e.containerName || 'unknown',
@@ -221,6 +225,7 @@ async function getEnvironmentStatsProgressive(
scannerEnabled: envStats.scannerEnabled,
updateCheckEnabled: envStats.updateCheckEnabled,
updateCheckAutoUpdate: envStats.updateCheckAutoUpdate,
containers: { ...envStats.containers },
loading: { ...envStats.loading }
});
@@ -251,6 +256,7 @@ async function getEnvironmentStatsProgressive(
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;
// Note: pendingUpdates is already set from DB query, preserve it
envStats.loading!.containers = false;
onPartialUpdate({
+1 -1
View File
@@ -109,7 +109,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
stackName: trimmedStackName,
environmentId: data.environmentId || null,
repositoryId: repositoryId,
composePath: data.composePath || 'docker-compose.yml',
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
+32 -12
View File
@@ -1,10 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
const PAGE_SIZE = 100;
export const GET: RequestHandler = async ({ url }) => {
try {
const registryId = url.searchParams.get('registry');
const lastParam = url.searchParams.get('last'); // For pagination
if (!registryId) {
return json({ error: 'Registry ID is required' }, { status: 400 });
@@ -20,22 +24,20 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 });
}
// Build the catalog URL
let catalogUrl = registry.url;
if (!catalogUrl.endsWith('/')) {
catalogUrl += '/';
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
// Build catalog URL with pagination
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
if (lastParam) {
catalogUrl += `&last=${encodeURIComponent(lastParam)}`;
}
catalogUrl += 'v2/_catalog';
// Prepare headers
const headers: HeadersInit = {
'Accept': 'application/json'
};
// Add auth if credentials are present
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(catalogUrl, {
@@ -56,7 +58,18 @@ export const GET: RequestHandler = async ({ url }) => {
const data = await response.json();
// The V2 API returns { repositories: [...] }
const repositories = data.repositories || [];
const repositories: string[] = data.repositories || [];
// Parse Link header for pagination
// Format: </v2/_catalog?last=xxx&n=100>; rel="next"
let nextLast: string | null = null;
const linkHeader = response.headers.get('Link');
if (linkHeader) {
const nextMatch = linkHeader.match(/<[^>]*[?&]last=([^&>]+)[^>]*>;\s*rel="next"/);
if (nextMatch) {
nextLast = decodeURIComponent(nextMatch[1]);
}
}
// For each repository, we could fetch tags, but that's expensive
// Just return the repository names for now
@@ -68,7 +81,14 @@ export const GET: RequestHandler = async ({ url }) => {
is_automated: false
}));
return json(results);
return json({
repositories: results,
pagination: {
pageSize: PAGE_SIZE,
hasMore: !!nextLast,
nextLast: nextLast
}
});
} catch (error: any) {
console.error('Error fetching registry catalog:', error);
+6 -9
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
function isDockerHub(url: string): boolean {
const lower = url.toLowerCase();
@@ -37,22 +38,18 @@ export const DELETE: RequestHandler = async ({ url }) => {
return json({ error: 'Docker Hub does not support image deletion via API. Please use the Docker Hub web interface.' }, { status: 400 });
}
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
const headers: HeadersInit = {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
// Step 1: Get the manifest digest
const manifestUrl = `${baseUrl}v2/${imageName}/manifests/${tag}`;
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
const headResponse = await fetch(manifestUrl, {
method: 'HEAD',
headers
@@ -74,7 +71,7 @@ export const DELETE: RequestHandler = async ({ url }) => {
}
// Step 2: Delete the manifest by digest
const deleteUrl = `${baseUrl}v2/${imageName}/manifests/${digest}`;
const deleteUrl = `${baseUrl}/v2/${imageName}/manifests/${digest}`;
const deleteResponse = await fetch(deleteUrl, {
method: 'DELETE',
headers
+116 -34
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
interface SearchResult {
name: string;
@@ -44,46 +45,30 @@ async function searchDockerHub(term: string, limit: number): Promise<SearchResul
}
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
// Private registries use the V2 catalog API
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const results: string[] = [];
const catalogUrl = `${baseUrl}v2/_catalog?n=1000`;
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
}
const response = await fetch(catalogUrl, {
method: 'GET',
headers
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
// 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
if (term.includes('/')) {
const directResult = await tryDirectImageLookup(registry, term);
if (directResult) {
results.push(term);
}
throw new Error(`Registry returned error: ${response.status}`);
}
const data = await response.json();
const repositories = data.repositories || [];
// Filter repositories by search term (case-insensitive)
const termLower = term.toLowerCase();
const filtered = repositories
.filter((name: string) => name.toLowerCase().includes(termLower))
.slice(0, limit);
// Strategy 2: Fall back to catalog search for partial matches or if direct lookup failed
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);
}
}
}
// Return results in the same format as Docker Hub
return filtered.map((name: string) => ({
return results.map((name: string) => ({
name,
description: '',
star_count: 0,
@@ -92,6 +77,103 @@ async function searchPrivateRegistry(registry: any, term: string, limit: number)
}));
}
// Try to directly check if an image exists by querying its tags endpoint
async function tryDirectImageLookup(registry: any, imageName: string): Promise<boolean> {
try {
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(`${baseUrl}/v2/${imageName}/tags/list`, {
method: 'GET',
headers
});
// 200 = image exists, 404 = doesn't exist
return response.ok;
} catch {
return false;
}
}
// Search through catalog (slow for large registries, limited to first few pages)
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const termLower = term.toLowerCase();
const results: string[] = [];
const PAGE_SIZE = 200;
const MAX_PAGES = 3; // Limit pages to avoid long waits on huge registries
let lastRepo: string | null = null;
let pagesSearched = 0;
while (results.length < limit && pagesSearched < MAX_PAGES) {
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
if (lastRepo) {
catalogUrl += `&last=${encodeURIComponent(lastRepo)}`;
}
const response = await fetch(catalogUrl, {
method: 'GET',
headers
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
}
throw new Error(`Registry returned error: ${response.status}`);
}
const data = await response.json();
const repositories: string[] = data.repositories || [];
if (repositories.length === 0) {
break;
}
// Filter and add matching repos
for (const name of repositories) {
if (name.toLowerCase().includes(termLower)) {
results.push(name);
if (results.length >= limit) {
break;
}
}
}
// Get last repo for next page
lastRepo = repositories[repositories.length - 1];
// Check if there are more pages
const linkHeader = response.headers.get('Link');
if (!linkHeader || !linkHeader.includes('rel="next"')) {
if (repositories.length < PAGE_SIZE) {
break;
}
}
pagesSearched++;
}
return results;
}
export const GET: RequestHandler = async ({ url }) => {
const term = url.searchParams.get('term');
const limit = parseInt(url.searchParams.get('limit') || '25', 10);
+5 -10
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
interface TagInfo {
name: string;
@@ -72,21 +73,15 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
}
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
// Standard V2 registry API
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const tagsUrl = `${baseUrl}v2/${imageName}/tags/list`;
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const tagsUrl = `${baseUrl}/v2/${imageName}/tags/list`;
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(tagsUrl, {
@@ -37,13 +37,17 @@ export const POST: RequestHandler = async ({ params, request, url, cookies }) =>
currentComposePath = source.composePath;
currentDir = dirname(source.composePath);
} else {
// Stack uses default directory structure
// Stack uses default directory structure - check all valid compose filenames
const stackDir = await findStackDir(name, envIdNum);
if (stackDir) {
const defaultComposePath = join(stackDir, 'docker-compose.yml');
if (existsSync(defaultComposePath)) {
currentComposePath = defaultComposePath;
currentDir = stackDir;
const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
for (const fileName of composeNames) {
const composePath = join(stackDir, fileName);
if (existsSync(composePath)) {
currentComposePath = composePath;
currentDir = stackDir;
break;
}
}
}
}
@@ -47,7 +47,7 @@ export const GET: RequestHandler = async ({ url }) => {
return json({
stackDir,
composePath: `${stackDir}/docker-compose.yml`,
composePath: `${stackDir}/compose.yaml`,
envPath: `${stackDir}/.env`,
source: location ? 'custom' : 'default'
});