1.0.34
This commit is contained in:
56 files changed
+9101
-117
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.<name>.rule</code> and <code>pangolin.proxy-resources.<name>.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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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');
|
||||
}}
|
||||
/>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user