mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.34
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "template_sources" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"source_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true,
|
||||||
|
"builtin" boolean DEFAULT false,
|
||||||
|
"sort_order" integer DEFAULT 0,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now(),
|
||||||
|
CONSTRAINT "template_sources_source_id_unique" UNIQUE("source_id")
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1781158711008,
|
"when": 1781158711008,
|
||||||
"tag": "0007_add_synced_files",
|
"tag": "0007_add_synced_files",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781620381909,
|
||||||
|
"tag": "0008_add_template_sources",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `template_sources` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`source_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`enabled` integer DEFAULT true,
|
||||||
|
`builtin` integer DEFAULT false,
|
||||||
|
`sort_order` integer DEFAULT 0,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `template_sources_source_id_unique` ON `template_sources` (`source_id`);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1781158702731,
|
"when": 1781158702731,
|
||||||
"tag": "0007_add_synced_files",
|
"tag": "0007_add_synced_files",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781620376161,
|
||||||
|
"tag": "0008_add_template_sources",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dockhand",
|
"name": "dockhand",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.33",
|
"version": "1.0.34",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx vite dev",
|
"dev": "npx vite dev",
|
||||||
@@ -82,14 +82,14 @@
|
|||||||
"fast-xml-parser": "5.7.3",
|
"fast-xml-parser": "5.7.3",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"ldapts": "8.1.3",
|
"ldapts": "8.1.3",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.9",
|
||||||
"otpauth": "9.4.1",
|
"otpauth": "9.4.1",
|
||||||
"postgres": "3.4.8",
|
"postgres": "3.4.8",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"rollup": "4.60.0",
|
"rollup": "4.60.0",
|
||||||
"svelte-sonner": "1.0.7",
|
"svelte-sonner": "1.0.7",
|
||||||
"undici": "7.24.5",
|
"undici": "7.24.5",
|
||||||
"ws": "8.20.1"
|
"ws": "8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.10.1",
|
"@internationalized/date": "^3.10.1",
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"d3-shape": "^3.2.0",
|
"d3-shape": "^3.2.0",
|
||||||
"drizzle-kit": "0.31.8",
|
"drizzle-kit": "0.31.8",
|
||||||
"layerchart": "^1.0.13",
|
"layerchart": "^1.0.13",
|
||||||
"lucide-svelte": "^0.562.0",
|
"lucide-svelte": "0.562.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"svelte": "5.55.7",
|
"svelte": "5.55.7",
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ globalThis.__terminalHandleExecMessage = (msg) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle WebSocket upgrade
|
// Handle WebSocket upgrade
|
||||||
server.on('upgrade', (req, socket, head) => {
|
server.on('upgrade', async (req, socket, head) => {
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||||
|
|
||||||
// Only handle our specific WebSocket paths
|
// Only handle our specific WebSocket paths
|
||||||
@@ -180,7 +180,30 @@ server.on('upgrade', (req, socket, head) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wsAuth = null;
|
||||||
|
if (isTerminal) {
|
||||||
|
try {
|
||||||
|
if (typeof globalThis.__authenticateWsUpgrade !== 'function') {
|
||||||
|
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsAuth = await globalThis.__authenticateWsUpgrade(req.headers);
|
||||||
|
if (!wsAuth) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WS] auth error during upgrade:', err);
|
||||||
|
socket.write('HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
if (wsAuth) ws.__auth = wsAuth;
|
||||||
wss.emit('connection', ws, req);
|
wss.emit('connection', ws, req);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -223,6 +246,22 @@ async function handleTerminalConnection(ws, url, connId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ws.__auth && typeof globalThis.__canAccessEnvForUser === 'function') {
|
||||||
|
try {
|
||||||
|
const ok = await globalThis.__canAccessEnvForUser(ws.__auth, envId);
|
||||||
|
if (!ok) {
|
||||||
|
console.warn(`[WS] env access denied: user=${ws.__auth.username} envId=${envId}`);
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
|
||||||
|
ws.close(1008, 'env access denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WS] env access check failed:', err);
|
||||||
|
ws.close(1011, 'internal error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Resolve Docker target via SvelteKit app's database
|
// Resolve Docker target via SvelteKit app's database
|
||||||
let target;
|
let target;
|
||||||
|
|||||||
+5
-10
@@ -18,6 +18,11 @@ import { join } from 'path';
|
|||||||
import type { HandleServerError, Handle } from '@sveltejs/kit';
|
import type { HandleServerError, Handle } from '@sveltejs/kit';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
|
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
|
||||||
|
import { getClientIp } from '$lib/server/client-ip';
|
||||||
|
// Side-effect import: installs globalThis.__authenticateWsUpgrade and
|
||||||
|
// globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in
|
||||||
|
// server.js / vite.config.ts to authenticate /api/containers/*/exec.
|
||||||
|
import '$lib/server/ws-auth';
|
||||||
|
|
||||||
// Content types worth compressing
|
// Content types worth compressing
|
||||||
const COMPRESSIBLE_TYPES = [
|
const COMPRESSIBLE_TYPES = [
|
||||||
@@ -218,16 +223,6 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, BEARER_COOLDOWN_MS).unref?.();
|
}, BEARER_COOLDOWN_MS).unref?.();
|
||||||
|
|
||||||
function getClientIp(event: { request: Request; getClientAddress?: () => string }): string {
|
|
||||||
// Prefer socket-level IP (SvelteKit resolves proxy headers via adapter config)
|
|
||||||
// This prevents X-Forwarded-For spoofing to bypass rate limiting
|
|
||||||
try {
|
|
||||||
const addr = event.getClientAddress?.();
|
|
||||||
if (addr) return addr;
|
|
||||||
} catch { /* getClientAddress may throw if unavailable */ }
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
function recordBearerFailure(ip: string): void {
|
function recordBearerFailure(ip: string): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entry = bearerFailCounts.get(ip);
|
const entry = bearerFailCounts.get(ip);
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
User,
|
User,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Activity,
|
Activity,
|
||||||
Timer
|
Timer,
|
||||||
|
LibraryBig
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { licenseStore } from '$lib/stores/license';
|
import { licenseStore } from '$lib/stores/license';
|
||||||
import { authStore, hasAnyAccess } from '$lib/stores/auth';
|
import { authStore, hasAnyAccess } from '$lib/stores/auth';
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
|
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
|
||||||
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
|
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
|
||||||
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
|
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
|
||||||
|
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
|
||||||
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
|
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
|
||||||
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
|
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
|
||||||
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
|
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
|
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } from 'lucide-svelte';
|
||||||
import { whale } from '@lucide/lab';
|
import { whale } from '@lucide/lab';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
||||||
@@ -95,6 +95,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display string for the env hostname / IP in the header (#962).
|
||||||
|
// Show both when available; drop only the field that is unknown/empty.
|
||||||
|
// Hide the whole block when neither is meaningful (e.g. hawser-edge
|
||||||
|
// reports 'unknown' for both).
|
||||||
|
const hostLabel = $derived.by(() => {
|
||||||
|
if (!hostInfo) return '';
|
||||||
|
const isMeaningful = (v: string | undefined) => {
|
||||||
|
const t = (v || '').trim();
|
||||||
|
return t && t.toLowerCase() !== 'unknown';
|
||||||
|
};
|
||||||
|
const h = isMeaningful(hostInfo.hostname) ? hostInfo.hostname.trim() : '';
|
||||||
|
const ip = isMeaningful(hostInfo.ipAddress) ? hostInfo.ipAddress.trim() : '';
|
||||||
|
if (h && ip && h !== ip) return `${h} (${ip})`;
|
||||||
|
return h || ip;
|
||||||
|
});
|
||||||
|
|
||||||
// Reactive environment list from store
|
// Reactive environment list from store
|
||||||
let envList = $derived($environments);
|
let envList = $derived($environments);
|
||||||
const showSearch = $derived(envList.length > 8);
|
const showSearch = $derived(envList.length > 8);
|
||||||
@@ -449,6 +465,16 @@
|
|||||||
{#if hostInfo}
|
{#if hostInfo}
|
||||||
<span class="text-border">|</span>
|
<span class="text-border">|</span>
|
||||||
|
|
||||||
|
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
|
||||||
|
Hidden on narrow viewports to keep the strip readable. -->
|
||||||
|
{#if hostLabel}
|
||||||
|
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
|
||||||
|
<Server class="{iconSizeClass()}" />
|
||||||
|
<span>{hostLabel}</span>
|
||||||
|
</div>
|
||||||
|
<span class="hidden xl:inline text-border">|</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Platform/OS -->
|
<!-- Platform/OS -->
|
||||||
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
|
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "1.0.34",
|
||||||
|
"date": "2026-06-17",
|
||||||
|
"changes": [
|
||||||
|
{ "type": "feature", "text": "raw file download — no tar wrapping (#1180)" },
|
||||||
|
{ "type": "fix", "text": "update modal stuck after closing mid-pull (#1094)" },
|
||||||
|
{ "type": "fix", "text": "vulnerability scans on Podman hosts (direct TCP and Hawser) (#1076)" },
|
||||||
|
{ "type": "fix", "text": "crash-looping containers now appear in the logs page list (#227)" },
|
||||||
|
{ "type": "feature", "text": "filter containers by \"Update available\" (#1063)" },
|
||||||
|
{ "type": "feature", "text": "show hostname / IP of the selected environment in the top header (#962)" },
|
||||||
|
{ "type": "feature", "text": "internal auth and validation hardening and dependency bumps" },
|
||||||
|
{ "type": "feature", "text": "Traefik and Pangolin integration — surface proxy URLs on container and stack panels (#2)" },
|
||||||
|
{ "type": "feature", "text": "release-notes link next to images with updates available (#538)" },
|
||||||
|
{ "type": "feature", "text": "lifecycle action buttons in the container details modal (#461)" },
|
||||||
|
{ "type": "feature", "text": "template library — browse and deploy compose templates from configurable sources (#48)" },
|
||||||
|
{ "type": "fix", "text": "file browser fails on containers with ls in /usr/sbin (#1185)" }
|
||||||
|
],
|
||||||
|
"imageTag": "fnsys/dockhand:v1.0.34"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.33",
|
"version": "1.0.33",
|
||||||
"date": "2026-06-15",
|
"date": "2026-06-15",
|
||||||
|
|||||||
+31
-4
@@ -137,6 +137,14 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dummyAuthHashCache: Promise<string> | null = null;
|
||||||
|
export function getDummyAuthHash(): Promise<string> {
|
||||||
|
if (!dummyAuthHashCache) {
|
||||||
|
dummyAuthHashCache = hashPassword(`dummy-${Math.random()}-${Date.now()}`);
|
||||||
|
}
|
||||||
|
return dummyAuthHashCache;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Session Management
|
// Session Management
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -241,11 +249,22 @@ function getSessionIdFromCookies(cookies: Cookies): string | null {
|
|||||||
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
|
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
|
||||||
const sessionId = getSessionIdFromCookies(cookies);
|
const sessionId = getSessionIdFromCookies(cookies);
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
|
return validateSessionById(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a session by raw session ID (without the SvelteKit Cookies object).
|
||||||
|
*
|
||||||
|
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
|
||||||
|
* have a raw Cookie header string. Mirrors validateSession() semantics:
|
||||||
|
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
|
||||||
|
*/
|
||||||
|
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
|
||||||
const session = await dbGetSession(sessionId);
|
const session = await dbGetSession(sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
// Check if session is expired
|
|
||||||
const expiresAt = new Date(session.expiresAt);
|
const expiresAt = new Date(session.expiresAt);
|
||||||
if (expiresAt < new Date()) {
|
if (expiresAt < new Date()) {
|
||||||
await dbDeleteSession(sessionId);
|
await dbDeleteSession(sessionId);
|
||||||
@@ -258,6 +277,13 @@ export async function validateSession(cookies: Cookies): Promise<AuthenticatedUs
|
|||||||
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
|
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie name used for browser session auth. Exported so raw header parsers
|
||||||
|
* (WebSocket upgrade handlers) can look it up without re-encoding the
|
||||||
|
* constant.
|
||||||
|
*/
|
||||||
|
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroy a session (logout)
|
* Destroy a session (logout)
|
||||||
*/
|
*/
|
||||||
@@ -461,13 +487,14 @@ export async function authenticateLocal(
|
|||||||
const user = await getUserByUsername(username);
|
const user = await getUserByUsername(username);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// Use constant time to prevent timing attacks
|
await verifyPassword(password, await getDummyAuthHash());
|
||||||
await hashPassword('dummy');
|
|
||||||
return { success: false, error: 'Invalid username or password' };
|
return { success: false, error: 'Invalid username or password' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
return { success: false, error: 'Account is disabled' };
|
await verifyPassword(password, await getDummyAuthHash());
|
||||||
|
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
|
||||||
|
return { success: false, error: 'Invalid username or password' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Resolve the client IP for rate limiting, logging, and audit.
|
||||||
|
*
|
||||||
|
* Defaults to the socket-level IP via getClientAddress(). X-Forwarded-For
|
||||||
|
* is consulted only when TRUST_FORWARDED_HEADERS=true is set explicitly —
|
||||||
|
* intended for deployments behind a reverse proxy (Traefik, nginx, Caddy)
|
||||||
|
* that controls XFF. In that mode the right-most XFF entry (closest to the
|
||||||
|
* trusted proxy) is returned; earlier entries in the chain are ignored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type IpEventLike = {
|
||||||
|
request: Request;
|
||||||
|
getClientAddress?: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalize(ip: string | null | undefined): string {
|
||||||
|
if (!ip) return 'unknown';
|
||||||
|
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.1';
|
||||||
|
if (ip.startsWith('::ffff:')) return ip.substring(7);
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClientIp(event: IpEventLike): string {
|
||||||
|
if (process.env.TRUST_FORWARDED_HEADERS === 'true') {
|
||||||
|
const xff = event.request.headers.get('x-forwarded-for');
|
||||||
|
if (xff) {
|
||||||
|
const parts = xff.split(',').map((p) => p.trim()).filter(Boolean);
|
||||||
|
if (parts.length > 0) return normalize(parts[parts.length - 1]);
|
||||||
|
}
|
||||||
|
const realIp = event.request.headers.get('x-real-ip');
|
||||||
|
if (realIp) return normalize(realIp.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const addr = event.getClientAddress?.();
|
||||||
|
if (addr) return normalize(addr);
|
||||||
|
} catch {
|
||||||
|
// getClientAddress may throw if unavailable (test contexts, raw upgrades)
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
@@ -4334,6 +4334,17 @@ export async function setExternalStackPaths(paths: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently add a directory to the external stack paths allowlist.
|
||||||
|
* Returns true if the path was newly added (false if already present).
|
||||||
|
*/
|
||||||
|
export async function addExternalStackPath(dir: string): Promise<boolean> {
|
||||||
|
const current = await getExternalStackPaths();
|
||||||
|
if (current.includes(dir)) return false;
|
||||||
|
await setExternalStackPaths([...current, dir]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// PRIMARY STACK LOCATION
|
// PRIMARY STACK LOCATION
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -769,7 +769,8 @@ async function seedDatabase(): Promise<void> {
|
|||||||
license: ['manage'],
|
license: ['manage'],
|
||||||
audit_logs: ['view'],
|
audit_logs: ['view'],
|
||||||
activity: ['view'],
|
activity: ['view'],
|
||||||
schedules: ['view', 'edit', 'run']
|
schedules: ['view', 'edit', 'run'],
|
||||||
|
templates: ['view', 'deploy', 'manage']
|
||||||
});
|
});
|
||||||
|
|
||||||
const operatorPermissions = JSON.stringify({
|
const operatorPermissions = JSON.stringify({
|
||||||
@@ -788,7 +789,8 @@ async function seedDatabase(): Promise<void> {
|
|||||||
license: [],
|
license: [],
|
||||||
audit_logs: [],
|
audit_logs: [],
|
||||||
activity: ['view'],
|
activity: ['view'],
|
||||||
schedules: ['view', 'edit', 'run']
|
schedules: ['view', 'edit', 'run'],
|
||||||
|
templates: ['view', 'deploy']
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewerPermissions = JSON.stringify({
|
const viewerPermissions = JSON.stringify({
|
||||||
@@ -807,9 +809,31 @@ async function seedDatabase(): Promise<void> {
|
|||||||
license: [],
|
license: [],
|
||||||
audit_logs: [],
|
audit_logs: [],
|
||||||
activity: ['view'],
|
activity: ['view'],
|
||||||
schedules: ['view']
|
schedules: ['view'],
|
||||||
|
templates: ['view']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Seed template sources if table is empty
|
||||||
|
const existingTemplateSources = await db.select().from(schema.templateSources);
|
||||||
|
if (existingTemplateSources.length === 0) {
|
||||||
|
// Inline defaults to avoid circular dependency (library.ts imports db/drizzle)
|
||||||
|
const defaultSources = [
|
||||||
|
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
|
||||||
|
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
|
||||||
|
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
|
||||||
|
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
|
||||||
|
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
|
||||||
|
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
|
||||||
|
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
|
||||||
|
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
|
||||||
|
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
|
||||||
|
];
|
||||||
|
for (const source of defaultSources) {
|
||||||
|
await db.insert(schema.templateSources).values(source);
|
||||||
|
}
|
||||||
|
logStep('Created default template sources');
|
||||||
|
}
|
||||||
|
|
||||||
const existingRoles = await db.select().from(schema.roles);
|
const existingRoles = await db.select().from(schema.roles);
|
||||||
if (existingRoles.length === 0) {
|
if (existingRoles.length === 0) {
|
||||||
await db.insert(schema.roles).values([
|
await db.insert(schema.roles).values([
|
||||||
|
|||||||
@@ -505,6 +505,19 @@ export const userPreferences = sqliteTable('user_preferences', {
|
|||||||
unique().on(table.userId, table.environmentId, table.key)
|
unique().on(table.userId, table.environmentId, table.key)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Template sources
|
||||||
|
export const templateSources = sqliteTable('template_sources', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
|
||||||
|
name: text('name').notNull(),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||||
|
builtin: integer('builtin', { mode: 'boolean' }).default(false),
|
||||||
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPE EXPORTS
|
// TYPE EXPORTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -507,3 +507,16 @@ export const userPreferences = pgTable('user_preferences', {
|
|||||||
}, (table) => [
|
}, (table) => [
|
||||||
unique().on(table.userId, table.environmentId, table.key)
|
unique().on(table.userId, table.environmentId, table.key)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Template sources
|
||||||
|
export const templateSources = pgTable('template_sources', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
|
||||||
|
name: text('name').notNull(),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
enabled: boolean('enabled').default(true),
|
||||||
|
builtin: boolean('builtin').default(false),
|
||||||
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||||
|
});
|
||||||
|
|||||||
@@ -5086,18 +5086,20 @@ export async function listContainerDirectory(
|
|||||||
// Sanitize path to prevent command injection
|
// Sanitize path to prevent command injection
|
||||||
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||||
|
|
||||||
// Commands to try in order of preference
|
// Commands to try in order of preference (includes /usr/sbin/ls for Wolfi/busybox images)
|
||||||
const commands = useSimpleLs
|
const commands = useSimpleLs
|
||||||
? [
|
? [
|
||||||
['ls', '-la', safePath],
|
['ls', '-la', safePath],
|
||||||
['/bin/ls', '-la', safePath],
|
['/bin/ls', '-la', safePath],
|
||||||
['/usr/bin/ls', '-la', safePath],
|
['/usr/bin/ls', '-la', safePath],
|
||||||
|
['/usr/sbin/ls', '-la', safePath],
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
['ls', '-la', '--time-style=long-iso', safePath],
|
['ls', '-la', '--time-style=long-iso', safePath],
|
||||||
['ls', '-la', safePath],
|
['ls', '-la', safePath],
|
||||||
['/bin/ls', '-la', safePath],
|
['/bin/ls', '-la', safePath],
|
||||||
['/usr/bin/ls', '-la', safePath],
|
['/usr/bin/ls', '-la', safePath],
|
||||||
|
['/usr/sbin/ls', '-la', safePath],
|
||||||
];
|
];
|
||||||
|
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
@@ -5180,7 +5182,7 @@ export async function statContainerPath(
|
|||||||
containerId: string,
|
containerId: string,
|
||||||
path: string,
|
path: string,
|
||||||
envId?: number | null
|
envId?: number | null
|
||||||
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string }> {
|
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
|
||||||
// Sanitize path
|
// Sanitize path
|
||||||
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||||
|
|
||||||
@@ -5202,7 +5204,10 @@ export async function statContainerPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statJson = Buffer.from(statHeader, 'base64').toString('utf-8');
|
const statJson = Buffer.from(statHeader, 'base64').toString('utf-8');
|
||||||
return JSON.parse(statJson);
|
const stat = JSON.parse(statJson);
|
||||||
|
// Go's os.FileMode encodes the file type in the high bits. ModeDir = 1<<31.
|
||||||
|
// Docker emits mode in that format, so the directory bit lives at 0x80000000.
|
||||||
|
return { ...stat, isDir: (stat.mode & 0x80000000) !== 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5755,6 +5760,22 @@ export async function getVolumeArchive(
|
|||||||
// Note: Container is kept alive for reuse. Cache TTL will handle cleanup.
|
// Note: Container is kept alive for reuse. Cache TTL will handle cleanup.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stat a path inside a volume via the helper container.
|
||||||
|
* Returns the same shape as statContainerPath (#1180 raw download needs isDir).
|
||||||
|
*/
|
||||||
|
export async function statVolumePath(
|
||||||
|
volumeName: string,
|
||||||
|
path: string,
|
||||||
|
envId?: number | null,
|
||||||
|
readOnly: boolean = true
|
||||||
|
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
|
||||||
|
const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly);
|
||||||
|
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||||
|
const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`;
|
||||||
|
return statContainerPath(containerId, fullPath, envId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read file content from volume
|
* Read file content from volume
|
||||||
* Uses cached helper containers for better performance.
|
* Uses cached helper containers for better performance.
|
||||||
|
|||||||
+31
-11
@@ -265,6 +265,16 @@ export async function handleEdgeMetrics(
|
|||||||
// Register global handler for metrics
|
// Register global handler for metrics
|
||||||
globalThis.__hawserHandleMetrics = handleEdgeMetrics;
|
globalThis.__hawserHandleMetrics = handleEdgeMetrics;
|
||||||
|
|
||||||
|
let dummyHawserHash: string | null = null;
|
||||||
|
async function getDummyHawserHash(): Promise<string> {
|
||||||
|
if (!dummyHawserHash) {
|
||||||
|
dummyHawserHash = await hashPassword('hawser_init_seed');
|
||||||
|
}
|
||||||
|
return dummyHawserHash;
|
||||||
|
}
|
||||||
|
// Warm the lazy init so first-call latency is consistent.
|
||||||
|
getDummyHawserHash().catch(() => {});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a Hawser token
|
* Validate a Hawser token
|
||||||
*/
|
*/
|
||||||
@@ -279,22 +289,32 @@ export async function validateHawserToken(
|
|||||||
.from(hawserTokens)
|
.from(hawserTokens)
|
||||||
.where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true)));
|
.where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true)));
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
await verifyPassword(token, await getDummyHawserHash());
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
for (const t of candidates) {
|
for (const t of candidates) {
|
||||||
try {
|
try {
|
||||||
const isValid = await verifyPassword(token, t.token);
|
const isValid = await verifyPassword(token, t.token);
|
||||||
if (isValid) {
|
if (!isValid) continue;
|
||||||
// Update last used timestamp
|
|
||||||
await db
|
|
||||||
.update(hawserTokens)
|
|
||||||
.set({ lastUsed: new Date().toISOString() })
|
|
||||||
.where(eq(hawserTokens.id, t.id));
|
|
||||||
|
|
||||||
return {
|
// Expiry check intentionally runs after the hash verify.
|
||||||
valid: true,
|
if (t.expiresAt && new Date(t.expiresAt) < new Date()) {
|
||||||
environmentId: t.environmentId ?? undefined,
|
return { valid: false };
|
||||||
tokenId: t.id
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
await db
|
||||||
|
.update(hawserTokens)
|
||||||
|
.set({ lastUsed: new Date().toISOString() })
|
||||||
|
.where(eq(hawserTokens.id, t.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
environmentId: t.environmentId ?? undefined,
|
||||||
|
tokenId: t.id
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid hash format, skip
|
// Invalid hash format, skip
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
|
|||||||
// Used by scanner to replicate how Dockhand connects to Docker
|
// Used by scanner to replicate how Dockhand connects to Docker
|
||||||
let cachedOwnDockerHost: string | null = null;
|
let cachedOwnDockerHost: string | null = null;
|
||||||
let cachedOwnNetworkMode: string | null = null;
|
let cachedOwnNetworkMode: string | null = null;
|
||||||
|
let cachedOwnAllNetworks: string[] | null = null;
|
||||||
let cachedOwnExtraHosts: string[] | null = null;
|
let cachedOwnExtraHosts: string[] | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,7 +167,10 @@ export async function detectHostDataDir(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache Dockhand's network (prefer non-default for service discovery)
|
// Cache Dockhand's networks. Picks one as the primary networkMode
|
||||||
|
// (custom net first, falling back to bridge) and keeps the full list
|
||||||
|
// so callers can warn when a setup is fragile — e.g. socket-proxy
|
||||||
|
// living on a network other than the one the scanner joins (#1011).
|
||||||
const networks = containerInfo.NetworkSettings?.Networks;
|
const networks = containerInfo.NetworkSettings?.Networks;
|
||||||
if (networks) {
|
if (networks) {
|
||||||
const custom = Object.keys(networks).filter(
|
const custom = Object.keys(networks).filter(
|
||||||
@@ -174,8 +178,9 @@ export async function detectHostDataDir(): Promise<string | null> {
|
|||||||
);
|
);
|
||||||
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
|
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
|
||||||
: networks.bridge ? 'bridge' : null;
|
: networks.bridge ? 'bridge' : null;
|
||||||
|
cachedOwnAllNetworks = Object.keys(networks);
|
||||||
if (cachedOwnNetworkMode) {
|
if (cachedOwnNetworkMode) {
|
||||||
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode}`);
|
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode} (all: ${cachedOwnAllNetworks.join(', ')})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +250,15 @@ export function getOwnNetworkMode(): string | null {
|
|||||||
return cachedOwnNetworkMode;
|
return cachedOwnNetworkMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All Docker networks Dockhand itself is attached to. The scanner uses
|
||||||
|
* this to detect split-network setups and warn that socket-proxy may not
|
||||||
|
* be reachable from the network it actually joins (#1011).
|
||||||
|
*/
|
||||||
|
export function getOwnAllNetworks(): string[] {
|
||||||
|
return cachedOwnAllNetworks ? [...cachedOwnAllNetworks] : [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ExtraHosts entries configured on Dockhand itself.
|
* Get the ExtraHosts entries configured on Dockhand itself.
|
||||||
* Used to mirror host aliases into sibling sidecar containers.
|
* Used to mirror host aliases into sibling sidecar containers.
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Detect the on-host Docker socket path for a remote daemon (#1076).
|
||||||
|
*
|
||||||
|
* Used by the vulnerability scanner when it needs to bind-mount the daemon
|
||||||
|
* socket into a helper container running on that daemon. Docker daemons use
|
||||||
|
* /var/run/docker.sock; Podman uses /run/podman/podman.sock (rootful) or
|
||||||
|
* /run/user/UID/podman/podman.sock (rootless). Hardcoding /var/run/docker.sock
|
||||||
|
* breaks Podman with a mkdir-permission-denied error.
|
||||||
|
*
|
||||||
|
* Detection runs against the remote daemon over the same connection
|
||||||
|
* Dockhand already uses (socket / direct TCP / Hawser), so no agent change
|
||||||
|
* is required.
|
||||||
|
*
|
||||||
|
* Result is cached per envId for 5 minutes — daemon identity doesn't change
|
||||||
|
* during a process lifetime in practice, but the short TTL lets us recover
|
||||||
|
* if the user reconfigures an env to point at a different daemon.
|
||||||
|
*/
|
||||||
|
import { dockerFetch } from './docker';
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const cache = new Map<number, { path: string; expires: number }>();
|
||||||
|
|
||||||
|
const DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock';
|
||||||
|
const PODMAN_ROOTFUL_SOCKET = '/run/podman/podman.sock';
|
||||||
|
|
||||||
|
export function clearRemoteSocketCache(envId?: number): void {
|
||||||
|
if (envId === undefined) cache.clear();
|
||||||
|
else cache.delete(envId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the absolute path to the daemon's API socket on its own host.
|
||||||
|
*
|
||||||
|
* Best-effort: any failure falls back to /var/run/docker.sock, which matches
|
||||||
|
* the historic behaviour and is correct for stock Docker.
|
||||||
|
*/
|
||||||
|
export async function detectRemoteSocketPath(envId: number | undefined): Promise<string> {
|
||||||
|
if (envId === undefined) return DEFAULT_DOCKER_SOCKET;
|
||||||
|
|
||||||
|
const cached = cache.get(envId);
|
||||||
|
if (cached && cached.expires > Date.now()) return cached.path;
|
||||||
|
|
||||||
|
let path = DEFAULT_DOCKER_SOCKET;
|
||||||
|
try {
|
||||||
|
const isPodman = await daemonIsPodman(envId);
|
||||||
|
if (isPodman) {
|
||||||
|
path = (await detectPodmanSocketPath(envId)) ?? PODMAN_ROOTFUL_SOCKET;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[Scanner] detectRemoteSocketPath(env=${envId}) failed, defaulting to ${DEFAULT_DOCKER_SOCKET}:`,
|
||||||
|
(err as Error)?.message ?? err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(envId, { path, expires: Date.now() + CACHE_TTL_MS });
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the remote daemon identifies itself as Podman.
|
||||||
|
* Used by both the scanner socket-path detection and the env list pill.
|
||||||
|
* Any transport / parse failure returns false — callers treat "unknown"
|
||||||
|
* as "assume Docker" so a transient network hiccup never breaks the UI.
|
||||||
|
*/
|
||||||
|
export async function daemonIsPodman(envId: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Docker-compat /version returns Components[].Name. Podman labels
|
||||||
|
// itself "Podman Engine"; Docker uses "Engine".
|
||||||
|
const res = await dockerFetch('/version', {}, envId);
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = (await res.json()) as { Components?: Array<{ Name?: string }> };
|
||||||
|
const components = data.Components ?? [];
|
||||||
|
return components.some((c) => typeof c?.Name === 'string' && c.Name.includes('Podman'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PodmanLibpodInfo {
|
||||||
|
host?: {
|
||||||
|
security?: { rootless?: boolean };
|
||||||
|
idMappings?: { uidmap?: Array<{ host_id?: number }> };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectPodmanSocketPath(envId: number): Promise<string | null> {
|
||||||
|
// Podman's native /libpod/info exposes rootless flag + uid mapping.
|
||||||
|
// Versioned path: /v4.0.0/libpod/info works across all Podman 4.x/5.x.
|
||||||
|
const res = await dockerFetch('/v4.0.0/libpod/info', {}, envId);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const info = (await res.json()) as PodmanLibpodInfo;
|
||||||
|
|
||||||
|
const isRootless = info.host?.security?.rootless === true;
|
||||||
|
if (!isRootless) return PODMAN_ROOTFUL_SOCKET;
|
||||||
|
|
||||||
|
// The first uidmap entry's host_id is the user the daemon runs as.
|
||||||
|
// Example uidmap: [{ container_id: 0, host_id: 1000, size: 1 }, ...]
|
||||||
|
const uid = info.host?.idMappings?.uidmap?.[0]?.host_id;
|
||||||
|
if (typeof uid !== 'number' || !Number.isInteger(uid) || uid < 0) {
|
||||||
|
// No usable uid — leave it to the caller's default
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `/run/user/${uid}/podman/podman.sock`;
|
||||||
|
}
|
||||||
@@ -16,13 +16,15 @@ import {
|
|||||||
} from './docker';
|
} from './docker';
|
||||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||||
import { sendEventNotification } from './notifications';
|
import { sendEventNotification } from './notifications';
|
||||||
|
import { detectRemoteSocketPath } from './scanner-socket-detect';
|
||||||
import {
|
import {
|
||||||
getHostDockerSocket,
|
getHostDockerSocket,
|
||||||
getHostDataDir,
|
getHostDataDir,
|
||||||
extractUidFromSocketPath,
|
extractUidFromSocketPath,
|
||||||
getOwnDockerHost,
|
getOwnDockerHost,
|
||||||
getOwnExtraHosts,
|
getOwnExtraHosts,
|
||||||
getOwnNetworkMode
|
getOwnNetworkMode,
|
||||||
|
getOwnAllNetworks
|
||||||
} from './host-path';
|
} from './host-path';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { mkdir, chown, rm } from 'node:fs/promises';
|
import { mkdir, chown, rm } from 'node:fs/promises';
|
||||||
@@ -632,7 +634,7 @@ async function runScannerContainerCore(
|
|||||||
let rootlessUid: string | undefined;
|
let rootlessUid: string | undefined;
|
||||||
let scannerNetworkMode: string | undefined;
|
let scannerNetworkMode: string | undefined;
|
||||||
let scannerDockerHost: string | undefined;
|
let scannerDockerHost: string | undefined;
|
||||||
const scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
|
let scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
|
||||||
|
|
||||||
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
|
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
|
||||||
// Detected at startup from Dockhand's own container inspect data.
|
// Detected at startup from Dockhand's own container inspect data.
|
||||||
@@ -641,9 +643,19 @@ async function runScannerContainerCore(
|
|||||||
const ownDockerHost = getOwnDockerHost();
|
const ownDockerHost = getOwnDockerHost();
|
||||||
|
|
||||||
if (!isHawser && ownDockerHost?.startsWith('tcp://')) {
|
if (!isHawser && ownDockerHost?.startsWith('tcp://')) {
|
||||||
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
|
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand.
|
||||||
scannerDockerHost = ownDockerHost;
|
scannerDockerHost = ownDockerHost;
|
||||||
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
|
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
|
||||||
|
const allNets = getOwnAllNetworks();
|
||||||
|
if (allNets.length > 1) {
|
||||||
|
// Multiple custom networks — if socket-proxy lives on a network
|
||||||
|
// other than the one we picked, DNS will fail. Make the choice
|
||||||
|
// visible so users with split-network setups can colocate
|
||||||
|
// socket-proxy with Dockhand on the primary network (#1011).
|
||||||
|
console.warn(
|
||||||
|
`[Scanner] Dockhand is on multiple networks (${allNets.join(', ')}); scanner will only join "${scannerNetworkMode}". If DOCKER_HOST=${scannerDockerHost} fails to resolve, put socket-proxy on this network.`
|
||||||
|
);
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`
|
`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`
|
||||||
);
|
);
|
||||||
@@ -651,9 +663,35 @@ async function runScannerContainerCore(
|
|||||||
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
|
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
|
||||||
}
|
}
|
||||||
} else if (isHawser) {
|
} else if (isHawser) {
|
||||||
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
|
// Hawser: scanner runs on remote host. Detect the actual socket path
|
||||||
hostSocketPath = '/var/run/docker.sock';
|
// because rootless Podman uses /run/user/UID/podman/podman.sock, not
|
||||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - using standard socket path`);
|
// /var/run/docker.sock (#1076). Falls back to the standard path on
|
||||||
|
// detection failure — no regression for stock Docker hosts.
|
||||||
|
hostSocketPath = await detectRemoteSocketPath(envId);
|
||||||
|
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`);
|
||||||
|
} else if (connectionType === 'direct' && env?.host) {
|
||||||
|
// Direct TCP to a remote daemon (e.g. Docker over TCP, Podman over TCP).
|
||||||
|
// The scanner container is created on the REMOTE daemon, not on
|
||||||
|
// Dockhand's host. Binding "/var/run/docker.sock" from Dockhand's
|
||||||
|
// host into a remote-daemon container is nonsense — on remote Docker
|
||||||
|
// it silently creates an empty dir (scans fall back to registry); on
|
||||||
|
// rootless Podman it errors with "mkdir /var/run/docker.sock:
|
||||||
|
// permission denied" (#1076, #1011). Instead, tell the scanner to
|
||||||
|
// talk to the daemon over the same TCP endpoint Dockhand uses.
|
||||||
|
// `host.containers.internal` resolves to the daemon's host from
|
||||||
|
// inside the scanner container on both Docker (with `host-gateway`)
|
||||||
|
// and Podman (built-in).
|
||||||
|
scannerDockerHost = `tcp://host.containers.internal:${env.port}`;
|
||||||
|
// Add the host-gateway mapping so Docker honours the hostname.
|
||||||
|
// Podman recognises host.containers.internal natively; the extra
|
||||||
|
// mapping is harmless there.
|
||||||
|
scannerExtraHosts = [
|
||||||
|
...(scannerExtraHosts ?? []),
|
||||||
|
'host.containers.internal:host-gateway'
|
||||||
|
];
|
||||||
|
console.log(
|
||||||
|
`[Scanner] Direct TCP env (${env.protocol ?? 'http'}://${env.host}:${env.port}) - DOCKER_HOST=${scannerDockerHost} (#1076, #1011)`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// Local socket — detect host socket path (handles rootless Docker)
|
// Local socket — detect host socket path (handles rootless Docker)
|
||||||
hostSocketPath = getHostDockerSocket();
|
hostSocketPath = getHostDockerSocket();
|
||||||
|
|||||||
+157
-3
@@ -5,8 +5,8 @@
|
|||||||
* All lifecycle operations use docker compose commands.
|
* All lifecycle operations use docker compose commands.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
|
||||||
import { join, resolve, dirname, basename } from 'node:path';
|
import { join, resolve, dirname, basename, normalize as pathNormalize, sep as pathSep } from 'node:path';
|
||||||
import { spawn as nodeSpawn } from 'node:child_process';
|
import { spawn as nodeSpawn } from 'node:child_process';
|
||||||
import type { ChildProcess } from 'node:child_process';
|
import type { ChildProcess } from 'node:child_process';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +34,9 @@ import {
|
|||||||
removePendingContainerUpdate,
|
removePendingContainerUpdate,
|
||||||
deleteAutoUpdateSchedule,
|
deleteAutoUpdateSchedule,
|
||||||
getAutoUpdateSetting,
|
getAutoUpdateSetting,
|
||||||
getStackSourceByComposePath
|
getStackSourceByComposePath,
|
||||||
|
getExternalStackPaths,
|
||||||
|
addExternalStackPath
|
||||||
} from './db';
|
} from './db';
|
||||||
import { unregisterSchedule } from './scheduler';
|
import { unregisterSchedule } from './scheduler';
|
||||||
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
||||||
@@ -348,6 +350,125 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro
|
|||||||
return join(stacksDir, stackName);
|
return join(stacksDir, stackName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filenames a stack is allowed to write. Compose files in the conventional
|
||||||
|
* names + .env. Anything else is rejected even when the directory is in
|
||||||
|
* the allowlist, so this code path can only ever produce stack-shaped files.
|
||||||
|
*/
|
||||||
|
const ALLOWED_STACK_FILENAMES = new Set([
|
||||||
|
'docker-compose.yml',
|
||||||
|
'docker-compose.yaml',
|
||||||
|
'compose.yml',
|
||||||
|
'compose.yaml',
|
||||||
|
'.env'
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a path against the parent's realpath when the parent exists, so
|
||||||
|
* symlinks resolve to their canonical location. We can't realpath the leaf
|
||||||
|
* because the file may not exist yet (new stack).
|
||||||
|
*/
|
||||||
|
function resolveStackPath(input: string): string {
|
||||||
|
const abs = resolve(input);
|
||||||
|
const parent = dirname(abs);
|
||||||
|
try {
|
||||||
|
if (existsSync(parent)) {
|
||||||
|
return join(realpathSync(parent), basename(abs));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// realpath may fail on permission errors; fall through to the plain resolve.
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInside(child: string, parent: string): boolean {
|
||||||
|
const c = pathNormalize(child);
|
||||||
|
const p = pathNormalize(parent);
|
||||||
|
if (c === p) return true;
|
||||||
|
return c.startsWith(p.endsWith(pathSep) ? p : p + pathSep);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StackPathValidation {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
resolved?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a custom compose or env file path is writable by this code
|
||||||
|
* path. A path is accepted when it lives inside getStacksDir() or any
|
||||||
|
* directory in the external_stack_paths allowlist (admin-controlled, set
|
||||||
|
* in Settings → General), its filename is one of the conventional
|
||||||
|
* compose/env names, and it does not contain .. segments. The parent is
|
||||||
|
* resolved via realpath so a symlinked component inside an allowlisted dir
|
||||||
|
* cannot point elsewhere.
|
||||||
|
*
|
||||||
|
* Callers that need to grandfather an existing admin-configured path should
|
||||||
|
* call validateStackPathWithGrandfather() instead.
|
||||||
|
*/
|
||||||
|
export async function validateStackPath(input: string): Promise<StackPathValidation> {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return { ok: false, error: 'Path is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = resolveStackPath(input);
|
||||||
|
|
||||||
|
// Normalized form must not contain a .. segment.
|
||||||
|
const segments = pathNormalize(resolvedPath).split(pathSep);
|
||||||
|
if (segments.includes('..')) {
|
||||||
|
return { ok: false, error: 'Path traversal not allowed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = basename(resolvedPath);
|
||||||
|
if (!ALLOWED_STACK_FILENAMES.has(filename)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `File "${filename}" is not an allowed stack filename (expected one of: ${[...ALLOWED_STACK_FILENAMES].join(', ')})`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stacksDir = getStacksDir();
|
||||||
|
if (isInside(resolvedPath, stacksDir)) {
|
||||||
|
return { ok: true, resolved: resolvedPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowlist = await getExternalStackPaths();
|
||||||
|
for (const dir of allowlist) {
|
||||||
|
if (!dir) continue;
|
||||||
|
const dirResolved = resolve(dir);
|
||||||
|
if (isInside(resolvedPath, dirResolved)) {
|
||||||
|
return { ok: true, resolved: resolvedPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Path "${resolvedPath}" is not inside an allowed stack directory. Add its parent directory in Settings → General → External stack paths.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as validateStackPath, but when the path comes from an existing
|
||||||
|
* stack source row (a stored custom path the admin set previously) and
|
||||||
|
* fails the allowlist check, add its parent dir to external_stack_paths
|
||||||
|
* and re-validate. Lets old custom-path configurations keep working
|
||||||
|
* without operator intervention.
|
||||||
|
*/
|
||||||
|
export async function validateStackPathWithGrandfather(
|
||||||
|
input: string,
|
||||||
|
isPreExisting: boolean
|
||||||
|
): Promise<StackPathValidation> {
|
||||||
|
const first = await validateStackPath(input);
|
||||||
|
if (first.ok || !isPreExisting) return first;
|
||||||
|
|
||||||
|
const parent = dirname(resolveStackPath(input));
|
||||||
|
const added = await addExternalStackPath(parent);
|
||||||
|
if (added) {
|
||||||
|
console.log(`[Stack] Grandfathered pre-existing custom path: ${parent}`);
|
||||||
|
}
|
||||||
|
return validateStackPath(input);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find stack directory, checking paths in order:
|
* Find stack directory, checking paths in order:
|
||||||
* 1. Database: Custom composePath in stackSources table (adopted/imported stacks)
|
* 1. Database: Custom composePath in stackSources table (adopted/imported stacks)
|
||||||
@@ -554,6 +675,31 @@ export async function saveStackComposeFile(
|
|||||||
const source = await getStackSource(name, envId);
|
const source = await getStackSource(name, envId);
|
||||||
const composePath = options?.composePath || source?.composePath;
|
const composePath = options?.composePath || source?.composePath;
|
||||||
|
|
||||||
|
// Path-allowlist validation. Pre-existing stored paths grandfather into
|
||||||
|
// the allowlist; new caller-supplied paths must already be inside it.
|
||||||
|
// See validateStackPath() docs.
|
||||||
|
if (composePath) {
|
||||||
|
const fromDb = !options?.composePath && !!source?.composePath;
|
||||||
|
const v = await validateStackPathWithGrandfather(composePath, fromDb);
|
||||||
|
if (!v.ok) return { success: false, error: v.error };
|
||||||
|
}
|
||||||
|
if (options?.envPath) {
|
||||||
|
const v = await validateStackPath(options.envPath);
|
||||||
|
if (!v.ok) return { success: false, error: v.error };
|
||||||
|
} else if (source?.envPath && !options?.envPath) {
|
||||||
|
// Grandfather a DB-stored env path that may have been configured before validation.
|
||||||
|
const v = await validateStackPathWithGrandfather(source.envPath, true);
|
||||||
|
if (!v.ok) return { success: false, error: v.error };
|
||||||
|
}
|
||||||
|
if (options?.oldComposePath) {
|
||||||
|
const v = await validateStackPathWithGrandfather(options.oldComposePath, true);
|
||||||
|
if (!v.ok) return { success: false, error: v.error };
|
||||||
|
}
|
||||||
|
if (options?.oldEnvPath) {
|
||||||
|
const v = await validateStackPathWithGrandfather(options.oldEnvPath, true);
|
||||||
|
if (!v.ok) return { success: false, error: v.error };
|
||||||
|
}
|
||||||
|
|
||||||
// Handle compose file move/rename when path changes
|
// Handle compose file move/rename when path changes
|
||||||
if (options?.oldComposePath && options?.composePath &&
|
if (options?.oldComposePath && options?.composePath &&
|
||||||
options.oldComposePath !== options.composePath &&
|
options.oldComposePath !== options.composePath &&
|
||||||
@@ -2700,6 +2846,10 @@ export async function writeStackEnvFile(
|
|||||||
envId?: number | null,
|
envId?: number | null,
|
||||||
customEnvPath?: string
|
customEnvPath?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (customEnvPath) {
|
||||||
|
const v = await validateStackPath(customEnvPath);
|
||||||
|
if (!v.ok) throw new Error(v.error || 'Invalid env path');
|
||||||
|
}
|
||||||
let envFilePath: string;
|
let envFilePath: string;
|
||||||
if (customEnvPath) {
|
if (customEnvPath) {
|
||||||
envFilePath = customEnvPath;
|
envFilePath = customEnvPath;
|
||||||
@@ -2745,6 +2895,10 @@ export async function writeRawStackEnvFile(
|
|||||||
envId?: number | null,
|
envId?: number | null,
|
||||||
customEnvPath?: string
|
customEnvPath?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (customEnvPath) {
|
||||||
|
const v = await validateStackPath(customEnvPath);
|
||||||
|
if (!v.ok) throw new Error(v.error || 'Invalid env path');
|
||||||
|
}
|
||||||
let envFilePath: string;
|
let envFilePath: string;
|
||||||
if (customEnvPath) {
|
if (customEnvPath) {
|
||||||
envFilePath = customEnvPath;
|
envFilePath = customEnvPath;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Single-file tar extraction for raw downloads (#1180).
|
||||||
|
*
|
||||||
|
* Docker's /archive endpoint always wraps file contents in a USTAR tar.
|
||||||
|
* When the user picks the "no archive" download format and the path is a
|
||||||
|
* regular file, we strip the wrapper and emit the bytes verbatim.
|
||||||
|
*
|
||||||
|
* Only handles the first regular-file entry — the caller has already
|
||||||
|
* guaranteed (via stat) that the requested path is a single file, so the
|
||||||
|
* tar contains exactly one entry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the bytes of the first regular file entry in a USTAR tar.
|
||||||
|
* Returns the file content as a Uint8Array.
|
||||||
|
*
|
||||||
|
* Throws when no regular file entry is found (e.g. the tar contained only
|
||||||
|
* a directory header) — that's an unexpected state, since the caller is
|
||||||
|
* supposed to have already verified the path points to a file.
|
||||||
|
*/
|
||||||
|
export function extractFirstFileFromTar(tarData: Uint8Array): Uint8Array {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset + 512 <= tarData.length) {
|
||||||
|
const header = tarData.subarray(offset, offset + 512);
|
||||||
|
|
||||||
|
// Two consecutive zero blocks mark end-of-archive.
|
||||||
|
if (isZeroBlock(header)) break;
|
||||||
|
|
||||||
|
const name = readString(header, 0, 100);
|
||||||
|
const sizeOctal = readString(header, 124, 12).trim();
|
||||||
|
const size = sizeOctal ? parseInt(sizeOctal, 8) : 0;
|
||||||
|
const typeFlag = header[156];
|
||||||
|
|
||||||
|
// Regular file: typeflag '0' (0x30) or NUL (0x00, legacy)
|
||||||
|
const isRegularFile = typeFlag === 0x30 || typeFlag === 0x00;
|
||||||
|
|
||||||
|
if (isRegularFile && name && size >= 0) {
|
||||||
|
const start = offset + 512;
|
||||||
|
const end = start + size;
|
||||||
|
if (end > tarData.length) {
|
||||||
|
throw new Error('Truncated tar archive');
|
||||||
|
}
|
||||||
|
return tarData.subarray(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip header + content (padded to 512-byte boundary)
|
||||||
|
offset += 512 + Math.ceil(size / 512) * 512;
|
||||||
|
}
|
||||||
|
throw new Error('No regular file entry found in tar archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZeroBlock(block: Uint8Array): boolean {
|
||||||
|
for (let i = 0; i < block.length; i++) {
|
||||||
|
if (block[i] !== 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(buf: Uint8Array, offset: number, length: number): string {
|
||||||
|
let end = offset;
|
||||||
|
const limit = offset + length;
|
||||||
|
while (end < limit && buf[end] !== 0) end++;
|
||||||
|
return new TextDecoder('utf-8').decode(buf.subarray(offset, end));
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { db } from './db/drizzle';
|
||||||
|
import { asc } from 'drizzle-orm';
|
||||||
|
|
||||||
|
// Dynamic schema import (same pattern as db.ts)
|
||||||
|
const isPostgres = !!process.env.DATABASE_URL;
|
||||||
|
const schema = isPostgres
|
||||||
|
? await import('./db/schema/pg-schema.js')
|
||||||
|
: await import('./db/schema/index.js');
|
||||||
|
|
||||||
|
const { templateSources } = schema;
|
||||||
|
|
||||||
|
export interface TemplateSource {
|
||||||
|
id: number;
|
||||||
|
sourceId: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
builtin: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TEMPLATE_SOURCES: Omit<TemplateSource, 'id'>[] = [
|
||||||
|
// Large collections
|
||||||
|
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
|
||||||
|
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
|
||||||
|
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
|
||||||
|
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
|
||||||
|
// Homelab / self-hosted
|
||||||
|
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
|
||||||
|
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
|
||||||
|
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
|
||||||
|
// ARM / Raspberry Pi
|
||||||
|
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
|
||||||
|
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed default sources into the library_sources table if empty.
|
||||||
|
*/
|
||||||
|
export async function seedTemplateSources(): Promise<void> {
|
||||||
|
const existing = await db.select().from(templateSources);
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
for (const source of DEFAULT_TEMPLATE_SOURCES) {
|
||||||
|
await db.insert(templateSources).values({
|
||||||
|
sourceId: source.sourceId,
|
||||||
|
name: source.name,
|
||||||
|
url: source.url,
|
||||||
|
enabled: source.enabled,
|
||||||
|
builtin: source.builtin,
|
||||||
|
sortOrder: source.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateSources(): Promise<TemplateSource[]> {
|
||||||
|
const rows = await db.select().from(templateSources).orderBy(asc(templateSources.sortOrder));
|
||||||
|
return rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
sourceId: r.sourceId,
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
enabled: r.enabled ?? true,
|
||||||
|
builtin: r.builtin ?? false,
|
||||||
|
sortOrder: r.sortOrder ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTemplateSource(id: number, updates: { enabled?: boolean; name?: string; url?: string }): Promise<void> {
|
||||||
|
const { eq } = await import('drizzle-orm');
|
||||||
|
await db.update(templateSources)
|
||||||
|
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||||
|
.where(eq(templateSources.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTemplateSource(source: { name: string; url: string }): Promise<TemplateSource> {
|
||||||
|
const sourceId = `custom-${Date.now()}`;
|
||||||
|
const maxOrder = await db.select().from(templateSources).orderBy(asc(templateSources.sortOrder));
|
||||||
|
const nextOrder = maxOrder.length > 0 ? (maxOrder[maxOrder.length - 1].sortOrder ?? 0) + 1 : 0;
|
||||||
|
|
||||||
|
const result = await db.insert(templateSources).values({
|
||||||
|
sourceId,
|
||||||
|
name: source.name,
|
||||||
|
url: source.url,
|
||||||
|
enabled: true,
|
||||||
|
builtin: false,
|
||||||
|
sortOrder: nextOrder,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
const r = result[0];
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
sourceId: r.sourceId,
|
||||||
|
name: r.name,
|
||||||
|
url: r.url,
|
||||||
|
enabled: r.enabled ?? true,
|
||||||
|
builtin: r.builtin ?? false,
|
||||||
|
sortOrder: r.sortOrder ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplateSource(id: number): Promise<void> {
|
||||||
|
const { eq } = await import('drizzle-orm');
|
||||||
|
await db.delete(templateSources).where(eq(templateSources.id, id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
|
||||||
|
import { validateApiToken } from './api-tokens';
|
||||||
|
import { isEnterprise } from './license';
|
||||||
|
import { userHasAdminRole, userCanAccessEnvironment } from './db';
|
||||||
|
|
||||||
|
export interface WsUpgradeAuth {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
authDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieHeader(header: string | undefined): Record<string, string> {
|
||||||
|
if (!header) return {};
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const eq = part.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
const k = part.slice(0, eq).trim();
|
||||||
|
let v = part.slice(eq + 1).trim();
|
||||||
|
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||||
|
if (k) out[k] = decodeURIComponent(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LowercasedHeaders = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
|
||||||
|
const v = headers[name];
|
||||||
|
if (Array.isArray(v)) return v[0];
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateWsUpgrade(
|
||||||
|
headers: LowercasedHeaders
|
||||||
|
): Promise<WsUpgradeAuth | null> {
|
||||||
|
const authEnabled = await isAuthEnabled();
|
||||||
|
if (!authEnabled) {
|
||||||
|
return { userId: -1, username: '__bootstrap__', isAdmin: true, authDisabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieHeader = pickHeader(headers, 'cookie');
|
||||||
|
const cookies = parseCookieHeader(cookieHeader);
|
||||||
|
const sessionId = cookies[SESSION_COOKIE];
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
const user = await validateSessionById(sessionId);
|
||||||
|
if (user) {
|
||||||
|
const enterprise = await isEnterprise();
|
||||||
|
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
|
||||||
|
return { userId: user.id, username: user.username, isAdmin, authDisabled: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = pickHeader(headers, 'authorization');
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.slice(7).trim();
|
||||||
|
const user = await validateApiToken(token);
|
||||||
|
if (user) {
|
||||||
|
const enterprise = await isEnterprise();
|
||||||
|
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
|
||||||
|
return { userId: user.id, username: user.username, isAdmin, authDisabled: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function canAccessEnvForUser(
|
||||||
|
auth: WsUpgradeAuth,
|
||||||
|
environmentId: number | undefined | null
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (auth.authDisabled) return true;
|
||||||
|
if (auth.isAdmin) return true;
|
||||||
|
const enterprise = await isEnterprise();
|
||||||
|
if (!enterprise) return true;
|
||||||
|
if (environmentId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return userCanAccessEnvironment(auth.userId, environmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __authenticateWsUpgrade:
|
||||||
|
| ((headers: LowercasedHeaders) => Promise<WsUpgradeAuth | null>)
|
||||||
|
| undefined;
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __canAccessEnvForUser:
|
||||||
|
| ((auth: WsUpgradeAuth, envId: number | undefined | null) => Promise<boolean>)
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.__authenticateWsUpgrade = authenticateWsUpgrade;
|
||||||
|
globalThis.__canAccessEnvForUser = canAccessEnvForUser;
|
||||||
@@ -18,6 +18,7 @@ export interface Permissions {
|
|||||||
audit_logs: string[];
|
audit_logs: string[];
|
||||||
activity: string[];
|
activity: string[];
|
||||||
schedules: string[];
|
schedules: string[];
|
||||||
|
templates: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { browser } from '$app/environment';
|
|||||||
|
|
||||||
export type TimeFormat = '12h' | '24h';
|
export type TimeFormat = '12h' | '24h';
|
||||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||||
export type DownloadFormat = 'tar' | 'tar.gz';
|
export type DownloadFormat = 'tar' | 'tar.gz' | 'raw';
|
||||||
export type EventCollectionMode = 'stream' | 'poll';
|
export type EventCollectionMode = 'stream' | 'poll';
|
||||||
export type LabelFilterMode = 'any' | 'all';
|
export type LabelFilterMode = 'any' | 'all';
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ export interface AppSettings {
|
|||||||
defaultTrivyImage: string;
|
defaultTrivyImage: string;
|
||||||
defaultComposeTemplate: string;
|
defaultComposeTemplate: string;
|
||||||
labelFilterMode: LabelFilterMode;
|
labelFilterMode: LabelFilterMode;
|
||||||
|
honorProxyLabels: boolean;
|
||||||
|
showImageChangelogLinks: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: AppSettings = {
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
@@ -72,6 +74,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
||||||
defaultTrivyImage: 'aquasec/trivy:0.69.3',
|
defaultTrivyImage: 'aquasec/trivy:0.69.3',
|
||||||
labelFilterMode: 'any',
|
labelFilterMode: 'any',
|
||||||
|
honorProxyLabels: true,
|
||||||
|
showImageChangelogLinks: true,
|
||||||
defaultComposeTemplate: `version: "3.8"
|
defaultComposeTemplate: `version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -151,7 +155,9 @@ function createSettingsStore() {
|
|||||||
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||||
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
|
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
|
||||||
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||||
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||||
|
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||||
|
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -202,7 +208,9 @@ function createSettingsStore() {
|
|||||||
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||||
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
|
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
|
||||||
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||||
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||||
|
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||||
|
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -446,6 +454,20 @@ function createSettingsStore() {
|
|||||||
return newSettings;
|
return newSettings;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setHonorProxyLabels: (value: boolean) => {
|
||||||
|
update((current) => {
|
||||||
|
const newSettings = { ...current, honorProxyLabels: value };
|
||||||
|
saveSettings({ honorProxyLabels: value });
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setShowImageChangelogLinks: (value: boolean) => {
|
||||||
|
update((current) => {
|
||||||
|
const newSettings = { ...current, showImageChangelogLinks: value };
|
||||||
|
saveSettings({ showImageChangelogLinks: value });
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
// Manual refresh from database
|
// Manual refresh from database
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
initialized = false;
|
initialized = false;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Resolve a changelog / release-notes URL for a container image (#538).
|
||||||
|
*
|
||||||
|
* Three tiers in priority order:
|
||||||
|
* 1. `dockhand.changelog.url` label — explicit override set by the image
|
||||||
|
* author or at runtime via `--label dockhand.changelog.url=…`. Wins over
|
||||||
|
* everything. Pattern matches the existing dockhand.* label convention.
|
||||||
|
* 2. `org.opencontainers.image.source` label — the OCI standard. When it
|
||||||
|
* points at github.com the canonical changelog page is `<source>/releases`.
|
||||||
|
* 3. GHCR images — `ghcr.io/<owner>/<repo>` is always the same as
|
||||||
|
* `github.com/<owner>/<repo>`, so the release page is deterministic.
|
||||||
|
*
|
||||||
|
* No tier 3-style fuzzy match against Docker Hub. Wrong-repo matches are a
|
||||||
|
* worse UX than no link, and there is no good answer for unlabelled
|
||||||
|
* upstream images like `nginx:latest` (nginx's changelog isn't on GitHub).
|
||||||
|
*
|
||||||
|
* Pure function: deterministic, no I/O, safe to call from a render loop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GITHUB_HOST = 'github.com';
|
||||||
|
const GHCR_PREFIX = 'ghcr.io/';
|
||||||
|
|
||||||
|
function stripTrailingSlash(s: string): string {
|
||||||
|
return s.endsWith('/') ? s.slice(0, -1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripImageTag(image: string): string {
|
||||||
|
// "image@sha256:…" — split on @ first so we don't lose the digest fragment.
|
||||||
|
const atIdx = image.indexOf('@');
|
||||||
|
const withoutDigest = atIdx >= 0 ? image.slice(0, atIdx) : image;
|
||||||
|
const colonIdx = withoutDigest.lastIndexOf(':');
|
||||||
|
// A colon before a slash is a port in a registry hostname, not a tag.
|
||||||
|
if (colonIdx > withoutDigest.lastIndexOf('/')) {
|
||||||
|
return withoutDigest.slice(0, colonIdx);
|
||||||
|
}
|
||||||
|
return withoutDigest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChangelogUrl(
|
||||||
|
imageName: string | null | undefined,
|
||||||
|
labels?: Record<string, string> | null
|
||||||
|
): string | null {
|
||||||
|
if (!imageName) return null;
|
||||||
|
|
||||||
|
const override = labels?.['dockhand.changelog.url'];
|
||||||
|
if (override && override.trim()) return override.trim();
|
||||||
|
|
||||||
|
const source = labels?.['org.opencontainers.image.source'];
|
||||||
|
if (source && source.includes(GITHUB_HOST)) {
|
||||||
|
return stripTrailingSlash(source) + '/releases';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageName.startsWith(GHCR_PREFIX)) {
|
||||||
|
const repo = stripImageTag(imageName.slice(GHCR_PREFIX.length));
|
||||||
|
// Sanity guard: GHCR images are always owner/repo. Single-segment values
|
||||||
|
// like `ghcr.io/something` are malformed; skip rather than emit a bad URL.
|
||||||
|
if (repo.split('/').length >= 2) {
|
||||||
|
return `https://github.com/${repo}/releases`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Pangolin label → public URL extraction (#2 follow-up).
|
||||||
|
*
|
||||||
|
* Pangolin Blueprints (https://docs.pangolin.net/manage/blueprints) annotates
|
||||||
|
* a container with one or more proxy resources. The relevant labels for URL
|
||||||
|
* extraction are:
|
||||||
|
*
|
||||||
|
* pangolin.proxy-resources.<name>.name human-friendly label
|
||||||
|
* pangolin.proxy-resources.<name>.full-domain public hostname (mandatory)
|
||||||
|
* pangolin.proxy-resources.<name>.protocol http | https (defaults to https)
|
||||||
|
*
|
||||||
|
* The `targets[N].port` family is intentionally ignored — Pangolin terminates
|
||||||
|
* the public connection at full-domain; the internal target port is not part
|
||||||
|
* of the URL a user sees.
|
||||||
|
*
|
||||||
|
* Returns one URL per resource that declares a full-domain. Multiple
|
||||||
|
* resources on the same container yield multiple URLs. Identical URLs across
|
||||||
|
* different resources are deduped.
|
||||||
|
*
|
||||||
|
* dockhand.url labels override this — Pangolin extraction is a fallback,
|
||||||
|
* never a winner over an explicit user-provided URL.
|
||||||
|
*/
|
||||||
|
export interface PangolinUrl {
|
||||||
|
url: string;
|
||||||
|
/** The Pangolin resource key (the `<name>` in the label key). */
|
||||||
|
resource: string;
|
||||||
|
/** Optional human-friendly name from the `.name` label, if set. */
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOURCE_KEY_RE =
|
||||||
|
/^pangolin\.proxy-resources\.([^.]+)\.(full-domain|protocol|name)$/;
|
||||||
|
|
||||||
|
export function extractPangolinUrls(
|
||||||
|
labels: Record<string, string> | undefined | null
|
||||||
|
): PangolinUrl[] {
|
||||||
|
if (!labels) return [];
|
||||||
|
|
||||||
|
// Group label values by resource key.
|
||||||
|
const byResource = new Map<
|
||||||
|
string,
|
||||||
|
{ fullDomain?: string; protocol?: string; name?: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(labels)) {
|
||||||
|
const m = key.match(RESOURCE_KEY_RE);
|
||||||
|
if (!m) continue;
|
||||||
|
const [, resource, field] = m;
|
||||||
|
let entry = byResource.get(resource);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {};
|
||||||
|
byResource.set(resource, entry);
|
||||||
|
}
|
||||||
|
const v = (value ?? '').trim();
|
||||||
|
if (!v) continue;
|
||||||
|
if (field === 'full-domain') entry.fullDomain = v;
|
||||||
|
else if (field === 'protocol') entry.protocol = v.toLowerCase();
|
||||||
|
else if (field === 'name') entry.name = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: PangolinUrl[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const [resource, entry] of byResource) {
|
||||||
|
if (!entry.fullDomain) continue;
|
||||||
|
|
||||||
|
const proto =
|
||||||
|
entry.protocol === 'http' || entry.protocol === 'https'
|
||||||
|
? entry.protocol
|
||||||
|
: 'https';
|
||||||
|
|
||||||
|
const url = `${proto}://${entry.fullDomain}`;
|
||||||
|
if (seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
url,
|
||||||
|
resource,
|
||||||
|
displayName: entry.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Traefik label → public URL extraction (#2).
|
||||||
|
*
|
||||||
|
* Parses standard Traefik v2/v3 router labels on a container:
|
||||||
|
* traefik.http.routers.<name>.rule e.g. Host(`app.example.com`)
|
||||||
|
* traefik.http.routers.<name>.entrypoints e.g. websecure
|
||||||
|
* traefik.http.routers.<name>.tls e.g. true
|
||||||
|
*
|
||||||
|
* Returns one URL per router whose rule contains at least one Host() match.
|
||||||
|
* Multiple Host() entries on the same router yield multiple URLs. Path
|
||||||
|
* prefixes (e.g. `Host(`x`) && PathPrefix(`/api`)`) are appended so the link
|
||||||
|
* lands on the actual served path.
|
||||||
|
*
|
||||||
|
* Scheme inference (in order):
|
||||||
|
* 1. explicit `tls=true` → https
|
||||||
|
* 2. entrypoints contains "websecure" → https
|
||||||
|
* 3. entrypoints contains "web" (and no secure variant) → http
|
||||||
|
* 4. default → https (the overwhelmingly common production setup)
|
||||||
|
*
|
||||||
|
* dockhand.url labels override this — Traefik extraction is a fallback,
|
||||||
|
* never a winner over an explicit user-provided URL.
|
||||||
|
*/
|
||||||
|
export interface TraefikUrl {
|
||||||
|
url: string;
|
||||||
|
router: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTER_RULE_RE = /^traefik\.http\.routers\.([^.]+)\.rule$/;
|
||||||
|
// Host(`a.b.c`) or Host("a.b.c") or Host('a.b.c') — Traefik accepts all three quotings.
|
||||||
|
const HOST_RE = /Host\(\s*[`"']([^`"']+)[`"']\s*\)/g;
|
||||||
|
// PathPrefix(`/api`) — same quoting variations.
|
||||||
|
const PATH_PREFIX_RE = /PathPrefix\(\s*[`"']([^`"']+)[`"']\s*\)/;
|
||||||
|
|
||||||
|
export function extractTraefikUrls(
|
||||||
|
labels: Record<string, string> | undefined | null
|
||||||
|
): TraefikUrl[] {
|
||||||
|
if (!labels) return [];
|
||||||
|
|
||||||
|
const out: TraefikUrl[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const [key, rule] of Object.entries(labels)) {
|
||||||
|
const m = key.match(ROUTER_RULE_RE);
|
||||||
|
if (!m) continue;
|
||||||
|
const router = m[1];
|
||||||
|
|
||||||
|
const hosts: string[] = [];
|
||||||
|
let hostMatch: RegExpExecArray | null;
|
||||||
|
HOST_RE.lastIndex = 0;
|
||||||
|
while ((hostMatch = HOST_RE.exec(rule)) !== null) {
|
||||||
|
hosts.push(hostMatch[1]);
|
||||||
|
}
|
||||||
|
if (hosts.length === 0) continue;
|
||||||
|
|
||||||
|
const pathMatch = rule.match(PATH_PREFIX_RE);
|
||||||
|
const path = pathMatch ? pathMatch[1] : '';
|
||||||
|
|
||||||
|
const scheme = inferScheme(labels, router);
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
const url = `${scheme}://${host}${path}`;
|
||||||
|
if (seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push({ url, router });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferScheme(labels: Record<string, string>, router: string): 'http' | 'https' {
|
||||||
|
const tls = labels[`traefik.http.routers.${router}.tls`];
|
||||||
|
if (tls && /^true$/i.test(tls.trim())) return 'https';
|
||||||
|
|
||||||
|
const entrypoints = labels[`traefik.http.routers.${router}.entrypoints`] || '';
|
||||||
|
const eps = entrypoints
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (eps.includes('websecure') || eps.includes('https')) return 'https';
|
||||||
|
if (eps.length > 0 && (eps.includes('web') || eps.includes('http'))) return 'http';
|
||||||
|
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
@@ -13,10 +13,11 @@ import {
|
|||||||
} from '$lib/server/auth';
|
} from '$lib/server/auth';
|
||||||
import { getUser, getUserByUsername } from '$lib/server/db';
|
import { getUser, getUserByUsername } from '$lib/server/db';
|
||||||
import { auditAuth } from '$lib/server/audit';
|
import { auditAuth } from '$lib/server/audit';
|
||||||
|
import { getClientIp } from '$lib/server/client-ip';
|
||||||
|
|
||||||
// POST /api/auth/login - Authenticate user
|
// POST /api/auth/login - Authenticate user
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { request, cookies, getClientAddress } = event;
|
const { request, cookies } = event;
|
||||||
// Check if auth is enabled
|
// Check if auth is enabled
|
||||||
if (!(await isAuthEnabled())) {
|
if (!(await isAuthEnabled())) {
|
||||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
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 });
|
return json({ error: 'Username and password are required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting by IP and username
|
// Rate-limit key derived from socket IP + username. See client-ip.ts
|
||||||
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
// for how XFF is handled (opt-in via TRUST_FORWARDED_HEADERS).
|
||||||
|| request.headers.get('x-real-ip')
|
const clientIp = getClientIp(event);
|
||||||
|| getClientAddress();
|
|
||||||
const rateLimitKey = `${clientIp}:${username}`;
|
const rateLimitKey = `${clientIp}:${username}`;
|
||||||
|
|
||||||
const { limited, retryAfter } = isRateLimited(rateLimitKey);
|
const { limited, retryAfter } = isRateLimited(rateLimitKey);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit';
|
|||||||
import { destroySession } from '$lib/server/auth';
|
import { destroySession } from '$lib/server/auth';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditAuth } from '$lib/server/audit';
|
import { auditAuth } from '$lib/server/audit';
|
||||||
|
import { getClientIp } from '$lib/server/client-ip';
|
||||||
|
|
||||||
// POST /api/auth/logout - End session
|
// POST /api/auth/logout - End session
|
||||||
export const POST: RequestHandler = async (event) => {
|
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
|
// Get current user before destroying session for audit log
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
const username = auth.user?.username || 'unknown';
|
const username = auth.user?.username || 'unknown';
|
||||||
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
const clientIp = getClientIp(event);
|
||||||
|| event.request.headers.get('x-real-ip')
|
|
||||||
|| event.getClientAddress();
|
|
||||||
|
|
||||||
await destroySession(cookies);
|
await destroySession(cookies);
|
||||||
console.log(`[Auth] Logout: user=${username} ip=${clientIp}`);
|
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 type { RequestHandler } from '@sveltejs/kit';
|
||||||
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
|
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
|
||||||
import { auditAuth } from '$lib/server/audit';
|
import { auditAuth } from '$lib/server/audit';
|
||||||
|
import { getClientIp } from '$lib/server/client-ip';
|
||||||
|
|
||||||
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
|
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
|
||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
@@ -17,10 +18,8 @@ export const GET: RequestHandler = async (event) => {
|
|||||||
const error = url.searchParams.get('error');
|
const error = url.searchParams.get('error');
|
||||||
const errorDescription = url.searchParams.get('error_description');
|
const errorDescription = url.searchParams.get('error_description');
|
||||||
|
|
||||||
// Extract client IP for logging
|
// Extract client IP for logging.
|
||||||
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
const clientIp = getClientIp(event);
|
||||||
|| event.request.headers.get('x-real-ip')
|
|
||||||
|| event.getClientAddress();
|
|
||||||
|
|
||||||
// Handle error from IdP
|
// Handle error from IdP
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { gzipSync } from 'node:zlib';
|
|||||||
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
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)
|
// Get format from query parameter (defaults to tar)
|
||||||
const format = url.searchParams.get('format') || '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 filename: string;
|
||||||
|
let isDir = false;
|
||||||
try {
|
try {
|
||||||
const stat = await statContainerPath(params.id, path, envIdNum);
|
const stat = await statContainerPath(params.id, path, envIdNum);
|
||||||
filename = stat.name || path.split('/').pop() || 'download';
|
filename = stat.name || path.split('/').pop() || 'download';
|
||||||
|
isDir = stat.isDir === true;
|
||||||
} catch {
|
} catch {
|
||||||
filename = path.split('/').pop() || 'download';
|
filename = path.split('/').pop() || 'download';
|
||||||
}
|
}
|
||||||
@@ -54,7 +58,13 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
let contentType = 'application/x-tar';
|
let contentType = 'application/x-tar';
|
||||||
let extension = '.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
|
// Compress with gzip
|
||||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||||
body = gzipSync(tarData);
|
body = gzipSync(tarData);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
||||||
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
|
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
|
||||||
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
|
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
|
||||||
|
import { daemonIsPodman } from '$lib/server/scanner-socket-detect';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params }) => {
|
export const POST: RequestHandler = async ({ params }) => {
|
||||||
try {
|
try {
|
||||||
@@ -35,14 +36,18 @@ export const POST: RequestHandler = async ({ params }) => {
|
|||||||
// Agent is connected - try to get Docker info with shorter timeout
|
// Agent is connected - try to get Docker info with shorter timeout
|
||||||
console.log(`[Test] Edge environment ${id} (${env.name}) - agent connected, testing Docker...`);
|
console.log(`[Test] Edge environment ${id} (${env.name}) - agent connected, testing Docker...`);
|
||||||
try {
|
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({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
info: {
|
info: {
|
||||||
serverVersion: info.ServerVersion,
|
serverVersion: info.ServerVersion,
|
||||||
containers: info.Containers,
|
containers: info.Containers,
|
||||||
images: info.Images,
|
images: info.Images,
|
||||||
name: info.Name
|
name: info.Name,
|
||||||
|
engine: isPodman ? 'podman' : 'docker'
|
||||||
},
|
},
|
||||||
isEdgeMode: true,
|
isEdgeMode: true,
|
||||||
hawser: edgeConn ? {
|
hawser: edgeConn ? {
|
||||||
@@ -70,17 +75,20 @@ export const POST: RequestHandler = async ({ params }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
|
// Fetch Docker info, podman detection, and (for hawser-standard) hawser
|
||||||
// (parallel calls are more efficient and avoid sequential connection issues)
|
// info in parallel — faster, and avoids serializing remote calls.
|
||||||
let info: any;
|
let info: any;
|
||||||
let hawserInfo = null;
|
let hawserInfo = null;
|
||||||
|
let isPodman = false;
|
||||||
if (env.connectionType === 'hawser-standard') {
|
if (env.connectionType === 'hawser-standard') {
|
||||||
const [dockerResult, hawserResult] = await Promise.all([
|
const [dockerResult, hawserResult, detected] = await Promise.all([
|
||||||
getDockerInfo(env.id),
|
getDockerInfo(env.id),
|
||||||
getHawserInfo(id)
|
getHawserInfo(id),
|
||||||
|
daemonIsPodman(env.id)
|
||||||
]);
|
]);
|
||||||
info = dockerResult;
|
info = dockerResult;
|
||||||
hawserInfo = hawserResult;
|
hawserInfo = hawserResult;
|
||||||
|
isPodman = detected;
|
||||||
if (hawserInfo?.hawserVersion) {
|
if (hawserInfo?.hawserVersion) {
|
||||||
await updateEnvironment(id, {
|
await updateEnvironment(id, {
|
||||||
hawserVersion: hawserInfo.hawserVersion,
|
hawserVersion: hawserInfo.hawserVersion,
|
||||||
@@ -90,7 +98,12 @@ export const POST: RequestHandler = async ({ params }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
info = await getDockerInfo(env.id);
|
const [dockerResult, detected] = await Promise.all([
|
||||||
|
getDockerInfo(env.id),
|
||||||
|
daemonIsPodman(env.id)
|
||||||
|
]);
|
||||||
|
info = dockerResult;
|
||||||
|
isPodman = detected;
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
@@ -99,7 +112,8 @@ export const POST: RequestHandler = async ({ params }) => {
|
|||||||
serverVersion: info.ServerVersion,
|
serverVersion: info.ServerVersion,
|
||||||
containers: info.Containers,
|
containers: info.Containers,
|
||||||
images: info.Images,
|
images: info.Images,
|
||||||
name: info.Name
|
name: info.Name,
|
||||||
|
engine: isPodman ? 'podman' : 'docker'
|
||||||
},
|
},
|
||||||
hawser: hawserInfo
|
hawser: hawserInfo
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ export interface GeneralSettings {
|
|||||||
defaultComposeTemplate: string;
|
defaultComposeTemplate: string;
|
||||||
// Label filter mode
|
// Label filter mode
|
||||||
labelFilterMode: 'any' | 'all';
|
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)
|
// Whether spinning icons (animate-spin etc.) are animated (#1169)
|
||||||
animateIcons: boolean;
|
animateIcons: boolean;
|
||||||
}
|
}
|
||||||
@@ -124,6 +131,8 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
|||||||
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
|
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
|
||||||
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
|
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
|
||||||
labelFilterMode: 'any' as const,
|
labelFilterMode: 'any' as const,
|
||||||
|
honorProxyLabels: true,
|
||||||
|
showImageChangelogLinks: true,
|
||||||
animateIcons: true,
|
animateIcons: true,
|
||||||
defaultComposeTemplate: `version: "3.8"
|
defaultComposeTemplate: `version: "3.8"
|
||||||
|
|
||||||
@@ -203,6 +212,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
defaultTrivyImage,
|
defaultTrivyImage,
|
||||||
defaultComposeTemplate,
|
defaultComposeTemplate,
|
||||||
labelFilterMode,
|
labelFilterMode,
|
||||||
|
honorProxyLabels,
|
||||||
|
showImageChangelogLinks,
|
||||||
animateIcons
|
animateIcons
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getSetting('confirm_destructive'),
|
getSetting('confirm_destructive'),
|
||||||
@@ -243,6 +254,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
getSetting('default_trivy_image'),
|
getSetting('default_trivy_image'),
|
||||||
getSetting('default_compose_template'),
|
getSetting('default_compose_template'),
|
||||||
getSetting('label_filter_mode'),
|
getSetting('label_filter_mode'),
|
||||||
|
getSetting('honor_proxy_labels'),
|
||||||
|
getSetting('show_image_changelog_links'),
|
||||||
getSetting('animate_icons')
|
getSetting('animate_icons')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -287,6 +300,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
|
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||||
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||||
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||||
|
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||||
|
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||||
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
|
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,7 +320,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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) {
|
if (confirmDestructive !== undefined) {
|
||||||
await setSetting('confirm_destructive', confirmDestructive);
|
await setSetting('confirm_destructive', confirmDestructive);
|
||||||
@@ -445,6 +460,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
|
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
|
||||||
await setSetting('label_filter_mode', labelFilterMode);
|
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') {
|
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
|
||||||
await setSetting('animate_icons', animateIcons);
|
await setSetting('animate_icons', animateIcons);
|
||||||
}
|
}
|
||||||
@@ -489,6 +510,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
defaultTrivyImageVal,
|
defaultTrivyImageVal,
|
||||||
defaultComposeTemplateVal,
|
defaultComposeTemplateVal,
|
||||||
labelFilterModeVal,
|
labelFilterModeVal,
|
||||||
|
honorProxyLabelsVal,
|
||||||
|
showImageChangelogLinksVal,
|
||||||
animateIconsVal
|
animateIconsVal
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getSetting('confirm_destructive'),
|
getSetting('confirm_destructive'),
|
||||||
@@ -529,6 +552,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
getSetting('default_trivy_image'),
|
getSetting('default_trivy_image'),
|
||||||
getSetting('default_compose_template'),
|
getSetting('default_compose_template'),
|
||||||
getSetting('label_filter_mode'),
|
getSetting('label_filter_mode'),
|
||||||
|
getSetting('honor_proxy_labels'),
|
||||||
|
getSetting('show_image_changelog_links'),
|
||||||
getSetting('animate_icons')
|
getSetting('animate_icons')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -573,6 +598,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
|
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
|
||||||
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||||
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
|
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||||
|
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||||
|
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
|
||||||
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
|
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 { gzipSync } from 'node:zlib';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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 { authorize } from '$lib/server/authorize';
|
||||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
const invalid = validateDockerIdParam(params.name, 'volume');
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
@@ -22,6 +23,18 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
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
|
// Prepare response based on format
|
||||||
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
|
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
|
// Compress with gzip — fully consumes the archive stream
|
||||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||||
body = gzipSync(tarData);
|
body = gzipSync(tarData);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
Plus,
|
Plus,
|
||||||
FileText,
|
FileText,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
NotepadText,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
CircleArrowUp,
|
CircleArrowUp,
|
||||||
X,
|
X,
|
||||||
@@ -88,6 +89,9 @@
|
|||||||
import { ipToNumber } from '$lib/utils/ip';
|
import { ipToNumber } from '$lib/utils/ip';
|
||||||
import { formatHostPortUrl } from '$lib/utils/url';
|
import { formatHostPortUrl } from '$lib/utils/url';
|
||||||
import { parseCustomUrl } from '$lib/utils/custom-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 { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellDetectionResult } from '$lib/utils/shell-detection';
|
||||||
import { DataGrid } from '$lib/components/data-grid';
|
import { DataGrid } from '$lib/components/data-grid';
|
||||||
import type { ColumnConfig } from '$lib/types';
|
import type { ColumnConfig } from '$lib/types';
|
||||||
@@ -119,8 +123,10 @@
|
|||||||
let sortField = $state<SortField>('name');
|
let sortField = $state<SortField>('name');
|
||||||
let sortDirection = $state<SortDirection>('asc');
|
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 STATUS_FILTER_STORAGE_KEY = 'dockhand-containers-status-filter';
|
||||||
|
const UPDATE_AVAILABLE_FILTER_VALUE = 'update-available';
|
||||||
let statusFilter = $state<string[]>([]);
|
let statusFilter = $state<string[]>([]);
|
||||||
|
|
||||||
// Status types with icons for filter and table
|
// Status types with icons for filter and table
|
||||||
@@ -305,6 +311,35 @@
|
|||||||
// Set of container IDs with updates available (for O(1) lookup)
|
// Set of container IDs with updates available (for O(1) lookup)
|
||||||
const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds));
|
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)
|
// Count of updatable containers (excluding system containers like Dockhand/Hawser)
|
||||||
const updatableContainersCount = $derived(
|
const updatableContainersCount = $derived(
|
||||||
batchUpdateContainerIds.filter(id => {
|
batchUpdateContainerIds.filter(id => {
|
||||||
@@ -746,9 +781,16 @@
|
|||||||
result = result.filter(c => c.state.toLowerCase() !== 'exited');
|
result = result.filter(c => c.state.toLowerCase() !== 'exited');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by status if any are selected
|
// Filter by status. The synthetic 'update-available' value (#1063)
|
||||||
if (statusFilter.length > 0) {
|
// is split off so it ANDs with real-state selections instead of
|
||||||
result = result.filter(c => statusFilter.includes(c.state.toLowerCase()));
|
// 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
|
// Filter by search query
|
||||||
@@ -1397,12 +1439,14 @@
|
|||||||
class="pl-8 h-8 w-48 text-sm"
|
class="pl-8 h-8 w-48 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<MultiSelectFilter
|
||||||
bind:value={statusFilter}
|
bind:value={statusFilter}
|
||||||
options={statusTypes}
|
options={filterOptions}
|
||||||
placeholder="All statuses"
|
placeholder="All statuses"
|
||||||
pluralLabel="statuses"
|
pluralLabel="filters"
|
||||||
width="w-44"
|
width="w-44"
|
||||||
defaultIcon={Box}
|
defaultIcon={Box}
|
||||||
/>
|
/>
|
||||||
@@ -1790,6 +1834,21 @@
|
|||||||
<span title="Update available">
|
<span title="Update available">
|
||||||
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
|
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
|
||||||
</span>
|
</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}
|
{/if}
|
||||||
<span class="text-xs text-muted-foreground truncate" title={container.image}>{container.image}</span>
|
<span class="text-xs text-muted-foreground truncate" title={container.image}>{container.image}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1897,7 +1956,9 @@
|
|||||||
{:else if column.id === 'ports'}
|
{:else if column.id === 'ports'}
|
||||||
{@const exposedPorts = $appSettings.showExposedPorts ? formatExposedPorts(container.ports) : []}
|
{@const exposedPorts = $appSettings.showExposedPorts ? formatExposedPorts(container.ports) : []}
|
||||||
{@const parsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])}
|
{@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 compactPorts = $appSettings.compactPorts}
|
||||||
{@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports}
|
{@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports}
|
||||||
{@const remainingCount = ports.length - 1}
|
{@const remainingCount = ports.length - 1}
|
||||||
@@ -1916,6 +1977,36 @@
|
|||||||
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/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}
|
{#each displayPorts as port}
|
||||||
{@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])}
|
{@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])}
|
||||||
{@const portUrl = portParsed?.url || null}
|
{@const portUrl = portParsed?.url || null}
|
||||||
@@ -2381,6 +2472,11 @@
|
|||||||
// Refresh the container list
|
// Refresh the container list
|
||||||
fetchContainers();
|
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
|
<FileBrowserModal
|
||||||
|
|||||||
@@ -76,6 +76,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let status = $state<'idle' | 'updating' | 'complete' | 'error'>('idle');
|
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 progress = $state<ContainerProgress[]>([]);
|
||||||
let progressListEl = $state<HTMLDivElement | null>(null);
|
let progressListEl = $state<HTMLDivElement | null>(null);
|
||||||
let scrollTick = $state(0);
|
let scrollTick = $state(0);
|
||||||
@@ -139,6 +143,7 @@
|
|||||||
async function startUpdate() {
|
async function startUpdate() {
|
||||||
if (containerIds.length === 0) return;
|
if (containerIds.length === 0) return;
|
||||||
|
|
||||||
|
const myRunId = ++runId;
|
||||||
status = 'updating';
|
status = 'updating';
|
||||||
progress = [];
|
progress = [];
|
||||||
currentIndex = 0;
|
currentIndex = 0;
|
||||||
@@ -164,6 +169,8 @@
|
|||||||
const blockedIds: string[] = [];
|
const blockedIds: string[] = [];
|
||||||
|
|
||||||
await watchJob(jobId, (line) => {
|
await watchJob(jobId, (line) => {
|
||||||
|
// If the user closed the modal (or started another run), drop the line.
|
||||||
|
if (myRunId !== runId) return;
|
||||||
try {
|
try {
|
||||||
const data = line.data as any;
|
const data = line.data as any;
|
||||||
scrollTick++;
|
scrollTick++;
|
||||||
@@ -282,6 +289,7 @@
|
|||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to update containers:', error);
|
console.error('Failed to update containers:', error);
|
||||||
|
if (myRunId !== runId) return;
|
||||||
status = 'error';
|
status = 'error';
|
||||||
errorMessage = error.message || 'Failed to update';
|
errorMessage = error.message || 'Failed to update';
|
||||||
}
|
}
|
||||||
@@ -290,6 +298,9 @@
|
|||||||
function handleClose() {
|
function handleClose() {
|
||||||
open = false;
|
open = false;
|
||||||
onClose();
|
onClose();
|
||||||
|
// Invalidate any in-flight poll so its onLine callbacks stop mutating
|
||||||
|
// state for this modal instance (#1094).
|
||||||
|
runId++;
|
||||||
// Reset state
|
// Reset state
|
||||||
status = 'idle';
|
status = 'idle';
|
||||||
progress = [];
|
progress = [];
|
||||||
@@ -300,10 +311,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOpenChange(isOpen: boolean) {
|
function handleOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen && status === 'updating') {
|
// The X button (DialogPrimitive.Close) bypasses controlled close, so
|
||||||
// Don't allow closing while updating
|
// returning early here without resetting state strands `status` at
|
||||||
return;
|
// '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) {
|
if (!isOpen) {
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
import * as Tabs from '$lib/components/ui/tabs';
|
import * as Tabs from '$lib/components/ui/tabs';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
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 * as Select from '$lib/components/ui/select';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
@@ -26,9 +27,73 @@
|
|||||||
containerId: string;
|
containerId: string;
|
||||||
containerName?: string;
|
containerName?: string;
|
||||||
onRename?: (newName: string) => void;
|
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
|
// Rename state
|
||||||
let isEditing = $state(false);
|
let isEditing = $state(false);
|
||||||
@@ -652,16 +717,91 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if containerData && !loading}
|
{#if containerData && !loading}
|
||||||
<Button
|
<div class="ml-auto mr-6 flex items-center gap-1">
|
||||||
variant="outline"
|
<!-- Lifecycle actions (#461). Mirrors the per-row action set on the containers page;
|
||||||
size="sm"
|
non-destructive actions refresh the inspect data in place, Delete closes the modal. -->
|
||||||
onclick={() => showRawJson = true}
|
{#if containerData.State?.Running}
|
||||||
title="View raw inspect data"
|
{#if onStop}
|
||||||
class="ml-auto mr-6"
|
<ConfirmPopover
|
||||||
>
|
open={confirmStopOpen}
|
||||||
<Code class="w-4 h-4 mr-1.5" />
|
action="Stop"
|
||||||
Inspect
|
itemType="container"
|
||||||
</Button>
|
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}
|
{/if}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|||||||
@@ -605,7 +605,12 @@
|
|||||||
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
|
<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.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.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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleClose}
|
onclick={handleClose}
|
||||||
|
|||||||
@@ -593,9 +593,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(appendEnvParam('/api/containers', envId));
|
const response = await fetch(appendEnvParam('/api/containers', envId));
|
||||||
const allContainers = await response.json();
|
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) =>
|
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
|
// Before updating containers, capture current running set for grouped mode change detection
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
containers: number;
|
containers: number;
|
||||||
images: number;
|
images: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
engine?: 'docker' | 'podman';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +577,12 @@
|
|||||||
<!-- Docker Version Column -->
|
<!-- Docker Version Column -->
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{#if testResult?.info?.serverVersion}
|
{#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}
|
{:else}
|
||||||
<span class="text-muted-foreground text-sm">—</span>
|
<span class="text-muted-foreground text-sm">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
let highlightUpdates = $derived($appSettings.highlightUpdates);
|
let highlightUpdates = $derived($appSettings.highlightUpdates);
|
||||||
let compactPorts = $derived($appSettings.compactPorts);
|
let compactPorts = $derived($appSettings.compactPorts);
|
||||||
let showExposedPorts = $derived($appSettings.showExposedPorts);
|
let showExposedPorts = $derived($appSettings.showExposedPorts);
|
||||||
|
let honorProxyLabels = $derived($appSettings.honorProxyLabels);
|
||||||
|
let showImageChangelogLinks = $derived($appSettings.showImageChangelogLinks);
|
||||||
let timeFormat = $derived($appSettings.timeFormat);
|
let timeFormat = $derived($appSettings.timeFormat);
|
||||||
let dateFormat = $derived($appSettings.dateFormat);
|
let dateFormat = $derived($appSettings.dateFormat);
|
||||||
let downloadFormat = $derived($appSettings.downloadFormat);
|
let downloadFormat = $derived($appSettings.downloadFormat);
|
||||||
@@ -116,6 +118,18 @@ services:
|
|||||||
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD', example: '2024-12-31' }
|
{ 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) {
|
function handleScheduleRetentionChange(e: Event) {
|
||||||
const value = Math.max(1, Math.min(365, parseInt((e.target as HTMLInputElement).value) || 30));
|
const value = Math.max(1, Math.min(365, parseInt((e.target as HTMLInputElement).value) || 30));
|
||||||
appSettings.setScheduleRetentionDays(value);
|
appSettings.setScheduleRetentionDays(value);
|
||||||
@@ -288,6 +302,28 @@ services:
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">Highlight container rows in amber when updates are available</p>
|
<p class="text-xs text-muted-foreground">Highlight container rows in amber when updates are available</p>
|
||||||
</div>
|
</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="space-y-1">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Label>Compact port display</Label>
|
<Label>Compact port display</Label>
|
||||||
@@ -324,6 +360,28 @@ services:
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">Display internal container ports in the container list grid</p>
|
<p class="text-xs text-muted-foreground">Display internal container ports in the container list grid</p>
|
||||||
</div>
|
</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="space-y-1">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Label>Time format</Label>
|
<Label>Time format</Label>
|
||||||
@@ -470,18 +528,34 @@ services:
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Label>Download format</Label>
|
<Label>Download format</Label>
|
||||||
<ToggleSwitch
|
<Select.Root
|
||||||
|
type="single"
|
||||||
value={downloadFormat}
|
value={downloadFormat}
|
||||||
leftValue="tar"
|
onValueChange={(value) => {
|
||||||
rightValue="tar.gz"
|
if (value) {
|
||||||
onchange={(newFormat) => {
|
appSettings.setDownloadFormat(value as DownloadFormat);
|
||||||
appSettings.setDownloadFormat(newFormat as DownloadFormat);
|
toast.success(`Download format set to ${downloadFormatLabel[value as DownloadFormat]}`);
|
||||||
toast.success(`Download format set to ${newFormat}`);
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!$canAccess('settings', 'edit')}
|
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>
|
</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>
|
</div>
|
||||||
<div class="space-y-4">
|
<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 { 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 { formatPorts } from '$lib/utils/port-format';
|
||||||
import { parseCustomUrl } from '$lib/utils/custom-url';
|
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 ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||||
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
|
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
|
||||||
@@ -2059,6 +2062,38 @@
|
|||||||
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
<!-- Clickable ports with range collapsing -->
|
<!-- Clickable ports with range collapsing -->
|
||||||
{#if container.ports.length > 0}
|
{#if container.ports.length > 0}
|
||||||
|
|||||||
@@ -35,11 +35,13 @@
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
stackName?: string; // Required for edit mode, optional for create
|
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;
|
onClose: () => void;
|
||||||
onSuccess: () => void; // Called after create or save
|
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
|
// Local effective state - can transition from create → edit after failed deploy
|
||||||
let mode = $state(propMode);
|
let mode = $state(propMode);
|
||||||
@@ -1222,8 +1224,11 @@
|
|||||||
validateEnvVars();
|
validateEnvVars();
|
||||||
});
|
});
|
||||||
} else if (mode === 'create') {
|
} else if (mode === 'create') {
|
||||||
// Set default compose content for create mode
|
// Set default compose content for create mode (library templates override default)
|
||||||
composeContent = defaultCompose;
|
composeContent = initialCompose || defaultCompose;
|
||||||
|
if (initialStackName) {
|
||||||
|
newStackName = initialStackName;
|
||||||
|
}
|
||||||
isDirty = false; // Reset dirty flag for new modal
|
isDirty = false; // Reset dirty flag for new modal
|
||||||
loading = false;
|
loading = false;
|
||||||
// Auto-validate default compose
|
// Auto-validate default compose
|
||||||
@@ -1351,6 +1356,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{stackName}
|
{stackName}
|
||||||
{/if}
|
{/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.Title>
|
||||||
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
|
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
{#if mode === 'create'}
|
{#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>
|
||||||
@@ -574,6 +574,20 @@ function webSocketPlugin(): Plugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authFn = (globalThis as any).__authenticateWsUpgrade;
|
||||||
|
if (typeof authFn !== 'function') {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'service unavailable' }));
|
||||||
|
ws.close(1011, 'service unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wsAuth = await authFn(req.headers);
|
||||||
|
if (!wsAuth) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
|
||||||
|
ws.close(1008, 'unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(ws as any).__auth = wsAuth;
|
||||||
|
|
||||||
// Assign unique connection ID to this WebSocket
|
// Assign unique connection ID to this WebSocket
|
||||||
const connId = `ws-${++wsConnectionCounter}`;
|
const connId = `ws-${++wsConnectionCounter}`;
|
||||||
meta.connId = connId;
|
meta.connId = connId;
|
||||||
@@ -594,6 +608,17 @@ function webSocketPlugin(): Plugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canAccessFn = (globalThis as any).__canAccessEnvForUser;
|
||||||
|
if (typeof canAccessFn === 'function') {
|
||||||
|
const ok = await canAccessFn(wsAuth, envId);
|
||||||
|
if (!ok) {
|
||||||
|
console.warn(`[WS] env access denied: user=${wsAuth.username} envId=${envId}`);
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
|
||||||
|
ws.close(1008, 'env access denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const target = getDockerTarget(envId);
|
const target = getDockerTarget(envId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user