mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.5
This commit is contained in:
+1
-1
@@ -59,7 +59,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Copy emergency scripts (only the emergency subfolder, not license generation scripts)
|
||||
COPY scripts/emergency/ ./scripts/
|
||||
RUN chmod +x ./scripts/*.sh 2>/dev/null || true
|
||||
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
|
||||
|
||||
# Create directories with proper ownership
|
||||
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
|
||||
@@ -12,6 +12,60 @@ if [ "$(id -u)" = "0" ]; then
|
||||
RUNNING_AS_ROOT=true
|
||||
fi
|
||||
|
||||
# === Non-root mode (user: directive in compose) ===
|
||||
# If container started as non-root, skip all user management and run directly
|
||||
if [ "$RUNNING_AS_ROOT" = "false" ]; then
|
||||
echo "Running as user $(id -u):$(id -g) (set via container user directive)"
|
||||
|
||||
# Ensure data directories exist (user must have write access to DATA_DIR via volume mount)
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
if [ ! -d "$DATA_DIR/db" ]; then
|
||||
echo "Creating database directory at $DATA_DIR/db"
|
||||
mkdir -p "$DATA_DIR/db" 2>/dev/null || {
|
||||
echo "ERROR: Cannot create $DATA_DIR/db directory"
|
||||
echo "Ensure the data volume is mounted with correct permissions for user $(id -u):$(id -g)"
|
||||
echo ""
|
||||
echo "Example docker-compose.yml:"
|
||||
echo " volumes:"
|
||||
echo " - ./data:/app/data # This directory must be writable by user $(id -u)"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [ ! -d "$DATA_DIR/stacks" ]; then
|
||||
mkdir -p "$DATA_DIR/stacks" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Check Docker socket access if mounted
|
||||
SOCKET_PATH="/var/run/docker.sock"
|
||||
if [ -S "$SOCKET_PATH" ]; then
|
||||
if test -r "$SOCKET_PATH" 2>/dev/null; then
|
||||
echo "Docker socket accessible at $SOCKET_PATH"
|
||||
# Detect hostname from Docker if not set
|
||||
if [ -z "$DOCKHAND_HOSTNAME" ]; then
|
||||
DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$DETECTED_HOSTNAME" ]; then
|
||||
export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME"
|
||||
echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown")
|
||||
echo "WARNING: Docker socket not readable by user $(id -u)"
|
||||
echo "Add --group-add $SOCKET_GID to your docker run command"
|
||||
fi
|
||||
else
|
||||
echo "No Docker socket found at $SOCKET_PATH"
|
||||
echo "Configure Docker environments via the web UI (Settings > Environments)"
|
||||
fi
|
||||
|
||||
# Run directly as current user (no su-exec needed)
|
||||
if [ "$1" = "" ]; then
|
||||
exec bun run ./build/index.js
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
# === User Setup ===
|
||||
# Root mode: PUID=0 requested OR already running as root with default PUID/PGID
|
||||
if [ "$PUID" = "0" ]; then
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.7 KiB |
@@ -3,11 +3,51 @@
|
||||
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
|
||||
// Simple dotenv/env file language parser
|
||||
const dotenvParser: StreamParser<{ inValue: boolean }> = {
|
||||
startState() {
|
||||
return { inValue: false };
|
||||
},
|
||||
token(stream, state) {
|
||||
// Start of line
|
||||
if (stream.sol()) {
|
||||
state.inValue = false;
|
||||
// Skip leading whitespace
|
||||
stream.eatSpace();
|
||||
// Comment line
|
||||
if (stream.peek() === '#') {
|
||||
stream.skipToEnd();
|
||||
return 'comment';
|
||||
}
|
||||
}
|
||||
// If in value part, consume the rest
|
||||
if (state.inValue) {
|
||||
stream.skipToEnd();
|
||||
return 'string';
|
||||
}
|
||||
// Variable name before =
|
||||
if (stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)) {
|
||||
if (stream.peek() === '=') {
|
||||
return 'variableName.definition';
|
||||
}
|
||||
return 'variableName';
|
||||
}
|
||||
// Equals sign - switch to value mode
|
||||
if (stream.eat('=')) {
|
||||
state.inValue = true;
|
||||
return 'operator';
|
||||
}
|
||||
// Skip anything else
|
||||
stream.next();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Docker Compose keywords for autocomplete
|
||||
const COMPOSE_TOP_LEVEL = ['services', 'networks', 'volumes', 'configs', 'secrets', 'name', 'version'];
|
||||
|
||||
@@ -453,6 +493,9 @@
|
||||
case 'sh':
|
||||
// No dedicated shell/dockerfile support, use basic highlighting
|
||||
return [];
|
||||
case 'dotenv':
|
||||
case 'env':
|
||||
return StreamLanguage.define(dotenvParser);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -542,6 +585,13 @@
|
||||
// Track if we're initialized (prevents multiple createEditor calls)
|
||||
let initialized = false;
|
||||
|
||||
// Debounce timer for marker updates (prevents flicker during fast typing)
|
||||
let markerUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const MARKER_UPDATE_DEBOUNCE_MS = 300;
|
||||
|
||||
// Track last applied markers to avoid redundant updates
|
||||
let lastAppliedMarkersJson = '';
|
||||
|
||||
function createEditor() {
|
||||
if (!container || view || initialized) return;
|
||||
initialized = true;
|
||||
@@ -551,12 +601,14 @@
|
||||
: [dockhandLight, syntaxHighlighting(defaultHighlightStyle)];
|
||||
|
||||
// Build autocompletion config - add Docker Compose completions for YAML
|
||||
// Note: activateOnTyping can interfere with key repeat, so we disable it
|
||||
// Users can still trigger autocomplete manually with Ctrl+Space
|
||||
const autocompletionConfig = language === 'yaml'
|
||||
? autocompletion({
|
||||
override: [composeCompletions, composeValueCompletions],
|
||||
activateOnTyping: true
|
||||
activateOnTyping: false
|
||||
})
|
||||
: autocompletion();
|
||||
: autocompletion({ activateOnTyping: false });
|
||||
|
||||
const extensions = [
|
||||
lineNumbers(),
|
||||
@@ -594,18 +646,25 @@
|
||||
extensions
|
||||
});
|
||||
|
||||
// Custom transaction handler - this is SYNCHRONOUS and more reliable than updateListener
|
||||
// Custom transaction handler - applies transactions synchronously but defers callback
|
||||
// Based on the Svelte Playground pattern: https://svelte.dev/playground/91649ba3e0ce4122b3b34f3a95a00104
|
||||
const dispatchTransactions = (trs: readonly import('@codemirror/state').Transaction[]) => {
|
||||
if (!view) return;
|
||||
|
||||
// Apply all transactions
|
||||
// Apply all transactions synchronously (required by CodeMirror)
|
||||
view.update(trs);
|
||||
|
||||
// Check if any transaction changed the document
|
||||
const lastChangingTr = trs.findLast(tr => tr.docChanged);
|
||||
if (lastChangingTr && onchangeRef) {
|
||||
onchangeRef(lastChangingTr.newDoc.toString());
|
||||
// Defer callback to next microtask to avoid blocking input handling
|
||||
// This allows key repeat to work properly
|
||||
const newContent = lastChangingTr.newDoc.toString();
|
||||
queueMicrotask(() => {
|
||||
if (onchangeRef) {
|
||||
onchangeRef(newContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -615,7 +674,6 @@
|
||||
dispatchTransactions
|
||||
});
|
||||
|
||||
|
||||
// Push initial markers if provided
|
||||
if (variableMarkers.length > 0) {
|
||||
view.dispatch({
|
||||
@@ -625,11 +683,16 @@
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
if (markerUpdateTimer) {
|
||||
clearTimeout(markerUpdateTimer);
|
||||
markerUpdateTimer = null;
|
||||
}
|
||||
if (view) {
|
||||
view.destroy();
|
||||
view = null;
|
||||
}
|
||||
initialized = false;
|
||||
lastAppliedMarkersJson = '';
|
||||
}
|
||||
|
||||
// Get current editor content
|
||||
@@ -656,11 +719,35 @@
|
||||
}
|
||||
|
||||
// Update variable markers - this is the key method for parent to call
|
||||
export function updateVariableMarkers(markers: VariableMarker[]) {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: updateMarkersEffect.of(markers)
|
||||
});
|
||||
// Debounced to prevent flicker during fast typing
|
||||
export function updateVariableMarkers(markers: VariableMarker[], immediate = false) {
|
||||
if (!view) return;
|
||||
|
||||
// Check if markers actually changed (compare by content, not reference)
|
||||
const newJson = JSON.stringify(markers);
|
||||
if (newJson === lastAppliedMarkersJson) {
|
||||
return; // No change, skip update
|
||||
}
|
||||
|
||||
// Clear any pending update
|
||||
if (markerUpdateTimer) {
|
||||
clearTimeout(markerUpdateTimer);
|
||||
markerUpdateTimer = null;
|
||||
}
|
||||
|
||||
const applyUpdate = () => {
|
||||
if (view) {
|
||||
lastAppliedMarkersJson = newJson;
|
||||
view.dispatch({
|
||||
effects: updateMarkersEffect.of(markers)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
applyUpdate();
|
||||
} else {
|
||||
markerUpdateTimer = setTimeout(applyUpdate, MARKER_UPDATE_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,12 +780,11 @@
|
||||
});
|
||||
|
||||
// Update markers when prop changes (backup mechanism, parent should also call updateVariableMarkers)
|
||||
// Uses the debounced update to prevent flicker during fast typing
|
||||
$effect(() => {
|
||||
const markers = variableMarkers;
|
||||
if (view && markers) {
|
||||
view.dispatch({
|
||||
effects: updateMarkersEffect.of(markers)
|
||||
});
|
||||
updateVariableMarkers(markers);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -706,7 +792,6 @@
|
||||
<div
|
||||
bind:this={container}
|
||||
class="h-full w-full overflow-hidden {className}"
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
let status = $state<PullStatus>('idle');
|
||||
let image = $state(initialImageName);
|
||||
let duration = $state(0);
|
||||
// Track whether image was set from initial prop vs typed by user
|
||||
let hasAutoStarted = $state(false);
|
||||
|
||||
// Notify parent of status changes
|
||||
$effect(() => {
|
||||
@@ -82,8 +84,10 @@
|
||||
onImageChange?.(image);
|
||||
});
|
||||
|
||||
// Auto-start only once for prefilled images, not when user is typing
|
||||
$effect(() => {
|
||||
if (autoStart && image && status === 'idle') {
|
||||
if (autoStart && initialImageName && image === initialImageName && status === 'idle' && !hasAutoStarted) {
|
||||
hasAutoStarted = true;
|
||||
startPull();
|
||||
}
|
||||
});
|
||||
@@ -133,6 +137,7 @@
|
||||
layerOrder = 0;
|
||||
outputLines = [];
|
||||
duration = 0;
|
||||
hasAutoStarted = false;
|
||||
}
|
||||
|
||||
export function getImage() {
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
<div class="space-y-3">
|
||||
<!-- Variables List -->
|
||||
<div class="space-y-3">
|
||||
{#each variables as variable, index}
|
||||
{#each variables as variable, index (index)}
|
||||
{@const source = getSource(variable.key)}
|
||||
{@const isVarRequired = isRequired(variable.key)}
|
||||
{@const isVarOptional = isOptional(variable.key)}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import CodeEditor from '$lib/components/CodeEditor.svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { Plus, Info, Upload, Trash2 } from 'lucide-svelte';
|
||||
import { Plus, Info, Upload, Trash2, List, FileText, AlertTriangle } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
variables: EnvVar[];
|
||||
variables: EnvVar[]; // Bindable - kept in sync with rawContent
|
||||
rawContent?: string; // The actual content saved to disk - source of truth
|
||||
validation?: ValidationResult | null;
|
||||
readonly?: boolean;
|
||||
showSource?: boolean;
|
||||
@@ -19,7 +22,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
variables = $bindable(),
|
||||
variables = $bindable([]),
|
||||
rawContent = $bindable(''),
|
||||
validation = null,
|
||||
readonly = false,
|
||||
showSource = false,
|
||||
@@ -31,44 +35,194 @@
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
const STORAGE_KEY_VIEW_MODE = 'dockhand-env-vars-view-mode';
|
||||
|
||||
let fileInputRef: HTMLInputElement;
|
||||
let viewMode = $state<'form' | 'text'>(
|
||||
(typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY_VIEW_MODE) as 'form' | 'text') || 'form'
|
||||
);
|
||||
let confirmClearOpen = $state(false);
|
||||
let contentAreaRef: HTMLDivElement;
|
||||
let parseWarnings = $state<string[]>([]);
|
||||
let editorTheme = $state<'light' | 'dark'>('dark');
|
||||
|
||||
function addEnvVariable() {
|
||||
variables = [...variables, { key: '', value: '', isSecret: false }];
|
||||
}
|
||||
// Track previous variables to detect form changes
|
||||
let prevVariablesJson = $state('');
|
||||
|
||||
function handleLoadFromFile() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
// Track if initial sync has been done (to distinguish initial load from user action)
|
||||
let initialized = $state(false);
|
||||
|
||||
function parseEnvFile(content: string): EnvVar[] {
|
||||
const lines = content.split('\n');
|
||||
const envVars: EnvVar[] = [];
|
||||
// Parse raw content to EnvVar array
|
||||
function parseRawContent(content: string): { vars: EnvVar[], warnings: string[] } {
|
||||
const result: EnvVar[] = [];
|
||||
const warnings: string[] = [];
|
||||
let lineNum = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip empty lines and comments
|
||||
for (const line of content.split('\n')) {
|
||||
lineNum++;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Parse KEY=VALUE format
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
if (eqIndex === -1) {
|
||||
warnings.push(`Line ${lineNum}: "${trimmed.slice(0, 30)}${trimmed.length > 30 ? '...' : ''}" (no = found)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
let value = trimmed.slice(eqIndex + 1);
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key) {
|
||||
envVars.push({ key, value, isSecret: false });
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
warnings.push(`Line ${lineNum}: "${key}" (invalid variable name)`);
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
key,
|
||||
value,
|
||||
isSecret: existingSecretKeys.has(key) || false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
return { vars: result, warnings };
|
||||
}
|
||||
|
||||
// Update rawContent when variables change - replace var lines by position, preserve comments
|
||||
function syncRawContentFromVariables(newVars: EnvVar[]) {
|
||||
const lines = rawContent.split('\n');
|
||||
const resultLines: string[] = [];
|
||||
const varsWithKeys = newVars.filter(v => v.key.trim());
|
||||
let varIdx = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Keep comments and blank lines
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
resultLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
// This is a valid variable line - replace with var at current index
|
||||
if (varIdx < varsWithKeys.length) {
|
||||
const v = varsWithKeys[varIdx];
|
||||
resultLines.push(`${v.key.trim()}=${v.value}`);
|
||||
varIdx++;
|
||||
}
|
||||
// If we have fewer vars, this line is deleted
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Keep invalid lines as-is
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
// Append any new variables
|
||||
while (varIdx < varsWithKeys.length) {
|
||||
const v = varsWithKeys[varIdx];
|
||||
resultLines.push(`${v.key.trim()}=${v.value}`);
|
||||
varIdx++;
|
||||
}
|
||||
|
||||
let result = resultLines.join('\n');
|
||||
if (result && !result.endsWith('\n')) {
|
||||
result += '\n';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// When rawContent changes externally (text view, file load), update variables
|
||||
$effect(() => {
|
||||
const { vars, warnings } = parseRawContent(rawContent);
|
||||
parseWarnings = warnings;
|
||||
|
||||
// Initial load with no .env file: don't overwrite DB-loaded variables
|
||||
// Let the second $effect generate rawContent from the existing variables instead
|
||||
if (!initialized && !rawContent.trim() && variables.length > 0) {
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
|
||||
// When rawContent has content, merge parsed vars with existing DB secrets
|
||||
// This handles the case where .env file exists but DB has additional secrets
|
||||
let finalVars = vars;
|
||||
if (rawContent.trim()) {
|
||||
const parsedKeys = new Set(vars.map(v => v.key));
|
||||
const existingSecrets = untrack(() =>
|
||||
variables.filter(v => v.isSecret && !parsedKeys.has(v.key))
|
||||
);
|
||||
if (existingSecrets.length > 0) {
|
||||
finalVars = [...vars, ...existingSecrets];
|
||||
}
|
||||
}
|
||||
|
||||
const newJson = JSON.stringify(finalVars.map(v => ({ key: v.key, value: v.value })));
|
||||
// Use untrack to read variables without creating a dependency on it
|
||||
// This prevents the effect from running when variables changes (only rawContent should trigger it)
|
||||
const currentNonEmptyJson = untrack(() =>
|
||||
JSON.stringify(variables.filter(v => v.key.trim()).map(v => ({ key: v.key, value: v.value })))
|
||||
);
|
||||
|
||||
if (newJson !== currentNonEmptyJson) {
|
||||
variables = finalVars;
|
||||
prevVariablesJson = newJson;
|
||||
}
|
||||
});
|
||||
|
||||
// When variables change from form edits, update rawContent
|
||||
$effect(() => {
|
||||
const currentJson = JSON.stringify(variables.map(v => ({ key: v.key, value: v.value })));
|
||||
|
||||
// Only sync if variables actually changed (not from parsing rawContent)
|
||||
if (currentJson !== prevVariablesJson) {
|
||||
prevVariablesJson = currentJson;
|
||||
const newRaw = syncRawContentFromVariables(variables);
|
||||
if (newRaw !== rawContent) {
|
||||
rawContent = newRaw;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function handleTextChange(value: string) {
|
||||
rawContent = value;
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
function handleViewModeChange(newMode: 'form' | 'text') {
|
||||
viewMode = newMode;
|
||||
localStorage.setItem(STORAGE_KEY_VIEW_MODE, newMode);
|
||||
}
|
||||
|
||||
async function addEnvVariable() {
|
||||
variables = [...variables, { key: '', value: '', isSecret: false }];
|
||||
onchange?.();
|
||||
await tick();
|
||||
if (contentAreaRef) {
|
||||
contentAreaRef.scrollTop = contentAreaRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMissingVariable(key: string) {
|
||||
variables = [...variables, { key, value: '', isSecret: false }];
|
||||
onchange?.();
|
||||
await tick();
|
||||
if (contentAreaRef) {
|
||||
contentAreaRef.scrollTop = contentAreaRef.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadFromFile() {
|
||||
fileInputRef?.click();
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
@@ -78,90 +232,90 @@
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
const parsedVars = parseEnvFile(content);
|
||||
|
||||
if (parsedVars.length > 0) {
|
||||
// Get existing keys to avoid duplicates
|
||||
const existingKeys = new Set(variables.filter(v => v.key.trim()).map(v => v.key.trim()));
|
||||
|
||||
// Filter empty entries from current variables
|
||||
const nonEmptyVars = variables.filter(v => v.key.trim());
|
||||
|
||||
// Add new variables, updating existing ones or appending new
|
||||
for (const newVar of parsedVars) {
|
||||
if (existingKeys.has(newVar.key)) {
|
||||
// Update existing variable
|
||||
const idx = nonEmptyVars.findIndex(v => v.key.trim() === newVar.key);
|
||||
if (idx !== -1) {
|
||||
nonEmptyVars[idx] = { ...nonEmptyVars[idx], value: newVar.value };
|
||||
}
|
||||
} else {
|
||||
// Add new variable
|
||||
nonEmptyVars.push(newVar);
|
||||
existingKeys.add(newVar.key);
|
||||
}
|
||||
}
|
||||
|
||||
variables = nonEmptyVars;
|
||||
// Notify parent of change (important for async file load)
|
||||
onchange?.();
|
||||
}
|
||||
rawContent = e.target?.result as string;
|
||||
onchange?.();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function clearAllVariables() {
|
||||
function clearAll() {
|
||||
rawContent = '';
|
||||
variables = [];
|
||||
onchange?.();
|
||||
}
|
||||
|
||||
// Count of non-empty variables
|
||||
const hasVariables = $derived(variables.some(v => v.key.trim()));
|
||||
const hasContent = $derived(!!rawContent?.trim() || variables.some(v => v.key.trim()));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full {className}">
|
||||
<!-- Header -->
|
||||
<div class="px-4 py-2.5 border-b border-zinc-200 dark:border-zinc-700 flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 flex-nowrap min-w-0">
|
||||
<span class="text-xs text-zinc-500 dark:text-zinc-400">Environment variables</span>
|
||||
{#if infoText}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="w-3.5 h-3.5 text-blue-400" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-md">
|
||||
<p class="text-xs">{infoText}</p>
|
||||
</Tooltip.Content>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="bottom" sideOffset={8} class="max-w-xs w-64 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
|
||||
<p class="text-xs text-left">{infoText}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
<!-- View mode toggle -->
|
||||
<div class="flex items-center gap-0.5 bg-zinc-100 dark:bg-zinc-800 rounded p-0.5 ml-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'form' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
|
||||
onclick={() => handleViewModeChange('form')}
|
||||
title="Form view"
|
||||
>
|
||||
<List class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs transition-colors {viewMode === 'text' ? 'bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}"
|
||||
onclick={() => handleViewModeChange('text')}
|
||||
title="Text view (raw .env file)"
|
||||
>
|
||||
<FileText class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if !readonly}
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1 shrink-0 ml-4">
|
||||
<Button type="button" size="sm" variant="ghost" onclick={handleLoadFromFile} class="h-6 text-xs px-2">
|
||||
<Upload class="w-3.5 h-3.5 mr-1" />
|
||||
Load .env
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
{#if hasVariables}
|
||||
<ConfirmPopover
|
||||
title="Clear all variables"
|
||||
description="This will remove all environment variables. This cannot be undone."
|
||||
confirmText="Clear all"
|
||||
onConfirm={clearAllVariables}
|
||||
>
|
||||
<Button type="button" size="sm" variant="ghost" class="h-6 text-xs px-2 text-destructive hover:text-destructive">
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</ConfirmPopover>
|
||||
{#if viewMode === 'form'}
|
||||
<Button type="button" size="sm" variant="ghost" onclick={addEnvVariable} class="h-6 text-xs px-2">
|
||||
<Plus class="w-3.5 h-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="{hasContent ? '' : 'invisible'}">
|
||||
<ConfirmPopover
|
||||
bind:open={confirmClearOpen}
|
||||
title="Clear all variables?"
|
||||
action="clear"
|
||||
itemType="environment variables"
|
||||
confirmText="Clear all"
|
||||
onConfirm={clearAll}
|
||||
onOpenChange={(o) => confirmClearOpen = o}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
<Button type="button" size="sm" variant="ghost" class="h-6 text-xs px-2 text-destructive hover:text-destructive">
|
||||
<Trash2 class="w-3.5 h-3.5 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInputRef}
|
||||
@@ -172,47 +326,44 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Variable syntax help -->
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-zinc-400 dark:text-zinc-500 font-mono">
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR}`}</span> required</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
|
||||
</div>
|
||||
<!-- Validation status pills -->
|
||||
{#if validation}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if validation.missing.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{validation.missing.length} missing
|
||||
</span>
|
||||
{/if}
|
||||
{#if validation.required.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{validation.required.length - validation.missing.length} required
|
||||
</span>
|
||||
{/if}
|
||||
{#if validation.optional.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
{validation.optional.length} optional
|
||||
</span>
|
||||
{/if}
|
||||
{#if validation.unused.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
{validation.unused.length} unused
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Help text -->
|
||||
{#if viewMode === 'form'}
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-2xs text-zinc-400 dark:text-zinc-500 font-mono">
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR}`}</span> required</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:-default}`}</span> optional</span>
|
||||
<span><span class="text-zinc-500 dark:text-zinc-400">${`{VAR:?error}`}</span> required w/ error</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-2xs text-zinc-400 dark:text-zinc-500">
|
||||
Raw .env file (comments preserved, saved exactly as typed)
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Add missing variables -->
|
||||
{#if validation && validation.missing.length > 0 && !readonly}
|
||||
<!-- Parse warnings (form mode only) -->
|
||||
{#if viewMode === 'form' && parseWarnings.length > 0}
|
||||
<div class="flex items-start gap-2 px-2 py-1.5 rounded bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
|
||||
<AlertTriangle class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div class="text-2xs text-amber-700 dark:text-amber-300">
|
||||
<span class="font-medium">Some lines couldn't be parsed:</span>
|
||||
<ul class="mt-0.5 list-disc list-inside">
|
||||
{#each parseWarnings.slice(0, 3) as warning}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
{#if parseWarnings.length > 3}
|
||||
<li>...and {parseWarnings.length - 3} more</li>
|
||||
{/if}
|
||||
</ul>
|
||||
<p class="mt-1 text-amber-600 dark:text-amber-400">Switch to text view to edit these lines.</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Add missing variables (form mode only) -->
|
||||
{#if viewMode === 'form' && validation && validation.missing.length > 0 && !readonly}
|
||||
<div class="flex flex-wrap gap-1 items-center">
|
||||
<span class="text-xs text-muted-foreground mr-1">Add missing:</span>
|
||||
{#each validation.missing as missing}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
variables = [...variables, { key: missing, value: '', isSecret: false }];
|
||||
}}
|
||||
onclick={() => addMissingVariable(missing)}
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50 transition-colors"
|
||||
>
|
||||
{missing}
|
||||
@@ -221,16 +372,27 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Variables list -->
|
||||
<div class="flex-1 overflow-auto px-4 py-3">
|
||||
<StackEnvVarsEditor
|
||||
bind:variables
|
||||
{validation}
|
||||
{readonly}
|
||||
{showSource}
|
||||
{sources}
|
||||
{placeholder}
|
||||
{existingSecretKeys}
|
||||
/>
|
||||
<!-- Content area -->
|
||||
<div bind:this={contentAreaRef} class="flex-1 overflow-auto px-4 py-3">
|
||||
{#if viewMode === 'form'}
|
||||
<StackEnvVarsEditor
|
||||
bind:variables
|
||||
{validation}
|
||||
{readonly}
|
||||
{showSource}
|
||||
{sources}
|
||||
{placeholder}
|
||||
{existingSecretKeys}
|
||||
/>
|
||||
{:else}
|
||||
<CodeEditor
|
||||
value={rawContent}
|
||||
language="dotenv"
|
||||
theme={editorTheme}
|
||||
readonly={readonly}
|
||||
onchange={handleTextChange}
|
||||
class="h-full min-h-[200px] rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.5",
|
||||
"date": "2026-01-01",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "Clicking container name opens container details" },
|
||||
{ "type": "feature", "text": "Clicking stack name opens stack editor (internal stacks)" },
|
||||
{ "type": "feature", "text": "Stack env editor now supports freestyle text entry for pasting env contents" },
|
||||
{ "type": "feature", "text": "Stack env vars saved as .env file next to compose, respecting external edits" },
|
||||
{ "type": "feature", "text": "Additional container options: ulimits, security options, DNS settings" },
|
||||
{ "type": "fix", "text": "More detailed error messages when stack fails to start" },
|
||||
{ "type": "fix", "text": "Container startup with user: directive in compose" },
|
||||
{ "type": "fix", "text": "Stack editor flickering when typing fast" },
|
||||
{ "type": "fix", "text": "Container unhealthy notifications not triggering" },
|
||||
{ "type": "fix", "text": "tlsSkipVerify not being saved in environment settings" },
|
||||
{ "type": "fix", "text": "MFA available to all users without enterprise license" },
|
||||
{ "type": "fix", "text": "Socket proxy documentation and examples" },
|
||||
{ "type": "fix", "text": "Edit container modal reloading during editing" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.5"
|
||||
},
|
||||
{
|
||||
"version": "1.0.4",
|
||||
"date": "2025-12-28",
|
||||
|
||||
+116
-13
@@ -747,12 +747,70 @@ function getAttributeValue(entry: any, attribute: string): string | undefined {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MFA (TOTP)
|
||||
// MFA (TOTP) with Backup Codes
|
||||
// ============================================
|
||||
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
// MFA data stored in mfaSecret field as JSON
|
||||
interface MfaData {
|
||||
secret: string; // TOTP secret (base32)
|
||||
backupCodes: string[]; // Hashed backup codes (unused ones)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 10 random backup codes (8 characters each, alphanumeric)
|
||||
*/
|
||||
function generateBackupCodes(): string[] {
|
||||
const codes: string[] = [];
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusable chars: 0, O, 1, I
|
||||
for (let i = 0; i < 10; i++) {
|
||||
let code = '';
|
||||
for (let j = 0; j < 8; j++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a backup code for storage
|
||||
*/
|
||||
async function hashBackupCode(code: string): Promise<string> {
|
||||
// Normalize: uppercase, remove spaces and dashes
|
||||
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
|
||||
const hasher = new Bun.CryptoHasher('sha256');
|
||||
hasher.update(normalized);
|
||||
return hasher.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse MFA data from database field
|
||||
*/
|
||||
function parseMfaData(mfaSecret: string | null | undefined): MfaData | null {
|
||||
if (!mfaSecret) return null;
|
||||
|
||||
try {
|
||||
// Try parsing as JSON first (new format)
|
||||
const parsed = JSON.parse(mfaSecret);
|
||||
if (parsed && typeof parsed.secret === 'string') {
|
||||
return {
|
||||
secret: parsed.secret,
|
||||
backupCodes: parsed.backupCodes || []
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Legacy format: plain base32 secret string
|
||||
return {
|
||||
secret: mfaSecret,
|
||||
backupCodes: []
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MFA secret and QR code for setup
|
||||
*/
|
||||
@@ -787,18 +845,24 @@ export async function generateMfaSetup(userId: number): Promise<{
|
||||
margin: 2
|
||||
});
|
||||
|
||||
// Store secret temporarily (user must verify before it's enabled)
|
||||
await updateUser(userId, { mfaSecret: secretBase32 });
|
||||
// Store secret temporarily as JSON (user must verify before it's enabled)
|
||||
// Backup codes will be generated after verification
|
||||
const mfaData: MfaData = { secret: secretBase32, backupCodes: [] };
|
||||
await updateUser(userId, { mfaSecret: JSON.stringify(mfaData) });
|
||||
|
||||
return { secret: secretBase32, qrDataUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA token and enable MFA if valid
|
||||
* Returns backup codes on success (shown only once)
|
||||
*/
|
||||
export async function verifyAndEnableMfa(userId: number, token: string): Promise<boolean> {
|
||||
export async function verifyAndEnableMfa(userId: number, token: string): Promise<{ success: false } | { success: true; backupCodes: string[] }> {
|
||||
const user = await getUser(userId);
|
||||
if (!user || !user.mfaSecret) return false;
|
||||
if (!user || !user.mfaSecret) return { success: false };
|
||||
|
||||
const mfaData = parseMfaData(user.mfaSecret);
|
||||
if (!mfaData) return { success: false };
|
||||
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'Dockhand',
|
||||
@@ -806,35 +870,74 @@ export async function verifyAndEnableMfa(userId: number, token: string): Promise
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(user.mfaSecret)
|
||||
secret: OTPAuth.Secret.fromBase32(mfaData.secret)
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token, window: 1 });
|
||||
if (delta === null) return false;
|
||||
if (delta === null) return { success: false };
|
||||
|
||||
// Enable MFA
|
||||
await updateUser(userId, { mfaEnabled: true });
|
||||
return true;
|
||||
// Generate backup codes
|
||||
const plainBackupCodes = generateBackupCodes();
|
||||
const hashedBackupCodes = await Promise.all(plainBackupCodes.map(hashBackupCode));
|
||||
|
||||
// Update MFA data with hashed backup codes and enable MFA
|
||||
const updatedMfaData: MfaData = {
|
||||
secret: mfaData.secret,
|
||||
backupCodes: hashedBackupCodes
|
||||
};
|
||||
await updateUser(userId, {
|
||||
mfaEnabled: true,
|
||||
mfaSecret: JSON.stringify(updatedMfaData)
|
||||
});
|
||||
|
||||
// Return plain backup codes (shown only once)
|
||||
return { success: true, backupCodes: plainBackupCodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA token during login
|
||||
* Verify MFA token during login (accepts TOTP code or backup code)
|
||||
*/
|
||||
export async function verifyMfaToken(userId: number, token: string): Promise<boolean> {
|
||||
const user = await getUser(userId);
|
||||
if (!user || !user.mfaEnabled || !user.mfaSecret) return false;
|
||||
|
||||
const mfaData = parseMfaData(user.mfaSecret);
|
||||
if (!mfaData) return false;
|
||||
|
||||
// First, try TOTP verification
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'Dockhand',
|
||||
label: user.username,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(user.mfaSecret)
|
||||
secret: OTPAuth.Secret.fromBase32(mfaData.secret)
|
||||
});
|
||||
|
||||
const delta = totp.validate({ token, window: 1 });
|
||||
return delta !== null;
|
||||
if (delta !== null) return true;
|
||||
|
||||
// If TOTP fails, try backup code
|
||||
if (mfaData.backupCodes && mfaData.backupCodes.length > 0) {
|
||||
const hashedInput = await hashBackupCode(token);
|
||||
const codeIndex = mfaData.backupCodes.indexOf(hashedInput);
|
||||
|
||||
if (codeIndex !== -1) {
|
||||
// Remove used backup code
|
||||
const updatedBackupCodes = [...mfaData.backupCodes];
|
||||
updatedBackupCodes.splice(codeIndex, 1);
|
||||
|
||||
const updatedMfaData: MfaData = {
|
||||
secret: mfaData.secret,
|
||||
backupCodes: updatedBackupCodes
|
||||
};
|
||||
await updateUser(userId, { mfaSecret: JSON.stringify(updatedMfaData) });
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -156,6 +156,7 @@ export async function updateEnvironment(id: number, env: Partial<Environment>):
|
||||
if (env.tlsCa !== undefined) updateData.tlsCa = env.tlsCa;
|
||||
if (env.tlsCert !== undefined) updateData.tlsCert = env.tlsCert;
|
||||
if (env.tlsKey !== undefined) updateData.tlsKey = env.tlsKey;
|
||||
if (env.tlsSkipVerify !== undefined) updateData.tlsSkipVerify = env.tlsSkipVerify;
|
||||
if (env.icon !== undefined) updateData.icon = env.icon;
|
||||
if (env.socketPath !== undefined) updateData.socketPath = env.socketPath;
|
||||
if (env.collectActivity !== undefined) updateData.collectActivity = env.collectActivity;
|
||||
@@ -808,6 +809,7 @@ export interface SmtpConfig {
|
||||
from_email: string;
|
||||
from_name?: string;
|
||||
to_emails: string[];
|
||||
skipTlsVerify?: boolean; // Skip TLS certificate verification (useful for self-signed certs)
|
||||
}
|
||||
|
||||
export interface AppriseConfig {
|
||||
|
||||
@@ -181,7 +181,7 @@ export const users = sqliteTable('users', {
|
||||
avatar: text('avatar'),
|
||||
authProvider: text('auth_provider').default('local'), // e.g., 'local', 'oidc:Keycloak', 'ldap:AD'
|
||||
mfaEnabled: integer('mfa_enabled', { mode: 'boolean' }).default(false),
|
||||
mfaSecret: text('mfa_secret'),
|
||||
mfaSecret: text('mfa_secret'), // JSON: { secret: string, backupCodes: string[] }
|
||||
isActive: integer('is_active', { mode: 'boolean' }).default(true),
|
||||
lastLogin: text('last_login'),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -184,7 +184,7 @@ export const users = pgTable('users', {
|
||||
avatar: text('avatar'),
|
||||
authProvider: text('auth_provider').default('local'), // e.g., 'local', 'oidc:Keycloak', 'ldap:AD'
|
||||
mfaEnabled: boolean('mfa_enabled').default(false),
|
||||
mfaSecret: text('mfa_secret'),
|
||||
mfaSecret: text('mfa_secret'), // JSON: { secret: string, backupCodes: string[] }
|
||||
isActive: boolean('is_active').default(true),
|
||||
lastLogin: timestamp('last_login', { mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
|
||||
+13
-21
@@ -677,10 +677,13 @@ export async function listContainers(all = true, envId?: number | null): Promise
|
||||
}));
|
||||
|
||||
// Extract health status from Status string
|
||||
// Docker formats: "(healthy)", "(unhealthy)", "(health: starting)"
|
||||
let health: string | undefined;
|
||||
const healthMatch = container.Status?.match(/\((healthy|unhealthy|starting)\)/i);
|
||||
const healthMatch = container.Status?.match(/\((healthy|unhealthy|health:\s*starting)\)/i);
|
||||
if (healthMatch) {
|
||||
health = healthMatch[1].toLowerCase();
|
||||
const matched = healthMatch[1].toLowerCase();
|
||||
// Normalize "health: starting" to just "starting"
|
||||
health = matched.includes('starting') ? 'starting' : matched;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -803,6 +806,7 @@ export interface CreateContainerOptions {
|
||||
labels?: { [key: string]: string };
|
||||
cmd?: string[];
|
||||
restartPolicy?: string;
|
||||
restartMaxRetries?: number;
|
||||
networkMode?: string;
|
||||
networks?: string[];
|
||||
user?: string;
|
||||
@@ -831,7 +835,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
Labels: options.labels || {},
|
||||
HostConfig: {
|
||||
RestartPolicy: {
|
||||
Name: options.restartPolicy || 'no'
|
||||
Name: options.restartPolicy || 'no',
|
||||
...(options.restartPolicy === 'on-failure' && options.restartMaxRetries !== undefined
|
||||
? { MaximumRetryCount: options.restartMaxRetries }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -888,9 +895,9 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
if (options.networks && options.networks.length > 0) {
|
||||
containerConfig.HostConfig.NetworkMode = options.networks[0];
|
||||
containerConfig.NetworkingConfig = {
|
||||
EndpointsConfig: {
|
||||
[options.networks[0]]: {}
|
||||
}
|
||||
EndpointsConfig: Object.fromEntries(
|
||||
options.networks.map(network => [network, {}])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -963,21 +970,6 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
envId
|
||||
);
|
||||
|
||||
// Connect to additional networks after container creation
|
||||
if (options.networks && options.networks.length > 1) {
|
||||
for (let i = 1; i < options.networks.length; i++) {
|
||||
await dockerFetch(
|
||||
`/networks/${options.networks[i]}/connect`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Container: result.Id })
|
||||
},
|
||||
envId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { id: result.Id, start: () => startContainer(result.Id, envId) };
|
||||
}
|
||||
|
||||
|
||||
+18
-4
@@ -1,5 +1,5 @@
|
||||
import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import {
|
||||
getGitRepository,
|
||||
getGitCredential,
|
||||
@@ -134,7 +134,9 @@ export interface SyncResult {
|
||||
success: boolean;
|
||||
commit?: string;
|
||||
composeContent?: string;
|
||||
composeDir?: string; // Directory containing the compose file (for copying all files)
|
||||
envFileVars?: Record<string, string>; // Variables from .env file in repo
|
||||
envFileContent?: string; // Raw .env file content (for Hawser deployments)
|
||||
error?: string;
|
||||
updated?: boolean;
|
||||
}
|
||||
@@ -609,16 +611,21 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
console.log(`${logPrefix} Compose content:`);
|
||||
console.log(composeContent);
|
||||
|
||||
// Determine the compose directory (for copying all files)
|
||||
const composeDir = dirname(composePath);
|
||||
console.log(`${logPrefix} Compose directory:`, composeDir);
|
||||
|
||||
// Read env file if configured (optional - don't fail if missing)
|
||||
let envFileVars: Record<string, string> | undefined;
|
||||
let envFileContent: string | undefined;
|
||||
if (gitStack.envFilePath) {
|
||||
const envFilePath = join(repoPath, gitStack.envFilePath);
|
||||
console.log(`${logPrefix} Looking for env file at:`, envFilePath);
|
||||
if (existsSync(envFilePath)) {
|
||||
try {
|
||||
console.log(`${logPrefix} Reading env file...`);
|
||||
const envContent = await Bun.file(envFilePath).text();
|
||||
envFileVars = parseEnvFileContent(envContent, gitStack.stackName);
|
||||
envFileContent = await Bun.file(envFilePath).text();
|
||||
envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName);
|
||||
console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);
|
||||
} catch (err) {
|
||||
// Log but don't fail - env file is optional
|
||||
@@ -653,6 +660,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
success: true,
|
||||
commit: currentCommit,
|
||||
composeContent,
|
||||
composeDir,
|
||||
envFileVars,
|
||||
updated
|
||||
};
|
||||
@@ -719,11 +727,13 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
// This ensures containers pick up new env var values even if compose file didn't change
|
||||
// Note: Without this, docker compose only detects compose file changes, not env var changes
|
||||
console.log(`${logPrefix} Calling deployStack...`);
|
||||
console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir);
|
||||
const result = await deployStack({
|
||||
name: gitStack.stackName,
|
||||
compose: syncResult.composeContent!,
|
||||
envId: gitStack.environmentId,
|
||||
envFileVars: syncResult.envFileVars,
|
||||
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
|
||||
forceRecreate
|
||||
});
|
||||
|
||||
@@ -917,6 +927,9 @@ export async function deployGitStackWithProgress(
|
||||
|
||||
const composeContent = await Bun.file(composePath).text();
|
||||
|
||||
// Determine the compose directory (for copying all files)
|
||||
const composeDir = dirname(composePath);
|
||||
|
||||
// Read env file if configured (optional - don't fail if missing)
|
||||
let envFileVars: Record<string, string> | undefined;
|
||||
if (gitStack.envFilePath) {
|
||||
@@ -951,7 +964,8 @@ export async function deployGitStackWithProgress(
|
||||
name: gitStack.stackName,
|
||||
compose: composeContent,
|
||||
envId: gitStack.environmentId,
|
||||
envFileVars
|
||||
envFileVars,
|
||||
sourceDir: composeDir // Copy entire directory from git repo
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -27,6 +27,9 @@ async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPay
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
|
||||
|
||||
+177
-13
@@ -5,11 +5,12 @@
|
||||
* All lifecycle operations use docker compose commands.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
getEnvironment,
|
||||
getStackEnvVarsAsRecord,
|
||||
setStackEnvVars,
|
||||
getStackSource,
|
||||
upsertStackSource,
|
||||
deleteStackSource,
|
||||
@@ -76,6 +77,7 @@ export interface DeployStackOptions {
|
||||
compose: string;
|
||||
envId?: number | null;
|
||||
envFileVars?: Record<string, string>;
|
||||
sourceDir?: string; // Directory to copy all files from (for git stacks)
|
||||
forceRecreate?: boolean;
|
||||
}
|
||||
|
||||
@@ -156,6 +158,35 @@ async function withStackLock<T>(stackName: string, fn: () => Promise<T>): Promis
|
||||
const COMPOSE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const COMPOSE_KILL_GRACE_MS = 5000; // 5 seconds grace period before SIGKILL
|
||||
|
||||
/**
|
||||
* Read all files from a directory as a map of relative path -> content.
|
||||
* Used to send files to Hawser for remote deployments.
|
||||
*/
|
||||
async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
async function scanDir(currentPath: string, relativePath: string = ''): Promise<void> {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(currentPath, entry.name);
|
||||
const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip .git directory
|
||||
if (entry.name === '.git') continue;
|
||||
await scanDir(fullPath, relPath);
|
||||
} else if (entry.isFile()) {
|
||||
// Read file content
|
||||
const content = await Bun.file(fullPath).text();
|
||||
files[relPath] = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scanDir(dirPath);
|
||||
return files;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEBUG UTILITIES
|
||||
// =============================================================================
|
||||
@@ -316,6 +347,7 @@ interface ComposeCommandOptions {
|
||||
envId?: number | null;
|
||||
forceRecreate?: boolean;
|
||||
removeVolumes?: boolean;
|
||||
stackFiles?: Record<string, string>; // All files to send to Hawser
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,11 +370,14 @@ async function executeLocalCompose(
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
await Bun.write(composeFile, composeContent);
|
||||
|
||||
// Note: .env file is written when env vars are saved via API
|
||||
// Docker compose automatically reads .env from the stack directory
|
||||
// We only need to pass env vars to process environment for variable substitution
|
||||
// in case the .env file doesn't exist yet (e.g., first deploy)
|
||||
const spawnEnv: Record<string, string> = { ...(process.env as Record<string, string>) };
|
||||
if (dockerHost) {
|
||||
spawnEnv.DOCKER_HOST = dockerHost;
|
||||
}
|
||||
// Add stack-specific environment variables
|
||||
if (envVars) {
|
||||
Object.assign(spawnEnv, envVars);
|
||||
}
|
||||
@@ -479,7 +514,8 @@ async function executeComposeViaHawser(
|
||||
envId: number,
|
||||
envVars?: Record<string, string>,
|
||||
forceRecreate?: boolean,
|
||||
removeVolumes?: boolean
|
||||
removeVolumes?: boolean,
|
||||
stackFiles?: Record<string, string>
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
// Import dockerFetch dynamically to avoid circular dependency
|
||||
@@ -497,13 +533,28 @@ async function executeComposeViaHawser(
|
||||
console.log(`${logPrefix} Env vars being sent (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
|
||||
}
|
||||
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
|
||||
console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0);
|
||||
if (stackFiles && Object.keys(stackFiles).length > 0) {
|
||||
console.log(`${logPrefix} Stack files:`, Object.keys(stackFiles).join(', '));
|
||||
}
|
||||
|
||||
try {
|
||||
// Build files map - include .env file if envVars provided
|
||||
const files: Record<string, string> = { ...(stackFiles || {}) };
|
||||
if (envVars && Object.keys(envVars).length > 0) {
|
||||
const envContent = Object.entries(envVars)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n');
|
||||
files['.env'] = envContent;
|
||||
console.log(`${logPrefix} Added .env file to files map with ${Object.keys(envVars).length} variables`);
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
operation,
|
||||
projectName: stackName,
|
||||
composeFile: composeContent,
|
||||
envVars: envVars || {},
|
||||
files, // All files including .env
|
||||
forceRecreate: forceRecreate || false,
|
||||
removeVolumes: removeVolumes || false
|
||||
});
|
||||
@@ -567,7 +618,7 @@ async function executeComposeCommand(
|
||||
composeContent: string,
|
||||
envVars?: Record<string, string>
|
||||
): Promise<StackOperationResult> {
|
||||
const { stackName, envId, forceRecreate, removeVolumes } = options;
|
||||
const { stackName, envId, forceRecreate, removeVolumes, stackFiles } = options;
|
||||
|
||||
// Get environment configuration
|
||||
const env = envId ? await getEnvironment(envId) : null;
|
||||
@@ -595,7 +646,8 @@ async function executeComposeCommand(
|
||||
envId!,
|
||||
envVars,
|
||||
forceRecreate,
|
||||
removeVolumes
|
||||
removeVolumes,
|
||||
stackFiles
|
||||
);
|
||||
|
||||
case 'direct': {
|
||||
@@ -808,7 +860,38 @@ async function requireComposeFile(
|
||||
}
|
||||
|
||||
// Get environment variables from database
|
||||
const envVars = await getStackEnvVarsAsRecord(stackName, envId);
|
||||
const dbEnvVars = await getStackEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Also read from .env file and merge (file + DB are equal sources)
|
||||
// DB values take precedence for secrets, file values for new/changed vars
|
||||
const stackDir = join(getStacksDir(), stackName);
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
let fileEnvVars: Record<string, string> = {};
|
||||
|
||||
if (existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1);
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fileEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore file read errors
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: file values as base, DB values override (DB is authoritative for managed vars)
|
||||
const envVars = { ...fileEnvVars, ...dbEnvVars };
|
||||
|
||||
return { content: composeResult.content!, envVars };
|
||||
}
|
||||
@@ -1018,7 +1101,7 @@ export async function removeStack(
|
||||
* Uses stack locking to prevent concurrent deployments.
|
||||
*/
|
||||
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
|
||||
const { name, compose, envId, envFileVars, forceRecreate } = options;
|
||||
const { name, compose, envId, envFileVars, sourceDir, forceRecreate } = options;
|
||||
const logPrefix = `[Stack:${name}]`;
|
||||
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
@@ -1026,6 +1109,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
console.log(`${logPrefix} Environment ID:`, envId ?? '(none - local)');
|
||||
console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false);
|
||||
console.log(`${logPrefix} Source directory:`, sourceDir ?? '(none)');
|
||||
console.log(`${logPrefix} Env file vars provided:`, envFileVars ? Object.keys(envFileVars).length : 0);
|
||||
if (envFileVars && Object.keys(envFileVars).length > 0) {
|
||||
console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', '));
|
||||
@@ -1043,14 +1127,34 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
}
|
||||
|
||||
return withStackLock(name, async () => {
|
||||
// Ensure stack directory exists and write compose file (for local reference)
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, name);
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
await Bun.write(composeFile, compose);
|
||||
console.log(`${logPrefix} Compose file written to:`, composeFile);
|
||||
// Read all files from source directory if provided (for Hawser deployments)
|
||||
let stackFiles: Record<string, string> | undefined;
|
||||
if (sourceDir && existsSync(sourceDir)) {
|
||||
stackFiles = await readDirFilesAsMap(sourceDir);
|
||||
console.log(`${logPrefix} Read ${Object.keys(stackFiles).length} files from source directory`);
|
||||
console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', '));
|
||||
}
|
||||
|
||||
// Handle stack directory setup
|
||||
if (sourceDir && existsSync(sourceDir)) {
|
||||
// Copy entire source directory to stack directory (for git stacks)
|
||||
console.log(`${logPrefix} Copying source directory to stack directory...`);
|
||||
if (existsSync(stackDir)) {
|
||||
rmSync(stackDir, { recursive: true, force: true });
|
||||
}
|
||||
cpSync(sourceDir, stackDir, { recursive: true });
|
||||
console.log(`${logPrefix} Copied ${sourceDir} -> ${stackDir}`);
|
||||
} else {
|
||||
// Traditional behavior: create directory and write compose file only
|
||||
mkdirSync(stackDir, { recursive: true });
|
||||
const composeFile = join(stackDir, 'docker-compose.yml');
|
||||
await Bun.write(composeFile, compose);
|
||||
console.log(`${logPrefix} Compose file written to:`, composeFile);
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Compose content length:`, compose.length, 'chars');
|
||||
console.log(`${logPrefix} Compose content (full):`);
|
||||
console.log(compose);
|
||||
@@ -1072,7 +1176,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Calling executeComposeCommand...`);
|
||||
const result = await executeComposeCommand('up', { stackName: name, envId, forceRecreate }, compose, envVars);
|
||||
const result = await executeComposeCommand('up', { stackName: name, envId, forceRecreate, stackFiles }, compose, envVars);
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
console.log(`${logPrefix} DEPLOY STACK RESULT`);
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
@@ -1099,6 +1203,66 @@ export async function pullStackImages(
|
||||
return executeComposeCommand('pull', { stackName, envId }, content, envVars);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENVIRONMENT VARIABLE HELPERS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Save environment variables for a stack to the database (for secret tracking)
|
||||
*/
|
||||
export async function saveStackEnvVarsToDb(
|
||||
stackName: string,
|
||||
variables: { key: string; value: string; isSecret?: boolean }[],
|
||||
envId?: number | null
|
||||
): Promise<void> {
|
||||
await setStackEnvVars(stackName, envId ?? null, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write environment variables to the .env file on disk (simple key=value format)
|
||||
*
|
||||
* WARNING: This generates a simple key=value file WITHOUT comments or formatting.
|
||||
* ONLY use during initial stack CREATION when no .env file exists.
|
||||
*
|
||||
* For EDITS, use PUT /api/stacks/[name]/env/raw which preserves the raw content
|
||||
* including all comments, formatting, and structure.
|
||||
*/
|
||||
export async function writeStackEnvFile(
|
||||
stackName: string,
|
||||
variables: { key: string; value: string; isSecret?: boolean }[]
|
||||
): Promise<void> {
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
|
||||
const rawContent = variables
|
||||
.filter(v => v.key?.trim())
|
||||
.map(v => `${v.key.trim()}=${v.value}`)
|
||||
.join('\n') + '\n';
|
||||
|
||||
await Bun.write(envFilePath, rawContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save environment variables for a stack (both to database and .env file)
|
||||
*
|
||||
* WARNING: Only use during initial stack CREATION - this generates a simple
|
||||
* key=value file that does NOT preserve comments or formatting.
|
||||
*
|
||||
* For EDITS, the StackModal saves to:
|
||||
* - PUT /api/stacks/[name]/env/raw (preserves raw content with comments)
|
||||
* - PUT /api/stacks/[name]/env (updates secret flags in DB only)
|
||||
*/
|
||||
export async function saveStackEnvVars(
|
||||
stackName: string,
|
||||
variables: { key: string; value: string; isSecret?: boolean }[],
|
||||
envId?: number | null
|
||||
): Promise<void> {
|
||||
// Save to database for secret tracking
|
||||
await saveStackEnvVarsToDb(stackName, variables, envId);
|
||||
// Write .env file to disk for Docker Compose
|
||||
await writeStackEnvFile(stackName, variables);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RE-EXPORTS FOR BACKWARDS COMPATIBILITY
|
||||
// =============================================================================
|
||||
|
||||
@@ -134,10 +134,16 @@ function processEvent(event: DockerEvent, envId: number) {
|
||||
if (event.Type !== 'container') return;
|
||||
|
||||
// Map Docker action to our action type
|
||||
const action = event.Action.split(':')[0] as ContainerEventAction;
|
||||
// For health_status events, Docker sends "health_status: unhealthy" or "health_status: healthy"
|
||||
// We need to preserve the full string for notifications to distinguish healthy vs unhealthy
|
||||
const rawAction = event.Action;
|
||||
const baseAction = rawAction.split(':')[0] as ContainerEventAction;
|
||||
|
||||
// Skip actions we don't care about
|
||||
if (!CONTAINER_ACTIONS.includes(action)) return;
|
||||
if (!CONTAINER_ACTIONS.includes(baseAction)) return;
|
||||
|
||||
// For notifications, preserve full action for health_status to enable proper mapping
|
||||
const action = rawAction.startsWith('health_status') ? rawAction : baseAction;
|
||||
|
||||
const containerId = event.Actor?.ID;
|
||||
const containerName = event.Actor?.Attributes?.name;
|
||||
@@ -169,14 +175,17 @@ function processEvent(event: DockerEvent, envId: number) {
|
||||
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
|
||||
|
||||
// Prepare notification data
|
||||
const actionLabel = action.charAt(0).toUpperCase() + action.slice(1);
|
||||
// For health_status events, create a cleaner label
|
||||
const actionLabel = action.startsWith('health_status')
|
||||
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
|
||||
: action.charAt(0).toUpperCase() + action.slice(1);
|
||||
const containerLabel = containerName || containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
action === 'die' || action === 'kill' || action === 'oom'
|
||||
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
|
||||
? 'error'
|
||||
: action === 'stop'
|
||||
? 'warning'
|
||||
: action === 'start'
|
||||
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
tlsCa: data.tlsCa,
|
||||
tlsCert: data.tlsCert,
|
||||
tlsKey: data.tlsKey,
|
||||
tlsSkipVerify: data.tlsSkipVerify || false,
|
||||
icon: data.icon || 'globe',
|
||||
socketPath: data.socketPath || '/var/run/docker.sock',
|
||||
collectActivity: data.collectActivity !== false,
|
||||
|
||||
@@ -65,6 +65,7 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
||||
tlsCa: data.tlsCa,
|
||||
tlsCert: data.tlsCert,
|
||||
tlsKey: data.tlsKey,
|
||||
tlsSkipVerify: data.tlsSkipVerify,
|
||||
icon: data.icon,
|
||||
socketPath: data.socketPath,
|
||||
collectActivity: data.collectActivity,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listComposeStacks, deployStack, saveStackComposeFile } from '$lib/server/stacks';
|
||||
import { listComposeStacks, deployStack, saveStackComposeFile, saveStackEnvVars } from '$lib/server/stacks';
|
||||
import { EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { upsertStackSource, getStackSources } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
@@ -78,7 +78,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, compose, start } = body;
|
||||
const { name, compose, start, envVars } = body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||
@@ -95,6 +95,11 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
return json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
// Save environment variables if provided (to both DB and .env file)
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
await saveStackEnvVars(name, envVars, envIdNum);
|
||||
}
|
||||
|
||||
// Record the stack as internally created
|
||||
await upsertStackSource({
|
||||
stackName: name,
|
||||
@@ -105,6 +110,14 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
return json({ success: true, started: false });
|
||||
}
|
||||
|
||||
// Save environment variables BEFORE deploying so they're available during start
|
||||
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
|
||||
// First ensure the stack directory exists by saving compose file
|
||||
await saveStackComposeFile(name, compose, true);
|
||||
// Save to both DB and .env file
|
||||
await saveStackEnvVars(name, envVars, envIdNum);
|
||||
}
|
||||
|
||||
// Deploy and start the stack
|
||||
const result = await deployStack({
|
||||
name,
|
||||
|
||||
+156
-8
@@ -1,11 +1,96 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
|
||||
import { getStacksDir } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Parse a .env file content into key-value pairs
|
||||
*/
|
||||
function parseEnvFile(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1);
|
||||
// Remove surrounding quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new variables into existing .env file content.
|
||||
* - Keeps comments and formatting
|
||||
* - Updates values for existing keys
|
||||
* - REMOVES keys that are not in newVars (user deleted them)
|
||||
* - Appends new keys at the end
|
||||
*/
|
||||
function mergeEnvFileContent(
|
||||
existingContent: string,
|
||||
newVars: { key: string; value: string }[]
|
||||
): string {
|
||||
const newVarsMap = new Map(newVars.map(v => [v.key, v.value]));
|
||||
const handledKeys = new Set<string>();
|
||||
const lines = existingContent.split('\n');
|
||||
const resultLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Keep comments and blank lines as-is
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
resultLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a variable line
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
|
||||
if (newVarsMap.has(key)) {
|
||||
// Update existing variable with new value from UI
|
||||
resultLines.push(`${key}=${newVarsMap.get(key)}`);
|
||||
handledKeys.add(key);
|
||||
}
|
||||
// If key not in newVarsMap, it was deleted - skip it (don't add to resultLines)
|
||||
} else {
|
||||
// Not a valid variable line, keep as-is
|
||||
resultLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any new variables that weren't in the original file
|
||||
for (const v of newVars) {
|
||||
if (!handledKeys.has(v.key)) {
|
||||
resultLines.push(`${v.key}=${v.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure file ends with newline
|
||||
let result = resultLines.join('\n');
|
||||
if (!result.endsWith('\n')) {
|
||||
result += '\n';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/stacks/[name]/env?env=X
|
||||
* Get all environment variables for a stack.
|
||||
* Merges variables from database with .env file (file values shown if different).
|
||||
* Secrets are masked with '***' in the response.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
@@ -25,15 +110,52 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const variables = await getStackEnvVars(stackName, envIdNum, true);
|
||||
|
||||
return json({
|
||||
variables: variables.map(v => ({
|
||||
key: v.key,
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
});
|
||||
// Get variables from database
|
||||
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
|
||||
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
|
||||
|
||||
// Try to read .env file from stack directory
|
||||
const stacksDir = getStacksDir();
|
||||
const envFilePath = join(stacksDir, stackName, '.env');
|
||||
let fileVars: Record<string, string> = {};
|
||||
|
||||
if (existsSync(envFilePath)) {
|
||||
try {
|
||||
const content = await Bun.file(envFilePath).text();
|
||||
fileVars = parseEnvFile(content);
|
||||
} catch (e) {
|
||||
// Ignore file read errors
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: start with DB variables, add any new keys from file
|
||||
const mergedKeys = new Set([...dbByKey.keys(), ...Object.keys(fileVars)]);
|
||||
const variables: { key: string; value: string; isSecret: boolean }[] = [];
|
||||
|
||||
for (const key of mergedKeys) {
|
||||
const dbVar = dbByKey.get(key);
|
||||
const fileValue = fileVars[key];
|
||||
|
||||
if (dbVar) {
|
||||
// Variable exists in DB
|
||||
if (dbVar.isSecret) {
|
||||
// Keep secret masked
|
||||
variables.push({ key, value: dbVar.value, isSecret: true });
|
||||
} else if (fileValue !== undefined && fileValue !== dbVar.value) {
|
||||
// File has different value - use file value (user may have edited it)
|
||||
variables.push({ key, value: fileValue, isSecret: false });
|
||||
} else {
|
||||
// Use DB value
|
||||
variables.push({ key, value: dbVar.value, isSecret: false });
|
||||
}
|
||||
} else if (fileValue !== undefined) {
|
||||
// Variable only in file - add it as non-secret
|
||||
variables.push({ key, value: fileValue, isSecret: false });
|
||||
}
|
||||
}
|
||||
|
||||
return json({ variables });
|
||||
} catch (error) {
|
||||
console.error('Error getting stack env vars:', error);
|
||||
return json({ error: 'Failed to get environment variables' }, { status: 500 });
|
||||
@@ -114,6 +236,32 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
|
||||
|
||||
await setStackEnvVars(stackName, envIdNum, variablesToSave);
|
||||
|
||||
// Also write the .env file to the stack directory
|
||||
// This allows users to see/edit variables outside of Dockhand
|
||||
const stacksDir = getStacksDir();
|
||||
const stackDir = join(stacksDir, stackName);
|
||||
const envFilePath = join(stackDir, '.env');
|
||||
|
||||
// Only write if stack directory exists
|
||||
if (existsSync(stackDir)) {
|
||||
// Read existing file to preserve comments and formatting
|
||||
let existingContent = '';
|
||||
if (existsSync(envFilePath)) {
|
||||
try {
|
||||
existingContent = await Bun.file(envFilePath).text();
|
||||
} catch {
|
||||
// File read failed, start fresh
|
||||
}
|
||||
}
|
||||
|
||||
// Merge UI vars with existing file (preserves comments, keeps file vars)
|
||||
const envContent = mergeEnvFileContent(
|
||||
existingContent,
|
||||
variablesToSave.map((v: { key: string; value: string }) => ({ key: v.key, value: v.value }))
|
||||
);
|
||||
await Bun.write(envFilePath, envContent);
|
||||
}
|
||||
|
||||
return json({ success: true, count: variablesToSave.length });
|
||||
} catch (error) {
|
||||
console.error('Error setting stack env vars:', error);
|
||||
|
||||
@@ -128,9 +128,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
}, { status: 201 });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create user:', error);
|
||||
if (error.message?.includes('UNIQUE constraint failed')) {
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
name: error.name,
|
||||
stack: error.stack
|
||||
});
|
||||
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505') {
|
||||
return json({ error: 'Username already exists' }, { status: 409 });
|
||||
}
|
||||
return json({ error: 'Failed to create user' }, { status: 500 });
|
||||
return json({ error: 'Failed to create user', details: error.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { isEnterprise } from '$lib/server/license';
|
||||
import {
|
||||
validateSession,
|
||||
checkPermission,
|
||||
generateMfaSetup,
|
||||
verifyAndEnableMfa,
|
||||
disableMfa
|
||||
@@ -11,11 +9,6 @@ import {
|
||||
|
||||
// POST /api/users/[id]/mfa - Setup MFA (generate QR code)
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
// Check enterprise license
|
||||
if (!(await isEnterprise())) {
|
||||
return json({ error: 'Enterprise license required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const currentUser = await validateSession(cookies);
|
||||
|
||||
if (!params.id) {
|
||||
@@ -38,12 +31,16 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
return json({ error: 'MFA token is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const success = await verifyAndEnableMfa(userId, body.token);
|
||||
if (!success) {
|
||||
const result = await verifyAndEnableMfa(userId, body.token);
|
||||
if (!result.success) {
|
||||
return json({ error: 'Invalid MFA code' }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ success: true, message: 'MFA enabled successfully' });
|
||||
return json({
|
||||
success: true,
|
||||
message: 'MFA enabled successfully',
|
||||
backupCodes: result.backupCodes
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new MFA setup
|
||||
@@ -64,11 +61,6 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
|
||||
// DELETE /api/users/[id]/mfa - Disable MFA
|
||||
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
// Check enterprise license
|
||||
if (!(await isEnterprise())) {
|
||||
return json({ error: 'Enterprise license required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const currentUser = await validateSession(cookies);
|
||||
|
||||
if (!params.id) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
@@ -1626,7 +1627,12 @@
|
||||
{@const ports = formatPorts(container.ports)}
|
||||
{@const stack = getComposeProject(container.labels)}
|
||||
{#if column.id === 'name'}
|
||||
<span class="text-xs font-medium truncate block" title={container.name}>{container.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium truncate block text-left hover:text-primary hover:underline cursor-pointer"
|
||||
title={container.name}
|
||||
onclick={(e) => { e.stopPropagation(); inspectContainer(container); }}
|
||||
>{container.name}</button>
|
||||
{:else if column.id === 'image'}
|
||||
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) ? 'update-border' : ''}">
|
||||
{#if containersWithUpdatesSet.has(container.id)}
|
||||
@@ -1765,7 +1771,13 @@
|
||||
{/if}
|
||||
{:else if column.id === 'stack'}
|
||||
{#if stack}
|
||||
<Badge variant="outline" class="text-xs py-0 px-1.5">{stack}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors">{stack}</Badge>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
|
||||
{/if}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -270,17 +270,14 @@
|
||||
<Input
|
||||
id="mfaToken"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code"
|
||||
placeholder="Enter code"
|
||||
bind:value={mfaToken}
|
||||
required
|
||||
disabled={loading}
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength={6}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Enter the code from your authenticator app
|
||||
Enter the 6-digit code from your authenticator app, or use a backup code
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -294,7 +294,8 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
||||
}
|
||||
|
||||
if (urlContainerId) {
|
||||
// Single container from URL
|
||||
// Single container from URL - always switch to single mode
|
||||
layoutMode = 'single';
|
||||
const container = fetchedContainers.find(c => c.id === urlContainerId || c.id.startsWith(urlContainerId));
|
||||
if (container) {
|
||||
selectContainer(container);
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
} from 'lucide-svelte';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import ChangePasswordModal from './ChangePasswordModal.svelte';
|
||||
@@ -542,26 +541,19 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if $licenseStore.isEnterprise}
|
||||
{#if profile.mfaEnabled}
|
||||
<Button variant="outline" onclick={() => showDisableMfaModal = true}>
|
||||
Disable MFA
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={setupMfa} disabled={mfaLoading}>
|
||||
{#if mfaLoading}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<QrCode class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Setup MFA
|
||||
</Button>
|
||||
{/if}
|
||||
{#if profile.mfaEnabled}
|
||||
<Button variant="outline" onclick={() => showDisableMfaModal = true}>
|
||||
Disable MFA
|
||||
</Button>
|
||||
{:else}
|
||||
<Badge variant="outline" class="gap-1 rounded-sm">
|
||||
<Crown class="w-3 h-3" />
|
||||
Enterprise
|
||||
</Badge>
|
||||
<Button onclick={setupMfa} disabled={mfaLoading}>
|
||||
{#if mfaLoading}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<QrCode class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Setup MFA
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { QrCode, RefreshCw, ShieldCheck, TriangleAlert } from 'lucide-svelte';
|
||||
import { QrCode, RefreshCw, ShieldCheck, TriangleAlert, Copy, Download, Check } from 'lucide-svelte';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
|
||||
@@ -21,10 +21,16 @@
|
||||
let token = $state('');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let backupCodes = $state<string[]>([]);
|
||||
let showBackupCodes = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
function resetForm() {
|
||||
token = '';
|
||||
error = '';
|
||||
backupCodes = [];
|
||||
showBackupCodes = false;
|
||||
copied = false;
|
||||
}
|
||||
|
||||
async function verifyAndEnableMfa() {
|
||||
@@ -43,11 +49,12 @@
|
||||
body: JSON.stringify({ action: 'verify', token })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
onSuccess();
|
||||
onClose();
|
||||
backupCodes = data.backupCodes || [];
|
||||
showBackupCodes = true;
|
||||
} else {
|
||||
const data = await response.json();
|
||||
error = data.error || 'Invalid verification code';
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -57,61 +64,143 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatBackupCodes(): string {
|
||||
return backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n');
|
||||
}
|
||||
|
||||
async function copyBackupCodes() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatBackupCodes());
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 2000);
|
||||
} catch {
|
||||
error = 'Failed to copy to clipboard';
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const content = `Dockhand MFA Backup Codes\n${'='.repeat(30)}\n\nThese codes can be used to sign in if you lose access to your authenticator app.\nEach code can only be used once.\n\n${formatBackupCodes()}\n\nGenerated: ${new Date().toISOString()}`;
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'dockhand-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { resetForm(); focusFirstInput(); } else onClose(); }}>
|
||||
<Dialog.Root bind:open onOpenChange={(o) => { if (o) { resetForm(); focusFirstInput(); } else if (!showBackupCodes) onClose(); }}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<QrCode class="w-5 h-5" />
|
||||
Setup two-factor authentication
|
||||
{#if showBackupCodes}
|
||||
<ShieldCheck class="w-5 h-5 text-green-500" />
|
||||
MFA enabled successfully
|
||||
{:else}
|
||||
<QrCode class="w-5 h-5" />
|
||||
Setup two-factor authentication
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive">
|
||||
|
||||
{#if showBackupCodes}
|
||||
<!-- Backup codes view -->
|
||||
<div class="space-y-4">
|
||||
<Alert.Root>
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
<Alert.Description>
|
||||
Save these backup codes in a safe place. Each code can only be used once to sign in if you lose access to your authenticator app.
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
|
||||
{#if qrCode}
|
||||
<div class="flex justify-center p-4 bg-white rounded-lg">
|
||||
<img src={qrCode} alt="MFA QR Code" class="w-48 h-48" />
|
||||
<div class="grid grid-cols-2 gap-2 p-3 bg-muted rounded-lg font-mono text-sm">
|
||||
{#each backupCodes as code, i}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground w-4">{i + 1}.</span>
|
||||
<span>{code}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">Or enter this code manually:</Label>
|
||||
<code class="block p-2 bg-muted rounded text-sm font-mono break-all">{secret}</code>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" class="flex-1" onclick={copyBackupCodes}>
|
||||
{#if copied}
|
||||
<Check class="w-4 h-4 mr-1" />
|
||||
Copied!
|
||||
{:else}
|
||||
<Copy class="w-4 h-4 mr-1" />
|
||||
Copy codes
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" class="flex-1" onclick={downloadBackupCodes}>
|
||||
<Download class="w-4 h-4 mr-1" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Verification code</Label>
|
||||
<Input
|
||||
bind:value={token}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxlength={6}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Enter the code from your authenticator app to verify setup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={onClose}>Cancel</Button>
|
||||
<Button onclick={verifyAndEnableMfa} disabled={loading || !token}>
|
||||
{#if loading}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<Dialog.Footer>
|
||||
<Button onclick={handleDone}>
|
||||
<ShieldCheck class="w-4 h-4 mr-1" />
|
||||
Done
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{:else}
|
||||
<!-- Setup view -->
|
||||
<div class="space-y-4">
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
Enable MFA
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||
</p>
|
||||
|
||||
{#if qrCode}
|
||||
<div class="flex justify-center p-4 bg-white rounded-lg">
|
||||
<img src={qrCode} alt="MFA QR Code" class="w-48 h-48" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">Or enter this code manually:</Label>
|
||||
<code class="block p-2 bg-muted rounded text-sm font-mono break-all">{secret}</code>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Verification code</Label>
|
||||
<Input
|
||||
bind:value={token}
|
||||
placeholder="Enter 6-digit code"
|
||||
maxlength={6}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Enter the code from your authenticator app to verify setup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={onClose}>Cancel</Button>
|
||||
<Button onclick={verifyAndEnableMfa} disabled={loading || !token}>
|
||||
{#if loading}
|
||||
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
|
||||
{:else}
|
||||
<ShieldCheck class="w-4 h-4 mr-1" />
|
||||
{/if}
|
||||
Enable MFA
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
toast.success('User created');
|
||||
} else {
|
||||
const data = await response.json();
|
||||
formError = data.error || 'Failed to create user';
|
||||
formError = data.details ? `${data.error}: ${data.details}` : (data.error || 'Failed to create user');
|
||||
toast.error(formError);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
let formSmtpHost = $state('');
|
||||
let formSmtpPort = $state(587);
|
||||
let formSmtpSecure = $state(false);
|
||||
let formSmtpSkipTlsVerify = $state(false);
|
||||
let formSmtpUsername = $state('');
|
||||
let formSmtpPassword = $state('');
|
||||
let formSmtpFromEmail = $state('');
|
||||
@@ -68,6 +69,7 @@
|
||||
formSmtpHost = '';
|
||||
formSmtpPort = 587;
|
||||
formSmtpSecure = false;
|
||||
formSmtpSkipTlsVerify = false;
|
||||
formSmtpUsername = '';
|
||||
formSmtpPassword = '';
|
||||
formSmtpFromEmail = '';
|
||||
@@ -98,6 +100,7 @@
|
||||
formSmtpHost = notification.config.host || '';
|
||||
formSmtpPort = notification.config.port || 587;
|
||||
formSmtpSecure = notification.config.secure || false;
|
||||
formSmtpSkipTlsVerify = notification.config.skipTlsVerify || false;
|
||||
formSmtpUsername = notification.config.username || '';
|
||||
formSmtpPassword = '';
|
||||
formSmtpFromEmail = notification.config.from_email || '';
|
||||
@@ -133,6 +136,7 @@
|
||||
host: formSmtpHost.trim(),
|
||||
port: formSmtpPort,
|
||||
secure: formSmtpSecure,
|
||||
skipTlsVerify: formSmtpSkipTlsVerify || undefined,
|
||||
username: formSmtpUsername.trim() || undefined,
|
||||
password: formSmtpPassword || undefined,
|
||||
from_email: formSmtpFromEmail.trim(),
|
||||
@@ -350,9 +354,15 @@
|
||||
<Input id="notif-smtp-port" type="number" bind:value={formSmtpPort} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label>TLS/SSL</Label>
|
||||
<TogglePill bind:checked={formSmtpSecure} onLabel="Yes" offLabel="No" />
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label>TLS/SSL</Label>
|
||||
<TogglePill bind:checked={formSmtpSecure} onLabel="Yes" offLabel="No" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label class="text-muted-foreground">Skip TLS verify</Label>
|
||||
<TogglePill bind:checked={formSmtpSkipTlsVerify} onLabel="Yes" offLabel="No" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -671,8 +672,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: name, message: data.error || 'Failed to start stack' };
|
||||
toast.error(`Failed to start ${name}`);
|
||||
const errorMsg = data.error || 'Failed to start stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
return;
|
||||
}
|
||||
@@ -680,8 +682,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to start stack:', error);
|
||||
operationError = { id: name, message: 'Failed to start stack' };
|
||||
toast.error(`Failed to start ${name}`);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to start stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
@@ -695,8 +698,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: name, message: data.error || 'Failed to stop stack' };
|
||||
toast.error(`Failed to stop ${name}`);
|
||||
const errorMsg = data.error || 'Failed to stop stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
return;
|
||||
}
|
||||
@@ -704,8 +708,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to stop stack:', error);
|
||||
operationError = { id: name, message: 'Failed to stop stack' };
|
||||
toast.error(`Failed to stop ${name}`);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
@@ -719,8 +724,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: name, message: data.error || 'Failed to restart stack' };
|
||||
toast.error(`Failed to restart ${name}`);
|
||||
const errorMsg = data.error || 'Failed to restart stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
return;
|
||||
}
|
||||
@@ -728,8 +734,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to restart stack:', error);
|
||||
operationError = { id: name, message: 'Failed to restart stack' };
|
||||
toast.error(`Failed to restart ${name}`);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
@@ -743,8 +750,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: name, message: data.error || 'Failed to bring down stack' };
|
||||
toast.error(`Failed to bring down ${name}`);
|
||||
const errorMsg = data.error || 'Failed to bring down stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
return;
|
||||
}
|
||||
@@ -752,8 +760,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to bring down stack:', error);
|
||||
operationError = { id: name, message: 'Failed to bring down stack' };
|
||||
toast.error(`Failed to bring down ${name}`);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
@@ -779,8 +788,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}?force=true`, envId), { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: name, message: data.error || 'Failed to remove stack' };
|
||||
toast.error(`Failed to remove ${name}`);
|
||||
const errorMsg = data.error || 'Failed to remove stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
return;
|
||||
}
|
||||
@@ -788,8 +798,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove stack:', error);
|
||||
operationError = { id: name, message: 'Failed to remove stack' };
|
||||
toast.error(`Failed to remove ${name}`);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to remove stack';
|
||||
operationError = { id: name, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(name);
|
||||
}
|
||||
}
|
||||
@@ -835,8 +846,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/start`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: containerId, message: data.error || 'Failed to start container' };
|
||||
toast.error('Failed to start container');
|
||||
const errorMsg = data.error || 'Failed to start container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
return;
|
||||
}
|
||||
@@ -844,8 +856,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to start container:', error);
|
||||
operationError = { id: containerId, message: 'Failed to start container' };
|
||||
toast.error('Failed to start container');
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to start container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
} finally {
|
||||
containerActionLoading = null;
|
||||
@@ -859,8 +872,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/stop`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: containerId, message: data.error || 'Failed to stop container' };
|
||||
toast.error('Failed to stop container');
|
||||
const errorMsg = data.error || 'Failed to stop container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
return;
|
||||
}
|
||||
@@ -868,8 +882,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to stop container:', error);
|
||||
operationError = { id: containerId, message: 'Failed to stop container' };
|
||||
toast.error('Failed to stop container');
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to stop container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
} finally {
|
||||
containerActionLoading = null;
|
||||
@@ -883,8 +898,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/restart`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: containerId, message: data.error || 'Failed to restart container' };
|
||||
toast.error('Failed to restart container');
|
||||
const errorMsg = data.error || 'Failed to restart container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
return;
|
||||
}
|
||||
@@ -892,8 +908,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to restart container:', error);
|
||||
operationError = { id: containerId, message: 'Failed to restart container' };
|
||||
toast.error('Failed to restart container');
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to restart container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
} finally {
|
||||
containerActionLoading = null;
|
||||
@@ -907,8 +924,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/pause`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: containerId, message: data.error || 'Failed to pause container' };
|
||||
toast.error('Failed to pause container');
|
||||
const errorMsg = data.error || 'Failed to pause container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
return;
|
||||
}
|
||||
@@ -916,8 +934,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to pause container:', error);
|
||||
operationError = { id: containerId, message: 'Failed to pause container' };
|
||||
toast.error('Failed to pause container');
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to pause container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
} finally {
|
||||
containerActionLoading = null;
|
||||
@@ -932,8 +951,9 @@
|
||||
const response = await fetch(appendEnvParam(`/api/containers/${containerId}/unpause`, envId), { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
operationError = { id: containerId, message: data.error || 'Failed to unpause container' };
|
||||
toast.error('Failed to unpause container');
|
||||
const errorMsg = data.error || 'Failed to unpause container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
return;
|
||||
}
|
||||
@@ -941,8 +961,9 @@
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to unpause container:', error);
|
||||
operationError = { id: containerId, message: 'Failed to unpause container' };
|
||||
toast.error('Failed to unpause container');
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to unpause container';
|
||||
operationError = { id: containerId, message: errorMsg };
|
||||
toast.error(errorMsg);
|
||||
clearErrorAfterDelay(containerId);
|
||||
} finally {
|
||||
containerActionLoading = null;
|
||||
@@ -1013,6 +1034,13 @@
|
||||
loadExpandedState();
|
||||
loadStatusFilter();
|
||||
|
||||
// Check for search query param from URL (e.g., from container stack link)
|
||||
const urlSearch = $page.url.searchParams.get('search');
|
||||
if (urlSearch) {
|
||||
searchInput = urlSearch;
|
||||
searchQuery = urlSearch;
|
||||
}
|
||||
|
||||
// Initial fetch is handled by $effect - no need to duplicate here
|
||||
|
||||
// Listen for tab visibility changes to refresh when user returns
|
||||
@@ -1258,7 +1286,17 @@
|
||||
{#snippet cell(column, stack, rowState)}
|
||||
{@const source = getStackSource(stack.name)}
|
||||
{#if column.id === 'name'}
|
||||
<span class="font-medium text-xs">{stack.name}</span>
|
||||
{#if source.sourceType === 'internal'}
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left"
|
||||
onclick={() => editStack(stack.name)}
|
||||
>
|
||||
{stack.name}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="font-medium text-xs">{stack.name}</span>
|
||||
{/if}
|
||||
{#if stackEnvVarCounts[stack.name]}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
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, FolderGit2, Github, Key, KeyRound, Lock, FileText } from 'lucide-svelte';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
@@ -582,9 +583,23 @@
|
||||
<p class="text-xs text-muted-foreground">Path to the compose file within the repository</p>
|
||||
</div>
|
||||
|
||||
<!-- .env file path -->
|
||||
<!-- Additional env file for variable substitution -->
|
||||
<div class="space-y-2">
|
||||
<Label for="env-file-path">.env file path</Label>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Label for="env-file-path">Additional .env file (optional)</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div class="w-80">
|
||||
<p class="text-xs">All files in the git repository are cloned automatically, including any files referenced by <code class="bg-muted px-1 rounded">env_file:</code> directives.</p>
|
||||
<p class="text-xs mt-2">Only use this field for additional environment variables to be passed to Docker Compose.</p>
|
||||
<p class="text-xs mt-2">The contents will be parsed and passed as shell environment variables.</p>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{#if gitStack && envFiles.length > 0}
|
||||
<!-- Dropdown selector for existing stacks with discovered .env files -->
|
||||
<Select.Root
|
||||
@@ -633,7 +648,9 @@
|
||||
placeholder=".env"
|
||||
/>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">Path to the .env file within the repository (optional)</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
For variable substitution from a file outside the compose directory.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-update section -->
|
||||
@@ -740,15 +757,17 @@
|
||||
|
||||
<!-- Deploy now option (only for new stacks) -->
|
||||
{#if !gitStack}
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<Rocket class="w-4 h-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Label class="text-sm font-normal">Deploy now</Label>
|
||||
<p class="text-xs text-muted-foreground">Clone and deploy the stack immediately</p>
|
||||
<div class="space-y-3 p-3 bg-muted/50 rounded-md">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 flex-1">
|
||||
<Rocket class="w-4 h-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Label class="text-sm font-normal">Deploy now</Label>
|
||||
<p class="text-xs text-muted-foreground">Clone and deploy the stack immediately</p>
|
||||
</div>
|
||||
</div>
|
||||
<TogglePill bind:checked={formDeployNow} />
|
||||
</div>
|
||||
<TogglePill bind:checked={formDeployNow} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
@@ -7,12 +7,16 @@
|
||||
import CodeEditor, { type VariableMarker } from '$lib/components/CodeEditor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable } from 'lucide-svelte';
|
||||
import { Layers, Save, Play, Code, GitGraph, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, ChevronsLeft, ChevronsRight, Variable, HelpCircle, GripVertical } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { currentEnvironment, appendEnvParam } from '$lib/stores/environment';
|
||||
import { focusFirstInput } from '$lib/utils';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
|
||||
|
||||
// localStorage key for persisted split ratio
|
||||
const STORAGE_KEY_SPLIT = 'dockhand-stack-modal-split';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
@@ -39,6 +43,8 @@
|
||||
// Environment variables state
|
||||
let envVars = $state<EnvVar[]>([]);
|
||||
let originalEnvVars = $state<EnvVar[]>([]);
|
||||
let rawEnvContent = $state('');
|
||||
let originalRawEnvContent = $state('');
|
||||
let envValidation = $state<ValidationResult | null>(null);
|
||||
let validating = $state(false);
|
||||
let existingSecretKeys = $state<Set<string>>(new Set());
|
||||
@@ -49,6 +55,11 @@
|
||||
// ComposeGraphViewer reference for resize on panel toggle
|
||||
let graphViewerRef: ComposeGraphViewer | null = $state(null);
|
||||
|
||||
// Resizable split panel state
|
||||
let splitRatio = $state(60); // percentage for compose panel
|
||||
let isDraggingSplit = $state(false);
|
||||
let containerRef: HTMLDivElement | null = $state(null);
|
||||
|
||||
// Debounce timer for validation
|
||||
let validateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -143,20 +154,22 @@ services:
|
||||
if (validateTimer) clearTimeout(validateTimer);
|
||||
validateTimer = setTimeout(() => {
|
||||
validateEnvVars();
|
||||
}, 500);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Explicitly push markers to the editor
|
||||
// Explicitly push markers to the editor (immediate=true since this is called after validation)
|
||||
function updateEditorMarkers() {
|
||||
if (!codeEditorRef) return;
|
||||
codeEditorRef.updateVariableMarkers(variableMarkers);
|
||||
codeEditorRef.updateVariableMarkers(variableMarkers, true);
|
||||
}
|
||||
|
||||
// Check for env var changes (compare by serializing)
|
||||
const hasEnvVarChanges = $derived.by(() => {
|
||||
const current = JSON.stringify(envVars.filter(v => v.key));
|
||||
const original = JSON.stringify(originalEnvVars);
|
||||
return current !== original;
|
||||
const currentVars = JSON.stringify(envVars.filter(v => v.key));
|
||||
const originalVars = JSON.stringify(originalEnvVars);
|
||||
const varsChanged = currentVars !== originalVars;
|
||||
const rawChanged = rawEnvContent !== originalRawEnvContent;
|
||||
return varsChanged || rawChanged;
|
||||
});
|
||||
|
||||
const hasChanges = $derived(hasComposeChanges || hasEnvVarChanges);
|
||||
@@ -173,8 +186,48 @@ services:
|
||||
// Fallback to system preference
|
||||
editorTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
// Load saved split ratio
|
||||
const savedSplit = localStorage.getItem(STORAGE_KEY_SPLIT);
|
||||
if (savedSplit) {
|
||||
const ratio = parseFloat(savedSplit);
|
||||
if (!isNaN(ratio) && ratio >= 30 && ratio <= 80) {
|
||||
splitRatio = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
// Add global mouse event listeners for split dragging
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
// Split panel drag handlers
|
||||
function startSplitDrag(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
isDraggingSplit = true;
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (isDraggingSplit && containerRef) {
|
||||
const rect = containerRef.getBoundingClientRect();
|
||||
const newRatio = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
splitRatio = Math.max(30, Math.min(80, newRatio));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (isDraggingSplit) {
|
||||
isDraggingSplit = false;
|
||||
// Save split ratio
|
||||
localStorage.setItem(STORAGE_KEY_SPLIT, splitRatio.toString());
|
||||
}
|
||||
}
|
||||
|
||||
async function loadComposeFile() {
|
||||
if (mode !== 'edit' || !stackName) return;
|
||||
|
||||
@@ -196,17 +249,29 @@ services:
|
||||
composeContent = data.content;
|
||||
originalContent = data.content;
|
||||
|
||||
// Load environment variables
|
||||
// Load environment variables (parsed)
|
||||
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId));
|
||||
if (envResponse.ok) {
|
||||
const envData = await envResponse.json();
|
||||
envVars = envData.variables || [];
|
||||
originalEnvVars = JSON.parse(JSON.stringify(envData.variables || []));
|
||||
// Track existing secret keys (secrets loaded from DB cannot have visibility toggled)
|
||||
existingSecretKeys = new Set(
|
||||
envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
|
||||
);
|
||||
}
|
||||
|
||||
// Load raw .env file content
|
||||
const rawEnvResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId));
|
||||
if (rawEnvResponse.ok) {
|
||||
const rawEnvData = await rawEnvResponse.json();
|
||||
rawEnvContent = rawEnvData.content || '';
|
||||
}
|
||||
|
||||
// Wait for $effects in StackEnvVarsPanel to settle (parses raw content, syncs variables)
|
||||
// Then set originals to the post-effect state to avoid false "unsaved changes"
|
||||
await tick();
|
||||
originalEnvVars = JSON.parse(JSON.stringify(envVars.filter(v => v.key.trim())));
|
||||
originalRawEnvContent = rawEnvContent;
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
@@ -276,14 +341,22 @@ services:
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
|
||||
// Create the stack
|
||||
// Collect environment variables
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
|
||||
// Create the stack (include env vars so they're available before start)
|
||||
const response = await fetch(appendEnvParam('/api/stacks', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newStackName.trim(),
|
||||
compose: content,
|
||||
start
|
||||
start,
|
||||
envVars: definedVars.length > 0 ? definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
})) : undefined
|
||||
})
|
||||
});
|
||||
|
||||
@@ -292,26 +365,6 @@ services:
|
||||
throw new Error(data.error || 'Failed to create stack');
|
||||
}
|
||||
|
||||
// Save environment variables if any are defined
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
if (definedVars.length > 0) {
|
||||
const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(newStackName.trim())}/env`, envId), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save environment variables');
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
handleClose();
|
||||
} catch (e: any) {
|
||||
@@ -354,31 +407,59 @@ services:
|
||||
throw new Error(data.error || 'Failed to save compose file');
|
||||
}
|
||||
|
||||
// Save environment variables if any are defined
|
||||
// Save environment variables
|
||||
// Always save to raw endpoint for consistency
|
||||
// If no raw content but has env vars, generate raw content from vars (backward compat)
|
||||
const definedVars = envVars.filter(v => v.key.trim());
|
||||
if (definedVars.length > 0 || originalEnvVars.length > 0) {
|
||||
const envResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
|
||||
let contentToSave = rawEnvContent;
|
||||
|
||||
// Backward compatibility: if no raw file but has DB envs, generate raw content
|
||||
if (!contentToSave.trim() && definedVars.length > 0) {
|
||||
contentToSave = definedVars.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
// Save if there's any content OR if we need to clear an existing file
|
||||
if (contentToSave.trim() || originalRawEnvContent.trim() || definedVars.length > 0 || originalEnvVars.length > 0) {
|
||||
const rawEnvResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
body: JSON.stringify({ content: contentToSave })
|
||||
}
|
||||
);
|
||||
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save environment variables');
|
||||
if (!rawEnvResponse.ok) {
|
||||
console.error('Failed to save environment file');
|
||||
}
|
||||
|
||||
// Also save to DB for secret tracking
|
||||
if (definedVars.some(v => v.isSecret)) {
|
||||
const envResponse = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId),
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
variables: definedVars.map(v => ({
|
||||
key: v.key.trim(),
|
||||
value: v.value,
|
||||
isSecret: v.isSecret
|
||||
}))
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!envResponse.ok) {
|
||||
console.error('Failed to save secret markers to database');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
originalContent = composeContent;
|
||||
originalEnvVars = JSON.parse(JSON.stringify(definedVars));
|
||||
originalEnvVars = JSON.parse(JSON.stringify(envVars.filter(v => v.key.trim())));
|
||||
originalRawEnvContent = contentToSave; // Use what was actually saved
|
||||
rawEnvContent = contentToSave; // Sync raw content if it was generated
|
||||
onSuccess();
|
||||
|
||||
if (!restart) {
|
||||
@@ -417,6 +498,8 @@ services:
|
||||
originalContent = '';
|
||||
envVars = [];
|
||||
originalEnvVars = [];
|
||||
rawEnvContent = '';
|
||||
originalRawEnvContent = '';
|
||||
envValidation = null;
|
||||
existingSecretKeys = new Set();
|
||||
activeTab = 'editor';
|
||||
@@ -462,7 +545,7 @@ services:
|
||||
// Debounce to avoid too many API calls while typing
|
||||
const timeout = setTimeout(() => {
|
||||
validateEnvVars();
|
||||
}, 300);
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
@@ -484,8 +567,11 @@ services:
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-w-7xl w-[95vw] h-[90vh] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700" showCloseButton={false}>
|
||||
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0 bg-zinc-50 dark:bg-zinc-800">
|
||||
<Dialog.Content
|
||||
class="max-w-none w-[calc(100vw-12rem)] h-[95vh] ml-[4.5rem] flex flex-col p-0 gap-0 shadow-xl border-zinc-200 dark:border-zinc-700"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -529,7 +615,7 @@ services:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Theme toggle (only in editor mode) -->
|
||||
{#if activeTab === 'editor'}
|
||||
<button
|
||||
@@ -612,10 +698,10 @@ services:
|
||||
{/if}
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
<div bind:this={containerRef} class="flex-1 min-h-0 flex {isDraggingSplit ? 'select-none' : ''}">
|
||||
{#if activeTab === 'editor'}
|
||||
<!-- Editor tab: Code editor + Env panel side by side -->
|
||||
<div class="w-[60%] flex-shrink-0 border-r border-zinc-200 dark:border-zinc-700 flex flex-col min-w-0">
|
||||
<div class="flex-shrink-0 flex flex-col min-w-0" style="width: {splitRatio}%">
|
||||
{#if open}
|
||||
<div class="flex-1 p-3 min-h-0">
|
||||
<CodeEditor
|
||||
@@ -630,18 +716,66 @@ services:
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Resizable divider -->
|
||||
<div
|
||||
class="w-1 flex-shrink-0 bg-zinc-200 dark:bg-zinc-700 hover:bg-blue-400 dark:hover:bg-blue-500 cursor-col-resize transition-colors flex items-center justify-center group {isDraggingSplit ? 'bg-blue-500 dark:bg-blue-400' : ''}"
|
||||
onmousedown={startSplitDrag}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="w-4 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity {isDraggingSplit ? 'opacity-100' : ''}">
|
||||
<GripVertical class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Environment variables panel -->
|
||||
<div class="flex-1 min-w-0 flex flex-col overflow-hidden bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div class="flex items-center gap-1.5 px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-700 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
<Variable class="w-3.5 h-3.5" />
|
||||
Environment variables
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground cursor-help" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<div class="w-64">
|
||||
<p class="text-xs">These variables will be written to a <code class="bg-muted px-1 rounded">.env</code> file in the stack directory.</p>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<!-- Validation status pills -->
|
||||
{#if envValidation}
|
||||
<div class="flex gap-1 ml-auto">
|
||||
{#if envValidation.missing.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{envValidation.missing.length} missing
|
||||
</span>
|
||||
{/if}
|
||||
{#if envValidation.required.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
{envValidation.required.length - envValidation.missing.length} required
|
||||
</span>
|
||||
{/if}
|
||||
{#if envValidation.optional.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
{envValidation.optional.length} optional
|
||||
</span>
|
||||
{/if}
|
||||
{#if envValidation.unused.length > 0}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
{envValidation.unused.length} unused
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<StackEnvVarsPanel
|
||||
bind:variables={envVars}
|
||||
bind:rawContent={rawEnvContent}
|
||||
validation={envValidation}
|
||||
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
|
||||
onchange={() => validateEnvVars()}
|
||||
onchange={debouncedValidate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,7 +793,7 @@ services:
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0 bg-zinc-50 dark:bg-zinc-800">
|
||||
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0">
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{#if hasChanges}
|
||||
<span class="text-amber-600 dark:text-amber-500">Unsaved changes</span>
|
||||
|
||||
Reference in New Issue
Block a user