v1.0.23
This commit is contained in:
1 parent
e9a9f0ca25
commit
0bb10cabb9
42 files changed
+7147
-239
No files matched your search
@@ -14,7 +14,7 @@ import {
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
@@ -156,8 +156,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
const imageName = config.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
// Skip Dockhand container - cannot update itself
|
||||
if (isDockhandContainer(imageName)) {
|
||||
// Skip system containers (Dockhand, Hawser)
|
||||
const systemType = isSystemContainer(imageName);
|
||||
if (systemType) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
@@ -166,7 +167,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
||||
message: `Skipping ${containerName} - cannot update ${systemType} container`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
@@ -331,9 +332,11 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect vulnerabilities from all scanners (cap at 100)
|
||||
// Collect vulnerabilities from all scanners (sort by severity, cap at 100)
|
||||
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3, negligible: 4, unknown: 5 };
|
||||
const vulnerabilities = scanResults
|
||||
.flatMap(r => r.vulnerabilities || [])
|
||||
.sort((a, b) => (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9))
|
||||
.slice(0, 100)
|
||||
.map(v => ({
|
||||
id: v.id,
|
||||
@@ -345,11 +348,11 @@ export const POST: RequestHandler = async (event) => {
|
||||
scanner: v.scanner
|
||||
}));
|
||||
|
||||
// Build scan message from individual results
|
||||
const totalCritical = individualScannerResults.reduce((s, r) => s + r.critical, 0);
|
||||
const totalHigh = individualScannerResults.reduce((s, r) => s + r.high, 0);
|
||||
const totalMedium = individualScannerResults.reduce((s, r) => s + r.medium, 0);
|
||||
const totalLow = individualScannerResults.reduce((s, r) => s + r.low, 0);
|
||||
// Derive combined totals from the displayed (sliced) array so summary matches the table
|
||||
const totalCritical = vulnerabilities.filter(v => v.severity === 'critical').length;
|
||||
const totalHigh = vulnerabilities.filter(v => v.severity === 'high').length;
|
||||
const totalMedium = vulnerabilities.filter(v => v.severity === 'medium').length;
|
||||
const totalLow = vulnerabilities.filter(v => v.severity === 'low').length;
|
||||
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
|
||||
|
||||
sendData({
|
||||
|
||||
@@ -102,7 +102,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
|
||||
|
||||
const updatesFound = results.filter(r => r.hasUpdate).length;
|
||||
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
if (envIdNum) {
|
||||
|
||||
@@ -119,7 +119,10 @@ export const POST: RequestHandler = async (event) => {
|
||||
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
|
||||
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
|
||||
webhookEnabled: data.webhookEnabled || false,
|
||||
webhookSecret: webhookSecret
|
||||
webhookSecret: webhookSecret,
|
||||
buildOnDeploy: data.buildOnDeploy ?? false,
|
||||
repullImages: data.repullImages ?? false,
|
||||
forceRedeploy: data.forceRedeploy ?? false
|
||||
});
|
||||
|
||||
// Create stack_sources entry so the stack appears in the list immediately
|
||||
|
||||
@@ -72,7 +72,10 @@ export const PUT: RequestHandler = async (event) => {
|
||||
autoUpdateSchedule: data.autoUpdateSchedule,
|
||||
autoUpdateCron: data.autoUpdateCron,
|
||||
webhookEnabled: data.webhookEnabled,
|
||||
webhookSecret: data.webhookSecret
|
||||
webhookSecret: data.webhookSecret,
|
||||
buildOnDeploy: data.buildOnDeploy,
|
||||
repullImages: data.repullImages,
|
||||
forceRedeploy: data.forceRedeploy
|
||||
});
|
||||
|
||||
// If stack name changed, update related records
|
||||
|
||||
@@ -10,6 +10,14 @@ function isDockerHub(url: string): boolean {
|
||||
lower.includes('registry.hub.docker.com');
|
||||
}
|
||||
|
||||
// Manifest types in priority order: single-platform first, then multi-arch
|
||||
const MANIFEST_TYPES = [
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
'application/vnd.oci.image.index.v1+json'
|
||||
];
|
||||
|
||||
export const DELETE: RequestHandler = async ({ url }) => {
|
||||
try {
|
||||
const registryId = url.searchParams.get('registry');
|
||||
@@ -39,43 +47,41 @@ export const DELETE: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
|
||||
// Note: orgPath is not used here because imageName already contains the full repo path
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
};
|
||||
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
// Step 1: Get the manifest digest
|
||||
// Step 1: Resolve manifest digest. Try each type individually because
|
||||
// the registry may only serve certain types, and DELETE requires the
|
||||
// Accept header to match the stored manifest type.
|
||||
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
|
||||
const headResponse = await fetch(manifestUrl, {
|
||||
method: 'HEAD',
|
||||
headers
|
||||
});
|
||||
let digest: string | null = null;
|
||||
let matchedType: string | null = null;
|
||||
|
||||
if (!headResponse.ok) {
|
||||
for (const mediaType of MANIFEST_TYPES) {
|
||||
const headers: HeadersInit = { 'Accept': mediaType };
|
||||
if (authHeader) headers['Authorization'] = authHeader;
|
||||
|
||||
const headResponse = await fetch(manifestUrl, { method: 'HEAD', headers });
|
||||
if (headResponse.ok) {
|
||||
digest = headResponse.headers.get('Docker-Content-Digest');
|
||||
matchedType = mediaType;
|
||||
break;
|
||||
}
|
||||
if (headResponse.status === 401) {
|
||||
return json({ error: 'Authentication failed' }, { status: 401 });
|
||||
}
|
||||
if (headResponse.status === 404) {
|
||||
return json({ error: 'Image or tag not found' }, { status: 404 });
|
||||
}
|
||||
return json({ error: `Failed to get manifest: ${headResponse.status}` }, { status: headResponse.status });
|
||||
}
|
||||
|
||||
const digest = headResponse.headers.get('Docker-Content-Digest');
|
||||
if (!digest) {
|
||||
return json({ error: 'Could not get image digest. Registry may not support deletion.' }, { status: 400 });
|
||||
if (!digest || !matchedType) {
|
||||
return json({ error: 'Image or tag not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Step 2: Delete the manifest by digest
|
||||
// Step 2: Delete the manifest by digest using the matched type
|
||||
const deleteHeaders: HeadersInit = { 'Accept': matchedType };
|
||||
if (authHeader) deleteHeaders['Authorization'] = authHeader;
|
||||
|
||||
const deleteUrl = `${baseUrl}/v2/${imageName}/manifests/${digest}`;
|
||||
const deleteResponse = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
headers
|
||||
headers: deleteHeaders
|
||||
});
|
||||
|
||||
if (!deleteResponse.ok) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
checkScannerAvailability,
|
||||
getScannerVersions,
|
||||
checkScannerUpdates,
|
||||
cleanupScannerVolumes,
|
||||
cleanupScannerCache,
|
||||
getGlobalScannerDefaults,
|
||||
type ScannerType
|
||||
} from '$lib/server/scanner';
|
||||
@@ -195,8 +195,8 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Also cleanup scanner database volumes
|
||||
await cleanupScannerVolumes(parsedEnvId);
|
||||
// Also cleanup scanner database cache (volumes + bind mount dirs)
|
||||
await cleanupScannerCache(parsedEnvId);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { cleanupScannerCache } from '$lib/server/scanner';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironments } from '$lib/server/db';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const envs = await getEnvironments();
|
||||
|
||||
// Clean local cache (volumes + bind mount dirs)
|
||||
const result = await cleanupScannerCache();
|
||||
|
||||
// Clean remote environment volumes
|
||||
const skippedEnvs: string[] = [];
|
||||
for (const env of envs) {
|
||||
try {
|
||||
const envResult = await cleanupScannerCache(env.id);
|
||||
result.volumes.push(...envResult.volumes);
|
||||
} catch {
|
||||
skippedEnvs.push(env.name);
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
removedVolumes: result.volumes,
|
||||
removedDirs: result.dirs,
|
||||
skippedEnvironments: skippedEnvs
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to clear scanner cache:', error);
|
||||
return json({ error: 'Failed to clear scanner cache' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { deployStack, requireComposeFile, ComposeFileNotFoundError } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditStack } from '$lib/server/audit';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies, request } = event;
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'start', envIdNum))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Environment access check (enterprise only)
|
||||
if (envIdNum && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { pull, build, forceRecreate } = body as {
|
||||
pull?: boolean;
|
||||
build?: boolean;
|
||||
forceRecreate?: boolean;
|
||||
};
|
||||
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
|
||||
send('progress', { status: 'Reading compose file...' });
|
||||
const composeResult = await requireComposeFile(stackName, envIdNum);
|
||||
|
||||
if (!composeResult.success) {
|
||||
send('result', {
|
||||
success: false,
|
||||
error: composeResult.needsFileLocation
|
||||
? 'Stack compose file location not configured'
|
||||
: composeResult.error || 'Compose file not found'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
send('progress', { status: 'Deploying stack...' });
|
||||
const result = await deployStack({
|
||||
name: stackName,
|
||||
compose: composeResult.content!,
|
||||
envId: envIdNum,
|
||||
pullPolicy: pull ? 'always' : undefined,
|
||||
build,
|
||||
forceRecreate,
|
||||
composePath: composeResult.composePath,
|
||||
envPath: composeResult.envPath
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'deploy', stackName, envIdNum, {
|
||||
pull, build, forceRecreate
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
send('result', { success: false, error: result.error });
|
||||
return;
|
||||
}
|
||||
send('result', { success: true, output: result.output });
|
||||
} catch (error) {
|
||||
if (error instanceof ComposeFileNotFoundError) {
|
||||
send('result', { success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('Error deploying compose stack:', error);
|
||||
send('result', { success: false, error: 'Failed to deploy compose stack' });
|
||||
}
|
||||
}, event.request);
|
||||
};
|
||||
@@ -22,10 +22,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
const mode = url.searchParams.get('mode') === 'recreate' ? 'recreate' : 'restart';
|
||||
|
||||
return createJobResponse(async (send) => {
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await restartStack(stackName, envIdNum);
|
||||
const result = await restartStack(stackName, envIdNum, mode);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'restart', stackName, envIdNum);
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import { ipToNumber } from '$lib/utils/ip';
|
||||
import { formatHostPortUrl } from '$lib/utils/url';
|
||||
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, 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 type { ColumnConfig } from '$lib/types';
|
||||
import type { DataGridRowState } from '$lib/components/data-grid/types';
|
||||
@@ -243,6 +243,8 @@
|
||||
let terminalPopoverStates = $state<Record<string, boolean>>({});
|
||||
let terminalShell = $state('/bin/bash');
|
||||
let terminalUser = $state('root');
|
||||
let terminalCustomUser = $state('');
|
||||
let terminalCustomUsers = $state<string[]>([]);
|
||||
|
||||
// Confirmation popover state
|
||||
let confirmStopId = $state<string | null>(null);
|
||||
@@ -470,7 +472,7 @@
|
||||
// Unlock button width
|
||||
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
|
||||
|
||||
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
|
||||
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate && !r.systemContainer);
|
||||
const failed = data.results.filter((r: any) => r.error && !r.hasUpdate);
|
||||
failedUpdateChecks = failed.map((r: any) => ({
|
||||
containerName: r.containerName,
|
||||
@@ -1041,13 +1043,18 @@
|
||||
currentTerminalContainerId = container.id;
|
||||
terminalPopoverStates[container.id] = false;
|
||||
} else {
|
||||
// Restore saved user for this container
|
||||
const savedUser = getSavedUser(container.id);
|
||||
terminalUser = savedUser ?? 'root';
|
||||
terminalCustomUsers = getCustomUsers();
|
||||
// Show popover to configure new terminal
|
||||
terminalPopoverStates[container.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function startTerminal(container: ContainerInfo) {
|
||||
// Create new terminal session
|
||||
saveUserForContainer(container.id, terminalUser);
|
||||
terminalCustomUsers = getCustomUsers();
|
||||
const terminal: ActiveTerminal = {
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
@@ -1336,6 +1343,7 @@
|
||||
onMount(async () => {
|
||||
loadLayoutMode();
|
||||
loadStatusFilter();
|
||||
terminalCustomUsers = getCustomUsers();
|
||||
|
||||
// Load persisted pending updates from database
|
||||
loadPendingUpdates();
|
||||
@@ -2138,7 +2146,7 @@
|
||||
<Select.Root type="single" bind:value={terminalUser}>
|
||||
<Select.Trigger class="w-full h-8 text-xs">
|
||||
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||
<span>{userOptions.find(o => o.value === terminalUser)?.label || 'Select'}</span>
|
||||
<span>{userOptions.find(o => o.value === terminalUser)?.label || terminalUser || 'Select'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each userOptions as option}
|
||||
@@ -2147,6 +2155,35 @@
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
{#if terminalCustomUsers.length > 0}
|
||||
<div class="h-px bg-border my-1"></div>
|
||||
{#each terminalCustomUsers as cu}
|
||||
<div class="flex items-center group">
|
||||
<Select.Item value={cu} label={cu} class="flex-1">
|
||||
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
|
||||
{cu}
|
||||
</Select.Item>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
||||
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); terminalCustomUsers = getCustomUsers(); if (terminalUser === cu) { terminalUser = 'root'; } }}
|
||||
title="Remove user"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="h-px bg-border my-1"></div>
|
||||
<div class="px-2 py-1">
|
||||
<Input
|
||||
class="h-7 text-xs"
|
||||
placeholder="Add user... (Enter)"
|
||||
bind:value={terminalCustomUser}
|
||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Enter' && terminalCustomUser.trim()) { const u = terminalCustomUser.trim(); terminalUser = u; saveUserForContainer(container.id, u); terminalCustomUsers = getCustomUsers(); terminalCustomUser = ''; } }}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
{#each oidcProviders as provider}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full justify-start gap-3"
|
||||
class="w-full justify-center gap-3"
|
||||
onclick={() => handleSsoLogin(provider)}
|
||||
disabled={ssoLoading !== null}
|
||||
>
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
|
||||
// Hostname pattern (allows letters, numbers, hyphens, dots)
|
||||
// Must start with letter/number, can contain dots for subdomains
|
||||
const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?)*$/;
|
||||
return hostnamePattern.test(host);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,30 @@
|
||||
let eventPollInterval = $derived($appSettings.eventPollInterval);
|
||||
let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
|
||||
|
||||
let clearingCache = $state(false);
|
||||
|
||||
async function clearScannerCache() {
|
||||
clearingCache = true;
|
||||
try {
|
||||
const res = await fetch('/api/settings/scanner/cache', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const total = (data.removedVolumes?.length || 0) + (data.removedDirs?.length || 0);
|
||||
if (total > 0) {
|
||||
toast.success(`Scanner cache cleared (${total} items removed)`);
|
||||
} else {
|
||||
toast.info('Scanner cache was already empty');
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to clear scanner cache');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to clear scanner cache');
|
||||
} finally {
|
||||
clearingCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
const dateFormatOptions: { value: DateFormat; label: string; example: string }[] = [
|
||||
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY', example: '31.12.2024' },
|
||||
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY', example: '31/12/2024' },
|
||||
@@ -455,6 +479,26 @@
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Use <code class="bg-muted px-1 rounded">{'{image}'}</code> as placeholder for the image name</p>
|
||||
</div>
|
||||
<div class="pt-2 border-t">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">Scanner cache</p>
|
||||
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to free disk space. Next scan will re-download fresh data (~200MB).</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={clearingCache || !$canAccess('settings', 'edit')}
|
||||
onclick={clearScannerCache}
|
||||
>
|
||||
{#if clearingCache}
|
||||
Clearing...
|
||||
{:else}
|
||||
Clear cache
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 pt-2 flex-wrap">
|
||||
<div class="flex items-center gap-2 pt-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.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 } from 'lucide-svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
@@ -20,6 +21,7 @@
|
||||
import GitStackModal from './GitStackModal.svelte';
|
||||
import ImportStackModal from './ImportStackModal.svelte';
|
||||
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
|
||||
import RedeployPopover from './RedeployPopover.svelte';
|
||||
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
|
||||
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
|
||||
import LogsPanel from '../logs/LogsPanel.svelte';
|
||||
@@ -419,6 +421,7 @@
|
||||
|
||||
// Stack operation loading state
|
||||
let stackActionLoading = $state<string | null>(null);
|
||||
let restartPopoverOpen = $state<Record<string, boolean>>({});
|
||||
let stackDownLoading = $state<string | null>(null);
|
||||
|
||||
// Container-level confirmation popover state
|
||||
@@ -877,17 +880,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function restartStack(name: string) {
|
||||
async function restartStack(name: string, mode: 'restart' | 'recreate' = 'restart') {
|
||||
operationError = null;
|
||||
stackActionLoading = name;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' });
|
||||
let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId);
|
||||
if (mode === 'recreate') {
|
||||
url += (url.includes('?') ? '&' : '?') + 'mode=recreate';
|
||||
}
|
||||
const response = await fetch(url, { method: 'POST' });
|
||||
const data = await readJobResponse(response);
|
||||
if (!data.success) {
|
||||
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
|
||||
return;
|
||||
}
|
||||
toast.success(`Restarted ${name}`);
|
||||
toast.success(mode === 'recreate' ? `Recreated ${name}` : `Restarted ${name}`);
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to restart stack:', error);
|
||||
@@ -898,6 +905,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function redeployStack(name: string, options: { pull: boolean; build: boolean; forceRecreate: boolean }) {
|
||||
operationError = null;
|
||||
stackActionLoading = name;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/deploy`, envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(options)
|
||||
});
|
||||
const data = await readJobResponse(response);
|
||||
if (!data.success) {
|
||||
showErrorDialog(`Failed to redeploy ${name}`, data.error || 'Failed to redeploy stack');
|
||||
return;
|
||||
}
|
||||
toast.success(`Redeployed ${name}`);
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to redeploy stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to redeploy stack';
|
||||
showErrorDialog(`Failed to redeploy ${name}`, errorMsg);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downStack(name: string) {
|
||||
operationError = null;
|
||||
stackActionLoading = name;
|
||||
@@ -1749,25 +1781,57 @@
|
||||
<ScrollText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if source.sourceType !== 'git' && source.sourceType !== 'external' && $canAccess('stacks', 'start')}
|
||||
<RedeployPopover
|
||||
stackName={stack.name}
|
||||
{envId}
|
||||
disabled={stackActionLoading === stack.name}
|
||||
onDeploy={(options) => redeployStack(stack.name, options)}
|
||||
>
|
||||
{#snippet children()}
|
||||
<Rocket class="w-3 h-3 text-muted-foreground hover:text-violet-500" />
|
||||
{/snippet}
|
||||
</RedeployPopover>
|
||||
{/if}
|
||||
{#if stackActionLoading === stack.name}
|
||||
<div class="p-1">
|
||||
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
|
||||
{#if $canAccess('stacks', 'restart')}
|
||||
<ConfirmPopover
|
||||
open={false}
|
||||
action="Restart"
|
||||
itemType="stack"
|
||||
itemName={stack.name}
|
||||
title="Restart"
|
||||
onConfirm={() => restartStack(stack.name)}
|
||||
onOpenChange={() => {}}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<RotateCcw class="w-3 h-3 {open ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
<Popover.Root open={restartPopoverOpen[stack.name] ?? false} onOpenChange={(v) => restartPopoverOpen[stack.name] = v}>
|
||||
<Popover.Trigger asChild>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
type="button"
|
||||
title="Restart"
|
||||
{...props}
|
||||
onclick={(e) => { e.stopPropagation(); restartPopoverOpen[stack.name] = !restartPopoverOpen[stack.name]; }}
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center"
|
||||
>
|
||||
<RotateCcw class="w-3 h-3 {restartPopoverOpen[stack.name] ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="w-auto p-2 z-[200]"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs text-muted-foreground">Restart stack <strong>{stack.name.length > 20 ? stack.name.slice(0, 20) + '...' : stack.name}</strong></span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="secondary" class="h-6 px-2 text-xs" onclick={() => { restartPopoverOpen[stack.name] = false; restartStack(stack.name, 'restart'); }}>
|
||||
Restart
|
||||
</Button>
|
||||
<Button size="sm" variant="default" class="h-6 px-2 text-xs" onclick={() => { restartPopoverOpen[stack.name] = false; restartStack(stack.name, 'recreate'); }}>
|
||||
Recreate (stop & up)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/if}
|
||||
{#if $canAccess('stacks', 'stop')}
|
||||
<ConfirmPopover
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download } from 'lucide-svelte';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
@@ -57,6 +57,9 @@
|
||||
autoUpdateCron: string;
|
||||
webhookEnabled: boolean;
|
||||
webhookSecret: string | null;
|
||||
buildOnDeploy: boolean;
|
||||
repullImages: boolean;
|
||||
forceRedeploy: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -87,6 +90,9 @@
|
||||
let formAutoUpdateCron = $state('0 3 * * *');
|
||||
let formWebhookEnabled = $state(false);
|
||||
let formWebhookSecret = $state('');
|
||||
let formBuildOnDeploy = $state(false);
|
||||
let formRepullImages = $state(false);
|
||||
let formForceRedeploy = $state(false);
|
||||
let formDeployNow = $state(false);
|
||||
let formError = $state('');
|
||||
let formSaving = $state(false);
|
||||
@@ -357,6 +363,9 @@
|
||||
formAutoUpdateCron = gitStack.autoUpdateCron || '0 3 * * *';
|
||||
formWebhookEnabled = gitStack.webhookEnabled;
|
||||
formWebhookSecret = gitStack.webhookSecret || '';
|
||||
formBuildOnDeploy = gitStack.buildOnDeploy ?? false;
|
||||
formRepullImages = gitStack.repullImages ?? false;
|
||||
formForceRedeploy = gitStack.forceRedeploy ?? false;
|
||||
formDeployNow = false;
|
||||
|
||||
// Load env files and overrides SYNCHRONOUSLY to avoid race conditions
|
||||
@@ -381,6 +390,9 @@
|
||||
formAutoUpdateCron = '0 3 * * *';
|
||||
formWebhookEnabled = false;
|
||||
formWebhookSecret = '';
|
||||
formBuildOnDeploy = false;
|
||||
formRepullImages = false;
|
||||
formForceRedeploy = false;
|
||||
formDeployNow = false;
|
||||
}
|
||||
}
|
||||
@@ -437,6 +449,9 @@
|
||||
autoUpdateCron: formAutoUpdateCron,
|
||||
webhookEnabled: formWebhookEnabled,
|
||||
webhookSecret: formWebhookEnabled ? formWebhookSecret : null,
|
||||
buildOnDeploy: formBuildOnDeploy,
|
||||
repullImages: formRepullImages,
|
||||
forceRedeploy: formForceRedeploy,
|
||||
deployNow: deployAfterSave,
|
||||
envVars: overrideVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
@@ -880,6 +895,41 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Deploy options section -->
|
||||
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Deploy options</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<Hammer class="w-4 h-4 text-muted-foreground" />
|
||||
<Label class="text-sm font-normal">Build images on deploy</Label>
|
||||
</div>
|
||||
<TogglePill bind:checked={formBuildOnDeploy} />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Run <code class="text-xs bg-muted px-1 rounded">--build</code> to build images from Dockerfiles before starting containers.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<ArrowDownToLine class="w-4 h-4 text-muted-foreground" />
|
||||
<Label class="text-sm font-normal">Re-pull images</Label>
|
||||
</div>
|
||||
<TogglePill bind:checked={formRepullImages} />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Always pull latest images before deploying, even if the compose file hasn't changed. Useful for CI/CD workflows with static tags like <code class="text-xs bg-muted px-1 rounded">:latest</code>.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<Zap class="w-4 h-4 text-muted-foreground" />
|
||||
<Label class="text-sm font-normal">Force redeployment</Label>
|
||||
</div>
|
||||
<TogglePill bind:checked={formForceRedeploy} />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Always redeploy the stack on webhook or scheduled sync, even if no git changes are detected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Deploy now option (only for new stacks) -->
|
||||
{#if !gitStack}
|
||||
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Import, Loader2, Play, Info } from 'lucide-svelte';
|
||||
import { Import, Loader2, Play, Info, ServerCog } from 'lucide-svelte';
|
||||
import FilesystemBrowser, { type FileEntry } from './FilesystemBrowser.svelte';
|
||||
import CodeEditor from '$lib/components/CodeEditor.svelte';
|
||||
import yaml from 'js-yaml';
|
||||
@@ -56,6 +56,11 @@
|
||||
// Look up the icon from the environments list since currentEnvironment doesn't store it
|
||||
const currentEnvData = $derived($environments.find(e => e.id === envId));
|
||||
const envIcon = $derived(currentEnvData?.icon || 'globe');
|
||||
const isRemoteEnv = $derived(
|
||||
currentEnvData?.connectionType === 'hawser-standard' ||
|
||||
currentEnvData?.connectionType === 'hawser-edge' ||
|
||||
(currentEnvData?.connectionType === 'direct' && !!currentEnvData?.host)
|
||||
);
|
||||
|
||||
// Reset when modal closes
|
||||
$effect(() => {
|
||||
@@ -299,9 +304,16 @@
|
||||
|
||||
// Browser title with environment info
|
||||
const browserTitle = $derived.by(() => {
|
||||
const envPart = envName ? ` · ${envName}` : '';
|
||||
const envPart = envName ? ` to ${envName}` : '';
|
||||
return `Adopt stacks${envPart}`;
|
||||
});
|
||||
|
||||
const browserDescription = $derived.by(() => {
|
||||
if (isRemoteEnv) {
|
||||
return `Browse the Dockhand host filesystem to find compose files. Files are managed locally — Hawser only proxies Docker API calls, not filesystem access.`;
|
||||
}
|
||||
return 'Browse to a compose file or scan a directory for stacks.';
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if view === 'browse'}
|
||||
@@ -311,7 +323,7 @@
|
||||
bind:open
|
||||
title={browserTitle}
|
||||
icon={Import}
|
||||
description="Browse to a compose file or scan a directory for stacks."
|
||||
description={browserDescription}
|
||||
selectMode="adopt"
|
||||
highlightFilter={/\.ya?ml$/i}
|
||||
onFilePreview={handleFilePreview}
|
||||
@@ -327,8 +339,7 @@
|
||||
<Dialog.Header class="px-6 py-4 border-b shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Import class="w-5 h-5" />
|
||||
Select stacks to adopt
|
||||
<span class="text-muted-foreground">·</span>
|
||||
Select stacks to adopt to
|
||||
<EnvironmentIcon icon={envIcon} envId={envId} class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-muted-foreground font-normal">{envName}</span>
|
||||
</Dialog.Title>
|
||||
@@ -381,7 +392,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Adopt info -->
|
||||
<div class="px-4 py-3 border-t shrink-0">
|
||||
<div class="px-4 py-3 border-t shrink-0 space-y-2">
|
||||
{#if isRemoteEnv}
|
||||
<div class="flex items-start gap-2.5 text-xs bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md px-3 py-2.5">
|
||||
<ServerCog class="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />
|
||||
<span class="text-blue-700 dark:text-blue-300">These compose files are on the <span class="font-medium">Dockhand host</span>, not on {envName}. Docker commands will be sent to {envName} via Hawser, but the files are managed locally.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5">
|
||||
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you adopt:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track these compose files, letting you edit, start, and stop the stacks from the UI. Your files stay in their current location.</span></span>
|
||||
@@ -475,7 +492,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Adopt info -->
|
||||
<div class="px-5 py-3 border-t shrink-0">
|
||||
<div class="px-5 py-3 border-t shrink-0 space-y-2">
|
||||
{#if isRemoteEnv}
|
||||
<div class="flex items-start gap-2.5 text-xs bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md px-3 py-2.5">
|
||||
<ServerCog class="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />
|
||||
<span class="text-blue-700 dark:text-blue-300">This compose file is on the <span class="font-medium">Dockhand host</span>, not on {envName}. Docker commands will be sent to {envName} via Hawser, but the file is managed locally.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5">
|
||||
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you adopt:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track this compose file, letting you edit, start, and stop the stack from the UI. Your files stay in their current location.</span></span>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
stackName: string;
|
||||
envId: number | null;
|
||||
disabled?: boolean;
|
||||
onDeploy: (options: { pull: boolean; build: boolean; forceRecreate: boolean }) => Promise<void>;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
stackName,
|
||||
envId,
|
||||
disabled = false,
|
||||
onDeploy,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let pull = $state(true);
|
||||
let build = $state(false);
|
||||
let forceRecreate = $state(false);
|
||||
let deploying = $state(false);
|
||||
|
||||
async function handleDeploy() {
|
||||
deploying = true;
|
||||
try {
|
||||
await onDeploy({ pull, build, forceRecreate });
|
||||
} finally {
|
||||
deploying = false;
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTriggerClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
open = !open;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger asChild>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
type="button"
|
||||
title="Redeploy"
|
||||
{...props}
|
||||
onclick={handleTriggerClick}
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="w-56 p-3 z-[200]"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-medium">Redeploy stack</p>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox bind:checked={pull} disabled={deploying} />
|
||||
<span class="text-xs">Pull images</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox bind:checked={build} disabled={deploying} />
|
||||
<span class="text-xs">Build images</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox bind:checked={forceRecreate} disabled={deploying} />
|
||||
<span class="text-xs">Force recreate</span>
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
class="w-full h-7 text-xs"
|
||||
onclick={handleDeploy}
|
||||
disabled={deploying}
|
||||
>
|
||||
{#if deploying}
|
||||
<Loader2 class="w-3 h-3 mr-1 animate-spin" />
|
||||
Deploying...
|
||||
{:else}
|
||||
Deploy
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -15,7 +15,7 @@
|
||||
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
|
||||
import Terminal from './Terminal.svelte';
|
||||
import { NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellInfo, type ShellDetectionResult } from '$lib/utils/shell-detection';
|
||||
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellInfo, type ShellDetectionResult } from '$lib/utils/shell-detection';
|
||||
|
||||
// Track if we've handled the initial container from URL
|
||||
let initialContainerHandled = $state(false);
|
||||
@@ -35,6 +35,8 @@
|
||||
// Shell/user options
|
||||
let selectedShell = $state('/bin/bash');
|
||||
let selectedUser = $state('root');
|
||||
let customUserInput = $state('');
|
||||
let customUsers = $state<string[]>([]);
|
||||
let terminalFontSize = $state(14);
|
||||
|
||||
// Track previous shell/user for reconnection
|
||||
@@ -115,6 +117,16 @@
|
||||
if (bestShell && bestShell !== selectedShell) {
|
||||
selectedShell = bestShell;
|
||||
}
|
||||
|
||||
// Restore saved user for this container
|
||||
const savedUser = getSavedUser(container.id);
|
||||
if (savedUser !== null) {
|
||||
selectedUser = savedUser;
|
||||
committedUser = savedUser;
|
||||
} else {
|
||||
selectedUser = 'root';
|
||||
committedUser = 'root';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to detect shells:', error);
|
||||
} finally {
|
||||
@@ -151,16 +163,42 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Committed user: only updates when a preset is selected or custom input is confirmed
|
||||
let committedUser = $state('root');
|
||||
|
||||
function commitUser(user: string) {
|
||||
committedUser = user;
|
||||
if (selectedContainer) {
|
||||
saveUserForContainer(selectedContainer.id, user);
|
||||
customUsers = getCustomUsers();
|
||||
}
|
||||
}
|
||||
|
||||
// When a user is selected from dropdown, commit immediately
|
||||
function onUserSelectChange(value: string) {
|
||||
commitUser(value);
|
||||
}
|
||||
|
||||
// Confirm custom user on Enter
|
||||
function onCustomUserKeydown(e: KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && customUserInput.trim()) {
|
||||
const newUser = customUserInput.trim();
|
||||
selectedUser = newUser;
|
||||
commitUser(newUser);
|
||||
customUserInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for shell/user changes while connected and trigger reconnect
|
||||
$effect(() => {
|
||||
if (selectedContainer && connected && terminalComponent) {
|
||||
if (selectedShell !== prevShell || selectedUser !== prevUser) {
|
||||
// Reconnect with new shell/user
|
||||
if (selectedShell !== prevShell || committedUser !== prevUser) {
|
||||
terminalComponent.reconnect();
|
||||
}
|
||||
}
|
||||
prevShell = selectedShell;
|
||||
prevUser = selectedUser;
|
||||
prevUser = committedUser;
|
||||
});
|
||||
|
||||
// Change font size
|
||||
@@ -175,6 +213,7 @@
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
customUsers = getCustomUsers();
|
||||
await fetchContainers();
|
||||
|
||||
// Check for container ID in URL query parameter
|
||||
@@ -336,10 +375,10 @@
|
||||
<!-- User selector - always visible -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-sm text-muted-foreground">User:</Label>
|
||||
<Select.Root type="single" bind:value={selectedUser}>
|
||||
<Select.Root type="single" bind:value={selectedUser} onValueChange={onUserSelectChange}>
|
||||
<Select.Trigger class="h-9 w-48">
|
||||
<User class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<span>{USER_OPTIONS.find(o => o.value === selectedUser)?.label || 'Select'}</span>
|
||||
<span>{USER_OPTIONS.find(o => o.value === selectedUser)?.label || selectedUser || 'Select'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each USER_OPTIONS as option}
|
||||
@@ -348,6 +387,36 @@
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
{#if customUsers.length > 0}
|
||||
<div class="h-px bg-border my-1"></div>
|
||||
{#each customUsers as cu}
|
||||
<div class="flex items-center group">
|
||||
<Select.Item value={cu} label={cu} class="flex-1">
|
||||
<User class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{cu}
|
||||
</Select.Item>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 mr-1 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
||||
onclick={(e) => { e.stopPropagation(); e.preventDefault(); removeCustomUser(cu); customUsers = getCustomUsers(); if (selectedUser === cu) { selectedUser = 'root'; commitUser('root'); } }}
|
||||
title="Remove user"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="h-px bg-border my-1"></div>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<div class="px-2 py-1">
|
||||
<Input
|
||||
class="h-7 text-xs"
|
||||
placeholder="Add user... (Enter)"
|
||||
bind:value={customUserInput}
|
||||
onkeydown={onCustomUserKeydown}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
@@ -428,13 +497,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 w-full">
|
||||
{#key `${selectedContainer.id}-${selectedShell}-${selectedUser}`}
|
||||
{#key `${selectedContainer.id}-${selectedShell}-${committedUser}`}
|
||||
<Terminal
|
||||
bind:this={terminalComponent}
|
||||
containerId={selectedContainer.id}
|
||||
containerName={selectedContainer.name}
|
||||
shell={selectedShell}
|
||||
user={selectedUser}
|
||||
user={committedUser}
|
||||
{envId}
|
||||
fontSize={terminalFontSize}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user