This commit is contained in:
jarek committed 2026-06-17 08:21:38 +02:00
1 parent aa45be6844
commit efb634701c
56 files changed
+9101 -117

No files matched your search

+5 -5
View File
@@ -13,10 +13,11 @@ import {
} from '$lib/server/auth';
import { getUser, getUserByUsername } from '$lib/server/db';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
// POST /api/auth/login - Authenticate user
export const POST: RequestHandler = async (event) => {
const { request, cookies, getClientAddress } = event;
const { request, cookies } = event;
// Check if auth is enabled
if (!(await isAuthEnabled())) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
@@ -29,10 +30,9 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Username and password are required' }, { status: 400 });
}
// Rate limiting by IP and username
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| request.headers.get('x-real-ip')
|| getClientAddress();
// Rate-limit key derived from socket IP + username. See client-ip.ts
// for how XFF is handled (opt-in via TRUST_FORWARDED_HEADERS).
const clientIp = getClientIp(event);
const rateLimitKey = `${clientIp}:${username}`;
const { limited, retryAfter } = isRateLimited(rateLimitKey);
+2 -3
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit';
import { destroySession } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
// POST /api/auth/logout - End session
export const POST: RequestHandler = async (event) => {
@@ -11,9 +12,7 @@ export const POST: RequestHandler = async (event) => {
// Get current user before destroying session for audit log
const auth = await authorize(cookies);
const username = auth.user?.username || 'unknown';
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| event.request.headers.get('x-real-ip')
|| event.getClientAddress();
const clientIp = getClientIp(event);
await destroySession(cookies);
console.log(`[Auth] Logout: user=${username} ip=${clientIp}`);
+3 -4
View File
@@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
export const GET: RequestHandler = async (event) => {
@@ -17,10 +18,8 @@ export const GET: RequestHandler = async (event) => {
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Extract client IP for logging
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| event.request.headers.get('x-real-ip')
|| event.getClientAddress();
// Extract client IP for logging.
const clientIp = getClientIp(event);
// Handle error from IdP
if (error) {
@@ -2,6 +2,7 @@ import { gzipSync } from 'node:zlib';
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
@@ -33,11 +34,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Get format from query parameter (defaults to tar)
const format = url.searchParams.get('format') || 'tar';
// Get stat info to determine filename
// Get stat info to determine filename and whether the path is a directory.
// Directories with format=raw fall back to tar (raw only makes sense for files).
let filename: string;
let isDir = false;
try {
const stat = await statContainerPath(params.id, path, envIdNum);
filename = stat.name || path.split('/').pop() || 'download';
isDir = stat.isDir === true;
} catch {
filename = path.split('/').pop() || 'download';
}
@@ -54,7 +58,13 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let contentType = 'application/x-tar';
let extension = '.tar';
if (format === 'tar.gz') {
if (format === 'raw' && !isDir) {
// Strip the tar wrapper and emit raw file bytes (#1180).
const tarData = new Uint8Array(await response.arrayBuffer());
body = extractFirstFileFromTar(tarData);
contentType = 'application/octet-stream';
extension = '';
} else if (format === 'tar.gz') {
// Compress with gzip
const tarData = new Uint8Array(await response.arrayBuffer());
body = gzipSync(tarData);
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment } from '$lib/server/db';
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
import { daemonIsPodman } from '$lib/server/scanner-socket-detect';
export const POST: RequestHandler = async ({ params }) => {
try {
@@ -35,14 +36,18 @@ export const POST: RequestHandler = async ({ params }) => {
// Agent is connected - try to get Docker info with shorter timeout
console.log(`[Test] Edge environment ${id} (${env.name}) - agent connected, testing Docker...`);
try {
const info = await getDockerInfo(env.id) as any;
const [info, isPodman] = await Promise.all([
getDockerInfo(env.id) as Promise<any>,
daemonIsPodman(env.id)
]);
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
name: info.Name,
engine: isPodman ? 'podman' : 'docker'
},
isEdgeMode: true,
hawser: edgeConn ? {
@@ -70,17 +75,20 @@ export const POST: RequestHandler = async ({ params }) => {
}
}
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
// (parallel calls are more efficient and avoid sequential connection issues)
// Fetch Docker info, podman detection, and (for hawser-standard) hawser
// info in parallel — faster, and avoids serializing remote calls.
let info: any;
let hawserInfo = null;
let isPodman = false;
if (env.connectionType === 'hawser-standard') {
const [dockerResult, hawserResult] = await Promise.all([
const [dockerResult, hawserResult, detected] = await Promise.all([
getDockerInfo(env.id),
getHawserInfo(id)
getHawserInfo(id),
daemonIsPodman(env.id)
]);
info = dockerResult;
hawserInfo = hawserResult;
isPodman = detected;
if (hawserInfo?.hawserVersion) {
await updateEnvironment(id, {
hawserVersion: hawserInfo.hawserVersion,
@@ -90,7 +98,12 @@ export const POST: RequestHandler = async ({ params }) => {
});
}
} else {
info = await getDockerInfo(env.id);
const [dockerResult, detected] = await Promise.all([
getDockerInfo(env.id),
daemonIsPodman(env.id)
]);
info = dockerResult;
isPodman = detected;
}
return json({
@@ -99,7 +112,8 @@ export const POST: RequestHandler = async ({ params }) => {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
name: info.Name,
engine: isPodman ? 'podman' : 'docker'
},
hawser: hawserInfo
});
+28 -1
View File
@@ -90,6 +90,13 @@ export interface GeneralSettings {
defaultComposeTemplate: string;
// Label filter mode
labelFilterMode: 'any' | 'all';
// Whether to surface URLs inferred from reverse-proxy labels — currently
// Traefik (traefik.http.routers.*) and Pangolin (pangolin.proxy-resources.*).
// When false both parsers are bypassed and no proxy-derived pills are rendered.
honorProxyLabels: boolean;
// Whether to surface a "view changelog" link next to the update badge.
// Resolved client-side from OCI labels / GHCR image names; no server hit.
showImageChangelogLinks: boolean;
// Whether spinning icons (animate-spin etc.) are animated (#1169)
animateIcons: boolean;
}
@@ -124,6 +131,8 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
labelFilterMode: 'any' as const,
honorProxyLabels: true,
showImageChangelogLinks: true,
animateIcons: true,
defaultComposeTemplate: `version: "3.8"
@@ -203,6 +212,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultTrivyImage,
defaultComposeTemplate,
labelFilterMode,
honorProxyLabels,
showImageChangelogLinks,
animateIcons
] = await Promise.all([
getSetting('confirm_destructive'),
@@ -243,6 +254,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons')
]);
@@ -287,6 +300,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
};
@@ -305,7 +320,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, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, animateIcons } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -445,6 +460,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
await setSetting('label_filter_mode', labelFilterMode);
}
if (honorProxyLabels !== undefined && typeof honorProxyLabels === 'boolean') {
await setSetting('honor_proxy_labels', honorProxyLabels);
}
if (showImageChangelogLinks !== undefined && typeof showImageChangelogLinks === 'boolean') {
await setSetting('show_image_changelog_links', showImageChangelogLinks);
}
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
await setSetting('animate_icons', animateIcons);
}
@@ -489,6 +510,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultTrivyImageVal,
defaultComposeTemplateVal,
labelFilterModeVal,
honorProxyLabelsVal,
showImageChangelogLinksVal,
animateIconsVal
] = await Promise.all([
getSetting('confirm_destructive'),
@@ -529,6 +552,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons')
]);
@@ -573,6 +598,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
};
+151
View File
@@ -0,0 +1,151 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getTemplateSources, type TemplateSource } from '$lib/server/templates';
export interface TemplateItem {
id: string;
type: 'container' | 'stack';
title: string;
description: string;
logo: string;
categories: string[];
source: string;
image?: string;
ports?: string[];
volumes?: { bind: string; container: string }[];
env?: { name: string; label: string; default?: string }[];
restartPolicy?: string;
repository?: { url: string; stackfile: string };
stars?: number;
pulls?: number;
network?: string;
}
// Server-side cache: url → { data, fetchedAt }
const cache = new Map<string, { data: TemplateItem[]; fetchedAt: number }>();
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
function hashId(source: string, title: string): string {
let hash = 0;
const str = `${source}:${title}`;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return Math.abs(hash).toString(36);
}
function normalizePortainerTemplate(entry: any, sourceName: string): TemplateItem | null {
if (!entry.title && !entry.name) return null;
// Skip Swarm templates (type 2)
if (entry.type === 2) return null;
const title = entry.title || entry.name || '';
const template: TemplateItem = {
id: hashId(sourceName, title),
type: entry.type === 3 ? 'stack' : 'container',
title,
description: entry.description || '',
logo: entry.logo || '',
categories: Array.isArray(entry.categories) ? entry.categories : [],
source: sourceName,
};
if (entry.type === 3 && entry.repository) {
template.repository = {
url: entry.repository.url,
stackfile: entry.repository.stackfile
};
} else {
template.image = entry.image || '';
template.ports = Array.isArray(entry.ports) ? entry.ports : [];
template.volumes = Array.isArray(entry.volumes) ? entry.volumes : [];
template.env = Array.isArray(entry.env) ? entry.env.map((e: any) => ({
name: e.name || '',
label: e.label || e.name || '',
default: e.default ?? e.set ?? ''
})) : [];
template.restartPolicy = entry.restart_policy || 'unless-stopped';
if (entry.network) template.network = entry.network;
}
return template;
}
function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
if (!entry.name || entry.deprecated) return null;
return {
id: hashId('LinuxServer.io', entry.name),
type: 'container',
title: entry.name,
description: entry.description || '',
logo: entry.project_logo || '',
categories: entry.category ? entry.category.split(',').map((c: string) => c.trim()).filter(Boolean) : [],
source: 'LinuxServer.io',
image: `lscr.io/linuxserver/${entry.name}:latest`,
env: [
{ name: 'PUID', label: 'User ID', default: '1000' },
{ name: 'PGID', label: 'Group ID', default: '1000' },
{ name: 'TZ', label: 'Timezone', default: 'Etc/UTC' },
],
restartPolicy: 'unless-stopped',
stars: entry.stars,
pulls: entry.monthly_pulls,
};
}
async function fetchSource(source: TemplateSource): Promise<TemplateItem[]> {
const cached = cache.get(source.url);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
return cached.data;
}
try {
const response = await fetch(source.url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
console.error(`[Templates] Failed to fetch ${source.name}: ${response.status}`);
return cached?.data || [];
}
const raw = await response.json();
let templates: TemplateItem[];
if (source.id === 'linuxserver' || source.url.includes('fleet.linuxserver.io')) {
// LinuxServer.io fleet API returns an array of images
const images = Array.isArray(raw) ? raw : (raw.images || []);
templates = images.map(normalizeLinuxServerTemplate).filter(Boolean) as TemplateItem[];
} else {
// Portainer-format templates
const entries = Array.isArray(raw) ? raw : (raw.templates || []);
templates = entries.map((e: any) => normalizePortainerTemplate(e, source.name)).filter(Boolean) as TemplateItem[];
}
cache.set(source.url, { data: templates, fetchedAt: Date.now() });
return templates;
} catch (error) {
console.error(`[Templates] Error fetching ${source.name}:`, error instanceof Error ? error.message : error);
return cached?.data || [];
}
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const sources = await getTemplateSources();
const enabledSources = sources.filter(s => s.enabled);
const results = await Promise.all(enabledSources.map(fetchSource));
const templates = results.flat();
return json(templates);
};
+108
View File
@@ -0,0 +1,108 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import type { TemplateItem } from '../+server';
function generateContainerCompose(template: TemplateItem): string {
const name = template.title.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
const lines: string[] = ['services:', ` ${name}:`];
if (template.image) {
lines.push(` image: ${template.image}`);
}
if (template.restartPolicy) {
lines.push(` restart: ${template.restartPolicy}`);
}
if (template.network) {
lines.push(` network_mode: ${template.network}`);
}
if (template.ports && template.ports.length > 0 && !template.network) {
lines.push(' ports:');
for (const port of template.ports) {
lines.push(` - "${port}"`);
}
}
if (template.volumes && template.volumes.length > 0) {
lines.push(' volumes:');
for (const vol of template.volumes) {
lines.push(` - ${vol.bind}:${vol.container}`);
}
}
if (template.env && template.env.length > 0) {
lines.push(' environment:');
for (const env of template.env) {
const value = env.default || '';
lines.push(` - ${env.name}=${value}`);
}
}
return lines.join('\n') + '\n';
}
async function fetchStackCompose(repository: { url: string; stackfile: string }): Promise<string> {
// Convert GitHub URL to raw content URL
let rawUrl = '';
const githubMatch = repository.url.match(/github\.com\/([^/]+\/[^/]+)/);
if (githubMatch) {
const repo = githubMatch[1].replace(/\.git$/, '');
// Try main branch first, then master
for (const branch of ['main', 'master']) {
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${repository.stackfile}`;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (response.ok) {
return await response.text();
}
} catch {
continue;
}
}
}
// Try the URL directly as fallback
if (!rawUrl) {
rawUrl = repository.url.endsWith('/')
? `${repository.url}${repository.stackfile}`
: `${repository.url}/${repository.stackfile}`;
}
const response = await fetch(rawUrl, { signal: AbortSignal.timeout(10000) });
if (!response.ok) {
throw new Error(`Failed to fetch compose file: ${response.status}`);
}
return await response.text();
}
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'deploy')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const { template } = await request.json() as { template: TemplateItem };
if (!template) {
return json({ error: 'Template is required' }, { status: 400 });
}
let compose: string;
if (template.type === 'stack' && template.repository) {
compose = await fetchStackCompose(template.repository);
} else {
compose = generateContainerCompose(template);
}
return json({ compose });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to generate compose';
return json({ error: message }, { status: 500 });
}
};
@@ -0,0 +1,63 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getTemplateSources, updateTemplateSource, addTemplateSource, deleteTemplateSource } from '$lib/server/templates';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const sources = await getTemplateSources();
return json(sources);
};
// Toggle enabled or update a source
export const PUT: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { id, enabled, name, url } = await request.json();
if (!id) return json({ error: 'Missing id' }, { status: 400 });
const updates: any = {};
if (enabled !== undefined) updates.enabled = enabled;
if (name !== undefined) updates.name = name;
if (url !== undefined) updates.url = url;
await updateTemplateSource(id, updates);
return json({ ok: true });
};
// Add a custom source
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { name, url } = await request.json();
if (!name?.trim() || !url?.trim()) {
return json({ error: 'Name and URL are required' }, { status: 400 });
}
const source = await addTemplateSource({ name: name.trim(), url: url.trim() });
return json(source);
};
// Delete a custom source
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = url.searchParams.get('id');
if (!id) return json({ error: 'Missing id' }, { status: 400 });
await deleteTemplateSource(parseInt(id));
return json({ ok: true });
};
@@ -1,9 +1,10 @@
import { gzipSync } from 'node:zlib';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVolumeArchive, releaseVolumeHelperContainer } from '$lib/server/docker';
import { getVolumeArchive, releaseVolumeHelperContainer, statVolumePath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
@@ -22,6 +23,18 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
try {
// For format=raw, check if the path is a single file. Directories fall back to tar.
let isDir = false;
if (format === 'raw' && path !== '/') {
try {
const stat = await statVolumePath(params.name, path, envIdNum);
isDir = stat.isDir === true;
} catch {
// Stat failure: let the archive call below produce the real error
}
} else if (format === 'raw') {
isDir = true; // root path '/' is always a directory
}
const { response } = await getVolumeArchive(params.name, path, envIdNum);
@@ -35,7 +48,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Prepare response based on format
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
if (format === 'tar.gz') {
if (format === 'raw' && !isDir) {
// Strip the tar wrapper and emit raw file bytes (#1180).
const tarData = new Uint8Array(await response.arrayBuffer());
body = extractFirstFileFromTar(tarData);
// Use the file's basename, not the volume-derived path-joined name.
filename = path.split('/').filter(Boolean).pop() || filename;
contentType = 'application/octet-stream';
extension = '';
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
} else if (format === 'tar.gz') {
// Compress with gzip — fully consumes the archive stream
const tarData = new Uint8Array(await response.arrayBuffer());
body = gzipSync(tarData);
+104 -8
View File
@@ -29,6 +29,7 @@
Plus,
FileText,
Pencil,
NotepadText,
RefreshCw,
CircleArrowUp,
X,
@@ -88,6 +89,9 @@
import { ipToNumber } from '$lib/utils/ip';
import { formatHostPortUrl } from '$lib/utils/url';
import { parseCustomUrl } from '$lib/utils/custom-url';
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
import { extractPangolinUrls } from '$lib/utils/pangolin-urls';
import { resolveChangelogUrl } from '$lib/utils/changelog-url';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { DataGrid } from '$lib/components/data-grid';
import type { ColumnConfig } from '$lib/types';
@@ -119,8 +123,10 @@
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
// Status filter state
// Status filter state — also carries the synthetic 'update-available'
// pseudo-status (#1063), which ANDs with actual Docker states.
const STATUS_FILTER_STORAGE_KEY = 'dockhand-containers-status-filter';
const UPDATE_AVAILABLE_FILTER_VALUE = 'update-available';
let statusFilter = $state<string[]>([]);
// Status types with icons for filter and table
@@ -305,6 +311,35 @@
// Set of container IDs with updates available (for O(1) lookup)
const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds));
// Filter dropdown entries: real statuses plus the synthetic
// "update-available" entry, only offered once we know about a pending
// update — picking it on an empty set would just empty the list (#1063).
const filterOptions = $derived(
containersWithUpdatesSet.size > 0
? [
...statusTypes,
{
value: UPDATE_AVAILABLE_FILTER_VALUE,
label: 'Update available',
icon: CircleArrowUp,
color: 'text-amber-500'
}
]
: statusTypes
);
// Drop the 'update-available' filter when no pending updates remain —
// otherwise the user has no way to deselect it (dropdown hides the
// entry) and the list stays empty (#1063).
$effect(() => {
if (
statusFilter.includes(UPDATE_AVAILABLE_FILTER_VALUE) &&
containersWithUpdatesSet.size === 0
) {
statusFilter = statusFilter.filter((v) => v !== UPDATE_AVAILABLE_FILTER_VALUE);
}
});
// Count of updatable containers (excluding system containers like Dockhand/Hawser)
const updatableContainersCount = $derived(
batchUpdateContainerIds.filter(id => {
@@ -746,9 +781,16 @@
result = result.filter(c => c.state.toLowerCase() !== 'exited');
}
// Filter by status if any are selected
if (statusFilter.length > 0) {
result = result.filter(c => statusFilter.includes(c.state.toLowerCase()));
// Filter by status. The synthetic 'update-available' value (#1063)
// is split off so it ANDs with real-state selections instead of
// being treated like another Docker state.
const stateValues = statusFilter.filter((v) => v !== UPDATE_AVAILABLE_FILTER_VALUE);
const updatesOnly = statusFilter.includes(UPDATE_AVAILABLE_FILTER_VALUE);
if (stateValues.length > 0) {
result = result.filter((c) => stateValues.includes(c.state.toLowerCase()));
}
if (updatesOnly) {
result = result.filter((c) => containersWithUpdatesSet.has(c.id));
}
// Filter by search query
@@ -1397,12 +1439,14 @@
class="pl-8 h-8 w-48 text-sm"
/>
</div>
<!-- Status filter (multi-select) -->
<!-- Status filter (multi-select). The synthetic 'update-available'
entry appears once at least one container has a pending update,
and ANDs with selected real states (#1063). -->
<MultiSelectFilter
bind:value={statusFilter}
options={statusTypes}
options={filterOptions}
placeholder="All statuses"
pluralLabel="statuses"
pluralLabel="filters"
width="w-44"
defaultIcon={Box}
/>
@@ -1790,6 +1834,21 @@
<span title="Update available">
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
</span>
{#if $appSettings.showImageChangelogLinks}
{@const changelogUrl = resolveChangelogUrl(container.image, container.labels)}
{#if changelogUrl}
<a
href={changelogUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
title="View changelog"
class="shrink-0 text-amber-500 hover:text-amber-400 transition-colors"
>
<NotepadText class="w-3 h-3" />
</a>
{/if}
{/if}
{/if}
<span class="text-xs text-muted-foreground truncate" title={container.image}>{container.image}</span>
</div>
@@ -1897,7 +1956,9 @@
{:else if column.id === 'ports'}
{@const exposedPorts = $appSettings.showExposedPorts ? formatExposedPorts(container.ports) : []}
{@const parsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])}
{#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl}
{@const traefikUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractTraefikUrls(container.labels)}
{@const pangolinUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractPangolinUrls(container.labels)}
{#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl || traefikUrls.length > 0 || pangolinUrls.length > 0}
{@const compactPorts = $appSettings.compactPorts}
{@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports}
{@const remainingCount = ports.length - 1}
@@ -1916,6 +1977,36 @@
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/if}
<!-- Traefik fallback URLs (#2). dockhand.url suppresses these. -->
{#each traefikUrls as t}
<a
href={t.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0"
title="Traefik router {t.router} → {t.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{t.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
<!-- Pangolin fallback URLs (#2 follow-up). dockhand.url suppresses these. -->
{#each pangolinUrls as p}
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0"
title="Pangolin resource {p.resource} → {p.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{p.displayName ?? p.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
{#each displayPorts as port}
{@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])}
{@const portUrl = portParsed?.url || null}
@@ -2381,6 +2472,11 @@
// Refresh the container list
fetchContainers();
}}
onStart={$canAccess('containers', 'start') ? (id) => startContainer(id) : undefined}
onStop={$canAccess('containers', 'stop') ? (id) => stopContainer(id) : undefined}
onRestart={$canAccess('containers', 'restart') ? (id) => restartContainer(id) : undefined}
onRemove={$canAccess('containers', 'remove') ? (id) => removeContainer(id) : undefined}
onEdit={$canAccess('containers', 'edit') ? (id) => editContainer(id) : undefined}
/>
<FileBrowserModal
+16 -4
View File
@@ -76,6 +76,10 @@
}
let status = $state<'idle' | 'updating' | 'complete' | 'error'>('idle');
// Incremented on each close. The startUpdate() poll loop captures the
// run id at start and stops mutating state once it no longer matches —
// prevents a stale background poll from clobbering the next run (#1094).
let runId = 0;
let progress = $state<ContainerProgress[]>([]);
let progressListEl = $state<HTMLDivElement | null>(null);
let scrollTick = $state(0);
@@ -139,6 +143,7 @@
async function startUpdate() {
if (containerIds.length === 0) return;
const myRunId = ++runId;
status = 'updating';
progress = [];
currentIndex = 0;
@@ -164,6 +169,8 @@
const blockedIds: string[] = [];
await watchJob(jobId, (line) => {
// If the user closed the modal (or started another run), drop the line.
if (myRunId !== runId) return;
try {
const data = line.data as any;
scrollTick++;
@@ -282,6 +289,7 @@
});
} catch (error: any) {
console.error('Failed to update containers:', error);
if (myRunId !== runId) return;
status = 'error';
errorMessage = error.message || 'Failed to update';
}
@@ -290,6 +298,9 @@
function handleClose() {
open = false;
onClose();
// Invalidate any in-flight poll so its onLine callbacks stop mutating
// state for this modal instance (#1094).
runId++;
// Reset state
status = 'idle';
progress = [];
@@ -300,10 +311,11 @@
}
function handleOpenChange(isOpen: boolean) {
if (!isOpen && status === 'updating') {
// Don't allow closing while updating
return;
}
// The X button (DialogPrimitive.Close) bypasses controlled close, so
// returning early here without resetting state strands `status` at
// 'updating' and the next open of this modal never re-triggers
// startUpdate() (#1094). Always reset; the in-flight poll loop
// finishes against the server but the UI is freed.
if (!isOpen) {
handleClose();
}
@@ -5,7 +5,8 @@
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink } from 'lucide-svelte';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink, Play, Square as SquareIcon, RotateCw, Trash2 } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import * as Select from '$lib/components/ui/select';
import { toast } from 'svelte-sonner';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -26,9 +27,73 @@
containerId: string;
containerName?: string;
onRename?: (newName: string) => void;
// Lifecycle handlers from the parent (#461). Non-destructive actions
// return a promise so the modal can refresh inspect data afterwards;
// onRemove and onEdit close the modal themselves.
onStart?: (id: string) => Promise<void> | void;
onStop?: (id: string) => Promise<void> | void;
onRestart?: (id: string) => Promise<void> | void;
onRemove?: (id: string) => Promise<void> | void;
onEdit?: (id: string) => void;
}
let { open = $bindable(), containerId, containerName, onRename }: Props = $props();
let { open = $bindable(), containerId, containerName, onRename, onStart, onStop, onRestart, onRemove, onEdit }: Props = $props();
// Confirmation-popover open state for the destructive actions in the header.
let confirmStopOpen = $state(false);
let confirmRestartOpen = $state(false);
let confirmRemoveOpen = $state(false);
// Per-action in-flight flags so the corresponding icon can spin/pulse.
let starting = $state(false);
let stopping = $state(false);
let restarting = $state(false);
async function doStart() {
if (!onStart) return;
starting = true;
try {
await onStart(containerId);
await fetchContainerInspect();
} finally {
starting = false;
}
}
async function doStop() {
if (!onStop) return;
stopping = true;
try {
await onStop(containerId);
await fetchContainerInspect();
} finally {
stopping = false;
}
}
async function doRestart() {
if (!onRestart) return;
restarting = true;
try {
await onRestart(containerId);
await fetchContainerInspect();
} finally {
restarting = false;
}
}
async function doRemove() {
if (!onRemove) return;
try {
await onRemove(containerId);
} finally {
// Always close: the container is either gone or the parent toasted an error.
open = false;
}
}
function doEdit() {
if (!onEdit) return;
// Close the inspect modal first so EditContainerModal isn't stacked on top.
open = false;
onEdit(containerId);
}
// Rename state
let isEditing = $state(false);
@@ -652,16 +717,91 @@
</span>
{/if}
{#if containerData && !loading}
<Button
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw inspect data"
class="ml-auto mr-6"
>
<Code class="w-4 h-4 mr-1.5" />
Inspect
</Button>
<div class="ml-auto mr-6 flex items-center gap-1">
<!-- Lifecycle actions (#461). Mirrors the per-row action set on the containers page;
non-destructive actions refresh the inspect data in place, Delete closes the modal. -->
{#if containerData.State?.Running}
{#if onStop}
<ConfirmPopover
open={confirmStopOpen}
action="Stop"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Stop"
onConfirm={doStop}
onOpenChange={(o) => confirmStopOpen = o}
>
{#snippet children({ open })}
<SquareIcon class="w-4 h-4 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stopping ? 'animate-pulse text-destructive' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{#if onRestart}
<ConfirmPopover
open={confirmRestartOpen}
action="Restart"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Restart"
variant="secondary"
onConfirm={doRestart}
onOpenChange={(o) => confirmRestartOpen = o}
>
{#snippet children({ open })}
<RotateCw class="w-4 h-4 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restarting ? 'animate-spin text-foreground' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{:else}
{#if onStart}
<button
type="button"
onclick={doStart}
title="Start"
disabled={starting}
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer disabled:opacity-50"
>
<Play class="w-4 h-4 {starting ? 'animate-pulse text-emerald-500' : 'text-muted-foreground hover:text-emerald-500'}" />
</button>
{/if}
{/if}
{#if onEdit}
<button
type="button"
onclick={doEdit}
title="Edit"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Pencil class="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if onRemove}
<ConfirmPopover
open={confirmRemoveOpen}
action="Delete"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Delete"
variant="destructive"
onConfirm={doRemove}
onOpenChange={(o) => confirmRemoveOpen = o}
>
{#snippet children({ open })}
<Trash2 class="w-4 h-4 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
<Button
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw inspect data"
class="ml-1"
>
<Code class="w-4 h-4 mr-1.5" />
Inspect
</Button>
</div>
{/if}
</Dialog.Title>
</Dialog.Header>
@@ -605,7 +605,12 @@
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-full h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
<Dialog.Title class="text-base font-semibold">Create new container</Dialog.Title>
<Dialog.Title class="text-base font-semibold">
Create new container
{#if $currentEnvironment}
<span class="font-medium">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
</Dialog.Title>
<button
type="button"
onclick={handleClose}
+5 -2
View File
@@ -593,9 +593,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
try {
const response = await fetch(appendEnvParam('/api/containers', envId));
const allContainers = await response.json();
// Show running and exited containers (logs are available for both)
// Docker keeps the log file across state transitions, so every
// state has logs to serve. Excluding `restarting` made crash-
// looping containers unselectable mid-loop (#227); paused/
// created/dead are listed for the same reason.
const loggableContainers = allContainers.filter((c: ContainerInfo) =>
c.state === 'running' || c.state === 'exited'
['running', 'exited', 'restarting', 'paused', 'created', 'dead'].includes(c.state)
);
// Before updating containers, capture current running set for grouped mode change detection
@@ -79,6 +79,7 @@
containers: number;
images: number;
name: string;
engine?: 'docker' | 'podman';
};
}
@@ -576,7 +577,12 @@
<!-- Docker Version Column -->
<Table.Cell>
{#if testResult?.info?.serverVersion}
<span class="text-sm text-muted-foreground">{testResult.info.serverVersion}</span>
<div class="flex items-center gap-1.5">
<span class="text-sm text-muted-foreground">{testResult.info.serverVersion}</span>
{#if testResult.info.engine === 'podman'}
<Badge variant="secondary" class="text-[10px] px-1.5 py-0 leading-tight">Podman</Badge>
{/if}
</div>
{:else}
<span class="text-muted-foreground text-sm">—</span>
{/if}
+82 -8
View File
@@ -23,6 +23,8 @@
let highlightUpdates = $derived($appSettings.highlightUpdates);
let compactPorts = $derived($appSettings.compactPorts);
let showExposedPorts = $derived($appSettings.showExposedPorts);
let honorProxyLabels = $derived($appSettings.honorProxyLabels);
let showImageChangelogLinks = $derived($appSettings.showImageChangelogLinks);
let timeFormat = $derived($appSettings.timeFormat);
let dateFormat = $derived($appSettings.dateFormat);
let downloadFormat = $derived($appSettings.downloadFormat);
@@ -116,6 +118,18 @@ services:
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD', example: '2024-12-31' }
];
const downloadFormatOptions: { value: DownloadFormat; label: string; description: string }[] = [
{ value: 'tar', label: 'tar', description: 'Uncompressed archive' },
{ value: 'tar.gz', label: 'tar.gz', description: 'Gzip-compressed archive' },
{ value: 'raw', label: 'No archive', description: 'Single file, raw bytes' }
];
const downloadFormatLabel: Record<DownloadFormat, string> = {
tar: 'tar',
'tar.gz': 'tar.gz',
raw: 'No archive'
};
function handleScheduleRetentionChange(e: Event) {
const value = Math.max(1, Math.min(365, parseInt((e.target as HTMLInputElement).value) || 30));
appSettings.setScheduleRetentionDays(value);
@@ -288,6 +302,28 @@ services:
</div>
<p class="text-xs text-muted-foreground">Highlight container rows in amber when updates are available</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Show changelog links</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p>Surface a release-notes link next to the image name on rows with updates available. The link is resolved from the image's <code>org.opencontainers.image.source</code> label, from the <code>ghcr.io</code> registry path, or from an explicit <code>dockhand.changelog.url</code> label override.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
checked={showImageChangelogLinks}
onchange={(checked) => {
appSettings.setShowImageChangelogLinks(checked);
toast.success(checked ? 'Changelog links shown' : 'Changelog links hidden');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Show a release-notes icon next to images with updates available</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Compact port display</Label>
@@ -324,6 +360,28 @@ services:
</div>
<p class="text-xs text-muted-foreground">Display internal container ports in the container list grid</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Honor Traefik/Pangolin labels</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p>Parse <code>traefik.http.routers.&lt;name&gt;.rule</code> and <code>pangolin.proxy-resources.&lt;name&gt;.full-domain</code> labels and surface the resulting URLs as clickable pills next to ports. When off, only explicit <code>dockhand.url</code> labels are shown.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
checked={honorProxyLabels}
onchange={(checked) => {
appSettings.setHonorProxyLabels(checked);
toast.success(checked ? 'Proxy labels honored' : 'Proxy labels ignored');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Show URLs inferred from Traefik and Pangolin labels alongside dockhand.url</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Time format</Label>
@@ -470,18 +528,34 @@ services:
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Download format</Label>
<ToggleSwitch
<Select.Root
type="single"
value={downloadFormat}
leftValue="tar"
rightValue="tar.gz"
onchange={(newFormat) => {
appSettings.setDownloadFormat(newFormat as DownloadFormat);
toast.success(`Download format set to ${newFormat}`);
onValueChange={(value) => {
if (value) {
appSettings.setDownloadFormat(value as DownloadFormat);
toast.success(`Download format set to ${downloadFormatLabel[value as DownloadFormat]}`);
}
}}
disabled={!$canAccess('settings', 'edit')}
/>
>
<Select.Trigger class="w-[180px]">
<FileText class="w-4 h-4 mr-2" />
<span>{downloadFormatLabel[downloadFormat]}</span>
</Select.Trigger>
<Select.Content>
{#each downloadFormatOptions as option}
<Select.Item value={option.value}>
<div class="flex items-center justify-between w-full gap-4">
<span>{option.label}</span>
<span class="text-xs text-muted-foreground">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<p class="text-xs text-muted-foreground">Archive format when downloading files from containers</p>
<p class="text-xs text-muted-foreground">Format when downloading files from containers or volumes. "No archive" emits raw bytes for single files; directories still download as tar.</p>
</div>
</div>
<div class="space-y-4">
+35
View File
@@ -18,6 +18,9 @@
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe } from 'lucide-svelte';
import { formatPorts } from '$lib/utils/port-format';
import { parseCustomUrl } from '$lib/utils/custom-url';
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
import { extractPangolinUrls } from '$lib/utils/pangolin-urls';
import { appSettings } from '$lib/stores/settings';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
@@ -2059,6 +2062,38 @@
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/if}
{:else}
<!-- Traefik fallback URLs (#2). dockhand.url suppresses these, as does the
"Honor Traefik/Pangolin labels" setting being off. -->
{#each ($appSettings.honorProxyLabels ? extractTraefikUrls(container.labels) : []) as t}
<a
href={t.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title="Traefik router {t.router} → {t.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{t.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
<!-- Pangolin fallback URLs (#2 follow-up). Same suppression rules. -->
{#each ($appSettings.honorProxyLabels ? extractPangolinUrls(container.labels) : []) as p}
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title="Pangolin resource {p.resource} → {p.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{p.displayName ?? p.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
{/if}
<!-- Clickable ports with range collapsing -->
{#if container.ports.length > 0}
+11 -3
View File
@@ -35,11 +35,13 @@
open: boolean;
mode: 'create' | 'edit';
stackName?: string; // Required for edit mode, optional for create
initialCompose?: string; // Pre-fill compose content (for library deploy)
initialStackName?: string; // Pre-fill stack name (for library deploy)
onClose: () => void;
onSuccess: () => void; // Called after create or save
}
let { open = $bindable(), mode: propMode, stackName: propStackName = '', onClose, onSuccess }: Props = $props();
let { open = $bindable(), mode: propMode, stackName: propStackName = '', initialCompose, initialStackName, onClose, onSuccess }: Props = $props();
// Local effective state - can transition from create → edit after failed deploy
let mode = $state(propMode);
@@ -1222,8 +1224,11 @@
validateEnvVars();
});
} else if (mode === 'create') {
// Set default compose content for create mode
composeContent = defaultCompose;
// Set default compose content for create mode (library templates override default)
composeContent = initialCompose || defaultCompose;
if (initialStackName) {
newStackName = initialStackName;
}
isDirty = false; // Reset dirty flag for new modal
loading = false;
// Auto-validate default compose
@@ -1351,6 +1356,9 @@
{:else}
{stackName}
{/if}
{#if $currentEnvironment}
<span class="font-medium">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
{#if mode === 'create'}
+301
View File
@@ -0,0 +1,301 @@
<svelte:head>
<title>Templates - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { Skeleton } from '$lib/components/ui/skeleton';
import { EmptyState } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import {
LibraryBig,
Search,
RefreshCw,
Loader2,
Package,
Settings2
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import type { TemplateItem } from '../api/templates/+server';
import TemplateCard from './TemplateCard.svelte';
import TemplateSourcesTab from './TemplateSourcesTab.svelte';
import StackModal from '../stacks/StackModal.svelte';
// State
let templates = $state<TemplateItem[]>([]);
let loading = $state(true);
let searchQuery = $state('');
let debouncedQuery = $state('');
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let selectedCategories = $state<string[]>([]);
let selectedSources = $state<string[]>([]);
let activeTab = $state<'browse' | 'sources'>('browse');
let loadingTemplateId = $state<string | null>(null);
// Debounce search input
$effect(() => {
const q = searchQuery;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debouncedQuery = q; }, 200);
});
onDestroy(() => { if (debounceTimer) clearTimeout(debounceTimer); });
// StackModal state
let showStackModal = $state(false);
let stackModalCompose = $state('');
let stackModalName = $state('');
// Client-side cache
let cacheTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Derived
let allCategories = $derived(
[...new Set(templates.flatMap(t => t.categories))].sort()
);
let allSources = $derived(
[...new Set(templates.map(t => t.source))]
);
let filteredTemplates = $derived(templates.filter(t => {
const q = debouncedQuery.toLowerCase();
const matchesSearch = !q ||
t.title.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q);
const matchesCategory = selectedCategories.length === 0 ||
t.categories.some(c => selectedCategories.includes(c));
const matchesSource = selectedSources.length === 0 ||
selectedSources.includes(t.source);
return matchesSearch && matchesCategory && matchesSource;
}));
async function fetchTemplates(force = false) {
if (!force && templates.length > 0 && Date.now() - cacheTimestamp < CACHE_TTL) {
return;
}
loading = true;
try {
const response = await fetch('/api/templates');
if (!response.ok) throw new Error('Failed to fetch');
templates = await response.json();
cacheTimestamp = Date.now();
} catch {
toast.error('Failed to load library templates');
} finally {
loading = false;
}
}
async function handleCardClick(template: TemplateItem) {
loadingTemplateId = template.id;
try {
const response = await fetch('/api/templates/compose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to generate compose');
}
const { compose } = await response.json();
stackModalName = template.title.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
stackModalCompose = compose;
showStackModal = true;
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load template');
} finally {
loadingTemplateId = null;
}
}
function handleSourcesChanged() {
fetchTemplates(true);
}
onMount(() => {
fetchTemplates();
});
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={LibraryBig} title="Templates" count={loading ? undefined : filteredTemplates.length} showConnection={false}>
<button
class="p-1 rounded hover:bg-muted transition-colors"
onclick={() => fetchTemplates(true)}
disabled={loading}
title="Refresh templates"
>
{#if loading}
<Loader2 class="w-3.5 h-3.5 animate-spin text-emerald-500" />
{:else}
<RefreshCw class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
{/if}
</button>
</PageHeader>
<div class="flex items-center gap-2">
<!-- Tab toggle -->
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5">
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-colors {activeTab === 'browse' ? 'bg-white dark:bg-zinc-900 shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'browse'}
>
<Package class="w-3.5 h-3.5" />
Browse
</button>
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-colors {activeTab === 'sources' ? 'bg-white dark:bg-zinc-900 shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'sources'}
>
<Settings2 class="w-3.5 h-3.5" />
Sources
</button>
</div>
</div>
</div>
{#if activeTab === 'browse'}
<!-- Filter bar -->
<div class="shrink-0 flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Search templates..."
class="pl-9 w-64 h-8 text-sm"
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
/>
</div>
<!-- Category filter -->
{#if allCategories.length > 0}
<Select.Root type="multiple" bind:value={selectedCategories}>
<Select.Trigger size="sm" class="w-44 text-sm">
<span class="truncate">
{#if selectedCategories.length === 0}
All categories
{:else if selectedCategories.length === 1}
{selectedCategories[0]}
{:else}
{selectedCategories.length} categories
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-64 overflow-y-auto">
{#each allCategories as category}
<Select.Item value={category}>{category}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Source filter -->
{#if allSources.length > 1}
<Select.Root type="multiple" bind:value={selectedSources}>
<Select.Trigger size="sm" class="w-48 text-sm">
<span class="truncate">
{#if selectedSources.length === 0}
All sources
{:else if selectedSources.length === 1}
{selectedSources[0]}
{:else}
{selectedSources.length} sources
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#each allSources as source}
<Select.Item value={source}>{source}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Active filter badges -->
{#if selectedCategories.length > 0 || selectedSources.length > 0 || searchQuery}
<Button
size="sm"
variant="ghost"
class="text-xs"
onclick={() => { selectedCategories = []; selectedSources = []; searchQuery = ''; }}
>
Clear filters
</Button>
{/if}
</div>
<!-- Card grid -->
<div class="flex-1 overflow-y-auto min-h-0">
{#if loading}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-1">
{#each Array(12) as _}
<div class="rounded-xl border bg-card p-4 space-y-3">
<div class="flex items-start gap-3">
<Skeleton class="w-10 h-10 rounded-lg" />
<div class="flex-1 space-y-2">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/3" />
</div>
</div>
<div class="space-y-1.5">
<Skeleton class="h-3 w-full" />
<Skeleton class="h-3 w-full" />
<Skeleton class="h-3 w-2/3" />
</div>
<div class="flex gap-1.5">
<Skeleton class="h-4 w-14 rounded-full" />
<Skeleton class="h-4 w-16 rounded-full" />
</div>
</div>
{/each}
</div>
{:else if filteredTemplates.length === 0}
<div class="flex items-center justify-center h-full">
<EmptyState
icon={Package}
title={templates.length === 0 ? 'No template sources configured' : 'No templates match your filters'}
description={templates.length === 0 ? 'Go to the Sources tab to enable template catalogs' : 'Try adjusting your search or filter criteria'}
/>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-1">
{#each filteredTemplates as template (template.id)}
<TemplateCard
{template}
loading={loadingTemplateId === template.id}
onclick={() => handleCardClick(template)}
/>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Sources tab -->
<div class="flex-1 overflow-y-auto min-h-0 p-1">
<TemplateSourcesTab onSourcesChanged={handleSourcesChanged} />
</div>
{/if}
</div>
<!-- StackModal for deploying templates -->
<StackModal
bind:open={showStackModal}
mode="create"
initialCompose={stackModalCompose}
initialStackName={stackModalName}
onClose={() => showStackModal = false}
onSuccess={() => {
showStackModal = false;
toast.success('Stack deployed from library template');
}}
/>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Package, Star, Download, Loader2 } from 'lucide-svelte';
import type { TemplateItem } from '../api/templates/+server';
interface Props {
template: TemplateItem;
loading?: boolean;
onclick: () => void;
}
let { template, loading = false, onclick }: Props = $props();
let logoError = $state(false);
const MAX_CATEGORIES = 3;
const visibleCategories = $derived(template.categories.slice(0, MAX_CATEGORIES));
const overflowCount = $derived(Math.max(0, template.categories.length - MAX_CATEGORIES));
function formatPulls(pulls: number): string {
if (pulls >= 1_000_000) return `${(pulls / 1_000_000).toFixed(1)}M`;
if (pulls >= 1_000) return `${(pulls / 1_000).toFixed(1)}K`;
return String(pulls);
}
// Convert markdown links [text](url) to HTML <a> tags, strip other HTML
function renderDescription(text: string): string {
return text
.replace(/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, '[$2]($1)') // normalize HTML links to markdown first
.replace(/<[^>]+>/g, '') // strip remaining HTML tags
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" class="text-primary hover:underline">$1</a>')
.trim();
}
</script>
<button
class="text-left w-full group"
onclick={onclick}
disabled={loading}
>
<Card.Root class="h-full gap-0 py-0 transition-all hover:border-primary/50 hover:shadow-md group-focus-visible:ring-2 group-focus-visible:ring-ring {loading ? 'opacity-60' : ''}">
<Card.Header class="p-3 pb-1.5">
<div class="flex items-start gap-2.5">
<!-- Logo -->
<div class="w-8 h-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
{#if template.logo && !logoError}
<img
src={template.logo}
alt={template.title}
class="w-8 h-8 object-contain rounded-md"
loading="lazy"
onerror={() => logoError = true}
/>
{:else}
<Package class="w-4 h-4 text-muted-foreground" />
{/if}
</div>
<!-- Title + source -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<Card.Title class="text-sm font-semibold truncate flex-1">{template.title}</Card.Title>
{#if loading}
<Loader2 class="w-3.5 h-3.5 animate-spin text-muted-foreground" />
{/if}
</div>
<Badge variant="outline" class="text-2xs px-1.5 py-0 font-normal">
{template.source}
</Badge>
</div>
</div>
</Card.Header>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<Card.Content class="px-3 pb-2 pt-0" onclick={(e: MouseEvent) => { if ((e.target as HTMLElement).tagName === 'A') e.stopPropagation(); }}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<p class="text-xs text-muted-foreground line-clamp-2">
{@html renderDescription(template.description) || 'No description available'}
</p>
</Card.Content>
<Card.Footer class="px-3 pb-3 pt-0 flex items-center gap-1.5 flex-wrap">
{#each visibleCategories as category}
<Badge variant="secondary" class="text-2xs px-1.5 py-0">
{category}
</Badge>
{/each}
{#if overflowCount > 0}
<span class="text-2xs text-muted-foreground">+{overflowCount}</span>
{/if}
<!-- LinuxServer metadata -->
{#if template.stars || template.pulls}
<div class="ml-auto flex items-center gap-2 text-2xs text-muted-foreground">
{#if template.stars}
<span class="flex items-center gap-0.5">
<Star class="w-3 h-3" />
{template.stars}
</span>
{/if}
{#if template.pulls}
<span class="flex items-center gap-0.5">
<Download class="w-3 h-3" />
{formatPulls(template.pulls)}
</span>
{/if}
</div>
{/if}
</Card.Footer>
</Card.Root>
</button>
@@ -0,0 +1,254 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, Globe, Loader2, CheckCircle2, XCircle, ShieldCheck } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import type { TemplateSource } from '$lib/server/templates';
interface Props {
onSourcesChanged: () => void;
}
let { onSourcesChanged }: Props = $props();
let sources = $state<TemplateSource[]>([]);
let loading = $state(true);
let addingNew = $state(false);
let newName = $state('');
let newUrl = $state('');
let validating = $state(false);
let validationResults = $state<Map<string, { ok: boolean; count?: number; error?: string }>>(new Map());
async function loadSources() {
loading = true;
try {
const response = await fetch('/api/templates/sources');
if (response.ok) {
sources = await response.json();
}
} catch {
toast.error('Failed to load template sources');
} finally {
loading = false;
}
}
async function toggleSource(source: TemplateSource) {
const newEnabled = !source.enabled;
source.enabled = newEnabled;
sources = sources;
try {
const response = await fetch('/api/templates/sources', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: source.id, enabled: newEnabled })
});
if (!response.ok) throw new Error();
onSourcesChanged();
} catch {
source.enabled = !newEnabled;
sources = sources;
toast.error('Failed to update source');
}
}
async function removeSource(source: TemplateSource) {
try {
const response = await fetch(`/api/templates/sources?id=${source.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error();
sources = sources.filter(s => s.id !== source.id);
toast.success('Source removed');
onSourcesChanged();
} catch {
toast.error('Failed to remove source');
}
}
async function addSource() {
if (!newName.trim() || !newUrl.trim()) return;
try {
const response = await fetch('/api/templates/sources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim(), url: newUrl.trim() })
});
if (!response.ok) throw new Error();
const newSource = await response.json();
sources = [...sources, newSource];
newName = '';
newUrl = '';
addingNew = false;
toast.success('Source added');
onSourcesChanged();
} catch {
toast.error('Failed to add source');
}
}
async function validateAllSources() {
validating = true;
validationResults = new Map();
let failedCount = 0;
const checks = sources.map(async (source) => {
const key = source.sourceId;
try {
const response = await fetch(source.url, {
signal: AbortSignal.timeout(15000)
});
if (!response.ok) {
validationResults.set(key, { ok: false, error: `HTTP ${response.status}` });
failedCount++;
return;
}
const data = await response.json();
const templates = Array.isArray(data) ? data : (data.templates || []);
validationResults.set(key, { ok: true, count: templates.length });
} catch (error) {
const msg = error instanceof Error ? error.message : 'Connection failed';
validationResults.set(key, { ok: false, error: msg });
failedCount++;
}
});
await Promise.allSettled(checks);
validationResults = new Map(validationResults);
validating = false;
if (failedCount > 0) {
toast.warning(`${failedCount} source(s) failed validation`);
} else {
toast.success('All sources are reachable');
}
}
async function disableInactive() {
let disabled = 0;
for (const source of sources) {
const result = validationResults.get(source.sourceId);
if (result && !result.ok && source.enabled) {
await fetch('/api/templates/sources', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: source.id, enabled: false })
});
source.enabled = false;
disabled++;
}
}
sources = sources;
if (disabled > 0) {
toast.success(`Disabled ${disabled} inactive source(s)`);
onSourcesChanged();
}
}
$effect(() => {
loadSources();
});
</script>
<div class="space-y-4 max-w-3xl">
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
Configure template catalog sources. Templates are fetched and cached for 1 hour.
</p>
<div class="flex items-center gap-2">
<Button size="sm" variant="outline" onclick={validateAllSources} disabled={validating}>
{#if validating}
<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />
Validating...
{:else}
<ShieldCheck class="w-3.5 h-3.5 mr-1.5" />
Validate
{/if}
</Button>
{#if validationResults.size > 0 && [...validationResults.values()].some(v => !v.ok)}
<Button size="sm" variant="outline" onclick={disableInactive}>
<XCircle class="w-3.5 h-3.5 mr-1.5" />
Disable inactive
</Button>
{/if}
<Button size="sm" onclick={() => addingNew = !addingNew}>
<Plus class="w-3.5 h-3.5 mr-1.5" />
Add source
</Button>
</div>
</div>
{#if addingNew}
<Card.Root class="gap-0 py-0 border-dashed border-primary/50">
<Card.Content class="p-3">
<div class="flex items-end gap-3">
<div class="flex-1 space-y-1">
<label for="new-source-name" class="text-xs font-medium text-muted-foreground">Name</label>
<Input id="new-source-name" bind:value={newName} placeholder="My templates" class="h-8 text-sm" />
</div>
<div class="flex-[2] space-y-1">
<label for="new-source-url" class="text-xs font-medium text-muted-foreground">URL</label>
<Input id="new-source-url" bind:value={newUrl} placeholder="https://example.com/templates.json" class="h-8 text-sm" />
</div>
<Button size="sm" onclick={addSource} disabled={!newName.trim() || !newUrl.trim()}>Add</Button>
<Button size="sm" variant="ghost" onclick={() => addingNew = false}>Cancel</Button>
</div>
</Card.Content>
</Card.Root>
{/if}
{#if loading}
<div class="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 class="w-5 h-5 animate-spin mr-2" />
Loading sources...
</div>
{:else}
<div class="space-y-2">
{#each sources as source (source.id)}
{@const validation = validationResults.get(source.sourceId)}
<Card.Root class="gap-0 py-0">
<Card.Content class="py-3 px-4">
<div class="flex items-center gap-4">
<div class="w-8 h-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0">
{#if validation}
{#if validation.ok}
<CheckCircle2 class="w-4 h-4 text-emerald-500" />
{:else}
<XCircle class="w-4 h-4 text-destructive" />
{/if}
{:else}
<Globe class="w-4 h-4 text-muted-foreground" />
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{source.name}</span>
{#if validation?.ok && validation.count !== undefined}
<span class="text-xs text-muted-foreground">({validation.count} templates)</span>
{/if}
</div>
<div class="text-xs text-muted-foreground truncate">{source.url}</div>
{#if validation && !validation.ok}
<div class="text-xs text-destructive mt-0.5">{validation.error}</div>
{/if}
</div>
<TogglePill
checked={source.enabled}
onchange={() => toggleSource(source)}
/>
{#if !source.builtin}
<Button
size="icon-sm"
variant="ghost"
onclick={() => removeSource(source)}
>
<Trash2 class="w-3.5 h-3.5 text-destructive" />
</Button>
{/if}
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
{/if}
</div>