mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
1.0.7
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "stack_sources" ADD COLUMN "compose_path" text;--> statement-breakpoint
|
||||
ALTER TABLE "stack_sources" ADD COLUMN "env_path" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `stack_sources` ADD `compose_path` text;--> statement-breakpoint
|
||||
ALTER TABLE `stack_sources` ADD `env_path` text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Crypto Fallback for Old Linux Kernels
|
||||
*
|
||||
* The getrandom() syscall was added in Linux 3.17. On older kernels (like 3.10.x),
|
||||
* Bun's built-in crypto functions will fail with "getrandom() failed to provide entropy".
|
||||
*
|
||||
* This module provides fallback implementations that read from /dev/urandom directly
|
||||
* when running on kernels older than 3.17.
|
||||
*/
|
||||
|
||||
import { existsSync, openSync, readSync, closeSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
// Cache kernel version check result
|
||||
let needsFallback: boolean | null = null;
|
||||
let fallbackInitialized = false;
|
||||
|
||||
/**
|
||||
* Parse Linux kernel version string (e.g., "3.10.108" -> { major: 3, minor: 10, patch: 108 })
|
||||
*/
|
||||
function parseKernelVersion(release: string): { major: number; minor: number; patch: number } | null {
|
||||
const match = release.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: parseInt(match[1], 10),
|
||||
minor: parseInt(match[2], 10),
|
||||
patch: parseInt(match[3], 10)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if kernel version is older than 3.17 (when getrandom() was added)
|
||||
*/
|
||||
function isOldKernel(): boolean {
|
||||
const release = os.release();
|
||||
const version = parseKernelVersion(release);
|
||||
|
||||
if (!version) {
|
||||
// Can't parse version, assume modern kernel
|
||||
return false;
|
||||
}
|
||||
|
||||
// getrandom() was added in Linux 3.17
|
||||
if (version.major < 3) return true;
|
||||
if (version.major === 3 && version.minor < 17) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're on Linux (only Linux has kernel version concerns)
|
||||
*/
|
||||
function isLinux(): boolean {
|
||||
return os.platform() === 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if we need to use the fallback (cached)
|
||||
*/
|
||||
function checkNeedsFallback(): boolean {
|
||||
if (needsFallback !== null) return needsFallback;
|
||||
|
||||
if (!isLinux()) {
|
||||
needsFallback = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldKernel = isOldKernel();
|
||||
if (oldKernel) {
|
||||
console.log(`[Crypto] Detected old Linux kernel (${os.release()}), using /dev/urandom fallback`);
|
||||
needsFallback = true;
|
||||
} else {
|
||||
needsFallback = false;
|
||||
}
|
||||
|
||||
return needsFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read random bytes from /dev/urandom (synchronous)
|
||||
*/
|
||||
function readFromUrandom(size: number): Buffer {
|
||||
const buffer = Buffer.alloc(size);
|
||||
const fd = openSync('/dev/urandom', 'r');
|
||||
try {
|
||||
readSync(fd, buffer, 0, size, null);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the crypto fallback - call this early at startup
|
||||
* Returns true if fallback is needed, false otherwise
|
||||
*/
|
||||
export function initCryptoFallback(): boolean {
|
||||
if (fallbackInitialized) return needsFallback ?? false;
|
||||
|
||||
const release = os.release();
|
||||
const platform = os.platform();
|
||||
const useFallback = checkNeedsFallback();
|
||||
|
||||
if (useFallback) {
|
||||
console.log(`[Crypto] Kernel: ${release} (old kernel detected, using /dev/urandom fallback)`);
|
||||
|
||||
// Verify /dev/urandom exists
|
||||
if (!existsSync('/dev/urandom')) {
|
||||
console.error('[Crypto] FATAL: /dev/urandom not found, cannot provide entropy');
|
||||
throw new Error('/dev/urandom not available');
|
||||
}
|
||||
|
||||
// Test that we can read from it
|
||||
try {
|
||||
const testBytes = readFromUrandom(8);
|
||||
if (testBytes.length !== 8) {
|
||||
throw new Error('Failed to read expected bytes');
|
||||
}
|
||||
console.log('[Crypto] /dev/urandom fallback initialized successfully');
|
||||
} catch (err) {
|
||||
console.error('[Crypto] FATAL: Failed to read from /dev/urandom:', err);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
console.log(`[Crypto] Kernel: ${platform === 'linux' ? release : platform} (using native crypto)`);
|
||||
}
|
||||
|
||||
fallbackInitialized = true;
|
||||
return useFallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure random bytes
|
||||
* Uses /dev/urandom on old kernels, native crypto otherwise
|
||||
*/
|
||||
export function secureRandomBytes(size: number): Buffer {
|
||||
if (checkNeedsFallback()) {
|
||||
return readFromUrandom(size);
|
||||
}
|
||||
|
||||
// Use native crypto on modern kernels
|
||||
const { randomBytes } = require('node:crypto');
|
||||
return randomBytes(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a Uint8Array with cryptographically secure random values
|
||||
* Compatible with crypto.getRandomValues() API
|
||||
*/
|
||||
export function secureGetRandomValues<T extends ArrayBufferView>(array: T): T {
|
||||
if (checkNeedsFallback()) {
|
||||
const bytes = readFromUrandom(array.byteLength);
|
||||
const target = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
|
||||
target.set(bytes);
|
||||
return array;
|
||||
}
|
||||
|
||||
// Use native crypto on modern kernels
|
||||
return crypto.getRandomValues(array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random UUID (v4)
|
||||
* Compatible with crypto.randomUUID() API
|
||||
*/
|
||||
export function secureRandomUUID(): string {
|
||||
if (checkNeedsFallback()) {
|
||||
// Generate 16 random bytes
|
||||
const bytes = readFromUrandom(16);
|
||||
|
||||
// Set version (4) and variant (RFC 4122)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
|
||||
|
||||
// Convert to UUID string
|
||||
const hex = bytes.toString('hex');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
// Use native crypto on modern kernels
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on an old kernel that needs the fallback
|
||||
*/
|
||||
export function usingFallback(): boolean {
|
||||
return checkNeedsFallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get kernel version info (useful for diagnostics)
|
||||
*/
|
||||
export function getKernelInfo(): { release: string; needsFallback: boolean } {
|
||||
return {
|
||||
release: os.release(),
|
||||
needsFallback: checkNeedsFallback()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Stack Scanner Service
|
||||
*
|
||||
* Scans external filesystem paths for Docker Compose files and adopts them as stacks.
|
||||
* Discovered stacks are editable - compose and .env files are modified in their original location.
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { join, basename, dirname, resolve } from 'node:path';
|
||||
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
|
||||
|
||||
// Compose file patterns to detect (in order of priority)
|
||||
const COMPOSE_PATTERNS = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
||||
|
||||
// Directories to skip during scanning
|
||||
const SKIP_DIRECTORIES = ['.git', 'node_modules', '.docker', '__pycache__', '.venv', 'venv'];
|
||||
|
||||
// Maximum recursion depth to prevent runaway scanning
|
||||
const MAX_DEPTH = 5;
|
||||
|
||||
export interface RunningStackInfo {
|
||||
envId: number;
|
||||
envName: string;
|
||||
containerCount: number;
|
||||
}
|
||||
|
||||
export interface DiscoveredStack {
|
||||
name: string;
|
||||
composePath: string;
|
||||
envPath: string | null;
|
||||
sourceDir: string;
|
||||
serviceCount?: number; // Number of services defined in compose file
|
||||
runningOn?: RunningStackInfo[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
discovered: DiscoveredStack[];
|
||||
adopted: string[];
|
||||
skipped: DiscoveredStack[];
|
||||
errors: { path: string; error: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a stack name to be valid (lowercase alphanumeric with hyphens/underscores)
|
||||
*/
|
||||
function normalizeStackName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file looks like a compose file (contains 'services:' key)
|
||||
*/
|
||||
async function isComposeFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
// Basic check for services key - could be more sophisticated
|
||||
return /^services:/m.test(content) || /\nservices:/m.test(content);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of services defined in a compose file
|
||||
* Uses simple regex to count top-level keys under 'services:' section
|
||||
*/
|
||||
async function countServices(filePath: string): Promise<number> {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const content = await file.text();
|
||||
|
||||
// Find the services section and count top-level keys
|
||||
const servicesMatch = content.match(/^services:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m) ||
|
||||
content.match(/\nservices:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m);
|
||||
|
||||
if (!servicesMatch) return 0;
|
||||
|
||||
const servicesBlock = servicesMatch[1];
|
||||
// Count lines that start with exactly 2 spaces followed by a non-space (service names)
|
||||
const serviceLines = servicesBlock.match(/^ [a-zA-Z0-9_-]+:/gm);
|
||||
return serviceLines?.length || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a single directory path for compose files
|
||||
*/
|
||||
async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[]; errors: { path: string; error: string }[] }> {
|
||||
const discovered: DiscoveredStack[] = [];
|
||||
const errors: { path: string; error: string }[] = [];
|
||||
|
||||
// Resolve to absolute path
|
||||
const absolutePath = resolve(basePath);
|
||||
|
||||
// Verify path exists and is a directory
|
||||
if (!existsSync(absolutePath)) {
|
||||
errors.push({ path: basePath, error: 'Path does not exist' });
|
||||
return { stacks: discovered, errors };
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(absolutePath);
|
||||
if (!stat.isDirectory()) {
|
||||
errors.push({ path: basePath, error: 'Path is not a directory' });
|
||||
return { stacks: discovered, errors };
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ path: basePath, error: 'Cannot access path' });
|
||||
return { stacks: discovered, errors };
|
||||
}
|
||||
|
||||
// Track which directories we've found compose files in (to avoid duplicate scanning)
|
||||
const foundStackDirs = new Set<string>();
|
||||
|
||||
async function scan(currentPath: string, depth: number = 0): Promise<void> {
|
||||
// Limit depth to prevent runaway scanning
|
||||
if (depth > MAX_DEPTH) return;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(currentPath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
// Skip inaccessible directories
|
||||
return;
|
||||
}
|
||||
|
||||
// First pass: check for compose files in this directory
|
||||
for (const pattern of COMPOSE_PATTERNS) {
|
||||
const composePath = join(currentPath, pattern);
|
||||
if (existsSync(composePath)) {
|
||||
// Found a stack! Stack name = directory name
|
||||
const stackName = normalizeStackName(basename(currentPath));
|
||||
if (stackName) {
|
||||
// Check for .env file
|
||||
const envPath = join(currentPath, '.env');
|
||||
// Count services in compose file
|
||||
const serviceCount = await countServices(composePath);
|
||||
discovered.push({
|
||||
name: stackName,
|
||||
composePath,
|
||||
envPath: existsSync(envPath) ? envPath : null,
|
||||
sourceDir: currentPath,
|
||||
serviceCount
|
||||
});
|
||||
foundStackDirs.add(currentPath);
|
||||
}
|
||||
// Don't continue scanning in this directory - it's a stack
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: check for standalone compose files (*.yml, *.yaml) and recurse into subdirectories
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(currentPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Skip excluded directories
|
||||
if (SKIP_DIRECTORIES.includes(entry.name)) continue;
|
||||
|
||||
// Skip if we already found a compose file here
|
||||
if (foundStackDirs.has(entryPath)) continue;
|
||||
|
||||
// Recurse into subdirectory
|
||||
await scan(entryPath, depth + 1);
|
||||
} else if (entry.isFile()) {
|
||||
const lowerName = entry.name.toLowerCase();
|
||||
|
||||
// Skip standard compose patterns (already handled above)
|
||||
if (COMPOSE_PATTERNS.includes(entry.name)) continue;
|
||||
|
||||
// Check for standalone compose files (e.g., myapp.yml, myapp.yaml)
|
||||
if (lowerName.endsWith('.yml') || lowerName.endsWith('.yaml')) {
|
||||
// Validate it's actually a compose file
|
||||
if (await isComposeFile(entryPath)) {
|
||||
const stackName = normalizeStackName(
|
||||
entry.name.replace(/\.(yml|yaml)$/i, '')
|
||||
);
|
||||
if (stackName) {
|
||||
// Check for .env file in same directory
|
||||
const envPath = join(currentPath, '.env');
|
||||
// Count services in compose file
|
||||
const serviceCount = await countServices(entryPath);
|
||||
discovered.push({
|
||||
name: stackName,
|
||||
composePath: entryPath,
|
||||
envPath: existsSync(envPath) ? envPath : null,
|
||||
sourceDir: currentPath,
|
||||
serviceCount
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await scan(absolutePath);
|
||||
return { stacks: discovered, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a single stack into the database
|
||||
* - Checks if stack already exists (by composePath)
|
||||
* - Creates stackSource record with sourceType: 'internal'
|
||||
* - Does NOT deploy - just registers the stack
|
||||
*/
|
||||
export async function adoptStack(
|
||||
stack: DiscoveredStack,
|
||||
environmentId: number
|
||||
): Promise<{ success: boolean; adoptedName?: string; error?: string }> {
|
||||
// Get all existing stack sources to check for duplicates
|
||||
const existingSources = await getStackSources();
|
||||
|
||||
// Check if already adopted (by composePath)
|
||||
const alreadyAdopted = existingSources.some(
|
||||
(s) => s.composePath === stack.composePath
|
||||
);
|
||||
|
||||
if (alreadyAdopted) {
|
||||
return { success: false, error: 'Already adopted' };
|
||||
}
|
||||
|
||||
// Check for name conflict within the same environment
|
||||
let finalName = stack.name;
|
||||
const existingNames = new Set(
|
||||
existingSources
|
||||
.filter((s) => s.environmentId === environmentId)
|
||||
.map((s) => s.stackName)
|
||||
);
|
||||
|
||||
if (existingNames.has(finalName)) {
|
||||
// Append suffix to make unique
|
||||
let suffix = 1;
|
||||
while (existingNames.has(`${stack.name}-${suffix}`)) {
|
||||
suffix++;
|
||||
}
|
||||
finalName = `${stack.name}-${suffix}`;
|
||||
}
|
||||
|
||||
// Create stack source record - use 'internal' since we know the file paths
|
||||
try {
|
||||
await upsertStackSource({
|
||||
stackName: finalName,
|
||||
environmentId,
|
||||
sourceType: 'internal' as StackSourceType,
|
||||
composePath: stack.composePath,
|
||||
envPath: stack.envPath
|
||||
});
|
||||
|
||||
return { success: true, adoptedName: finalName };
|
||||
} catch (err) {
|
||||
console.error(`[Stack Scanner] Failed to adopt ${stack.name}:`, err);
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt multiple selected stacks into the database
|
||||
*/
|
||||
export async function adoptSelectedStacks(
|
||||
stacks: DiscoveredStack[],
|
||||
environmentId: number
|
||||
): Promise<{ adopted: string[]; failed: { name: string; error: string }[] }> {
|
||||
const adopted: string[] = [];
|
||||
const failed: { name: string; error: string }[] = [];
|
||||
|
||||
for (const stack of stacks) {
|
||||
const result = await adoptStack(stack, environmentId);
|
||||
if (result.success && result.adoptedName) {
|
||||
adopted.push(result.adoptedName);
|
||||
} else {
|
||||
failed.push({ name: stack.name, error: result.error || 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
return { adopted, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan specific paths and return discovered stacks (without adopting)
|
||||
*/
|
||||
export async function scanPaths(paths: string[]): Promise<ScanResult> {
|
||||
if (paths.length === 0) {
|
||||
return { discovered: [], adopted: [], skipped: [], errors: [] };
|
||||
}
|
||||
|
||||
console.log(`[Stack Scanner] Scanning ${paths.length} path(s)...`);
|
||||
|
||||
const allDiscovered: DiscoveredStack[] = [];
|
||||
const allErrors: { path: string; error: string }[] = [];
|
||||
|
||||
// Scan all paths
|
||||
for (const path of paths) {
|
||||
const { stacks, errors } = await scanPath(path);
|
||||
allDiscovered.push(...stacks);
|
||||
allErrors.push(...errors);
|
||||
}
|
||||
|
||||
console.log(`[Stack Scanner] Found ${allDiscovered.length} compose file(s)`);
|
||||
|
||||
// Check which stacks are already adopted
|
||||
const existingSources = await getStackSources();
|
||||
const alreadyAdopted: DiscoveredStack[] = [];
|
||||
const newStacks: DiscoveredStack[] = [];
|
||||
|
||||
for (const stack of allDiscovered) {
|
||||
const isAdopted = existingSources.some(s => s.composePath === stack.composePath);
|
||||
if (isAdopted) {
|
||||
alreadyAdopted.push(stack);
|
||||
} else {
|
||||
newStacks.push(stack);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
discovered: newStacks,
|
||||
adopted: [],
|
||||
skipped: alreadyAdopted,
|
||||
errors: allErrors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all configured external paths and return discovered stacks (without adopting)
|
||||
*/
|
||||
export async function scanExternalPaths(): Promise<ScanResult> {
|
||||
const paths = await getExternalStackPaths();
|
||||
|
||||
if (paths.length === 0) {
|
||||
return { discovered: [], adopted: [], skipped: [], errors: [] };
|
||||
}
|
||||
|
||||
console.log(`[Stack Scanner] Scanning ${paths.length} external path(s)...`);
|
||||
|
||||
const allDiscovered: DiscoveredStack[] = [];
|
||||
const allErrors: { path: string; error: string }[] = [];
|
||||
|
||||
// Scan all paths
|
||||
for (const path of paths) {
|
||||
const { stacks, errors } = await scanPath(path);
|
||||
allDiscovered.push(...stacks);
|
||||
allErrors.push(...errors);
|
||||
}
|
||||
|
||||
console.log(`[Stack Scanner] Found ${allDiscovered.length} compose file(s)`);
|
||||
|
||||
// Check which stacks are already adopted
|
||||
const existingSources = await getStackSources();
|
||||
const alreadyAdopted: DiscoveredStack[] = [];
|
||||
const newStacks: DiscoveredStack[] = [];
|
||||
|
||||
for (const stack of allDiscovered) {
|
||||
const isAdopted = existingSources.some(s => s.composePath === stack.composePath);
|
||||
if (isAdopted) {
|
||||
alreadyAdopted.push(stack);
|
||||
} else {
|
||||
newStacks.push(stack);
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyAdopted.length > 0) {
|
||||
console.log(`[Stack Scanner] ${alreadyAdopted.length} stack(s) already adopted`);
|
||||
}
|
||||
if (newStacks.length > 0) {
|
||||
console.log(`[Stack Scanner] ${newStacks.length} new stack(s) available for adoption`);
|
||||
}
|
||||
if (allErrors.length > 0) {
|
||||
console.warn(`[Stack Scanner] ${allErrors.length} error(s) during scanning`);
|
||||
}
|
||||
|
||||
return {
|
||||
discovered: newStacks, // Only return stacks not yet adopted
|
||||
adopted: [], // No auto-adopt anymore
|
||||
skipped: alreadyAdopted,
|
||||
errors: allErrors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two paths overlap (one is parent/child of the other)
|
||||
*/
|
||||
function pathsOverlap(path1: string, path2: string): 'parent' | 'child' | 'same' | null {
|
||||
const resolved1 = resolve(path1);
|
||||
const resolved2 = resolve(path2);
|
||||
|
||||
if (resolved1 === resolved2) {
|
||||
return 'same';
|
||||
}
|
||||
|
||||
// Normalize paths with trailing slash for proper prefix matching
|
||||
const normalized1 = resolved1.endsWith('/') ? resolved1 : resolved1 + '/';
|
||||
const normalized2 = resolved2.endsWith('/') ? resolved2 : resolved2 + '/';
|
||||
|
||||
if (normalized2.startsWith(normalized1)) {
|
||||
// path1 is parent of path2
|
||||
return 'parent';
|
||||
}
|
||||
|
||||
if (normalized1.startsWith(normalized2)) {
|
||||
// path1 is child of path2
|
||||
return 'child';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path exists, is a directory, and doesn't overlap with existing paths
|
||||
*/
|
||||
export function validatePath(
|
||||
path: string,
|
||||
existingPaths: string[] = []
|
||||
): { valid: boolean; error?: string; resolvedPath?: string } {
|
||||
if (!path || typeof path !== 'string') {
|
||||
return { valid: false, error: 'Path is required' };
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(path.trim());
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return { valid: false, error: 'Path does not exist' };
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return { valid: false, error: 'Path is not a directory' };
|
||||
}
|
||||
} catch {
|
||||
return { valid: false, error: 'Cannot access path' };
|
||||
}
|
||||
|
||||
// Check for overlapping paths
|
||||
for (const existingPath of existingPaths) {
|
||||
const overlap = pathsOverlap(resolvedPath, existingPath);
|
||||
if (overlap === 'same') {
|
||||
return { valid: false, error: 'This location is already added' };
|
||||
}
|
||||
if (overlap === 'parent') {
|
||||
return { valid: false, error: `This path contains an existing location: ${existingPath}` };
|
||||
}
|
||||
if (overlap === 'child') {
|
||||
return { valid: false, error: `This path is inside an existing location: ${existingPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, resolvedPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which discovered stacks are already running on any environment.
|
||||
* Matches by stack name (com.docker.compose.project label) since paths may differ.
|
||||
*/
|
||||
export async function detectRunningStacks(
|
||||
discovered: DiscoveredStack[]
|
||||
): Promise<DiscoveredStack[]> {
|
||||
if (discovered.length === 0) {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
// Dynamic imports to avoid circular dependencies
|
||||
const { listComposeStacks } = await import('./stacks.js');
|
||||
const { getEnvironments } = await import('./db.js');
|
||||
|
||||
// Get all environments
|
||||
const environments = await getEnvironments();
|
||||
|
||||
if (environments.length === 0) {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
// Build map of stack name -> running info across all environments
|
||||
const runningStacksMap = new Map<string, RunningStackInfo[]>();
|
||||
|
||||
// Query each environment in parallel for running stacks
|
||||
await Promise.all(
|
||||
environments.map(async (env) => {
|
||||
try {
|
||||
const stacks = await listComposeStacks(env.id);
|
||||
for (const stack of stacks) {
|
||||
const existing = runningStacksMap.get(stack.name) || [];
|
||||
existing.push({
|
||||
envId: env.id,
|
||||
envName: env.name,
|
||||
containerCount: stack.containers?.length || 0
|
||||
});
|
||||
runningStacksMap.set(stack.name, existing);
|
||||
}
|
||||
} catch (error) {
|
||||
// Environment might be offline - skip silently
|
||||
console.warn(`[Stack Scanner] Failed to query environment ${env.name}:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Attach running info to discovered stacks by matching name
|
||||
return discovered.map((stack) => ({
|
||||
...stack,
|
||||
runningOn: runningStacksMap.get(stack.name)
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Clean PEM content by removing whitespace artifacts from copy/paste.
|
||||
* Bun's TLS is strict about PEM format - it fails when certificates have
|
||||
* leading/trailing spaces on lines or extra blank lines.
|
||||
*
|
||||
* @param pem - The PEM content to clean
|
||||
* @returns Cleaned PEM content with trimmed lines and no empty lines, or null if empty
|
||||
*/
|
||||
export function cleanPem(pem: string | null | undefined): string | null {
|
||||
if (!pem) return null;
|
||||
|
||||
const cleaned = pem
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
|
||||
return cleaned.length > 0 ? cleaned : null;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getStackSource } from '$lib/server/db';
|
||||
import { findStackDir } from '$lib/server/stacks';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
||||
/**
|
||||
* POST /api/stacks/[name]/check-path-change
|
||||
*
|
||||
* Check if the proposed compose path differs from current and if old directory has files.
|
||||
* Returns information about what would need to be moved if location changes.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'edit'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = params;
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { newComposePath } = body;
|
||||
|
||||
// Get current source info
|
||||
const source = await getStackSource(name, envIdNum);
|
||||
|
||||
// Determine current compose path and directory
|
||||
let currentComposePath: string | null = null;
|
||||
let currentDir: string | null = null;
|
||||
|
||||
if (source?.composePath) {
|
||||
currentComposePath = source.composePath;
|
||||
currentDir = dirname(source.composePath);
|
||||
} else {
|
||||
// Stack uses default directory structure
|
||||
const stackDir = await findStackDir(name, envIdNum);
|
||||
if (stackDir) {
|
||||
const defaultComposePath = join(stackDir, 'docker-compose.yml');
|
||||
if (existsSync(defaultComposePath)) {
|
||||
currentComposePath = defaultComposePath;
|
||||
currentDir = stackDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine new directory
|
||||
const newDir = newComposePath ? dirname(newComposePath) : null;
|
||||
|
||||
// Check if directories are different and old directory exists with files
|
||||
let hasChanges = false;
|
||||
let fileCount = 0;
|
||||
|
||||
if (currentDir && newDir && currentDir !== newDir && existsSync(currentDir)) {
|
||||
try {
|
||||
const files = readdirSync(currentDir);
|
||||
fileCount = files.length;
|
||||
hasChanges = fileCount > 0;
|
||||
} catch {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
hasChanges,
|
||||
oldDir: currentDir,
|
||||
newDir,
|
||||
fileCount,
|
||||
currentComposePath
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`Error checking path change for stack ${name}:`, error);
|
||||
return json({ error: error.message || 'Failed to check path changes' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getStackSource, updateStackSource } from '$lib/server/db';
|
||||
import { existsSync, readdirSync, renameSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
||||
/**
|
||||
* POST /api/stacks/[name]/relocate
|
||||
*
|
||||
* Move all stack files from old directory to new location.
|
||||
* Updates the database with new paths and returns refreshed content.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'edit'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { name } = params;
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { oldDir, newComposePath, newEnvPath } = body;
|
||||
|
||||
if (!oldDir || !newComposePath) {
|
||||
return json({ error: 'oldDir and newComposePath are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newDir = dirname(newComposePath);
|
||||
|
||||
// Verify old directory exists
|
||||
if (!existsSync(oldDir)) {
|
||||
return json({ error: 'Source directory does not exist' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create new directory if it doesn't exist
|
||||
if (!existsSync(newDir)) {
|
||||
mkdirSync(newDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Move all files from old directory to new directory
|
||||
const files = readdirSync(oldDir);
|
||||
const movedFiles: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const oldFilePath = join(oldDir, file);
|
||||
const newFilePath = join(newDir, file);
|
||||
|
||||
try {
|
||||
// Use rename for atomic move (same filesystem) or copy+delete for cross-filesystem
|
||||
renameSync(oldFilePath, newFilePath);
|
||||
movedFiles.push(file);
|
||||
} catch (renameErr: any) {
|
||||
if (renameErr.code === 'EXDEV') {
|
||||
// Cross-filesystem move - copy then delete
|
||||
try {
|
||||
const data = readFileSync(oldFilePath);
|
||||
writeFileSync(newFilePath, data);
|
||||
unlinkSync(oldFilePath);
|
||||
movedFiles.push(file);
|
||||
} catch (copyErr: any) {
|
||||
errors.push(`Failed to copy ${file}: ${copyErr.message}`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to move ${file}: ${renameErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old directory if it's now empty
|
||||
try {
|
||||
const remaining = readdirSync(oldDir);
|
||||
if (remaining.length === 0) {
|
||||
rmSync(oldDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when checking/removing old directory
|
||||
}
|
||||
|
||||
// Update database with new paths
|
||||
await updateStackSource(name, envIdNum ?? null, {
|
||||
composePath: newComposePath,
|
||||
envPath: newEnvPath || null
|
||||
});
|
||||
|
||||
// Read content from new location
|
||||
let composeContent = '';
|
||||
let rawEnvContent = '';
|
||||
const envVars: { key: string; value: string; isSecret: boolean }[] = [];
|
||||
|
||||
// Read compose file
|
||||
if (existsSync(newComposePath)) {
|
||||
composeContent = readFileSync(newComposePath, 'utf-8');
|
||||
}
|
||||
|
||||
// Read env file if it exists
|
||||
const envFilePath = newEnvPath || join(newDir, '.env');
|
||||
if (existsSync(envFilePath)) {
|
||||
rawEnvContent = readFileSync(envFilePath, 'utf-8');
|
||||
|
||||
// Parse env vars from raw content
|
||||
const lines = rawEnvContent.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex);
|
||||
const value = trimmed.substring(eqIndex + 1);
|
||||
envVars.push({ key, value, isSecret: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
movedFiles,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
composeContent,
|
||||
rawEnvContent,
|
||||
envVars
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`Error relocating stack ${name}:`, error);
|
||||
return json({ error: error.message || 'Failed to relocate stack' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { adoptSelectedStacks, type DiscoveredStack } from '$lib/server/stack-scanner';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('stacks', 'create')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const stacks = body.stacks as DiscoveredStack[];
|
||||
const environmentId = body.environmentId as number | undefined;
|
||||
|
||||
if (!stacks || !Array.isArray(stacks) || stacks.length === 0) {
|
||||
return json({ error: 'No stacks provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!environmentId || typeof environmentId !== 'number') {
|
||||
return json({ error: 'Environment ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate each stack has required fields
|
||||
for (const stack of stacks) {
|
||||
if (!stack.name || !stack.composePath) {
|
||||
return json({ error: 'Invalid stack data: missing name or composePath' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await adoptSelectedStacks(stacks, environmentId);
|
||||
|
||||
return json({
|
||||
adopted: result.adopted,
|
||||
failed: result.failed
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getStacksDir } from '$lib/server/stacks';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* GET /api/stacks/base-path
|
||||
*
|
||||
* Returns the default Dockhand stacks directory path.
|
||||
* This is where stacks are stored by default ($DATA_DIR/stacks/).
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
const basePath = getStacksDir();
|
||||
return json({ basePath });
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { join } from 'path';
|
||||
import { getStackDir } from '$lib/server/stacks';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* Get the default path for a new stack
|
||||
* Used by the UI to show where files will be created
|
||||
*
|
||||
* Query params:
|
||||
* - name: Stack name (required)
|
||||
* - env: Environment ID (optional)
|
||||
* - location: Custom base location path (optional)
|
||||
*
|
||||
* If location is provided, path will be: {location}/{envName}/{stackName}/
|
||||
* Otherwise uses Dockhand's default: $DATA_DIR/stacks/{envName}/{stackName}/
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const stackName = url.searchParams.get('name');
|
||||
const envId = url.searchParams.get('env');
|
||||
const location = url.searchParams.get('location');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (!stackName) {
|
||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let stackDir: string;
|
||||
|
||||
if (location) {
|
||||
// Custom location: {location}/{envName}/{stackName}/
|
||||
if (envIdNum) {
|
||||
const env = await getEnvironment(envIdNum);
|
||||
if (env) {
|
||||
stackDir = join(location, env.name, stackName);
|
||||
} else {
|
||||
stackDir = join(location, stackName);
|
||||
}
|
||||
} else {
|
||||
stackDir = join(location, stackName);
|
||||
}
|
||||
} else {
|
||||
// Dockhand default location
|
||||
stackDir = await getStackDir(stackName, envIdNum);
|
||||
}
|
||||
|
||||
return json({
|
||||
stackDir,
|
||||
composePath: `${stackDir}/docker-compose.yml`,
|
||||
envPath: `${stackDir}/.env`,
|
||||
source: location ? 'custom' : 'default'
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { getStackPathHints } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
/**
|
||||
* GET /api/stacks/path-hints?name=stackName&env=envId
|
||||
* Returns path hints extracted from Docker container labels for a stack.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const stackName = url.searchParams.get('name');
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
if (!stackName) {
|
||||
return json({ error: 'Stack name is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const hints = await getStackPathHints(stackName, envId ? parseInt(envId) : undefined);
|
||||
|
||||
return json({
|
||||
stackName,
|
||||
workingDir: hints.workingDir,
|
||||
configFiles: hints.configFiles
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get stack path hints:', error);
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get path hints' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { scanExternalPaths, scanPaths, detectRunningStacks } from '$lib/server/stack-scanner';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('stacks', 'create')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const { path } = body;
|
||||
|
||||
let result;
|
||||
if (path) {
|
||||
// Scan a specific path provided by the user
|
||||
result = await scanPaths([path]);
|
||||
} else {
|
||||
// Scan all configured external paths (legacy behavior)
|
||||
result = await scanExternalPaths();
|
||||
}
|
||||
|
||||
// Detect which stacks are already running on any environment
|
||||
const discoveredWithRunning = await detectRunningStacks(result.discovered);
|
||||
|
||||
return json({
|
||||
...result,
|
||||
discovered: discoveredWithRunning
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({ error: message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validatePath } from '$lib/server/stack-scanner';
|
||||
import { getExternalStackPaths } from '$lib/server/db';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { path } = await request.json();
|
||||
|
||||
if (!path || typeof path !== 'string') {
|
||||
return json({ valid: false, error: 'Path is required' });
|
||||
}
|
||||
|
||||
// Get existing paths to check for overlaps
|
||||
const existingPaths = await getExternalStackPaths();
|
||||
|
||||
const result = validatePath(path, existingPaths);
|
||||
return json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({ valid: false, error: message });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
mtime: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/system/files
|
||||
* Browse Dockhand's local filesystem (for mount browsing)
|
||||
*
|
||||
* Query params:
|
||||
* - path: Directory path to list
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const path = url.searchParams.get('path') || '/';
|
||||
|
||||
try {
|
||||
if (!existsSync(path)) {
|
||||
return json({ error: `Path not found: ${path}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = statSync(path);
|
||||
if (!stat.isDirectory()) {
|
||||
return json({ error: `Not a directory: ${path}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries: FileEntry[] = [];
|
||||
const dirEntries = readdirSync(path, { withFileTypes: true });
|
||||
|
||||
for (const entry of dirEntries) {
|
||||
try {
|
||||
const fullPath = join(path, entry.name);
|
||||
const entryStat = statSync(fullPath);
|
||||
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
type: entry.isDirectory() ? 'directory' : entry.isSymbolicLink() ? 'symlink' : 'file',
|
||||
size: entryStat.size,
|
||||
mtime: entryStat.mtime.toISOString(),
|
||||
mode: (entryStat.mode & 0o777).toString(8).padStart(3, '0')
|
||||
});
|
||||
} catch {
|
||||
// Skip entries we can't stat (permission issues, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
entries.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return json({
|
||||
path,
|
||||
parent: path === '/' ? null : join(path, '..'),
|
||||
entries
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing directory:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({ error: `Failed to list directory: ${message}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
/**
|
||||
* GET /api/system/files/content
|
||||
* Read file content from Dockhand's local filesystem
|
||||
*
|
||||
* Query params:
|
||||
* - path: File path to read
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
|
||||
if (!path) {
|
||||
return json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(path)) {
|
||||
return json({ error: `File not found: ${path}` }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) {
|
||||
return json({ error: `Cannot read directory as file: ${path}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Limit file size to 10MB
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (stat.size > maxSize) {
|
||||
return json({ error: `File too large (max ${maxSize / 1024 / 1024}MB)` }, { status: 400 });
|
||||
}
|
||||
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
|
||||
return json({
|
||||
path,
|
||||
content,
|
||||
size: stat.size,
|
||||
mtime: stat.mtime.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({ error: `Failed to read file: ${message}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { compact = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if compact}
|
||||
<div class="flex items-center gap-2 text-muted-foreground py-1">
|
||||
<Loader2 class="w-4 h-4 animate-spin opacity-50" />
|
||||
<span class="text-xs">Connecting...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 class="w-8 h-8 mb-2 animate-spin opacity-50" />
|
||||
<span class="text-sm">Connecting to environment...</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,601 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Search, FolderOpen, CheckCircle2, SkipForward, AlertCircle, FileText, Import, Loader2, Play, HelpCircle } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onMount } from 'svelte';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
|
||||
interface RunningStackInfo {
|
||||
envId: number;
|
||||
envName: string;
|
||||
containerCount: number;
|
||||
}
|
||||
|
||||
interface DiscoveredStack {
|
||||
name: string;
|
||||
composePath: string;
|
||||
envPath: string | null;
|
||||
sourceDir?: string;
|
||||
runningOn?: RunningStackInfo[];
|
||||
}
|
||||
|
||||
interface AdoptedStack {
|
||||
name: string;
|
||||
envId: number;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
adopted: string[];
|
||||
skipped: DiscoveredStack[];
|
||||
errors: { path: string; error: string }[];
|
||||
discovered: DiscoveredStack[];
|
||||
}
|
||||
|
||||
interface Environment {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
result: ScanResult | null;
|
||||
scannedPaths: string[];
|
||||
onclose: () => void;
|
||||
onAdopted?: () => void; // Callback when stacks are adopted
|
||||
}
|
||||
|
||||
let { open = $bindable(), result, scannedPaths, onclose, onAdopted }: Props = $props();
|
||||
|
||||
// Selection state - maps composePath to environmentId
|
||||
let stackSelections = $state<Map<string, number>>(new Map());
|
||||
let adopting = $state(false);
|
||||
|
||||
// Track adopted stacks with their environment (for display)
|
||||
let adoptedStacks = $state<AdoptedStack[]>([]);
|
||||
|
||||
// Environment state
|
||||
let environments = $state<Environment[]>([]);
|
||||
let defaultEnvId = $state<number | null>(null);
|
||||
let loadingEnvs = $state(true);
|
||||
|
||||
// Load environments on mount
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/environments');
|
||||
if (response.ok) {
|
||||
environments = await response.json();
|
||||
// Default to first environment
|
||||
if (environments.length > 0) {
|
||||
defaultEnvId = environments[0].id;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load environments:', err);
|
||||
} finally {
|
||||
loadingEnvs = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Track if we've initialized selections for this modal session
|
||||
let hasInitialized = $state(false);
|
||||
|
||||
// Reset selection when modal opens with new results (only on initial open)
|
||||
$effect(() => {
|
||||
if (open && result && defaultEnvId !== null && !hasInitialized) {
|
||||
// Pre-select all discovered stacks
|
||||
// Auto-select the environment where stack is already running, otherwise use default
|
||||
const newSelections = new Map<string, number>();
|
||||
for (const stack of result.discovered) {
|
||||
const runningEnvId = stack.runningOn?.[0]?.envId;
|
||||
newSelections.set(stack.composePath, runningEnvId ?? defaultEnvId);
|
||||
}
|
||||
stackSelections = newSelections;
|
||||
hasInitialized = true;
|
||||
}
|
||||
// Reset tracker when modal closes
|
||||
if (!open) {
|
||||
hasInitialized = false;
|
||||
adoptedStacks = [];
|
||||
}
|
||||
});
|
||||
|
||||
const selectedCount = $derived(stackSelections.size);
|
||||
|
||||
const allSelected = $derived(
|
||||
result?.discovered.length > 0 &&
|
||||
stackSelections.size === result.discovered.length
|
||||
);
|
||||
|
||||
const someSelected = $derived(
|
||||
stackSelections.size > 0 &&
|
||||
result?.discovered.length > 0 &&
|
||||
stackSelections.size < result.discovered.length
|
||||
);
|
||||
|
||||
const defaultEnv = $derived(environments.find(e => e.id === defaultEnvId));
|
||||
const defaultEnvName = $derived(defaultEnv?.name || 'Select environment');
|
||||
const DefaultEnvIcon = $derived(defaultEnv ? getIconComponent(defaultEnv.icon || 'globe') : null);
|
||||
|
||||
function isSelected(composePath: string): boolean {
|
||||
return stackSelections.has(composePath);
|
||||
}
|
||||
|
||||
function getStackEnvId(composePath: string): number | null {
|
||||
return stackSelections.get(composePath) ?? null;
|
||||
}
|
||||
|
||||
function getStackEnv(composePath: string): Environment | null {
|
||||
const envId = stackSelections.get(composePath);
|
||||
return envId ? environments.find(e => e.id === envId) ?? null : null;
|
||||
}
|
||||
|
||||
function toggleStack(composePath: string) {
|
||||
const newMap = new Map(stackSelections);
|
||||
if (newMap.has(composePath)) {
|
||||
newMap.delete(composePath);
|
||||
} else if (defaultEnvId !== null) {
|
||||
newMap.set(composePath, defaultEnvId);
|
||||
}
|
||||
stackSelections = newMap;
|
||||
}
|
||||
|
||||
function setStackEnv(composePath: string, envId: number) {
|
||||
const newMap = new Map(stackSelections);
|
||||
newMap.set(composePath, envId);
|
||||
stackSelections = newMap;
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (!result || defaultEnvId === null) return;
|
||||
|
||||
if (allSelected) {
|
||||
stackSelections = new Map();
|
||||
} else {
|
||||
const newSelections = new Map<string, number>();
|
||||
for (const stack of result.discovered) {
|
||||
// Preserve existing env selection or use default
|
||||
const existingEnv = stackSelections.get(stack.composePath);
|
||||
newSelections.set(stack.composePath, existingEnv ?? defaultEnvId);
|
||||
}
|
||||
stackSelections = newSelections;
|
||||
}
|
||||
}
|
||||
|
||||
function applyDefaultEnvToAll() {
|
||||
if (!result || defaultEnvId === null) return;
|
||||
const newMap = new Map<string, number>();
|
||||
for (const [path] of stackSelections) {
|
||||
newMap.set(path, defaultEnvId);
|
||||
}
|
||||
stackSelections = newMap;
|
||||
}
|
||||
|
||||
async function handleAdopt() {
|
||||
if (!result || stackSelections.size === 0) return;
|
||||
|
||||
// Group stacks by environment
|
||||
const stacksByEnv = new Map<number, DiscoveredStack[]>();
|
||||
for (const [composePath, envId] of stackSelections) {
|
||||
const stack = result.discovered.find(d => d.composePath === composePath);
|
||||
if (stack) {
|
||||
if (!stacksByEnv.has(envId)) {
|
||||
stacksByEnv.set(envId, []);
|
||||
}
|
||||
stacksByEnv.get(envId)!.push(stack);
|
||||
}
|
||||
}
|
||||
|
||||
adopting = true;
|
||||
let totalAdopted: AdoptedStack[] = [];
|
||||
let totalFailed: { name: string; error: string }[] = [];
|
||||
|
||||
try {
|
||||
// Adopt each group to its environment
|
||||
for (const [envId, stacks] of stacksByEnv) {
|
||||
const response = await fetch('/api/stacks/adopt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stacks,
|
||||
environmentId: envId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
// Add all stacks in this batch as failed
|
||||
for (const stack of stacks) {
|
||||
totalFailed.push({ name: stack.name, error: data.error || 'Failed to adopt' });
|
||||
}
|
||||
} else {
|
||||
// Track adopted stacks with their environment
|
||||
for (const name of (data.adopted || [])) {
|
||||
totalAdopted.push({ name, envId });
|
||||
}
|
||||
totalFailed.push(...(data.failed || []));
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAdopted.length > 0) {
|
||||
toast.success(`Adopted ${totalAdopted.length} stack(s)`);
|
||||
}
|
||||
if (totalFailed.length > 0) {
|
||||
toast.error(`Failed to adopt ${totalFailed.length} stack(s)`);
|
||||
}
|
||||
|
||||
// Update the result to reflect adopted stacks
|
||||
const adoptedNames = new Set(totalAdopted.map(s => s.name));
|
||||
const adoptedPaths = new Set(
|
||||
result.discovered
|
||||
.filter(d => stackSelections.has(d.composePath) && adoptedNames.has(d.name))
|
||||
.map(d => d.composePath)
|
||||
);
|
||||
|
||||
// Add to local adopted stacks list (with env info)
|
||||
adoptedStacks = [...adoptedStacks, ...totalAdopted];
|
||||
|
||||
result = {
|
||||
...result,
|
||||
adopted: [...result.adopted, ...totalAdopted.map(s => s.name)],
|
||||
discovered: result.discovered.filter(d => !adoptedPaths.has(d.composePath))
|
||||
};
|
||||
|
||||
// Clear selections for adopted stacks
|
||||
const newSelections = new Map(stackSelections);
|
||||
for (const path of adoptedPaths) {
|
||||
newSelections.delete(path);
|
||||
}
|
||||
stackSelections = newSelections;
|
||||
|
||||
// Notify parent of adoption
|
||||
onAdopted?.();
|
||||
|
||||
} catch (err) {
|
||||
toast.error('Failed to adopt stacks');
|
||||
} finally {
|
||||
adopting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => !isOpen && onclose()}>
|
||||
<Dialog.Content class="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Search class="w-5 h-5" />
|
||||
External stack scan results
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Scanned {scannedPaths.length} configured path{scannedPaths.length !== 1 ? 's' : ''} for Docker Compose files
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if result}
|
||||
<!-- Sticky header with summary and environment selector -->
|
||||
<div class="space-y-3 py-2 border-b bg-background">
|
||||
<!-- Summary -->
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderOpen class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{result.discovered.length + result.skipped.length + adoptedStacks.length} found</span>
|
||||
</div>
|
||||
{#if result.discovered.length > 0}
|
||||
<div class="flex items-center gap-1.5 text-blue-600 dark:text-blue-500">
|
||||
<Import class="w-4 h-4" />
|
||||
<span class="text-sm">{result.discovered.length} new</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if adoptedStacks.length > 0}
|
||||
<div class="flex items-center gap-1.5 text-green-600 dark:text-green-500">
|
||||
<CheckCircle2 class="w-4 h-4" />
|
||||
<span class="text-sm">{adoptedStacks.length} adopted</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if result.skipped.length > 0}
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<SkipForward class="w-4 h-4" />
|
||||
<span class="text-sm">{result.skipped.length} already adopted</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if result.errors.length > 0}
|
||||
<div class="flex items-center gap-1.5 text-destructive">
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
<span class="text-sm">{result.errors.length} errors</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Default environment selector (apply to all) - only show when multiple environments -->
|
||||
{#if result.discovered.length > 0}
|
||||
{#if loadingEnvs}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg border bg-muted/30">
|
||||
<Label class="text-sm font-medium shrink-0">Adopt to:</Label>
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
<span class="text-sm">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if environments.length === 0}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg border bg-muted/30">
|
||||
<p class="text-sm text-destructive">No environments configured</p>
|
||||
</div>
|
||||
{:else if environments.length > 1}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg border bg-muted/30">
|
||||
<Label class="text-sm font-medium shrink-0">Adopt to:</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={defaultEnvId?.toString()}
|
||||
onValueChange={(v) => {
|
||||
defaultEnvId = v ? parseInt(v) : null;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-[220px]">
|
||||
{#if DefaultEnvIcon}
|
||||
<DefaultEnvIcon class="w-4 h-4 mr-2 shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate">{defaultEnvName}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each environments as env}
|
||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
||||
<Select.Item value={env.id.toString()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<EnvIcon class="w-4 h-4 shrink-0" />
|
||||
<span>{env.name}</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Button variant="outline" size="sm" onclick={applyDefaultEnvToAll} disabled={stackSelections.size === 0}>
|
||||
Apply to all
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="flex-1 overflow-y-auto space-y-4 py-2">
|
||||
<!-- Discovered stacks (available for import) -->
|
||||
{#if result.discovered.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium flex items-center gap-2 text-blue-600 dark:text-blue-500">
|
||||
<Import class="w-4 h-4" />
|
||||
Available for adoption
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onclick={toggleAll}
|
||||
>
|
||||
<Checkbox checked={allSelected} indeterminate={someSelected} />
|
||||
<span>{allSelected ? 'Deselect all' : 'Select all'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
{#each result.discovered as stack}
|
||||
{@const stackEnvId = getStackEnvId(stack.composePath)}
|
||||
{@const stackEnv = stackEnvId ? environments.find(e => e.id === stackEnvId) : null}
|
||||
<div
|
||||
class="flex items-start gap-3 p-2 rounded-md border transition-colors
|
||||
{isSelected(stack.composePath)
|
||||
? 'bg-blue-500/10 border-blue-500/30'
|
||||
: 'bg-muted/30 border-transparent hover:bg-muted/50'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="pt-0.5 shrink-0"
|
||||
onclick={() => toggleStack(stack.composePath)}
|
||||
>
|
||||
<Checkbox checked={isSelected(stack.composePath)} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 min-w-0 text-left"
|
||||
onclick={() => toggleStack(stack.composePath)}
|
||||
>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="text-sm font-medium">{stack.name}</p>
|
||||
{#if stack.runningOn && stack.runningOn.length > 0}
|
||||
<Badge variant="outline" class="text-xs text-green-600 dark:text-green-500 border-green-300 dark:border-green-600 gap-1">
|
||||
<Play class="w-3 h-3" />
|
||||
Running on {stack.runningOn.map(r => r.envName).join(', ')}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3 h-3 opacity-60" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-sm">
|
||||
<p class="text-xs">This stack is already running (detected via Docker's <code class="bg-muted px-1 rounded">com.docker.compose.project</code> label). Adopting will allow you to manage it through Dockhand.</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<code class="text-xs text-muted-foreground block truncate" title={stack.composePath}>{stack.composePath}</code>
|
||||
{#if stack.envPath}
|
||||
<p class="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||
<FileText class="w-3 h-3" />
|
||||
.env file detected
|
||||
</p>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Per-stack environment selector - only show when multiple environments -->
|
||||
{#if isSelected(stack.composePath) && environments.length > 1}
|
||||
<div class="shrink-0 flex items-center gap-2" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Note if importing to different environment than running -->
|
||||
{#if stack.runningOn && stack.runningOn.length > 0 && !stack.runningOn.some(r => r.envId === stackEnvId)}
|
||||
<Badge variant="outline" class="text-xs text-amber-600 dark:text-amber-500 border-amber-300 dark:border-amber-600 gap-1">
|
||||
Running elsewhere
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3 h-3 opacity-60" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="max-w-sm">
|
||||
<p class="text-xs">This stack is running on a different environment ({stack.runningOn?.map(r => r.envName).join(', ')}). You can still adopt it here, but it won't affect the running containers.</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Badge>
|
||||
{/if}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={stackEnvId?.toString() || ''}
|
||||
onValueChange={(v) => {
|
||||
if (v) setStackEnv(stack.composePath, parseInt(v));
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="h-8 w-[220px] text-xs">
|
||||
{#if getStackEnv(stack.composePath)}
|
||||
{@const StackIcon = getIconComponent(getStackEnv(stack.composePath)?.icon || 'globe')}
|
||||
<StackIcon class="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate">{getStackEnv(stack.composePath)?.name || 'Select'}</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each environments as env}
|
||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
||||
<Select.Item value={env.id.toString()}>
|
||||
<div class="flex items-center gap-2">
|
||||
<EnvIcon class="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{env.name}</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Adopted stacks (just adopted in this session) -->
|
||||
{#if adoptedStacks.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium flex items-center gap-2 text-green-600 dark:text-green-500">
|
||||
<CheckCircle2 class="w-4 h-4" />
|
||||
Adopted stacks
|
||||
</h4>
|
||||
<div class="space-y-1.5">
|
||||
{#each adoptedStacks as adopted}
|
||||
{@const env = environments.find(e => e.id === adopted.envId)}
|
||||
{@const EnvIcon = env ? getIconComponent(env.icon || 'globe') : null}
|
||||
<div class="flex items-center gap-2 p-2 rounded-md bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600 dark:text-green-500 shrink-0" />
|
||||
<p class="text-sm font-medium flex-1">{adopted.name}</p>
|
||||
{#if env}
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
{#if EnvIcon}
|
||||
<EnvIcon class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
<span>{env.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Skipped stacks (already adopted) -->
|
||||
{#if result.skipped.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<SkipForward class="w-4 h-4" />
|
||||
Already adopted
|
||||
</h4>
|
||||
<div class="space-y-1.5">
|
||||
{#each result.skipped as stack}
|
||||
<div class="flex items-start gap-2 p-2 rounded-md bg-muted/50">
|
||||
<SkipForward class="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium">{stack.name}</p>
|
||||
<code class="text-xs text-muted-foreground block truncate" title={stack.composePath}>{stack.composePath}</code>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Errors -->
|
||||
{#if result.errors.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium flex items-center gap-2 text-destructive">
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
Errors
|
||||
</h4>
|
||||
<div class="space-y-1.5">
|
||||
{#each result.errors as error}
|
||||
<div class="flex items-start gap-2 p-2 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle class="w-4 h-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<code class="text-xs block truncate" title={error.path}>{error.path}</code>
|
||||
<p class="text-xs text-destructive">{error.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- No results message -->
|
||||
{#if result.discovered.length === 0 && result.skipped.length === 0 && result.adopted.length === 0 && result.errors.length === 0}
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
<FolderOpen class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p class="text-sm">No Docker Compose files found in the configured paths.</p>
|
||||
<p class="text-xs mt-1">Make sure your paths contain docker-compose.yml, compose.yml, or similar files.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scanned paths -->
|
||||
<div class="space-y-2 pt-2 border-t">
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Scanned paths</h4>
|
||||
<div class="space-y-1">
|
||||
{#each scannedPaths as path}
|
||||
<code class="text-xs text-muted-foreground block">{path}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Dialog.Footer class="flex items-center justify-between">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{#if selectedCount > 0}
|
||||
{selectedCount} stack{selectedCount !== 1 ? 's' : ''} selected
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" onclick={() => { open = false; onclose(); }}>Close</Button>
|
||||
{#if result && result.discovered.length > 0}
|
||||
<Button
|
||||
onclick={handleAdopt}
|
||||
disabled={selectedCount === 0 || adopting}
|
||||
>
|
||||
{#if adopting}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Adopting...
|
||||
{:else}
|
||||
<Import class="w-4 h-4 mr-2" />
|
||||
Adopt selected
|
||||
{/if}
|
||||
</Button>
|
||||
{:else if adoptedStacks.length > 0}
|
||||
<Button href="/stacks">View stacks</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,394 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Loader2, FolderOpen, File, FileText, ChevronRight, ArrowUp, AlertCircle, FolderPlus, Search, Import } from 'lucide-svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import RecentLocationsPanel from './RecentLocationsPanel.svelte';
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory' | 'symlink';
|
||||
size: number;
|
||||
mtime: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
/** Optional icon component to display before the title */
|
||||
icon?: Component<{ class?: string }>;
|
||||
description?: string;
|
||||
initialPath?: string;
|
||||
selectFilter?: RegExp;
|
||||
selectMode?: 'file' | 'directory' | 'file_or_directory' | 'adopt';
|
||||
/** For adopt mode: filter to highlight (e.g., /\.ya?ml$/i for compose files) */
|
||||
highlightFilter?: RegExp;
|
||||
/** For adopt mode: called when user clicks on a matching file */
|
||||
onFilePreview?: (entry: FileEntry) => void;
|
||||
/** For adopt mode: called when user clicks "Scan this folder" */
|
||||
onScanDirectory?: (path: string) => void;
|
||||
/** For adopt mode: show loading state on scan button */
|
||||
scanning?: boolean;
|
||||
onSelect: (path: string, name: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = 'Select file',
|
||||
icon,
|
||||
description,
|
||||
initialPath = '/',
|
||||
selectFilter,
|
||||
selectMode = 'file',
|
||||
highlightFilter,
|
||||
onFilePreview,
|
||||
onScanDirectory,
|
||||
scanning = false,
|
||||
onSelect,
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
let currentPath = $state<string | null>(null);
|
||||
let entries = $state<FileEntry[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Track selected file
|
||||
let selectedPath = $state<string | null>(null);
|
||||
let selectedName = $state<string | null>(null);
|
||||
|
||||
// Reference to recent locations panel
|
||||
let recentLocationsPanel = $state<{ addLocation: (path: string) => Promise<void>; getFirstLocation: () => string | null } | null>(null);
|
||||
|
||||
// Expose methods for parent to call
|
||||
export function getCurrentPath(): string | null {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
export function addRecentLocation(path: string): Promise<void> {
|
||||
return recentLocationsPanel?.addLocation(path) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
// Load directory when dialog opens
|
||||
$effect(() => {
|
||||
if (open && !currentPath) {
|
||||
// Wait a tick for the panel to load, then use first location or initialPath
|
||||
setTimeout(() => {
|
||||
const firstLocation = recentLocationsPanel?.getFirstLocation();
|
||||
loadDirectory(firstLocation || initialPath);
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
function handleRecentSelect(path: string) {
|
||||
loadDirectory(path);
|
||||
}
|
||||
|
||||
async function loadDirectory(path: string) {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/system/files?path=${encodeURIComponent(path)}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
error = data.error || 'Failed to load directory';
|
||||
return;
|
||||
}
|
||||
|
||||
currentPath = data.path;
|
||||
entries = data.entries;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load directory';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEntryClick(entry: FileEntry, doubleClick: boolean = false) {
|
||||
if (selectMode === 'adopt') {
|
||||
// Adopt mode: click on directory navigates, click on highlighted file triggers preview
|
||||
if (entry.type === 'directory') {
|
||||
loadDirectory(entry.path);
|
||||
} else if (highlightFilter?.test(entry.name) && onFilePreview) {
|
||||
onFilePreview(entry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.type === 'directory') {
|
||||
if (selectMode === 'file_or_directory' && !doubleClick) {
|
||||
// Single click on directory in file_or_directory mode - select it
|
||||
selectedPath = entry.path;
|
||||
selectedName = entry.name;
|
||||
} else {
|
||||
// Double click or other modes - navigate into directory
|
||||
selectedPath = null;
|
||||
selectedName = null;
|
||||
loadDirectory(entry.path);
|
||||
}
|
||||
} else if (selectMode === 'file' || selectMode === 'file_or_directory') {
|
||||
// Select file
|
||||
selectedPath = entry.path;
|
||||
selectedName = entry.name;
|
||||
}
|
||||
}
|
||||
|
||||
function handleGoUp() {
|
||||
if (!currentPath || currentPath === '/') return;
|
||||
|
||||
const parent = currentPath.replace(/\/[^/]+$/, '') || '/';
|
||||
selectedPath = null;
|
||||
selectedName = null;
|
||||
loadDirectory(parent);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (selectMode === 'directory' && currentPath) {
|
||||
// In directory mode, select the current directory
|
||||
const name = currentPath === '/' ? '/' : currentPath.split('/').pop() || '';
|
||||
onSelect(currentPath, name);
|
||||
handleClose();
|
||||
} else if (selectedPath && selectedName) {
|
||||
// In file or file_or_directory mode, select the selected item
|
||||
onSelect(selectedPath, selectedName);
|
||||
handleClose();
|
||||
} else if (selectMode === 'file_or_directory' && currentPath) {
|
||||
// In file_or_directory mode with nothing selected, use current directory
|
||||
const name = currentPath === '/' ? '/' : currentPath.split('/').pop() || '';
|
||||
onSelect(currentPath, name);
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
currentPath = null;
|
||||
entries = [];
|
||||
selectedPath = null;
|
||||
selectedName = null;
|
||||
error = null;
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleScan() {
|
||||
if (currentPath && onScanDirectory) {
|
||||
onScanDirectory(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectable(entry: FileEntry): boolean {
|
||||
if (selectMode === 'adopt') {
|
||||
// In adopt mode, nothing is "selectable" in the traditional sense
|
||||
return false;
|
||||
}
|
||||
if (selectMode === 'directory') {
|
||||
return entry.type === 'directory';
|
||||
}
|
||||
if (selectMode === 'file_or_directory') {
|
||||
// Directories are always selectable, files must match filter
|
||||
if (entry.type === 'directory') return true;
|
||||
if (!selectFilter) return true;
|
||||
return selectFilter.test(entry.name);
|
||||
}
|
||||
// File mode
|
||||
if (entry.type === 'directory') return false;
|
||||
if (!selectFilter) return true;
|
||||
return selectFilter.test(entry.name);
|
||||
}
|
||||
|
||||
function isHighlighted(entry: FileEntry): boolean {
|
||||
if (entry.type === 'directory') return false;
|
||||
if (!highlightFilter) return false;
|
||||
return highlightFilter.test(entry.name);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const canGoUp = $derived(currentPath && currentPath !== '/');
|
||||
|
||||
// In directory mode, only show directories; otherwise show all
|
||||
const filteredEntries = $derived(
|
||||
selectMode === 'directory'
|
||||
? entries.filter(e => e.type === 'directory')
|
||||
: entries
|
||||
);
|
||||
|
||||
const isAdoptMode = $derived(selectMode === 'adopt');
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen) handleClose(); }}>
|
||||
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col {isAdoptMode ? 'p-0 gap-0' : ''}">
|
||||
<Dialog.Header class={isAdoptMode ? 'px-6 py-4 border-b shrink-0' : ''}>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
{#if icon}
|
||||
<svelte:component this={icon} class="w-5 h-5" />
|
||||
{/if}
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
{#if description}
|
||||
<Dialog.Description>{description}</Dialog.Description>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-hidden flex {isAdoptMode ? 'min-h-0' : ''}">
|
||||
<!-- Recent locations sidebar -->
|
||||
<RecentLocationsPanel
|
||||
bind:this={recentLocationsPanel}
|
||||
{currentPath}
|
||||
onSelect={handleRecentSelect}
|
||||
/>
|
||||
|
||||
<!-- Main browser area -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Path bar -->
|
||||
<div class="flex items-center gap-2 px-4 py-2 border-b bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={!canGoUp}
|
||||
onclick={handleGoUp}
|
||||
title="Go up"
|
||||
>
|
||||
<ArrowUp class="w-4 h-4" />
|
||||
</button>
|
||||
<code class="text-xs bg-muted px-2 py-1 rounded truncate flex-1">{currentPath || '/'}</code>
|
||||
{#if isAdoptMode}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onclick={handleScan}
|
||||
disabled={scanning || !currentPath}
|
||||
>
|
||||
{#if scanning}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Scanning...
|
||||
{:else}
|
||||
<Search class="w-4 h-4 mr-2" />
|
||||
Scan this folder
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
|
||||
<AlertCircle class="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<p class="text-red-600 dark:text-red-400 font-medium">Unable to browse files</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">{error}</p>
|
||||
<Button variant="outline" size="sm" class="mt-4" onclick={() => currentPath && loadDirectory(currentPath)}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
{:else if filteredEntries.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<FolderOpen class="w-12 h-12 mb-3 opacity-50" />
|
||||
<p>{selectMode === 'directory' ? 'No subdirectories' : 'Directory is empty'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y">
|
||||
{#each filteredEntries as entry}
|
||||
{@const selectable = isSelectable(entry)}
|
||||
{@const highlighted = isHighlighted(entry)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 {isAdoptMode ? 'py-1.5' : 'py-2'} hover:bg-muted/50 text-left transition-colors
|
||||
{entry.type === 'directory' ? 'cursor-pointer' : (selectable || highlighted) ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}
|
||||
{selectedPath === entry.path ? 'bg-blue-50 dark:bg-blue-900/20' : ''}"
|
||||
onclick={() => (entry.type === 'directory' || selectable || highlighted) && handleEntryClick(entry, false)}
|
||||
ondblclick={() => entry.type === 'directory' && handleEntryClick(entry, true)}
|
||||
disabled={entry.type !== 'directory' && !selectable && !highlighted}
|
||||
>
|
||||
{#if entry.type === 'directory'}
|
||||
<FolderOpen class="w-4 h-4 text-blue-500 shrink-0" />
|
||||
{:else if highlighted}
|
||||
<FileText class="w-4 h-4 text-green-500 shrink-0" />
|
||||
{:else}
|
||||
<File class="w-4 h-4 text-zinc-400 shrink-0 {selectable ? 'text-green-500' : ''}" />
|
||||
{/if}
|
||||
<span class="flex-1 truncate {isAdoptMode ? 'text-xs' : 'text-sm'} {(selectable || highlighted) && entry.type !== 'directory' ? 'text-green-600 dark:text-green-400 font-medium' : ''}">
|
||||
{entry.name}
|
||||
</span>
|
||||
{#if highlighted && isAdoptMode}
|
||||
<Badge variant="secondary" class="text-xs">Compose file</Badge>
|
||||
{:else if entry.type !== 'directory' && !isAdoptMode}
|
||||
<span class="text-xs text-muted-foreground">{formatSize(entry.size)}</span>
|
||||
{/if}
|
||||
{#if entry.type === 'directory'}
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !isAdoptMode}
|
||||
<Dialog.Footer class="border-t pt-4">
|
||||
{#if selectMode === 'directory'}
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs text-muted-foreground shrink-0">Selected:</span>
|
||||
<code class="text-xs font-mono bg-muted px-2 py-1 rounded truncate" title={currentPath || '/'}>{currentPath || '/'}</code>
|
||||
</div>
|
||||
{:else if selectMode === 'file_or_directory'}
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
{#if selectedPath}
|
||||
<span class="text-xs text-muted-foreground shrink-0">Selected:</span>
|
||||
<code class="text-xs font-mono bg-muted px-2 py-1 rounded truncate" title={selectedPath}>{selectedPath}</code>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">Click to select file or folder, double-click to enter folder</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if selectedPath}
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="text-xs text-muted-foreground shrink-0">Selected:</span>
|
||||
<code class="text-xs font-mono bg-muted px-2 py-1 rounded truncate" title={selectedPath}>{selectedPath}</code>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 text-xs text-muted-foreground">
|
||||
Click a file to select it
|
||||
</div>
|
||||
{/if}
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{#if selectMode === 'directory'}
|
||||
<Button onclick={handleConfirm}>
|
||||
<FolderPlus class="w-4 h-4 mr-2" />
|
||||
Select
|
||||
</Button>
|
||||
{:else if selectMode === 'file_or_directory'}
|
||||
<Button onclick={handleConfirm}>
|
||||
Select
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
disabled={!selectedPath}
|
||||
onclick={handleConfirm}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,494 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Import, Loader2, Play, Info } from 'lucide-svelte';
|
||||
import FilesystemBrowser, { type FileEntry } from './FilesystemBrowser.svelte';
|
||||
import CodeEditor from '$lib/components/CodeEditor.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { currentEnvironment, environments } from '$lib/stores/environment';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
|
||||
interface DiscoveredStack {
|
||||
name: string;
|
||||
composePath: string;
|
||||
envPath: string | null;
|
||||
sourceDir?: string;
|
||||
serviceCount?: number;
|
||||
running?: boolean;
|
||||
containerCount?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAdopted?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onClose, onAdopted }: Props = $props();
|
||||
|
||||
// View state: 'browse' | 'results'
|
||||
let view = $state<'browse' | 'results'>('browse');
|
||||
|
||||
// Reference to filesystem browser
|
||||
let filesystemBrowser = $state<{ getCurrentPath: () => string | null; addRecentLocation: (path: string) => Promise<void> } | null>(null);
|
||||
|
||||
// Scan results state
|
||||
let scanResults = $state<DiscoveredStack[]>([]);
|
||||
let scanning = $state(false);
|
||||
|
||||
// Selection and adopt state
|
||||
let stackSelections = $state<Map<string, boolean>>(new Map());
|
||||
let adopting = $state(false);
|
||||
|
||||
// Preview dialog state (for single file click)
|
||||
let showPreview = $state(false);
|
||||
let previewFile = $state<FileEntry | null>(null);
|
||||
let previewContent = $state<string | null>(null);
|
||||
let previewServiceCount = $state<number>(0);
|
||||
let loadingPreview = $state(false);
|
||||
|
||||
// Use current environment from store
|
||||
const envId = $derived($currentEnvironment?.id ?? null);
|
||||
const envName = $derived($currentEnvironment?.name ?? 'Unknown');
|
||||
// Look up the icon from the environments list since currentEnvironment doesn't store it
|
||||
const currentEnvData = $derived($environments.find(e => e.id === envId));
|
||||
const envIcon = $derived(currentEnvData?.icon || 'globe');
|
||||
const EnvIconComponent = $derived(getIconComponent(envIcon));
|
||||
|
||||
// Reset when modal closes
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
view = 'browse';
|
||||
scanResults = [];
|
||||
stackSelections = new Map();
|
||||
showPreview = false;
|
||||
previewFile = null;
|
||||
previewContent = null;
|
||||
previewServiceCount = 0;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleFilePreview(entry: FileEntry) {
|
||||
previewFile = entry;
|
||||
showPreview = true;
|
||||
loadingPreview = true;
|
||||
previewContent = null;
|
||||
previewServiceCount = 0;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/system/files/content?path=${encodeURIComponent(entry.path)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
previewContent = data.content || '';
|
||||
// Count services in the compose file
|
||||
const serviceMatches = previewContent?.match(/^services:\s*\n((?:\s{2,}\w+:.*\n?)+)/m);
|
||||
if (serviceMatches) {
|
||||
const servicesBlock = serviceMatches[1];
|
||||
const serviceNames = servicesBlock.match(/^\s{2}\w+:/gm);
|
||||
previewServiceCount = serviceNames?.length || 0;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load preview:', e);
|
||||
} finally {
|
||||
loadingPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAdoptFromPreview() {
|
||||
if (previewFile) {
|
||||
adoptSingleFile(previewFile);
|
||||
showPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScanDirectory(path: string) {
|
||||
scanning = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/stacks/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || 'Failed to scan directory');
|
||||
return;
|
||||
}
|
||||
|
||||
const discovered: DiscoveredStack[] = data.discovered || [];
|
||||
|
||||
// Detect running stacks on current environment
|
||||
if (envId && discovered.length > 0) {
|
||||
try {
|
||||
const runningRes = await fetch(`/api/stacks?env=${envId}`);
|
||||
if (runningRes.ok) {
|
||||
const runningStacks: Array<{ name: string; containers?: any[] }> = await runningRes.json();
|
||||
const runningMap = new Map(
|
||||
runningStacks.map((s) => [s.name.toLowerCase(), s])
|
||||
);
|
||||
|
||||
for (const stack of discovered) {
|
||||
const runningStack = runningMap.get(stack.name.toLowerCase());
|
||||
if (runningStack) {
|
||||
stack.running = true;
|
||||
stack.containerCount = runningStack.containers?.length || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to detect running stacks:', e);
|
||||
}
|
||||
}
|
||||
|
||||
scanResults = discovered;
|
||||
|
||||
if (discovered.length === 0) {
|
||||
toast.info('No compose stacks found in this directory');
|
||||
} else {
|
||||
const selections = new Map<string, boolean>();
|
||||
for (const stack of discovered) {
|
||||
// Don't pre-select stacks that are already running on this environment
|
||||
selections.set(stack.composePath, !stack.running);
|
||||
}
|
||||
stackSelections = selections;
|
||||
view = 'results';
|
||||
}
|
||||
|
||||
await filesystemBrowser?.addRecentLocation(path);
|
||||
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to scan directory');
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function adoptSingleFile(entry: FileEntry) {
|
||||
if (!envId) {
|
||||
toast.error('No environment selected');
|
||||
return;
|
||||
}
|
||||
|
||||
adopting = true;
|
||||
|
||||
try {
|
||||
const parentDir = entry.path.replace(/\/[^/]+$/, '');
|
||||
const stackName = parentDir.split('/').pop() || 'adopted-stack';
|
||||
const envFilePath = `${parentDir}/.env`;
|
||||
|
||||
const stack: DiscoveredStack = {
|
||||
name: stackName,
|
||||
composePath: entry.path,
|
||||
envPath: envFilePath,
|
||||
sourceDir: parentDir
|
||||
};
|
||||
|
||||
const res = await fetch('/api/stacks/adopt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stacks: [stack],
|
||||
environmentId: envId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || 'Failed to adopt stack');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.adopted?.length > 0) {
|
||||
toast.success(`Adopted stack "${data.adopted[0]}"`);
|
||||
await filesystemBrowser?.addRecentLocation(parentDir);
|
||||
onAdopted?.();
|
||||
handleClose();
|
||||
} else if (data.failed?.length > 0) {
|
||||
toast.error(`Failed: ${data.failed[0].error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to adopt');
|
||||
} finally {
|
||||
adopting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdoptSelected() {
|
||||
if (!envId || stackSelections.size === 0) return;
|
||||
|
||||
const selectedStacks = scanResults.filter(s => stackSelections.get(s.composePath));
|
||||
if (selectedStacks.length === 0) return;
|
||||
|
||||
adopting = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/stacks/adopt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stacks: selectedStacks,
|
||||
environmentId: envId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || 'Failed to adopt stacks');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.adopted?.length > 0) {
|
||||
toast.success(`Adopted ${data.adopted.length} stack(s)`);
|
||||
onAdopted?.();
|
||||
handleClose();
|
||||
}
|
||||
if (data.failed?.length > 0) {
|
||||
toast.error(`Failed to adopt ${data.failed.length} stack(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to adopt');
|
||||
} finally {
|
||||
adopting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStack(composePath: string) {
|
||||
const newMap = new Map(stackSelections);
|
||||
newMap.set(composePath, !newMap.get(composePath));
|
||||
stackSelections = newMap;
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const allSelected = scanResults.every(s => stackSelections.get(s.composePath));
|
||||
const newMap = new Map<string, boolean>();
|
||||
for (const stack of scanResults) {
|
||||
newMap.set(stack.composePath, !allSelected);
|
||||
}
|
||||
stackSelections = newMap;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function goBackToBrowse() {
|
||||
view = 'browse';
|
||||
scanResults = [];
|
||||
stackSelections = new Map();
|
||||
}
|
||||
|
||||
const selectedCount = $derived([...stackSelections.values()].filter(v => v).length);
|
||||
const allSelected = $derived(scanResults.length > 0 && scanResults.every(s => stackSelections.get(s.composePath)));
|
||||
|
||||
// Browser title with environment info
|
||||
const browserTitle = $derived.by(() => {
|
||||
const envPart = envName ? ` · ${envName}` : '';
|
||||
return `Adopt stacks${envPart}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if view === 'browse'}
|
||||
<!-- File Browser View - using FilesystemBrowser component -->
|
||||
<FilesystemBrowser
|
||||
bind:this={filesystemBrowser}
|
||||
bind:open
|
||||
title={browserTitle}
|
||||
icon={Import}
|
||||
description="Browse to a compose file or scan a directory for stacks."
|
||||
selectMode="adopt"
|
||||
highlightFilter={/\.ya?ml$/i}
|
||||
onFilePreview={handleFilePreview}
|
||||
onScanDirectory={handleScanDirectory}
|
||||
{scanning}
|
||||
onSelect={() => {}}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Scan Results View -->
|
||||
<Dialog.Root bind:open onOpenChange={(o) => !o && handleClose()}>
|
||||
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
|
||||
<Dialog.Header class="px-6 py-4 border-b shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Import class="w-5 h-5" />
|
||||
Select stacks to adopt
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<EnvIconComponent class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-muted-foreground font-normal">{envName}</span>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{scanResults.length} stack(s) found. Select which ones to adopt into {envName}.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Stack list -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-2">
|
||||
{#each scanResults as stack}
|
||||
{@const isSelected = stackSelections.get(stack.composePath)}
|
||||
{@const countsMismatch = stack.running && stack.serviceCount && stack.containerCount !== stack.serviceCount}
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 rounded-lg border {isSelected ? 'border-primary/50 bg-primary/5' : 'border-border'}"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleStack(stack.composePath)}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium truncate">{stack.name}</span>
|
||||
{#if stack.serviceCount}
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{stack.serviceCount} service{stack.serviceCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if stack.running}
|
||||
<Badge variant="default" class="text-xs {countsMismatch ? 'bg-amber-600' : 'bg-green-600'}">
|
||||
<Play class="w-3 h-3 mr-1" />
|
||||
{stack.containerCount} running
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground truncate mt-0.5" title={stack.composePath}>
|
||||
{stack.composePath}
|
||||
</p>
|
||||
{#if stack.envPath}
|
||||
<p class="text-xs text-muted-foreground truncate" title={stack.envPath}>
|
||||
.env: {stack.envPath}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Adopt info -->
|
||||
<div class="px-4 py-3 border-t shrink-0">
|
||||
<div class="flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5">
|
||||
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you adopt:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track these compose files, letting you edit, start, and stop the stacks from the UI. Your files stay in their current location.</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 border-t flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">{selectedCount}</span> of {scanResults.length} selected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={goBackToBrowse}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onclick={handleAdoptSelected}
|
||||
disabled={adopting || selectedCount === 0}
|
||||
>
|
||||
{#if adopting}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Adopting...
|
||||
{:else}
|
||||
<Import class="w-4 h-4 mr-2" />
|
||||
Adopt {selectedCount} stack(s)
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Preview dialog for single file adopt -->
|
||||
<Dialog.Root bind:open={showPreview}>
|
||||
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col p-0 gap-0">
|
||||
<Dialog.Header class="px-5 py-4 border-b shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Import class="w-5 h-5" />
|
||||
Adopt this stack?
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Review the compose file before adopting.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if previewFile}
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<!-- Stack info bar -->
|
||||
<div class="px-5 py-3 border-b bg-muted/30 flex items-center gap-4 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">Stack:</span>
|
||||
<span class="font-medium">{previewFile.path.replace(/\/[^/]+$/, '').split('/').pop() || 'unknown'}</span>
|
||||
{#if previewServiceCount > 0}
|
||||
<Badge variant="outline" class="text-xs">
|
||||
{previewServiceCount} service{previewServiceCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<code class="text-xs bg-muted px-2 py-1 rounded truncate block">{previewFile.path}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview content with syntax highlighting -->
|
||||
<div class="flex-1 min-h-0 p-3">
|
||||
{#if loadingPreview}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if previewContent}
|
||||
<CodeEditor
|
||||
value={previewContent}
|
||||
language="yaml"
|
||||
theme="dark"
|
||||
readonly={true}
|
||||
class="h-full rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Adopt info -->
|
||||
<div class="px-5 py-3 border-t shrink-0">
|
||||
<div class="flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5">
|
||||
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you adopt:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track this compose file, letting you edit, start, and stop the stack from the UI. Your files stay in their current location.</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="px-5 py-3 border-t flex justify-end gap-2 shrink-0">
|
||||
<Button variant="outline" onclick={() => showPreview = false}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={confirmAdoptFromPreview} disabled={adopting}>
|
||||
{#if adopting}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
Adopting...
|
||||
{:else}
|
||||
<Import class="w-4 h-4 mr-2" />
|
||||
Adopt stack
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { Copy, Check, FolderOpen, FolderSync } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
path: string | null;
|
||||
placeholder: string;
|
||||
onCopy: () => void;
|
||||
onBrowse: () => void;
|
||||
onChangeLocation?: () => void; // Optional: relocate entire folder
|
||||
defaultText?: string;
|
||||
isSuggested?: boolean;
|
||||
copied?: boolean;
|
||||
sourceHint?: string; // e.g., "Using default location"
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
path,
|
||||
placeholder,
|
||||
onCopy,
|
||||
onBrowse,
|
||||
onChangeLocation,
|
||||
defaultText = 'Default location',
|
||||
isSuggested = false,
|
||||
copied = false,
|
||||
sourceHint
|
||||
}: Props = $props();
|
||||
|
||||
// Truncate long paths - only truncate if really necessary
|
||||
function truncatePath(pathStr: string, maxLength: number = 80): { truncated: string; full: string } {
|
||||
if (!pathStr || pathStr.length <= maxLength) return { truncated: pathStr, full: pathStr };
|
||||
|
||||
const parts = pathStr.split('/');
|
||||
const filename = parts.pop() || '';
|
||||
const parent = parts.pop() || '';
|
||||
const grandparent = parts.pop() || '';
|
||||
|
||||
// Try to show more context: .../{grandparent}/{parent}/{filename}
|
||||
let truncated = `.../${grandparent}/${parent}/${filename}`;
|
||||
if (truncated.length > maxLength) {
|
||||
// Fall back to just parent/filename
|
||||
truncated = `.../${parent}/${filename}`;
|
||||
}
|
||||
return { truncated, full: pathStr };
|
||||
}
|
||||
|
||||
const displayPath = $derived(path ? truncatePath(path) : { truncated: '', full: '' });
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<span class="font-medium text-zinc-600 dark:text-zinc-300 shrink-0">{label}</span>
|
||||
<code
|
||||
class="flex-1 min-w-0 truncate font-mono h-6 leading-6 {!path || isSuggested ? 'text-zinc-400 dark:text-zinc-500 italic' : ''}"
|
||||
title={displayPath.full}
|
||||
>
|
||||
{displayPath.truncated || defaultText}
|
||||
</code>
|
||||
<button
|
||||
onclick={onBrowse}
|
||||
class="p-1 rounded transition-colors shrink-0 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
title={`Browse for ${label.toLowerCase()}`}
|
||||
>
|
||||
<FolderOpen class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{#if onChangeLocation}
|
||||
<button
|
||||
onclick={onChangeLocation}
|
||||
class="p-1 rounded transition-colors shrink-0 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
title="Change location"
|
||||
>
|
||||
<FolderSync class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onCopy}
|
||||
class="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors shrink-0 {!path ? 'opacity-40 cursor-not-allowed' : ''}"
|
||||
title="Copy path"
|
||||
disabled={!path}
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="w-3.5 h-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if sourceHint}
|
||||
<span class="text-[10px] text-zinc-400 dark:text-zinc-500 pl-[4.5rem]">{sourceHint}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen, X, Home } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
currentPath?: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
currentPath = null,
|
||||
onSelect
|
||||
}: Props = $props();
|
||||
|
||||
let locations = $state<string[]>([]);
|
||||
let defaultBasePath = $state<string | null>(null);
|
||||
|
||||
// Load recent locations and default base path on mount
|
||||
$effect(() => {
|
||||
loadLocations();
|
||||
loadDefaultBasePath();
|
||||
});
|
||||
|
||||
async function loadDefaultBasePath() {
|
||||
try {
|
||||
const response = await fetch('/api/stacks/base-path');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
defaultBasePath = data.basePath;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load default base path:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await fetch('/api/settings/general');
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
locations = settings.externalStackPaths || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load recent locations:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLocations(newLocations: string[]) {
|
||||
try {
|
||||
await fetch('/api/settings/general', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ externalStackPaths: newLocations })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to save recent locations:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(path: string) {
|
||||
const newLocations = locations.filter(p => p !== path);
|
||||
locations = newLocations;
|
||||
await saveLocations(newLocations);
|
||||
}
|
||||
|
||||
export async function addLocation(path: string) {
|
||||
if (!path || locations.includes(path)) return;
|
||||
const newLocations = [path, ...locations].slice(0, 10);
|
||||
locations = newLocations;
|
||||
await saveLocations(newLocations);
|
||||
}
|
||||
|
||||
export function getFirstLocation(): string | null {
|
||||
return locations[0] || null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-56 border-r p-3 overflow-y-auto shrink-0">
|
||||
<h3 class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">Locations</h3>
|
||||
<div class="space-y-1">
|
||||
<!-- Default Dockhand location (always shown, not removable) -->
|
||||
{#if defaultBasePath}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-muted text-left {currentPath === defaultBasePath ? 'bg-muted' : ''}"
|
||||
onclick={() => onSelect(defaultBasePath!)}
|
||||
>
|
||||
<Home class="w-4 h-4 shrink-0 text-sky-500" />
|
||||
<span class="truncate" title={defaultBasePath}>Dockhand default</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Recent locations -->
|
||||
{#each locations.filter(l => l !== defaultBasePath) as location}
|
||||
<div class="group flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-muted text-left truncate {currentPath === location ? 'bg-muted' : ''}"
|
||||
onclick={() => onSelect(location)}
|
||||
>
|
||||
<FolderOpen class="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate" title={location}>{location.split('/').pop() || location}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 opacity-0 group-hover:opacity-100 hover:bg-muted rounded transition-opacity"
|
||||
onclick={() => handleRemove(location)}
|
||||
title="Remove from recent"
|
||||
>
|
||||
<X class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !defaultBasePath && locations.length === 0}
|
||||
<p class="text-xs text-muted-foreground italic px-2">
|
||||
No locations yet. Browse folders to add them here.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user