1.0.8
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite dev",
|
||||
|
||||
@@ -420,38 +420,61 @@
|
||||
// Effect to update variable markers
|
||||
const updateMarkersEffect = StateEffect.define<VariableMarker[]>();
|
||||
|
||||
// State field to store current markers (used for recalculation on doc change)
|
||||
const currentMarkersField = StateField.define<VariableMarker[]>({
|
||||
create() {
|
||||
return [];
|
||||
},
|
||||
update(markers, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(updateMarkersEffect)) {
|
||||
return effect.value;
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
});
|
||||
|
||||
// State field to track variable markers (gutter)
|
||||
// IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug)
|
||||
// Recalculates on doc change to avoid position mapping issues
|
||||
const variableMarkersField = StateField.define<RangeSet<GutterMarker>>({
|
||||
create() {
|
||||
// Start empty - markers will be pushed via effect
|
||||
return RangeSet.empty;
|
||||
},
|
||||
update(markers, tr) {
|
||||
// Check for marker updates first
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(updateMarkersEffect)) {
|
||||
return createVariableDecorations(tr.state.doc, effect.value);
|
||||
}
|
||||
}
|
||||
// Don't recalculate on docChanged - wait for explicit effect from parent
|
||||
// Recalculate on doc change using stored markers
|
||||
if (tr.docChanged) {
|
||||
const currentMarkers = tr.state.field(currentMarkersField);
|
||||
return createVariableDecorations(tr.state.doc, currentMarkers);
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
});
|
||||
|
||||
// State field to track value decorations (inline widgets)
|
||||
// IMPORTANT: Only updates via effects, not closure reference (fixes stale closure bug)
|
||||
// Recalculates on doc change to avoid widget duplication issues
|
||||
const valueDecorationsField = StateField.define<DecorationSet>({
|
||||
create() {
|
||||
// Start empty - decorations will be pushed via effect
|
||||
return Decoration.none;
|
||||
},
|
||||
update(decorations, tr) {
|
||||
// Check for marker updates first
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(updateMarkersEffect)) {
|
||||
return createValueDecorations(tr.state.doc, effect.value);
|
||||
}
|
||||
}
|
||||
// Don't recalculate on docChanged - wait for explicit effect from parent
|
||||
// Recalculate on doc change using stored markers
|
||||
if (tr.docChanged) {
|
||||
const currentMarkers = tr.state.field(currentMarkersField);
|
||||
return createValueDecorations(tr.state.doc, currentMarkers);
|
||||
}
|
||||
return decorations;
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f)
|
||||
@@ -647,7 +670,7 @@
|
||||
}
|
||||
|
||||
// Always add variable markers gutter and value decorations (can be updated dynamically)
|
||||
extensions.push(variableMarkersField, variableGutter, valueDecorationsField);
|
||||
extensions.push(currentMarkersField, variableMarkersField, variableGutter, valueDecorationsField);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
@@ -666,14 +689,11 @@
|
||||
// Skip onchange during programmatic value sync (only fire for user edits)
|
||||
const lastChangingTr = trs.findLast(tr => tr.docChanged);
|
||||
if (lastChangingTr && onchangeRef && !isSyncingExternalValue) {
|
||||
// Defer callback to next microtask to avoid blocking input handling
|
||||
// This allows key repeat to work properly
|
||||
// Call synchronously to ensure parent state updates before any
|
||||
// reactive $effect runs - this prevents race conditions on iPad Safari
|
||||
// where paste content was being overwritten by stale external value
|
||||
const newContent = lastChangingTr.newDoc.toString();
|
||||
queueMicrotask(() => {
|
||||
if (onchangeRef) {
|
||||
onchangeRef(newContent);
|
||||
}
|
||||
});
|
||||
onchangeRef(newContent);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="space-y-3">
|
||||
<!-- Variables List -->
|
||||
<div class="space-y-3">
|
||||
{#each variables as variable, index (`${index}-${variable.key}`)}
|
||||
{#each variables as variable, index (index)}
|
||||
{@const source = getSource(variable.key)}
|
||||
{@const isVarRequired = isRequired(variable.key)}
|
||||
{@const isVarOptional = isOptional(variable.key)}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.8",
|
||||
"date": "2026-01-13",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "Fix imported stack working directory for relative volume paths" },
|
||||
{ "type": "fix", "text": "Fix environment refresh after auth login" },
|
||||
{ "type": "fix", "text": "Fix single container update clearing up all update badges" },
|
||||
{ "type": "fix", "text": "Fix code editor paste issue on Safari on iPad" },
|
||||
{ "type": "fix", "text": "Fix registry login failing due to Bun stdin API incompatibility" },
|
||||
{ "type": "fix", "text": "Fix env var editor focus issues" },
|
||||
{ "type": "fix", "text": "Fix git stack naming issues: validation, rename sync, and delete cleanup" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.8"
|
||||
},
|
||||
{
|
||||
"version": "1.0.7",
|
||||
"date": "2026-01-06",
|
||||
|
||||
@@ -2643,6 +2643,25 @@ export async function deleteStackSource(stackName: string, environmentId?: numbe
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateStackSourceName(
|
||||
oldStackName: string,
|
||||
newStackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<boolean> {
|
||||
await db.update(stackSources)
|
||||
.set({
|
||||
stackName: newStackName,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(and(
|
||||
eq(stackSources.stackName, oldStackName),
|
||||
environmentId !== undefined && environmentId !== null
|
||||
? eq(stackSources.environmentId, environmentId)
|
||||
: isNull(stackSources.environmentId)
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VULNERABILITY SCAN RESULTS
|
||||
// =============================================================================
|
||||
|
||||
+96
-23
@@ -688,10 +688,9 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Pr
|
||||
}
|
||||
);
|
||||
|
||||
// Write password to stdin
|
||||
const writer = proc.stdin.getWriter();
|
||||
await writer.write(new TextEncoder().encode(reg.password));
|
||||
await writer.close();
|
||||
// Write password to stdin (Bun's FileSink API)
|
||||
proc.stdin.write(reg.password);
|
||||
proc.stdin.end();
|
||||
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
@@ -717,6 +716,10 @@ interface ComposeCommandOptions {
|
||||
forceRecreate?: boolean;
|
||||
removeVolumes?: boolean;
|
||||
stackFiles?: Record<string, string>; // All files to send to Hawser
|
||||
/** Working directory for compose execution (for imported stacks) */
|
||||
workingDir?: string;
|
||||
/** Full path to the compose file (for imported stacks, to avoid writing to internal dir) */
|
||||
composePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -724,6 +727,8 @@ interface ComposeCommandOptions {
|
||||
*
|
||||
* @param envVars - Non-secret environment variables (from .env file, passed for backward compat)
|
||||
* @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk)
|
||||
* @param workingDir - Optional working directory for compose execution (for imported stacks)
|
||||
* @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing)
|
||||
*/
|
||||
async function executeLocalCompose(
|
||||
operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull',
|
||||
@@ -734,17 +739,33 @@ async function executeLocalCompose(
|
||||
secretVars?: Record<string, string>,
|
||||
forceRecreate?: boolean,
|
||||
removeVolumes?: boolean,
|
||||
envId?: number | null
|
||||
envId?: number | null,
|
||||
workingDir?: string,
|
||||
customComposePath?: string
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
// For operations that write (up), use getStackDir; for others, try to find existing first
|
||||
const stackDir = operation === 'up'
|
||||
? await getStackDir(stackName, envId)
|
||||
: (await findStackDir(stackName, envId) || await getStackDir(stackName, envId));
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
await Bun.write(composeFile, composeContent);
|
||||
// Determine working directory and compose file path
|
||||
// For imported stacks (custom paths), use the provided workingDir and composePath
|
||||
// For internal stacks, use the default data directory
|
||||
let stackDir: string;
|
||||
let composeFile: string;
|
||||
|
||||
if (customComposePath && workingDir) {
|
||||
// Imported stack: use original location, don't copy files
|
||||
stackDir = workingDir;
|
||||
composeFile = customComposePath;
|
||||
// Don't write to the compose file - it already exists at the custom location
|
||||
// The user manages this file externally
|
||||
} else {
|
||||
// Internal stack: use default data directory
|
||||
stackDir = operation === 'up'
|
||||
? await getStackDir(stackName, envId)
|
||||
: (await findStackDir(stackName, envId) || await getStackDir(stackName, envId));
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
composeFile = join(stackDir, 'docker-compose.yml');
|
||||
await Bun.write(composeFile, composeContent);
|
||||
}
|
||||
|
||||
// Build spawn environment:
|
||||
// 1. Start with process.env
|
||||
@@ -1042,7 +1063,7 @@ async function executeComposeCommand(
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>
|
||||
): Promise<StackOperationResult> {
|
||||
const { stackName, envId, forceRecreate, removeVolumes, stackFiles } = options;
|
||||
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath } = options;
|
||||
|
||||
// Get environment configuration
|
||||
const env = envId ? await getEnvironment(envId) : null;
|
||||
@@ -1058,7 +1079,9 @@ async function executeComposeCommand(
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes,
|
||||
envId
|
||||
envId,
|
||||
workingDir,
|
||||
composePath
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1089,7 +1112,9 @@ async function executeComposeCommand(
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes,
|
||||
envId
|
||||
envId,
|
||||
workingDir,
|
||||
composePath
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1104,7 +1129,9 @@ async function executeComposeCommand(
|
||||
secretVars,
|
||||
forceRecreate,
|
||||
removeVolumes,
|
||||
envId
|
||||
envId,
|
||||
workingDir,
|
||||
composePath
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1313,6 +1340,10 @@ export interface RequireComposeResult {
|
||||
secretVars?: Record<string, string>;
|
||||
needsFileLocation?: boolean;
|
||||
error?: string;
|
||||
/** Directory containing the compose file (for working directory) */
|
||||
stackDir?: string;
|
||||
/** Full path to the compose file (for imported stacks) */
|
||||
composePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1400,7 +1431,14 @@ async function requireComposeFile(
|
||||
// This ensures external edits to .env are respected during deployment
|
||||
const envVars = { ...dbNonSecretVars, ...fileEnvVars };
|
||||
|
||||
return { success: true, content: composeResult.content!, envVars, secretVars };
|
||||
return {
|
||||
success: true,
|
||||
content: composeResult.content!,
|
||||
envVars,
|
||||
secretVars,
|
||||
stackDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1418,7 +1456,13 @@ export async function startStack(
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
}
|
||||
|
||||
return executeComposeCommand('up', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
return executeComposeCommand(
|
||||
'up',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1436,7 +1480,13 @@ export async function stopStack(
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
return executeComposeCommand('stop', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
return executeComposeCommand(
|
||||
'stop',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1454,7 +1504,13 @@ export async function restartStack(
|
||||
return withContainerFallback(stackName, envId, 'restart');
|
||||
}
|
||||
|
||||
return executeComposeCommand('restart', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
return executeComposeCommand(
|
||||
'restart',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1473,7 +1529,13 @@ export async function downStack(
|
||||
return withContainerFallback(stackName, envId, 'stop');
|
||||
}
|
||||
|
||||
return executeComposeCommand('down', { stackName, envId, removeVolumes }, result.content!, result.envVars, result.secretVars);
|
||||
return executeComposeCommand(
|
||||
'down',
|
||||
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1495,7 +1557,12 @@ export async function removeStack(
|
||||
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
|
||||
const downResult = await executeComposeCommand(
|
||||
'down',
|
||||
{ stackName, envId },
|
||||
{
|
||||
stackName,
|
||||
envId,
|
||||
workingDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath
|
||||
},
|
||||
composeResult.content!,
|
||||
envVars,
|
||||
secretVars
|
||||
@@ -1710,7 +1777,13 @@ export async function pullStackImages(
|
||||
};
|
||||
}
|
||||
|
||||
return executeComposeCommand('pull', { stackName, envId }, result.content!, result.envVars, result.secretVars);
|
||||
return executeComposeCommand(
|
||||
'pull',
|
||||
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
|
||||
result.content!,
|
||||
result.envVars,
|
||||
result.secretVars
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { SidebarProvider, SidebarTrigger } from '$lib/components/ui/sidebar';
|
||||
import { startStatsCollection, stopStatsCollection } from '$lib/stores/stats';
|
||||
import { connectSSE, disconnectSSE } from '$lib/stores/events';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
import { currentEnvironment, environments } from '$lib/stores/environment';
|
||||
import { licenseStore, daysUntilExpiry } from '$lib/stores/license';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import { themeStore, applyTheme } from '$lib/stores/theme';
|
||||
@@ -51,6 +51,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh environments when user becomes authenticated
|
||||
// This handles OIDC callback where login happens server-side
|
||||
let wasAuthenticated = $state(false);
|
||||
$effect(() => {
|
||||
if (!$authStore.loading && $authStore.authenticated && !wasAuthenticated) {
|
||||
environments.refresh();
|
||||
}
|
||||
if (!$authStore.loading) {
|
||||
wasAuthenticated = $authStore.authenticated;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Apply theme from localStorage immediately (for flash-free loading)
|
||||
applyTheme(themeStore.get());
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.svelte';
|
||||
import { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard';
|
||||
import { currentEnvironment } from '$lib/stores/environment';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
import type { EnvironmentStats } from './api/dashboard/stats/+server';
|
||||
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule } from '$lib/server/scheduler';
|
||||
import { secureRandomBytes } from '$lib/server/crypto-fallback';
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -49,6 +52,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const trimmedStackName = data.stackName.trim();
|
||||
if (!STACK_NAME_REGEX.test(trimmedStackName)) {
|
||||
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Either repositoryId or new repo details (url, branch) must be provided
|
||||
let repositoryId = data.repositoryId;
|
||||
|
||||
@@ -98,7 +106,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
}
|
||||
|
||||
const gitStack = await createGitStack({
|
||||
stackName: data.stackName,
|
||||
stackName: trimmedStackName,
|
||||
environmentId: data.environmentId || null,
|
||||
repositoryId: repositoryId,
|
||||
composePath: data.composePath || 'docker-compose.yml',
|
||||
@@ -112,7 +120,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
// Create stack_sources entry so the stack appears in the list immediately
|
||||
await upsertStackSource({
|
||||
stackName: data.stackName,
|
||||
stackName: trimmedStackName,
|
||||
environmentId: data.environmentId || null,
|
||||
sourceType: 'git',
|
||||
gitRepositoryId: repositoryId,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitStack, updateGitStack, deleteGitStack } from '$lib/server/db';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName } from '$lib/server/db';
|
||||
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -43,6 +46,20 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
|
||||
// Validate stack name if it's being changed
|
||||
if (data.stackName !== undefined) {
|
||||
const trimmedStackName = data.stackName.trim();
|
||||
if (!trimmedStackName) {
|
||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||
}
|
||||
if (!STACK_NAME_REGEX.test(trimmedStackName)) {
|
||||
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
|
||||
}
|
||||
data.stackName = trimmedStackName;
|
||||
}
|
||||
|
||||
const oldStackName = existing.stackName;
|
||||
const updated = await updateGitStack(id, {
|
||||
stackName: data.stackName,
|
||||
composePath: data.composePath,
|
||||
@@ -54,6 +71,11 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
||||
webhookSecret: data.webhookSecret
|
||||
});
|
||||
|
||||
// If stack name changed, update the stack_sources record too
|
||||
if (data.stackName && data.stackName !== oldStackName) {
|
||||
await updateStackSourceName(oldStackName, data.stackName, existing.environmentId);
|
||||
}
|
||||
|
||||
// Register or unregister schedule with croner
|
||||
if (updated.autoUpdate && updated.autoUpdateCron) {
|
||||
await registerSchedule(id, 'git_stack_sync', updated.environmentId);
|
||||
@@ -101,6 +123,9 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
// Delete git files first
|
||||
deleteGitStackFiles(id);
|
||||
|
||||
// Delete the stack_sources record to free up the stack name
|
||||
await deleteStackSource(existing.stackName, existing.environmentId);
|
||||
|
||||
// Delete from database
|
||||
await deleteGitStack(id);
|
||||
|
||||
|
||||
@@ -234,6 +234,10 @@
|
||||
let batchUpdateContainerIds = $state<string[]>([]);
|
||||
let batchUpdateContainerNames = $state<Map<string, string>>(new Map());
|
||||
|
||||
// Single container update mode (doesn't overwrite batch list)
|
||||
let singleUpdateContainerId = $state<string | null>(null);
|
||||
let singleUpdateContainerName = $state<string | null>(null);
|
||||
|
||||
// Operation error state
|
||||
let operationError = $state<{ id: string; message: string } | null>(null);
|
||||
|
||||
@@ -459,13 +463,16 @@
|
||||
}
|
||||
|
||||
function updateSingleContainer(containerId: string, containerName: string) {
|
||||
batchUpdateContainerIds = [containerId];
|
||||
batchUpdateContainerNames = new Map([[containerId, containerName]]);
|
||||
// Use single-container mode to avoid overwriting the batch list
|
||||
singleUpdateContainerId = containerId;
|
||||
singleUpdateContainerName = containerName;
|
||||
showBatchUpdateModal = true;
|
||||
}
|
||||
|
||||
function handleBatchUpdateClose() {
|
||||
showBatchUpdateModal = false;
|
||||
singleUpdateContainerId = null;
|
||||
singleUpdateContainerName = null;
|
||||
updateCheckStatus = 'idle';
|
||||
}
|
||||
|
||||
@@ -481,13 +488,12 @@
|
||||
}
|
||||
selectedContainers = new Set();
|
||||
|
||||
// Keep blocked containers in the update list - they still have updates available
|
||||
// Only remove successfully updated containers
|
||||
const successSet = new Set(results.success);
|
||||
batchUpdateContainerIds = batchUpdateContainerIds.filter(id => !successSet.has(id));
|
||||
for (const id of results.success) {
|
||||
batchUpdateContainerNames.delete(id);
|
||||
}
|
||||
// Clear single-update mode
|
||||
singleUpdateContainerId = null;
|
||||
singleUpdateContainerName = null;
|
||||
|
||||
// Reload pending updates from database to restore highlighting for remaining containers
|
||||
loadPendingUpdates();
|
||||
|
||||
fetchContainers();
|
||||
}
|
||||
@@ -2137,8 +2143,8 @@
|
||||
|
||||
<BatchUpdateModal
|
||||
bind:open={showBatchUpdateModal}
|
||||
containerIds={batchUpdateContainerIds}
|
||||
containerNames={batchUpdateContainerNames}
|
||||
containerIds={singleUpdateContainerId ? [singleUpdateContainerId] : batchUpdateContainerIds}
|
||||
containerNames={singleUpdateContainerId && singleUpdateContainerName ? new Map([[singleUpdateContainerId, singleUpdateContainerName]]) : batchUpdateContainerNames}
|
||||
{envId}
|
||||
vulnerabilityCriteria={envHasScanning ? envVulnerabilityCriteria : 'never'}
|
||||
onClose={handleBatchUpdateClose}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Loader2, LogIn, Shield, AlertCircle, Network, User, KeyRound, TriangleAlert } from 'lucide-svelte';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import { environments } from '$lib/stores/environment';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
|
||||
interface AuthProvider {
|
||||
@@ -96,7 +97,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - redirect
|
||||
// Success - refresh environments (they were cleared during pre-login fetch) then redirect
|
||||
await environments.refresh();
|
||||
goto(redirectUrl);
|
||||
} catch (e) {
|
||||
error = 'An unexpected error occurred';
|
||||
|
||||
@@ -1724,7 +1724,7 @@
|
||||
<!-- Clickable ports (dedupe by publicPort for IPv4/IPv6) -->
|
||||
{#if container.ports.length > 0}
|
||||
{@const uniquePorts = container.ports.filter((p, i, arr) => p.publicPort && arr.findIndex(x => x.publicPort === p.publicPort) === i)}
|
||||
{#each uniquePorts.slice(0, 2) as port}
|
||||
{#each uniquePorts as port}
|
||||
{@const url = getPortUrl(port.publicPort)}
|
||||
{#if url}
|
||||
<a
|
||||
@@ -1744,9 +1744,6 @@
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if uniquePorts.length > 2}
|
||||
<span class="text-muted-foreground">+{uniquePorts.length - 2}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Network with IP -->
|
||||
{#if container.networks.length > 0}
|
||||
|
||||
@@ -88,6 +88,9 @@
|
||||
let formError = $state('');
|
||||
let formSaving = $state(false);
|
||||
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string }>({});
|
||||
|
||||
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
|
||||
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
let copiedWebhookUrl = $state(false);
|
||||
let copiedWebhookSecret = $state(false);
|
||||
|
||||
@@ -309,9 +312,13 @@
|
||||
errors = {};
|
||||
let hasErrors = false;
|
||||
|
||||
if (!formStackName.trim()) {
|
||||
const trimmedStackName = formStackName.trim();
|
||||
if (!trimmedStackName) {
|
||||
errors.stackName = 'Stack name is required';
|
||||
hasErrors = true;
|
||||
} else if (!STACK_NAME_REGEX.test(trimmedStackName)) {
|
||||
errors.stackName = 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (formRepoMode === 'existing' && !formRepositoryId) {
|
||||
|
||||
+73
-4
@@ -18,6 +18,13 @@ interface DockerTarget {
|
||||
port?: number;
|
||||
hawserToken?: string;
|
||||
environmentId?: number;
|
||||
// TLS configuration for mTLS connections
|
||||
tls?: {
|
||||
ca?: string;
|
||||
cert?: string;
|
||||
key?: string;
|
||||
rejectUnauthorized?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface EnvironmentRow {
|
||||
@@ -28,6 +35,11 @@ interface EnvironmentRow {
|
||||
host?: string;
|
||||
port?: number;
|
||||
hawser_token?: string;
|
||||
protocol?: string;
|
||||
tls_ca?: string;
|
||||
tls_cert?: string;
|
||||
tls_key?: string;
|
||||
tls_skip_verify?: boolean | number;
|
||||
}
|
||||
|
||||
// ============ Docker Target Resolution ============
|
||||
@@ -51,11 +63,23 @@ function resolveDockerTarget(
|
||||
return { type: 'hawser-edge', environmentId: envId };
|
||||
}
|
||||
|
||||
// Build TLS config if using HTTPS protocol
|
||||
let tls: DockerTarget['tls'] | undefined;
|
||||
if (env.protocol === 'https') {
|
||||
tls = {
|
||||
rejectUnauthorized: !env.tls_skip_verify
|
||||
};
|
||||
if (env.tls_ca) tls.ca = env.tls_ca;
|
||||
if (env.tls_cert) tls.cert = env.tls_cert;
|
||||
if (env.tls_key) tls.key = env.tls_key;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tcp',
|
||||
host: env.host || 'localhost',
|
||||
port: env.port || 2375,
|
||||
hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined
|
||||
hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined,
|
||||
tls
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,10 +278,23 @@ async function createExecForWs(containerId: string, cmd: string[], user: string,
|
||||
url = 'http://localhost/containers/' + containerId + '/exec';
|
||||
fetchOpts.unix = target.socket;
|
||||
} else {
|
||||
url = 'http://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec';
|
||||
const protocol = target.tls ? 'https' : 'http';
|
||||
url = protocol + '://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec';
|
||||
if (target.hawserToken) {
|
||||
headers['X-Hawser-Token'] = target.hawserToken;
|
||||
}
|
||||
// Add TLS options for mTLS connections
|
||||
if (target.tls) {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0, // Disable TLS session caching
|
||||
servername: target.host,
|
||||
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
if (target.tls.ca) fetchOpts.tls.ca = [target.tls.ca];
|
||||
if (target.tls.cert) fetchOpts.tls.cert = [target.tls.cert];
|
||||
if (target.tls.key) fetchOpts.tls.key = target.tls.key;
|
||||
fetchOpts.keepalive = false;
|
||||
}
|
||||
}
|
||||
const res = await fetch(url, fetchOpts);
|
||||
if (!res.ok) throw new Error('Failed to create exec: ' + (await res.text()));
|
||||
@@ -272,10 +309,23 @@ async function resizeExecForWs(execId: string, cols: number, rows: number, targe
|
||||
url = 'http://localhost/exec/' + execId + '/resize?h=' + rows + '&w=' + cols;
|
||||
fetchOpts.unix = target.socket;
|
||||
} else {
|
||||
url = 'http://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols;
|
||||
const protocol = target.tls ? 'https' : 'http';
|
||||
url = protocol + '://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols;
|
||||
if (target.hawserToken) {
|
||||
fetchOpts.headers = { 'X-Hawser-Token': target.hawserToken };
|
||||
}
|
||||
// Add TLS options for mTLS connections
|
||||
if (target.tls) {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: target.host,
|
||||
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
if (target.tls.ca) fetchOpts.tls.ca = [target.tls.ca];
|
||||
if (target.tls.cert) fetchOpts.tls.cert = [target.tls.cert];
|
||||
if (target.tls.key) fetchOpts.tls.key = target.tls.key;
|
||||
fetchOpts.keepalive = false;
|
||||
}
|
||||
}
|
||||
await fetch(url, fetchOpts);
|
||||
} catch {
|
||||
@@ -536,7 +586,17 @@ function webSocketPlugin(): Plugin {
|
||||
if (target.type === 'unix') {
|
||||
dockerStream = await Bun.connect({ unix: target.socket, socket: socketHandler });
|
||||
} else if (target.type === 'tcp') {
|
||||
dockerStream = await Bun.connect({ hostname: target.host, port: target.port, socket: socketHandler });
|
||||
// Build connection options with TLS if configured
|
||||
const connectOpts: any = { hostname: target.host, port: target.port, socket: socketHandler };
|
||||
if (target.tls) {
|
||||
connectOpts.tls = {
|
||||
ca: target.tls.ca,
|
||||
cert: target.tls.cert,
|
||||
key: target.tls.key,
|
||||
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
}
|
||||
dockerStream = await Bun.connect(connectOpts);
|
||||
}
|
||||
|
||||
dockerStreams.set(connId, { stream: dockerStream, execId, target, state, ws });
|
||||
@@ -982,6 +1042,15 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
include: ['lucide-svelte', '@xterm/xterm', '@xterm/addon-fit']
|
||||
},
|
||||
resolve: {
|
||||
dedupe: [
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@codemirror/language',
|
||||
'@lezer/common',
|
||||
'@lezer/highlight'
|
||||
]
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
minify: 'esbuild',
|
||||
|
||||
Reference in New Issue
Block a user