mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-03 13:18:56 +00:00
235 lines
7.6 KiB
Svelte
235 lines
7.6 KiB
Svelte
<script lang="ts">
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
|
import { Plus, Trash2, Key, AlertCircle, CheckCircle2, FileText, Pencil, CircleDot } from 'lucide-svelte';
|
|
|
|
export interface EnvVar {
|
|
key: string;
|
|
value: string;
|
|
isSecret: boolean;
|
|
}
|
|
|
|
export interface ValidationResult {
|
|
valid: boolean;
|
|
required: string[];
|
|
optional: string[];
|
|
defined: string[];
|
|
missing: string[];
|
|
unused: string[];
|
|
}
|
|
|
|
interface Props {
|
|
variables: EnvVar[];
|
|
validation?: ValidationResult | null;
|
|
readonly?: boolean;
|
|
showSource?: boolean; // For git stacks - show where variable comes from
|
|
sources?: Record<string, 'file' | 'override'>; // Key -> source mapping
|
|
placeholder?: { key: string; value: string };
|
|
existingSecretKeys?: Set<string>; // Keys of secrets loaded from DB (can't toggle visibility)
|
|
}
|
|
|
|
let {
|
|
variables = $bindable(),
|
|
validation = null,
|
|
readonly = false,
|
|
showSource = false,
|
|
sources = {},
|
|
placeholder = { key: 'VARIABLE_NAME', value: 'value' },
|
|
existingSecretKeys = new Set<string>()
|
|
}: Props = $props();
|
|
|
|
// Check if a variable is an existing secret that was loaded from DB
|
|
function isExistingSecret(key: string, isSecret: boolean): boolean {
|
|
return isSecret && existingSecretKeys.has(key);
|
|
}
|
|
|
|
function addVariable() {
|
|
variables = [...variables, { key: '', value: '', isSecret: false }];
|
|
}
|
|
|
|
function removeVariable(index: number) {
|
|
variables = variables.filter((_, i) => i !== index);
|
|
}
|
|
|
|
function toggleSecret(index: number) {
|
|
variables[index].isSecret = !variables[index].isSecret;
|
|
}
|
|
|
|
// Check if a variable key is missing (required but not defined)
|
|
function isMissing(key: string): boolean {
|
|
return validation?.missing?.includes(key) ?? false;
|
|
}
|
|
|
|
// Check if a variable key is unused (defined but not in compose)
|
|
function isUnused(key: string): boolean {
|
|
return validation?.unused?.includes(key) ?? false;
|
|
}
|
|
|
|
// Check if a variable key is required
|
|
function isRequired(key: string): boolean {
|
|
return validation?.required?.includes(key) ?? false;
|
|
}
|
|
|
|
// Check if a variable key is optional
|
|
function isOptional(key: string): boolean {
|
|
return validation?.optional?.includes(key) ?? false;
|
|
}
|
|
|
|
// Get validation status class for key input
|
|
function getKeyValidationClass(key: string): string {
|
|
if (!key || !validation) return '';
|
|
if (isMissing(key)) return 'border-red-500 dark:border-red-400';
|
|
if (isUnused(key)) return 'border-amber-500 dark:border-amber-400';
|
|
if (isRequired(key) || isOptional(key)) return 'border-green-500 dark:border-green-400';
|
|
return '';
|
|
}
|
|
|
|
// Get source icon for a variable
|
|
function getSource(key: string): 'file' | 'override' | null {
|
|
if (!showSource || !sources) return null;
|
|
return sources[key] || null;
|
|
}
|
|
|
|
// Count non-empty variables
|
|
const variableCount = $derived(variables.filter(v => v.key).length);
|
|
const secretCount = $derived(variables.filter(v => v.key && v.isSecret).length);
|
|
</script>
|
|
|
|
<div class="space-y-3">
|
|
<!-- Variables List -->
|
|
<div class="space-y-3">
|
|
{#each variables as variable, index}
|
|
{@const source = getSource(variable.key)}
|
|
{@const isVarRequired = isRequired(variable.key)}
|
|
{@const isVarOptional = isOptional(variable.key)}
|
|
{@const isVarMissing = isMissing(variable.key)}
|
|
{@const isVarUnused = isUnused(variable.key)}
|
|
<div class="flex gap-2 items-center">
|
|
<!-- Source indicator (for git stacks) - always reserve space if showSource -->
|
|
{#if showSource}
|
|
<div class="flex items-center h-9 w-5 justify-center shrink-0">
|
|
{#if source === 'file'}
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger>
|
|
<FileText class="w-3.5 h-3.5 text-muted-foreground" />
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content><p>From .env file</p></Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{:else if source === 'override'}
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger>
|
|
<Pencil class="w-3.5 h-3.5 text-blue-500" />
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content><p>Manual override</p></Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Validation status indicator - always reserve space if validation exists -->
|
|
{#if validation}
|
|
<div class="flex items-center h-9 w-5 justify-center shrink-0">
|
|
{#if variable.key}
|
|
{#if isVarRequired && !isVarMissing}
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger>
|
|
<CheckCircle2 class="w-4 h-4 text-green-500" />
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content><p>Required variable defined</p></Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{:else if isVarOptional}
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger>
|
|
<CircleDot class="w-4 h-4 text-blue-400" />
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content><p>Optional variable (has default)</p></Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{:else if isVarUnused}
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger>
|
|
<AlertCircle class="w-4 h-4 text-amber-500" />
|
|
</Tooltip.Trigger>
|
|
<Tooltip.Content><p>Unused variable</p></Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Key Input with floating label -->
|
|
<div class="flex-1 relative">
|
|
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Name</span>
|
|
<Input
|
|
bind:value={variable.key}
|
|
disabled={readonly}
|
|
class="h-9 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Value Input with floating label -->
|
|
<div class="flex-1 relative">
|
|
<span class="absolute -top-2 left-2 text-2xs text-muted-foreground bg-background px-1">Value</span>
|
|
<Input
|
|
bind:value={variable.value}
|
|
type={variable.isSecret ? 'password' : 'text'}
|
|
disabled={readonly}
|
|
class="h-9 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Secret Toggle Button -->
|
|
{#if !readonly}
|
|
{@const existingSecret = isExistingSecret(variable.key, variable.isSecret)}
|
|
{#if existingSecret}
|
|
<!-- Existing secret from DB - show locked icon, no toggle (value can still be modified) -->
|
|
<div class="flex items-center h-9 w-9 justify-center shrink-0" title="Secret value (cannot unhide)">
|
|
<Key class="w-3.5 h-3.5 text-amber-500" />
|
|
</div>
|
|
{:else}
|
|
<!-- New or non-secret variable - show toggle button -->
|
|
<button
|
|
type="button"
|
|
onclick={() => toggleSecret(index)}
|
|
title={variable.isSecret ? 'Marked as secret' : 'Mark as secret'}
|
|
class="h-9 w-9 flex items-center justify-center rounded-md shrink-0 transition-colors {variable.isSecret ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}"
|
|
>
|
|
<Key class="w-3.5 h-3.5" />
|
|
</button>
|
|
{/if}
|
|
{:else if variable.isSecret}
|
|
<div class="flex items-center h-9 w-9 justify-center shrink-0">
|
|
<Key class="w-3.5 h-3.5 text-amber-500" />
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Remove Button -->
|
|
{#if !readonly}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onclick={() => removeVariable(index)}
|
|
class="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash2 class="w-3.5 h-3.5" />
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
|
|
<!-- Empty state -->
|
|
{#if variables.length === 0}
|
|
<div class="text-center py-6 text-muted-foreground">
|
|
<p class="text-sm">No environment variables defined.</p>
|
|
{#if !readonly}
|
|
<Button type="button" variant="link" onclick={addVariable} class="mt-1 text-xs">
|
|
<Plus class="w-3 h-3 mr-1" />
|
|
Add your first variable
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|