Files
dockhand/routes/settings/environments/EnvironmentModal.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

2531 lines
93 KiB
Svelte

<script lang="ts">
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import {
Plus,
Trash2,
Pencil,
Globe,
RefreshCw,
CircleArrowUp,
CircleFadingArrowUp,
Check,
ShieldCheck,
Activity,
Bell,
Download,
Play,
Square,
RotateCcw,
Skull,
Heart,
AlertTriangle,
Layers,
Loader2,
Info,
CheckCircle2,
Lock,
LockOpen,
Mail,
Send,
AlertCircle,
GitBranch,
ShieldX,
ShieldAlert,
Shield,
Wifi,
WifiOff,
Unplug,
Key,
Image,
Cpu,
Route,
UndoDot,
HelpCircle,
ExternalLink,
Copy,
Clock,
Icon,
Pipette,
X,
Tags,
ChevronDown,
ChevronRight
} from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Alert from '$lib/components/ui/alert';
import * as Popover from '$lib/components/ui/popover';
import IconPicker from '$lib/components/icon-picker.svelte';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { whale } from '@lucide/lab';
import ImagePullProgressPopover from '../../images/ImagePullProgressPopover.svelte';
import { TogglePill, ToggleGroup } from '$lib/components/ui/toggle-pill';
import { ShieldOff } from 'lucide-svelte';
import { focusFirstInput } from '$lib/utils';
import { authStore, canAccess } from '$lib/stores/auth';
import { licenseStore } from '$lib/stores/license';
import { getLabelColor, getLabelBgColor, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import EventTypesEditor from './EventTypesEditor.svelte';
// Scanner options for ToggleGroup
const scannerOptions = [
{ value: 'grype', label: 'Grype' },
{ value: 'trivy', label: 'Trivy' },
{ value: 'both', label: 'Both', icon: ShieldCheck }
];
// Types
type ConnectionType = 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
interface Environment {
id: number;
name: string;
host?: string;
port: number;
protocol: string;
tlsCa?: string;
tlsCert?: string;
tlsKey?: string;
tlsSkipVerify?: boolean;
icon?: string;
socketPath?: string;
collectActivity: boolean;
collectMetrics: boolean;
highlightChanges: boolean;
connectionType?: ConnectionType;
hawserLastSeen?: string;
hawserAgentId?: string;
hawserAgentName?: string;
hawserVersion?: string;
hawserCapabilities?: string;
publicIp?: string;
createdAt: string;
updatedAt: string;
}
interface HawserToken {
id: number;
tokenPrefix: string;
name: string;
environmentId: number;
isActive: boolean;
lastUsed?: string;
createdAt: string;
expiresAt?: string;
}
interface EnvNotification {
id: number;
environmentId: number;
notificationId: number;
enabled: boolean;
eventTypes: string[];
channelName?: string;
channelType?: 'smtp' | 'apprise';
channelEnabled?: boolean;
}
interface NotificationSetting {
id: number;
type: 'smtp' | 'apprise';
name: string;
enabled: boolean;
config: any;
eventTypes: string[];
createdAt: string;
updatedAt: string;
}
type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
// Notification event types - grouped by category
const NOTIFICATION_EVENT_GROUPS = [
{
id: 'container',
label: 'Container events',
events: [
{ id: 'container_started', label: 'Container started', description: 'When a container starts running' },
{ id: 'container_stopped', label: 'Container stopped', description: 'When a container is stopped' },
{ id: 'container_restarted', label: 'Container restarted', description: 'When a container restarts' },
{ id: 'container_exited', label: 'Container exited', description: 'When a container exits unexpectedly' },
{ id: 'container_unhealthy', label: 'Container unhealthy', description: 'When a container health check fails' },
{ id: 'container_oom', label: 'Container OOM killed', description: 'When a container is killed due to out of memory' },
{ id: 'container_updated', label: 'Container updated', description: 'When a container image is updated' }
]
},
{
id: 'auto_update',
label: 'Auto-update events',
events: [
{ id: 'auto_update_success', label: 'Update succeeded', description: 'Container successfully updated to new image' },
{ id: 'auto_update_failed', label: 'Update failed', description: 'Container auto-update failed' },
{ id: 'auto_update_blocked', label: 'Update blocked by vulns', description: 'Update blocked due to vulnerability criteria' },
{ id: 'updates_detected', label: 'Updates detected', description: 'Container image updates are available (scheduled check)' },
{ id: 'batch_update_success', label: 'Batch update completed', description: 'Scheduled container updates completed successfully' }
]
},
{
id: 'git_stack',
label: 'Git stack events',
events: [
{ id: 'git_sync_success', label: 'Git sync succeeded', description: 'Git stack synced and deployed successfully' },
{ id: 'git_sync_failed', label: 'Git sync failed', description: 'Git stack sync or deploy failed' },
{ id: 'git_sync_skipped', label: 'Git sync skipped', description: 'Git stack sync skipped (no changes)' }
]
},
{
id: 'stack',
label: 'Stack events',
events: [
{ id: 'stack_started', label: 'Stack started', description: 'When a compose stack starts' },
{ id: 'stack_stopped', label: 'Stack stopped', description: 'When a compose stack stops' },
{ id: 'stack_deployed', label: 'Stack deployed', description: 'Stack deployed (new or update)' },
{ id: 'stack_deploy_failed', label: 'Stack deploy failed', description: 'Stack deployment failed' }
]
},
{
id: 'security',
label: 'Security events',
events: [
{ id: 'vulnerability_critical', label: 'Critical vulns found', description: 'Critical vulnerabilities found in image scan' },
{ id: 'vulnerability_high', label: 'High vulns found', description: 'High severity vulnerabilities found' },
{ id: 'vulnerability_any', label: 'Any vulns found', description: 'Any vulnerabilities found (medium/low)' }
]
},
{
id: 'system',
label: 'System events',
events: [
{ id: 'image_pulled', label: 'Image pulled', description: 'When a new image is pulled' },
{ id: 'environment_offline', label: 'Environment offline', description: 'Environment became unreachable' },
{ id: 'environment_online', label: 'Environment online', description: 'Environment came back online' },
{ id: 'disk_space_warning', label: 'Disk space warning', description: 'Docker disk usage exceeds threshold' }
// Note: license_expiring is a global event configured at the notification channel level
]
}
];
// Flat list of all event types (for backwards compatibility)
const NOTIFICATION_EVENT_TYPES = NOTIFICATION_EVENT_GROUPS.flatMap(g => g.events);
// Props
interface Props {
open: boolean;
environment?: Environment | null;
notifications: NotificationSetting[];
existingLabels?: string[];
onClose: () => void;
onSaved: () => void;
onScannerStatusChange?: (envId: number, enabled: boolean) => void;
}
let { open = $bindable(), environment = null, notifications, existingLabels = [], onClose, onSaved, onScannerStatusChange }: Props = $props();
// Derived
const isEditing = $derived(environment !== null);
// Filtered label suggestions (not already selected, matching input)
const filteredLabelSuggestions = $derived.by(() => {
const input = newLabelInput.trim().toLowerCase();
return existingLabels
.filter(label => !formLabels.includes(label))
.filter(label => !input || label.toLowerCase().includes(input));
});
// Modal tab state
let modalTab = $state<string>('general');
// Form state
let formName = $state('');
let formHost = $state('');
let formPort = $state(2375); // Default for direct Docker connection
let formProtocol = $state('http');
let formTlsCa = $state('');
let formTlsCert = $state('');
let formTlsKey = $state('');
let formTlsSkipVerify = $state(false);
let formIcon = $state('globe');
let formSocketPath = $state('/var/run/docker.sock');
let formCollectActivity = $state(true);
let formCollectMetrics = $state(true);
let formHighlightChanges = $state(true);
let formConnectionType = $state<ConnectionType>('socket');
let formHawserToken = $state('');
let formLabels = $state<string[]>([]);
let newLabelInput = $state('');
let showLabelDropdown = $state(false);
let formPublicIp = $state('');
let formTimezone = $state('UTC');
let formError = $state('');
let formErrors = $state<{ name?: string; host?: string }>({});
let formSaving = $state(false);
/**
* Clean a PEM certificate - remove leading/trailing whitespace from each line
* This handles copy/paste from formatted output with line numbers
*/
function cleanCertificate(cert: string | undefined): string | undefined {
if (!cert) return undefined;
const cleaned = cert
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('\n');
return cleaned || undefined;
}
/**
* Extract hostname/IP from a URL string
* Handles tcp://, http://, https:// protocols and plain hostnames
*/
function extractHostFromUrl(url: string): string | null {
if (!url) return null;
// Handle tcp://, http://, https:// protocols
const match = url.match(/^(?:\w+:\/\/)?([^:\/]+)/);
return match ? match[1] : null;
}
/**
* Auto-copy host to publicIp when user enters host value
* @param force - If true, always update publicIp (used on blur)
*/
function handleHostInput(force = false) {
if ((force || !formPublicIp) && formHost) {
const extracted = extractHostFromUrl(formHost.trim());
if (extracted && extracted !== 'localhost' && extracted !== '127.0.0.1') {
formPublicIp = extracted;
}
}
}
// Hawser state (simplified - one token per environment)
let hawserToken = $state<HawserToken | null>(null);
let hawserTokenLoading = $state(false);
let generatingToken = $state(false);
let generatedToken = $state<string | null>(null); // Full token shown once after generation
let copySuccess = $state(false);
let copyCmdSuccess = $state(false);
// For add mode - auto-generated token stored until save
let pendingToken = $state<string | null>(null);
// Test connection state
let testingConnection = $state(false);
let testResult = $state<{ success: boolean; info?: any; error?: string; isEdgeMode?: boolean } | null>(null);
// Socket detection state
let detectingSockets = $state(false);
let detectedSockets = $state<{ path: string; name: string }[]>([]);
let showSocketDropdown = $state(false);
// Add mode specific form state
let formEnableScanner = $state(false);
let formScannerType = $state<ScannerType>('both');
let formSelectedNotifications = $state<{ id: number; eventTypes: string[] }[]>([]);
// Scanner settings state (for edit mode)
let scannerEnabled = $state(false);
let selectedScanner = $state<ScannerType>('both');
let scannerAvailability = $state<{ grype: boolean; trivy: boolean }>({ grype: false, trivy: false });
let scannerVersions = $state<{ grype: string | null; trivy: string | null }>({ grype: null, trivy: null });
let scannerLoading = $state(true);
let loadingScannerVersions = $state(false);
let removingGrype = $state(false);
let removingTrivy = $state(false);
let checkingGrypeUpdate = $state(false);
let checkingTrivyUpdate = $state(false);
let grypeUpdateStatus = $state<'idle' | 'up-to-date' | 'update-available'>('idle');
let trivyUpdateStatus = $state<'idle' | 'up-to-date' | 'update-available'>('idle');
let pullingGrype = $state(false);
let pullingTrivy = $state(false);
// Environment notifications state (for edit mode)
let envNotifications = $state<EnvNotification[]>([]);
let envNotifLoading = $state(false);
let collapsedChannels = $state<Set<number>>(new Set());
function toggleChannelCollapse(channelId: number) {
if (collapsedChannels.has(channelId)) {
collapsedChannels = new Set([...collapsedChannels].filter(id => id !== channelId));
} else {
collapsedChannels = new Set([...collapsedChannels, channelId]);
}
}
// Update check settings state
let updateCheckEnabled = $state(false);
let updateCheckCron = $state('0 4 * * *'); // Default: 4 AM daily
let updateCheckAutoUpdate = $state(false);
let updateCheckVulnerabilityCriteria = $state<VulnerabilityCriteria>('never');
let updateCheckLoading = $state(false);
// === Validation Functions ===
function isValidHost(host: string): boolean {
if (!host) return false;
// IPv4 pattern
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Pattern.test(host)) {
// Validate each octet is 0-255
const octets = host.split('.');
return octets.every(o => {
const num = parseInt(o, 10);
return num >= 0 && num <= 255;
});
}
// IPv6 pattern (simplified - allows :: shorthand)
const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
if (ipv6Pattern.test(host) || host === '::1') {
return true;
}
// Hostname pattern (allows letters, numbers, hyphens, dots)
// Must start with letter/number, can contain dots for subdomains
const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
return hostnamePattern.test(host);
}
// === Form Functions ===
function resetForm() {
if (environment) {
formName = environment.name;
formHost = environment.host || '';
formPort = environment.port;
formProtocol = environment.protocol;
formTlsCa = environment.tlsCa || '';
formTlsCert = environment.tlsCert || '';
formTlsKey = environment.tlsKey || '';
formTlsSkipVerify = environment.tlsSkipVerify ?? false;
formIcon = environment.icon || 'globe';
formSocketPath = environment.socketPath || '/var/run/docker.sock';
formCollectActivity = environment.collectActivity ?? true;
formCollectMetrics = environment.collectMetrics ?? true;
formHighlightChanges = environment.highlightChanges ?? true;
formConnectionType = (environment.connectionType as ConnectionType) || 'socket';
formHawserToken = environment.hawserToken || '';
formLabels = parseLabels(environment.labels);
newLabelInput = '';
formPublicIp = environment.publicIp || '';
modalTab = 'general';
// Load scanner settings, notifications, update check settings, and timezone
loadScannerSettings(environment.id);
loadEnvNotifications(environment.id);
loadUpdateCheckSettings(environment.id);
loadTimezone(environment.id);
// Load Hawser token if edge mode
if (formConnectionType === 'hawser-edge') {
loadHawserToken(environment.id);
}
} else {
formName = '';
formHost = '';
formPort = 2375;
formProtocol = 'http';
formTlsCa = '';
formTlsCert = '';
formTlsKey = '';
formTlsSkipVerify = false;
formIcon = 'globe';
formSocketPath = '/var/run/docker.sock';
formCollectActivity = true;
formCollectMetrics = true;
formHighlightChanges = true;
formConnectionType = 'socket';
formHawserToken = '';
formLabels = [];
newLabelInput = '';
formPublicIp = '';
formEnableScanner = false;
formScannerType = 'both';
formSelectedNotifications = [];
modalTab = 'general';
scannerEnabled = false;
envNotifications = [];
envNotifLoading = false;
hawserToken = null;
generatedToken = null;
pendingToken = null;
// Reset update check settings
updateCheckEnabled = false;
updateCheckCron = '0 4 * * *';
updateCheckAutoUpdate = false;
// Load default timezone from global settings
loadDefaultTimezone();
}
formError = '';
formErrors = {};
formSaving = false;
testingConnection = false;
testResult = null;
detectingSockets = false;
detectedSockets = [];
showSocketDropdown = false;
}
// Track which environment was initialized to avoid repeated resets
let lastInitializedEnvId = $state<number | null | undefined>(undefined);
// Reset form when modal opens OR when environment prop changes (for edit mode)
$effect(() => {
if (open) {
const currentEnvId = environment?.id ?? null;
if (lastInitializedEnvId !== currentEnvId) {
lastInitializedEnvId = currentEnvId;
resetForm();
}
} else {
lastInitializedEnvId = undefined;
}
});
// === Client-side token generation for add mode ===
function generatePendingToken() {
// Generate a secure token client-side (32 bytes = 256 bits, base64url encoded)
const array = new Uint8Array(32);
crypto.getRandomValues(array);
// Convert to base64url (same format the server uses)
const base64 = btoa(String.fromCharCode(...array));
pendingToken = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// === Test Connection ===
async function testConnection() {
testingConnection = true;
testResult = null;
try {
const response = await fetch('/api/environments/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connectionType: formConnectionType,
socketPath: formSocketPath,
host: formHost,
port: formPort,
protocol: formProtocol,
tlsCa: cleanCertificate(formTlsCa),
tlsCert: cleanCertificate(formTlsCert),
tlsKey: cleanCertificate(formTlsKey),
tlsSkipVerify: formTlsSkipVerify,
hawserToken: formHawserToken || pendingToken
})
});
const result = await response.json();
testResult = result;
if (result.success) {
if (result.isEdgeMode) {
toast.info('Edge mode - connection will be tested when agent connects');
} else {
toast.success(`Connected! Docker ${result.info.serverVersion} - ${result.info.containers} containers`);
}
} else {
toast.error(result.error || 'Connection failed');
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection test failed';
testResult = { success: false, error: message };
toast.error(message);
} finally {
testingConnection = false;
}
}
// === Socket Detection ===
async function detectDockerSockets() {
detectingSockets = true;
try {
const response = await fetch('/api/environments/detect-socket');
const result = await response.json();
detectedSockets = result.sockets || [];
if (detectedSockets.length === 0) {
toast.error('No Docker sockets found');
} else if (detectedSockets.length === 1) {
// Auto-select if only one found
formSocketPath = detectedSockets[0].path;
toast.success(`Found ${detectedSockets[0].name}`);
} else {
// Show dropdown to select
showSocketDropdown = true;
toast.success(`Found ${detectedSockets.length} Docker sockets`);
}
} catch (error) {
toast.error('Failed to detect sockets');
} finally {
detectingSockets = false;
}
}
function selectSocket(path: string) {
formSocketPath = path;
showSocketDropdown = false;
}
// === Environment CRUD ===
async function createEnvironment() {
// Validation based on connection type
formErrors = {};
let hasErrors = false;
if (!formName.trim()) {
formErrors.name = 'Name is required';
hasErrors = true;
}
// Host is only required for direct and hawser-standard connection types
if (formConnectionType === 'direct' || formConnectionType === 'hawser-standard') {
if (!formHost.trim()) {
formErrors.host = 'Host is required';
hasErrors = true;
} else if (!isValidHost(formHost.trim())) {
formErrors.host = 'Invalid host. Enter a valid IP address or hostname.';
hasErrors = true;
}
}
if (hasErrors) return;
formSaving = true;
formError = '';
try {
const response = await fetch('/api/environments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim(),
host: formConnectionType === 'hawser-edge' ? 'edge-agent' : (formConnectionType === 'socket' ? undefined : formHost.trim()),
port: formConnectionType === 'socket' ? undefined : formPort,
protocol: formConnectionType === 'socket' ? undefined : formProtocol,
tlsCa: cleanCertificate(formTlsCa),
tlsCert: cleanCertificate(formTlsCert),
tlsKey: cleanCertificate(formTlsKey),
tlsSkipVerify: formTlsSkipVerify,
icon: formIcon,
socketPath: formConnectionType === 'socket' ? formSocketPath : undefined,
collectActivity: formCollectActivity,
collectMetrics: formCollectMetrics,
highlightChanges: formHighlightChanges,
labels: formLabels,
connectionType: formConnectionType,
hawserToken: formHawserToken || undefined,
publicIp: formConnectionType !== 'hawser-edge' ? (formPublicIp.trim() || undefined) : undefined
})
});
if (response.ok) {
const newEnv = await response.json();
// If scanner was enabled, save scanner settings for the new environment
if (formEnableScanner && newEnv?.id) {
scannerEnabled = true;
selectedScanner = formScannerType;
await saveScannerSettings(newEnv.id);
}
// If notification channels were selected, save them for the new environment
if (formSelectedNotifications.length > 0 && newEnv?.id) {
for (const notif of formSelectedNotifications) {
await fetch(`/api/environments/${newEnv.id}/notifications`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notificationId: notif.id,
enabled: true,
eventTypes: notif.eventTypes
})
});
}
}
// If edge mode with pending token, save the token
if (formConnectionType === 'hawser-edge' && pendingToken && newEnv?.id) {
await fetch('/api/hawser/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim() || 'default',
environmentId: newEnv.id,
rawToken: pendingToken // Send the pre-generated token
})
});
}
// Save update check settings if enabled
if (updateCheckEnabled && newEnv?.id) {
await saveUpdateCheckSettings(newEnv.id);
}
// Save timezone if not default
if (newEnv?.id) {
await saveTimezone(newEnv.id);
}
onSaved();
onClose();
} else {
const data = await response.json();
formError = data.error || 'Failed to create environment';
}
} catch (error) {
formError = 'Failed to create environment';
} finally {
formSaving = false;
}
}
async function updateEnvironment() {
if (!environment) return;
formErrors = {};
let hasErrors = false;
if (!formName.trim()) {
formErrors.name = 'Name is required';
hasErrors = true;
}
// Host is only required for direct and hawser-standard connection types
if (formConnectionType === 'direct' || formConnectionType === 'hawser-standard') {
if (!formHost.trim()) {
formErrors.host = 'Host is required';
hasErrors = true;
} else if (!isValidHost(formHost.trim())) {
formErrors.host = 'Invalid host. Enter a valid IP address or hostname.';
hasErrors = true;
}
}
if (hasErrors) return;
formSaving = true;
formError = '';
try {
const response = await fetch(`/api/environments/${environment.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim(),
host: formConnectionType === 'hawser-edge' ? 'edge-agent' : (formConnectionType === 'socket' ? undefined : formHost.trim()),
port: formConnectionType === 'socket' ? undefined : formPort,
protocol: formConnectionType === 'socket' ? undefined : formProtocol,
tlsCa: cleanCertificate(formTlsCa),
tlsCert: cleanCertificate(formTlsCert),
tlsKey: cleanCertificate(formTlsKey),
tlsSkipVerify: formTlsSkipVerify,
icon: formIcon,
socketPath: formConnectionType === 'socket' ? formSocketPath : undefined,
collectActivity: formCollectActivity,
collectMetrics: formCollectMetrics,
highlightChanges: formHighlightChanges,
labels: formLabels,
connectionType: formConnectionType,
hawserToken: formHawserToken || undefined,
publicIp: formConnectionType !== 'hawser-edge' ? (formPublicIp.trim() || null) : null
})
});
if (response.ok) {
await saveScannerSettings(environment.id);
await saveUpdateCheckSettings(environment.id);
await saveTimezone(environment.id);
toast.success(`Updated environment: ${formName}`);
onSaved();
onClose();
} else {
const data = await response.json();
formError = data.error || 'Failed to update environment';
}
} catch (error) {
formError = 'Failed to update environment';
} finally {
formSaving = false;
}
}
// === Timezone Functions ===
async function loadTimezone(envId: number) {
try {
const response = await fetch(`/api/environments/${envId}/timezone`);
if (response.ok) {
const data = await response.json();
formTimezone = data.timezone || 'UTC';
}
} catch (error) {
console.error('Failed to load timezone:', error);
formTimezone = 'UTC';
}
}
async function loadDefaultTimezone() {
try {
const response = await fetch('/api/settings/general');
if (response.ok) {
const data = await response.json();
formTimezone = data.defaultTimezone || 'UTC';
}
} catch (error) {
console.error('Failed to load default timezone:', error);
formTimezone = 'UTC';
}
}
async function saveTimezone(envId: number) {
try {
await fetch(`/api/environments/${envId}/timezone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timezone: formTimezone })
});
} catch (error) {
console.error('Failed to save timezone:', error);
}
}
// === Scanner Settings Functions ===
async function loadScannerSettings(envId?: number) {
scannerLoading = true;
scannerVersions = { grype: null, trivy: null };
try {
const envParam = envId !== undefined ? `&env=${envId}` : '';
const settingsResponse = await fetch(`/api/settings/scanner?settingsOnly=true${envParam}`);
const settingsData = await settingsResponse.json();
if (settingsData.settings) {
const savedScanner = settingsData.settings.scanner || 'none';
scannerEnabled = savedScanner !== 'none';
selectedScanner = savedScanner === 'none' ? 'both' : savedScanner;
}
scannerLoading = false;
loadScannerVersionsAsync(envId);
} catch (error) {
console.error('Failed to load scanner settings:', error);
scannerLoading = false;
}
}
async function loadScannerVersionsAsync(envId?: number) {
loadingScannerVersions = true;
try {
const envParam = envId !== undefined ? `env=${envId}` : '';
const fullResponse = await fetch(`/api/settings/scanner?${envParam}`);
const fullData = await fullResponse.json();
if (fullData.availability) {
scannerAvailability = fullData.availability;
}
if (fullData.versions) {
scannerVersions = fullData.versions;
}
} catch (error) {
console.error('Failed to load scanner versions:', error);
} finally {
loadingScannerVersions = false;
}
}
async function saveScannerSettings(envId?: number) {
try {
const response = await fetch('/api/settings/scanner', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scanner: scannerEnabled ? selectedScanner : 'none',
envId
})
});
const data = await response.json();
if (data.success && envId !== undefined) {
onScannerStatusChange?.(envId, scannerEnabled);
}
} catch (error) {
console.error('Failed to save scanner settings:', error);
}
}
// === Update Check Settings Functions ===
async function loadUpdateCheckSettings(envId: number) {
updateCheckLoading = true;
try {
const response = await fetch(`/api/environments/${envId}/update-check`);
if (response.ok) {
const data = await response.json();
if (data.settings) {
updateCheckEnabled = data.settings.enabled ?? false;
updateCheckCron = data.settings.cron || '0 4 * * *';
updateCheckAutoUpdate = data.settings.autoUpdate ?? false;
updateCheckVulnerabilityCriteria = data.settings.vulnerabilityCriteria || 'never';
} else {
// No settings found - use defaults
updateCheckEnabled = false;
updateCheckCron = '0 4 * * *';
updateCheckAutoUpdate = false;
updateCheckVulnerabilityCriteria = 'never';
}
}
} catch (error) {
console.error('Failed to load update check settings:', error);
} finally {
updateCheckLoading = false;
}
}
async function saveUpdateCheckSettings(envId: number) {
try {
await fetch(`/api/environments/${envId}/update-check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: updateCheckEnabled,
cron: updateCheckCron,
autoUpdate: updateCheckAutoUpdate,
vulnerabilityCriteria: updateCheckVulnerabilityCriteria
})
});
} catch (error) {
console.error('Failed to save update check settings:', error);
}
}
async function removeGrype(envId?: number) {
removingGrype = true;
try {
const envParam = envId !== undefined ? `&env=${envId}` : '';
const response = await fetch(`/api/settings/scanner?removeImages=true&scanner=grype${envParam}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
scannerAvailability = { ...scannerAvailability, grype: false };
scannerVersions = { ...scannerVersions, grype: null };
}
} catch (error) {
console.error('Failed to remove Grype:', error);
} finally {
removingGrype = false;
}
}
async function removeTrivy(envId?: number) {
removingTrivy = true;
try {
const envParam = envId !== undefined ? `&env=${envId}` : '';
const response = await fetch(`/api/settings/scanner?removeImages=true&scanner=trivy${envParam}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
scannerAvailability = { ...scannerAvailability, trivy: false };
scannerVersions = { ...scannerVersions, trivy: null };
}
} catch (error) {
console.error('Failed to remove Trivy:', error);
} finally {
removingTrivy = false;
}
}
async function checkGrypeUpdate() {
checkingGrypeUpdate = true;
grypeUpdateStatus = 'idle';
try {
const response = await fetch('/api/settings/scanner?checkUpdates=true');
const data = await response.json();
if (data.updates) {
grypeUpdateStatus = data.updates.grype?.hasUpdate ? 'update-available' : 'up-to-date';
setTimeout(() => { grypeUpdateStatus = 'idle'; }, 3000);
}
} catch (error) {
console.error('Failed to check Grype update:', error);
} finally {
checkingGrypeUpdate = false;
}
}
async function checkTrivyUpdate() {
checkingTrivyUpdate = true;
trivyUpdateStatus = 'idle';
try {
const response = await fetch('/api/settings/scanner?checkUpdates=true');
const data = await response.json();
if (data.updates) {
trivyUpdateStatus = data.updates.trivy?.hasUpdate ? 'update-available' : 'up-to-date';
setTimeout(() => { trivyUpdateStatus = 'idle'; }, 3000);
}
} catch (error) {
console.error('Failed to check Trivy update:', error);
} finally {
checkingTrivyUpdate = false;
}
}
async function pullGrypeImage() {
if (pullingGrype) return;
pullingGrype = true;
grypeUpdateStatus = 'idle';
try {
const pullUrl = environment?.id ? `/api/images/pull?env=${environment.id}` : '/api/images/pull';
const response = await fetch(pullUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: 'anchore/grype:latest' })
});
if (!response.ok) {
throw new Error('Failed to pull Grype image');
}
// Read SSE stream to completion
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
if (text.includes('"status":"error"')) {
throw new Error('Pull failed');
}
}
}
// Refresh scanner status after pull
await checkScannerImages();
grypeUpdateStatus = 'up-to-date';
setTimeout(() => { grypeUpdateStatus = 'idle'; }, 3000);
} catch (error) {
console.error('Failed to pull Grype image:', error);
} finally {
pullingGrype = false;
}
}
async function pullTrivyImage() {
if (pullingTrivy) return;
pullingTrivy = true;
trivyUpdateStatus = 'idle';
try {
const pullUrl = environment?.id ? `/api/images/pull?env=${environment.id}` : '/api/images/pull';
const response = await fetch(pullUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: 'aquasec/trivy:latest' })
});
if (!response.ok) {
throw new Error('Failed to pull Trivy image');
}
// Read SSE stream to completion
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
if (text.includes('"status":"error"')) {
throw new Error('Pull failed');
}
}
}
// Refresh scanner status after pull
await checkScannerImages();
trivyUpdateStatus = 'up-to-date';
setTimeout(() => { trivyUpdateStatus = 'idle'; }, 3000);
} catch (error) {
console.error('Failed to pull Trivy image:', error);
} finally {
pullingTrivy = false;
}
}
// === Notification Functions ===
async function loadEnvNotifications(envId: number) {
envNotifLoading = true;
try {
const response = await fetch(`/api/environments/${envId}/notifications`);
if (response.ok) {
envNotifications = await response.json();
}
} catch (error) {
console.error('Failed to load environment notifications:', error);
} finally {
envNotifLoading = false;
}
}
async function addEnvNotification(envId: number, notificationId: number) {
try {
if (environment && !environment.collectActivity) {
await fetch(`/api/environments/${envId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collectActivity: true })
});
environment.collectActivity = true;
formCollectActivity = true;
}
const response = await fetch(`/api/environments/${envId}/notifications`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notificationId })
});
if (response.ok) {
await loadEnvNotifications(envId);
toast.success('Notification channel added');
} else {
const data = await response.json();
toast.error(data.error || 'Failed to add notification channel');
}
} catch (error) {
console.error('Failed to add environment notification:', error);
toast.error('Failed to add notification channel');
}
}
async function updateEnvNotification(envId: number, notificationId: number, data: { enabled?: boolean; eventTypes?: string[] }) {
const idx = envNotifications.findIndex(n => n.notificationId === notificationId);
if (idx !== -1) {
if (data.enabled !== undefined) envNotifications[idx].enabled = data.enabled;
if (data.eventTypes !== undefined) envNotifications[idx].eventTypes = data.eventTypes;
}
try {
await fetch(`/api/environments/${envId}/notifications/${notificationId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} catch (error) {
console.error('Failed to update environment notification:', error);
await loadEnvNotifications(envId);
}
}
async function deleteEnvNotification(envId: number, notificationId: number) {
try {
const response = await fetch(`/api/environments/${envId}/notifications/${notificationId}`, {
method: 'DELETE'
});
if (response.ok) {
await loadEnvNotifications(envId);
}
} catch (error) {
console.error('Failed to delete environment notification:', error);
}
}
// === Hawser Token Functions (simplified - one token per environment) ===
async function loadHawserToken(envId: number) {
hawserTokenLoading = true;
try {
const response = await fetch('/api/hawser/tokens');
if (response.ok) {
const allTokens = await response.json();
const tokens = allTokens.filter((t: HawserToken) => t.environmentId === envId);
hawserToken = tokens.length > 0 ? tokens[0] : null;
}
} catch (error) {
console.error('Failed to load Hawser token:', error);
} finally {
hawserTokenLoading = false;
}
}
async function generateHawserToken(envId: number) {
generatingToken = true;
try {
const response = await fetch('/api/hawser/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim() || 'default',
environmentId: envId
})
});
if (response.ok) {
const data = await response.json();
generatedToken = data.token;
await loadHawserToken(envId);
toast.success('Token generated successfully');
} else {
const data = await response.json();
toast.error(data.error || 'Failed to generate token');
}
} catch (error) {
console.error('Failed to generate Hawser token:', error);
toast.error('Failed to generate token');
} finally {
generatingToken = false;
}
}
async function regenerateHawserToken(envId: number) {
// Delete existing token first, then generate new one
if (hawserToken) {
try {
await fetch(`/api/hawser/tokens?id=${hawserToken.id}`, { method: 'DELETE' });
} catch (error) {
console.error('Failed to revoke old token:', error);
}
}
await generateHawserToken(envId);
}
function copyToken(token: string) {
navigator.clipboard.writeText(token);
copySuccess = true;
setTimeout(() => { copySuccess = false; }, 2000);
}
function copyCommand(token: string) {
const cmd = `DOCKHAND_SERVER_URL=${getConnectionUrl()} TOKEN=${token} hawser`;
navigator.clipboard.writeText(cmd);
copyCmdSuccess = true;
setTimeout(() => { copyCmdSuccess = false; }, 2000);
}
function getConnectionUrl() {
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
const host = typeof window !== 'undefined' ? window.location.host : 'your-dockhand-server';
return `${protocol === 'https:' ? 'wss' : 'ws'}://${host}/api/hawser/connect`;
}
</script>
<Dialog.Root bind:open onOpenChange={(o) => { if (o) focusFirstInput(); else onClose(); }}>
<Dialog.Content class="max-w-2xl min-h-[800px] max-h-[90vh] flex flex-col overflow-hidden">
<Dialog.Header class="flex-shrink-0 border-b pb-4">
<Dialog.Title class="flex items-center gap-2">
{#if !isEditing}
Add environment
{:else}
Edit environment
{/if}
{#if environment}
<Badge variant="secondary" class="text-xs">{environment.name}</Badge>
{/if}
</Dialog.Title>
</Dialog.Header>
{#if formError}
<div class="text-sm text-red-600 dark:text-red-400 px-1 pt-4">{formError}</div>
{/if}
<Tabs.Root bind:value={modalTab} class="flex-1 flex flex-col overflow-hidden mt-4">
<Tabs.List class="flex-shrink-0 mb-0 w-full grid grid-cols-5">
<Tabs.Trigger value="general" class="flex items-center justify-center gap-1.5">
<Globe class="w-3.5 h-3.5" />
General
</Tabs.Trigger>
<Tabs.Trigger value="updates" class="flex items-center justify-center gap-1.5">
<CircleFadingArrowUp class="w-3.5 h-3.5" />
Updates
</Tabs.Trigger>
<Tabs.Trigger value="activity" class="flex items-center justify-center gap-1.5">
<Activity class="w-3.5 h-3.5" />
Activity
</Tabs.Trigger>
<Tabs.Trigger value="security" class="flex items-center justify-center gap-1.5">
<ShieldCheck class="w-3.5 h-3.5" />
Security
</Tabs.Trigger>
<Tabs.Trigger value="notifications" class="flex items-center justify-center gap-1.5">
<Bell class="w-3.5 h-3.5" />
Notifications
</Tabs.Trigger>
</Tabs.List>
<div class="overflow-y-auto py-4 h-[520px]">
<!-- General Tab (Connection Settings) -->
<Tabs.Content value="general" class="space-y-4 mt-0 h-full">
<!-- Name field -->
<div class="space-y-2">
<Label for="edit-env-name">Name</Label>
<div class="flex gap-2">
<IconPicker value={formIcon} onchange={(icon) => formIcon = icon} />
<Input
id="edit-env-name"
bind:value={formName}
placeholder="Production"
class="flex-1 {formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}"
oninput={() => formErrors.name = undefined}
/>
</div>
{#if formErrors.name}
<p class="text-xs text-destructive">{formErrors.name}</p>
{/if}
</div>
<!-- Labels section -->
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label>Labels</Label>
<span class="text-xs text-muted-foreground">({formLabels.length}/{MAX_LABELS})</span>
</div>
{#if formLabels.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each formLabels as label}
<Badge
variant="secondary"
class="gap-1 pr-1 rounded-md"
style="background-color: {getLabelBgColor(label)}; border-color: {getLabelColor(label)}; color: {getLabelColor(label)};"
>
{label}
<button
type="button"
onclick={() => formLabels = formLabels.filter(l => l !== label)}
class="ml-0.5 rounded-full hover:bg-black/10 p-0.5"
>
<X class="w-3 h-3" />
</button>
</Badge>
{/each}
</div>
{/if}
{#if formLabels.length < MAX_LABELS}
<div class="flex gap-2">
<div class="relative flex-1">
<Input
bind:value={newLabelInput}
placeholder="Add label..."
onfocus={() => showLabelDropdown = true}
onblur={() => setTimeout(() => showLabelDropdown = false, 150)}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = newLabelInput.trim().toLowerCase();
if (trimmed && !formLabels.includes(trimmed)) {
formLabels = [...formLabels, trimmed];
newLabelInput = '';
}
} else if (e.key === 'Escape') {
showLabelDropdown = false;
}
}}
/>
{#if showLabelDropdown && filteredLabelSuggestions.length > 0}
<div class="absolute z-50 w-full mt-1 bg-popover border rounded-md shadow-md max-h-48 overflow-y-auto">
{#each filteredLabelSuggestions as suggestion}
{@const colors = { color: getLabelColor(suggestion), bgColor: getLabelBgColor(suggestion) }}
<button
type="button"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent flex items-center gap-2"
onmousedown={(e) => {
e.preventDefault();
formLabels = [...formLabels, suggestion];
newLabelInput = '';
}}
>
<span
class="px-1.5 py-0.5 text-2xs rounded-md font-medium"
style="background-color: {colors.bgColor}; color: {colors.color}"
>
{suggestion}
</span>
</button>
{/each}
</div>
{/if}
</div>
<Button
variant="outline"
size="sm"
onclick={() => {
const trimmed = newLabelInput.trim().toLowerCase();
if (trimmed && !formLabels.includes(trimmed)) {
formLabels = [...formLabels, trimmed];
newLabelInput = '';
}
}}
disabled={!newLabelInput.trim() || formLabels.includes(newLabelInput.trim().toLowerCase())}
>
<Plus class="w-4 h-4" />
</Button>
</div>
{:else}
<p class="text-xs text-muted-foreground">Maximum labels reached</p>
{/if}
</div>
<!-- Connection type selector -->
<div class="space-y-2">
<div class="flex items-center gap-1.5">
<Label for="edit-env-connection-type">Connection type</Label>
<Popover.Root>
<Popover.Trigger>
<button type="button" class="text-muted-foreground hover:text-foreground">
<HelpCircle class="w-3.5 h-3.5" />
</button>
</Popover.Trigger>
<Popover.Content class="w-80 text-sm" side="right">
<div class="space-y-3">
<div class="flex items-start gap-2">
<Unplug class="w-4 h-4 mt-0.5 text-cyan-500 shrink-0" />
<div>
<p class="font-medium">Unix socket</p>
<p class="text-xs text-muted-foreground">Connect via Docker socket on the same machine. Default path: /var/run/docker.sock. Also works with Docker Desktop and OrbStack.</p>
</div>
</div>
<div class="flex items-start gap-2">
<Icon iconNode={whale} class="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
<div>
<p class="font-medium">Direct connection</p>
<p class="text-xs text-muted-foreground">Connect directly to Docker Engine API. Requires Docker to expose its API on a TCP port (default 2375/2376). Best for LAN environments.</p>
</div>
</div>
<div class="flex items-start gap-2">
<Route class="w-4 h-4 mt-0.5 text-purple-500 shrink-0" />
<div>
<p class="font-medium">Hawser standard</p>
<p class="text-xs text-muted-foreground">Hawser agent listens on a port and Dockhand connects to it. Good for LAN with static IPs.</p>
</div>
</div>
<div class="flex items-start gap-2">
<UndoDot class="w-4 h-4 mt-0.5 text-green-500 shrink-0" />
<div>
<p class="font-medium">Hawser edge</p>
<p class="text-xs text-muted-foreground">Hawser agent initiates outbound WebSocket to Dockhand. No port forwarding needed. Perfect for VPS, NAT, or dynamic IPs.</p>
</div>
</div>
<a href="https://github.com/Finsys/hawser" target="_blank" class="flex items-center gap-1 text-xs text-blue-500 hover:underline">
<ExternalLink class="w-3 h-3" />
Learn more about Hawser
</a>
</div>
</Popover.Content>
</Popover.Root>
</div>
<Select.Root type="single" value={formConnectionType} onValueChange={(v) => {
formConnectionType = v as ConnectionType;
// Set default port based on connection type
if (v === 'direct') {
formPort = 2375;
} else if (v === 'hawser-standard') {
formPort = 2376;
}
}}>
<Select.Trigger class="w-full">
<span class="flex items-center gap-2">
{#if formConnectionType === 'socket'}
<Unplug class="w-4 h-4 text-cyan-500" />
Unix socket
{:else if formConnectionType === 'direct'}
<Icon iconNode={whale} class="w-4 h-4 text-blue-500" />
Direct connection
{:else if formConnectionType === 'hawser-standard'}
<Route class="w-4 h-4 text-purple-500" />
Hawser agent (standard)
{:else}
<UndoDot class="w-4 h-4 text-green-500" />
Hawser agent (edge)
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="socket">
<span class="flex items-center gap-2">
<Unplug class="w-4 h-4 text-cyan-500" />
Unix socket
</span>
</Select.Item>
<Select.Item value="direct">
<span class="flex items-center gap-2">
<Icon iconNode={whale} class="w-4 h-4 text-blue-500" />
Direct connection
</span>
</Select.Item>
<Select.Item value="hawser-standard">
<span class="flex items-center gap-2">
<Route class="w-4 h-4 text-purple-500" />
Hawser agent (standard)
</span>
</Select.Item>
<Select.Item value="hawser-edge">
<span class="flex items-center gap-2">
<UndoDot class="w-4 h-4 text-green-500" />
Hawser agent (edge)
</span>
</Select.Item>
</Select.Content>
</Select.Root>
<!-- Short description with link -->
<p class="text-xs text-muted-foreground">
{#if formConnectionType === 'socket'}
Connect via Unix socket on the same machine.
{:else if formConnectionType === 'direct'}
Connect directly to Docker Engine API on TCP port.
{:else if formConnectionType === 'hawser-standard'}
<a href="https://github.com/Finsys/hawser" target="_blank" class="text-blue-500 hover:underline">Hawser</a> agent listens, Dockhand connects.
{:else}
<a href="https://github.com/Finsys/hawser" target="_blank" class="text-blue-500 hover:underline">Hawser</a> agent connects out to Dockhand. No port forwarding needed.
{/if}
</p>
</div>
<!-- Socket connection settings -->
{#if formConnectionType === 'socket'}
<div class="space-y-2">
<Label for="edit-env-socket-path">Socket path</Label>
<div class="relative">
<div class="flex gap-2">
<Input
id="edit-env-socket-path"
bind:value={formSocketPath}
placeholder="/var/run/docker.sock"
class="flex-1"
/>
<Button
variant="outline"
size="icon"
onclick={detectDockerSockets}
disabled={detectingSockets}
title="Auto-detect Docker socket"
>
{#if detectingSockets}
<Loader2 class="w-4 h-4 animate-spin" />
{:else}
<Pipette class="w-4 h-4" />
{/if}
</Button>
</div>
{#if showSocketDropdown && detectedSockets.length > 1}
<div class="absolute top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-lg z-50">
<div class="py-1">
{#each detectedSockets as socket}
<button
onclick={() => selectSocket(socket.path)}
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left cursor-pointer"
>
<Unplug class="w-4 h-4 text-muted-foreground shrink-0" />
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{socket.name}</div>
<div class="text-xs text-muted-foreground truncate">{socket.path}</div>
</div>
{#if formSocketPath === socket.path}
<Check class="w-4 h-4 text-primary shrink-0" />
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
<p class="text-xs text-muted-foreground">
Click <Pipette class="w-3 h-3 inline" /> to auto-detect available Docker sockets
</p>
</div>
{/if}
<!-- Direct connection settings -->
{#if formConnectionType === 'direct'}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-env-host">Host</Label>
<Input
id="edit-env-host"
bind:value={formHost}
placeholder="192.168.1.100"
class={formErrors.host ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => { formErrors.host = undefined; handleHostInput(); }}
onblur={() => handleHostInput(true)}
/>
{#if formErrors.host}
<p class="text-xs text-destructive">{formErrors.host}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="edit-env-port">Port</Label>
<Input id="edit-env-port" type="number" bind:value={formPort} />
</div>
</div>
<div class="space-y-2">
<Label for="edit-env-protocol">Protocol</Label>
<Select.Root type="single" value={formProtocol} onValueChange={(v) => formProtocol = v}>
<Select.Trigger class="w-full">
<span class="flex items-center gap-2">
{#if formProtocol === 'https'}
<Lock class="w-4 h-4 text-green-500" />
HTTPS (TLS)
{:else}
<LockOpen class="w-4 h-4 text-muted-foreground" />
HTTP
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="http">
<span class="flex items-center gap-2">
<LockOpen class="w-4 h-4 text-muted-foreground" />
HTTP
</span>
</Select.Item>
<Select.Item value="https">
<span class="flex items-center gap-2">
<Lock class="w-4 h-4 text-green-500" />
HTTPS (TLS)
</span>
</Select.Item>
</Select.Content>
</Select.Root>
</div>
{#if formProtocol === 'https'}
<div class="space-y-4 pt-2 border-t">
<p class="text-xs text-muted-foreground">TLS certificates for mTLS authentication (RSA or ECDSA)</p>
<div class="space-y-2">
<Label for="edit-env-tls_ca">CA certificate</Label>
<textarea
id="edit-env-tls_ca"
bind:value={formTlsCa}
placeholder="-----BEGIN CERTIFICATE-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
<div class="space-y-2">
<Label for="edit-env-tls_cert">Client certificate</Label>
<textarea
id="edit-env-tls_cert"
bind:value={formTlsCert}
placeholder="-----BEGIN CERTIFICATE-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
<div class="space-y-2">
<Label for="edit-env-tls_key">Client key</Label>
<textarea
id="edit-env-tls_key"
bind:value={formTlsKey}
placeholder="-----BEGIN PRIVATE KEY-----"
class="flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
></textarea>
</div>
</div>
{/if}
{/if}
<!-- Hawser standard mode settings -->
{#if formConnectionType === 'hawser-standard'}
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="edit-env-host">Agent host</Label>
<Input
id="edit-env-host"
bind:value={formHost}
placeholder="192.168.1.100"
class={formErrors.host ? 'border-destructive focus-visible:ring-destructive' : ''}
oninput={() => { formErrors.host = undefined; handleHostInput(); }}
onblur={() => handleHostInput(true)}
/>
{#if formErrors.host}
<p class="text-xs text-destructive">{formErrors.host}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="edit-env-port">Agent port</Label>
<Input id="edit-env-port" type="number" bind:value={formPort} placeholder="2376" />
</div>
</div>
<div class="space-y-2">
<Label for="edit-env-protocol">Protocol</Label>
<Select.Root type="single" value={formProtocol} onValueChange={(v) => formProtocol = v}>
<Select.Trigger class="w-full">
<span class="flex items-center gap-2">
{#if formProtocol === 'https'}
<Lock class="w-4 h-4 text-green-500" />
HTTPS (TLS)
{:else}
<LockOpen class="w-4 h-4 text-muted-foreground" />
HTTP
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="http">
<span class="flex items-center gap-2">
<LockOpen class="w-4 h-4 text-muted-foreground" />
HTTP
</span>
</Select.Item>
<Select.Item value="https">
<span class="flex items-center gap-2">
<Lock class="w-4 h-4 text-green-500" />
HTTPS (TLS)
</span>
</Select.Item>
</Select.Content>
</Select.Root>
</div>
{#if formProtocol === 'https'}
<div class="space-y-2">
<Label for="edit-env-hawser-tls-ca">CA certificate (for self-signed)</Label>
<textarea
id="edit-env-hawser-tls-ca"
bind:value={formTlsCa}
placeholder="-----BEGIN CERTIFICATE-----"
class="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring font-mono text-xs"
disabled={formTlsSkipVerify}
></textarea>
<p class="text-xs text-muted-foreground">Paste the CA certificate if agent uses self-signed TLS (RSA or ECDSA).</p>
</div>
<div class="flex items-center justify-between">
<div>
<Label>Skip TLS verification</Label>
<p class="text-xs text-muted-foreground">Disable certificate validation (insecure)</p>
</div>
<TogglePill bind:checked={formTlsSkipVerify} />
</div>
{/if}
<div class="space-y-2">
<Label for="edit-env-hawser-token">Agent token (optional)</Label>
<Input id="edit-env-hawser-token" type="password" bind:value={formHawserToken} placeholder="Token for agent authentication" />
<p class="text-xs text-muted-foreground">If the Hawser agent is configured with TOKEN, enter it here.</p>
</div>
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-2 flex items-start gap-2">
<Info class="w-3 h-3 mt-0.5 shrink-0" />
<span>Run Hawser agent on the target host: <code class="bg-muted px-1 rounded">hawser --port {formPort}</code></span>
</div>
{/if}
<!-- Hawser edge mode settings -->
{#if formConnectionType === 'hawser-edge'}
<div class="space-y-4">
<!-- Connection status (edit mode only) -->
{#if isEditing && environment}
<div class="flex items-center justify-between">
<Label>Connection status</Label>
{#if environment.hawserAgentId}
<Badge variant="outline" class="bg-green-50 text-green-700 border-green-300 dark:bg-green-900/30 dark:text-green-400 dark:border-green-700">
<Wifi class="w-3 h-3 mr-1" />
Connected
</Badge>
{:else}
<Badge variant="outline" class="bg-slate-50 text-slate-500 border-slate-300 dark:bg-slate-900/30 dark:text-slate-400 dark:border-slate-700">
<WifiOff class="w-3 h-3 mr-1" />
Waiting for agent
</Badge>
{/if}
</div>
<!-- Agent info if connected -->
{#if environment.hawserAgentId}
<div class="text-xs bg-muted/30 rounded-md p-2 space-y-1">
<p><span class="text-muted-foreground">Agent:</span> {environment.hawserAgentName || environment.hawserAgentId}</p>
{#if environment.hawserVersion}
<p><span class="text-muted-foreground">Version:</span> {environment.hawserVersion}</p>
{/if}
{#if environment.hawserLastSeen}
<p><span class="text-muted-foreground">Last seen:</span> {new Date(environment.hawserLastSeen).toLocaleString()}</p>
{/if}
</div>
{/if}
{/if}
<!-- Token section -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label>Connection token</Label>
{#if isEditing && hawserToken}
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
onclick={() => environment && regenerateHawserToken(environment.id)}
disabled={generatingToken}
>
{#if generatingToken}
<Loader2 class="w-3 h-3 mr-1 animate-spin" />
{:else}
<RefreshCw class="w-3 h-3 mr-1" />
{/if}
Regenerate
</Button>
{:else if isEditing && !hawserToken && !hawserTokenLoading}
<Button
variant="outline"
size="sm"
class="h-7 text-xs"
onclick={() => environment && generateHawserToken(environment.id)}
disabled={generatingToken}
>
{#if generatingToken}
<Loader2 class="w-3 h-3 mr-1 animate-spin" />
{:else}
<Plus class="w-3 h-3 mr-1" />
{/if}
Generate
</Button>
{/if}
</div>
<!-- Add mode: auto-generate token on first visit -->
{#if !isEditing}
{#if !pendingToken}
<Button
variant="outline"
size="sm"
class="w-full"
onclick={generatePendingToken}
>
<Key class="w-3.5 h-3.5 mr-1.5" />
Generate connection token
</Button>
<p class="text-xs text-muted-foreground">
Generate a token now. It will be saved when you add the environment.
</p>
{:else}
<!-- Show pending token -->
<div class="p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-700 rounded-md space-y-2">
<p class="text-xs font-medium text-amber-700 dark:text-amber-400 flex items-center gap-1">
<AlertTriangle class="w-3 h-3" />
Copy this token now - you'll need it for the Hawser agent!
</p>
<div class="flex gap-2">
<Input
type="text"
value={pendingToken}
readonly
class="font-mono text-xs flex-1"
/>
<Button variant="outline" size="sm" onclick={() => copyToken(pendingToken!)}>
{#if copySuccess}
<Check class="w-4 h-4 text-green-500" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</Button>
</div>
<div class="text-xs text-amber-600 dark:text-amber-300 space-y-1">
<span>Run on your host:</span>
<div class="flex items-start gap-1.5">
<code class="bg-amber-100 dark:bg-amber-900/50 px-1.5 py-0.5 rounded break-all flex-1">DOCKHAND_SERVER_URL={getConnectionUrl()} TOKEN={pendingToken} hawser</code>
<button
class="shrink-0 p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
onclick={() => copyCommand(pendingToken!)}
title="Copy command"
>
{#if copyCmdSuccess}
<Check class="w-3 h-3 text-green-600" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
</div>
</div>
<Button variant="ghost" size="sm" class="h-6 text-xs" onclick={generatePendingToken}>
<RefreshCw class="w-3 h-3 mr-1" />
Generate new token
</Button>
</div>
{/if}
{/if}
<!-- Edit mode: show existing token or newly generated -->
{#if isEditing}
{#if hawserTokenLoading}
<div class="flex items-center justify-center py-4">
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else if generatedToken}
<!-- Just generated a new token - show full value -->
<div class="p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-700 rounded-md space-y-2">
<p class="text-xs font-medium text-amber-700 dark:text-amber-400 flex items-center gap-1">
<AlertTriangle class="w-3 h-3" />
Save this token now - it won't be shown again!
</p>
<div class="flex gap-2">
<Input
type="text"
value={generatedToken}
readonly
class="font-mono text-xs flex-1"
/>
<Button variant="outline" size="sm" onclick={() => copyToken(generatedToken!)}>
{#if copySuccess}
<Check class="w-4 h-4 text-green-500" />
{:else}
<Copy class="w-4 h-4" />
{/if}
</Button>
</div>
<div class="text-xs text-amber-600 dark:text-amber-300 space-y-1">
<span>Run on your host:</span>
<div class="flex items-start gap-1.5">
<code class="bg-amber-100 dark:bg-amber-900/50 px-1.5 py-0.5 rounded break-all flex-1">DOCKHAND_SERVER_URL={getConnectionUrl()} TOKEN={generatedToken} hawser</code>
<button
class="shrink-0 p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
onclick={() => copyCommand(generatedToken!)}
title="Copy command"
>
{#if copyCmdSuccess}
<Check class="w-3 h-3 text-green-600" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</button>
</div>
</div>
</div>
{:else if hawserToken}
<!-- Existing token - show partial -->
<div class="flex items-center gap-2 p-2 bg-muted/30 rounded-md text-xs">
<Key class="w-3.5 h-3.5 text-muted-foreground" />
<span class="font-mono">{hawserToken.tokenPrefix}...</span>
{#if hawserToken.lastUsed}
<span class="text-muted-foreground ml-auto flex items-center gap-1">
<Clock class="w-3 h-3" />
Last used: {new Date(hawserToken.lastUsed).toLocaleDateString()}
</span>
{/if}
</div>
{:else}
<p class="text-xs text-muted-foreground text-center py-2">No token generated yet. Click Generate above.</p>
{/if}
{/if}
</div>
</div>
{/if}
<!-- Public IP field (for all types except hawser-edge) -->
{#if formConnectionType !== 'hawser-edge'}
<div class="space-y-2 pt-4 border-t">
<div class="flex items-center gap-2">
<Label for="edit-env-public-ip">Public IP</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="bottom" class="w-72">
<p>IP address or hostname where container ports are accessible from your browser. For local Docker, use the server's LAN IP.</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Input
id="edit-env-public-ip"
bind:value={formPublicIp}
placeholder="e.g., 192.168.1.4"
class="w-full"
/>
<p class="text-xs text-muted-foreground">
Used for clickable port links on the containers page
</p>
</div>
{/if}
</Tabs.Content>
<!-- Updates Tab -->
<Tabs.Content value="updates" class="space-y-4 mt-0 h-full">
<div class="space-y-4">
<div class="text-sm font-medium">
Scheduled update check
</div>
<p class="text-xs text-muted-foreground">
Periodically check all containers in this environment for available image updates.
</p>
{#if updateCheckLoading}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else}
<div class="flex items-start gap-2">
<CircleFadingArrowUp class="w-4 h-4 text-green-500 glow-green mt-0.5 shrink-0" />
<div class="flex-1">
<Label>Enable scheduled update check</Label>
<p class="text-xs text-muted-foreground">Automatically check for container updates on a schedule</p>
</div>
<TogglePill bind:checked={updateCheckEnabled} />
</div>
{#if updateCheckEnabled}
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1 space-y-2">
<Label>Schedule</Label>
<CronEditor value={updateCheckCron} onchange={(cron) => updateCheckCron = cron} />
</div>
</div>
<div class="flex items-start gap-2">
<CircleArrowUp class="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
<div class="flex-1">
<Label>Automatically update containers</Label>
<p class="text-xs text-muted-foreground">
When enabled, containers will be updated automatically when new images are found.
When disabled, only sends notifications about available updates.
</p>
</div>
<TogglePill bind:checked={updateCheckAutoUpdate} />
</div>
{#if updateCheckAutoUpdate && scannerEnabled}
<div class="flex items-start gap-2">
<div class="w-4 shrink-0"></div>
<div class="flex-1">
<Label>Block updates with vulnerabilities</Label>
<p class="text-xs text-muted-foreground">
Block auto-updates if the new image has vulnerabilities exceeding this criteria
</p>
</div>
<VulnerabilityCriteriaSelector
bind:value={updateCheckVulnerabilityCriteria}
class="w-[200px]"
/>
</div>
{/if}
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-2 flex items-start gap-2">
<Info class="w-3 h-3 mt-0.5 shrink-0" />
{#if updateCheckAutoUpdate}
{#if scannerEnabled && updateCheckVulnerabilityCriteria !== 'never'}
<span>New images are pulled to a temporary tag, scanned, then deployed if they pass the vulnerability check. Blocked images are deleted automatically.</span>
{:else}
<span>Containers will be updated automatically when new images are available.</span>
{/if}
{:else}
<span>You'll receive notifications when updates are available. Containers won't be modified.</span>
{/if}
</div>
{/if}
{/if}
</div>
<!-- Timezone selector -->
<div class="space-y-2">
<Label>Timezone</Label>
<TimezoneSelector
bind:value={formTimezone}
id="edit-env-timezone"
/>
<p class="text-xs text-muted-foreground">
Used for scheduling auto-updates and git syncs
</p>
</div>
</Tabs.Content>
<!-- Activity Tab -->
<Tabs.Content value="activity" class="space-y-4 mt-0 h-full">
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Collect container activity</Label>
<p class="text-xs text-muted-foreground">Track container events (start, stop, restart, etc.) from this environment in real-time</p>
</div>
<TogglePill bind:checked={formCollectActivity} />
</div>
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Collect system metrics</Label>
<p class="text-xs text-muted-foreground">Collect CPU and memory usage statistics from this environment</p>
</div>
<TogglePill bind:checked={formCollectMetrics} />
</div>
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Highlight value changes</Label>
<p class="text-xs text-muted-foreground">Show amber glow when container values change in the containers list</p>
</div>
<TogglePill bind:checked={formHighlightChanges} />
</div>
</Tabs.Content>
<!-- Security Tab -->
<Tabs.Content value="security" class="space-y-4 mt-0 h-full">
<div class="space-y-4">
<div class="flex items-center gap-2 text-sm font-medium">
<ShieldCheck class="w-4 h-4" />
Vulnerability scanning
</div>
{#if !isEditing}
<!-- Add mode - full security settings -->
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Enable scanning</Label>
<p class="text-xs text-muted-foreground">Scan images for known security vulnerabilities</p>
</div>
<TogglePill bind:checked={formEnableScanner} />
</div>
{#if formEnableScanner}
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Scanner</Label>
<p class="text-xs text-muted-foreground">Choose vulnerability scanner</p>
</div>
<ToggleGroup
value={formScannerType}
options={scannerOptions}
onchange={(v) => { formScannerType = v as ScannerType; }}
/>
</div>
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-2 flex items-start gap-2">
<Info class="w-3 h-3 mt-0.5 shrink-0" />
<span>Scanner images will be pulled automatically on first scan. Vulnerability databases are cached in Docker volumes for faster subsequent scans.</span>
</div>
{/if}
{:else if scannerLoading}
<div class="flex items-center justify-center py-4">
<RefreshCw class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else}
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Enable scanning</Label>
<p class="text-xs text-muted-foreground">Scan images for known security vulnerabilities</p>
</div>
<TogglePill bind:checked={scannerEnabled} />
</div>
{#if scannerEnabled}
<div class="flex items-start gap-3">
<div class="flex-1">
<Label>Scanner</Label>
<p class="text-xs text-muted-foreground">Choose vulnerability scanner</p>
</div>
<ToggleGroup
value={selectedScanner}
options={scannerOptions}
onchange={(v) => { selectedScanner = v as ScannerType; }}
/>
</div>
<div class="space-y-2">
<!-- Grype row -->
{#if selectedScanner === 'grype' || selectedScanner === 'both'}
<div class="px-3 py-2 rounded-md bg-muted/30 space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium w-12">Grype</span>
{#if loadingScannerVersions}
<Badge variant="outline" class="text-2xs px-1 py-0 h-4 flex items-center gap-0.5">
<Loader2 class="w-2 h-2 animate-spin text-muted-foreground" />
</Badge>
{:else if scannerAvailability.grype && scannerVersions.grype}
<Badge variant="outline" class="text-2xs px-1 py-0 h-4 bg-green-500/10 text-green-600 border-green-500/30">v{scannerVersions.grype}</Badge>
{:else if scannerAvailability.grype}
<Badge variant="outline" class="text-2xs px-1 py-0 h-4 bg-green-500/10 text-green-600 border-green-500/30">Ready</Badge>
{:else}
<Badge variant="outline" class="text-2xs px-1 py-0 h-4 bg-amber-500/10 text-amber-600 border-amber-500/30">Not installed</Badge>
{/if}
{#if !loadingScannerVersions}
{#if !scannerAvailability.grype}
<ImagePullProgressPopover imageName="anchore/grype:latest" envId={environment?.id} onComplete={() => loadScannerSettings(environment?.id)}>
<button class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
<Download class="w-2.5 h-2.5 mr-0.5" />
Pull
</button>
</ImagePullProgressPopover>
{:else}
<button
class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border border-destructive/30 bg-destructive/10 hover:bg-destructive/20 text-destructive transition-colors disabled:opacity-50"
onclick={() => removeGrype(environment?.id)}
disabled={removingGrype}
>
{#if removingGrype}
<Loader2 class="w-2.5 h-2.5 mr-0.5 animate-spin" />
{:else}
<Trash2 class="w-2.5 h-2.5 mr-0.5" />
{/if}
Remove
</button>
{#if grypeUpdateStatus === 'up-to-date'}
<span class="inline-flex items-center text-2xs px-1.5 py-0 h-4 text-green-600">
<CheckCircle2 class="w-2.5 h-2.5 mr-0.5" />
Latest
</span>
{:else if grypeUpdateStatus === 'update-available' || pullingGrype}
<button
class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border border-amber-500/30 bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 transition-colors disabled:opacity-50"
onclick={() => pullGrypeImage()}
disabled={pullingGrype}
>
{#if pullingGrype}
<Loader2 class="w-2.5 h-2.5 mr-0.5 animate-spin" />
Pulling
{:else}
<Download class="w-2.5 h-2.5 mr-0.5" />
Update
{/if}
</button>
{:else}
<button
class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
onclick={() => checkGrypeUpdate()}
disabled={checkingGrypeUpdate}
>
{#if checkingGrypeUpdate}
<Loader2 class="w-2.5 h-2.5 mr-0.5 animate-spin" />
Checking
{:else}
<RefreshCw class="w-2.5 h-2.5 mr-0.5" />
Check
{/if}
</button>
{/if}
{/if}
{/if}
</div>
</div>
{/if}
<!-- Trivy row -->
{#if selectedScanner === 'trivy' || selectedScanner === 'both'}
<div class="px-3 py-2 rounded-md bg-muted/30 space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium w-12">Trivy</span>
{#if loadingScannerVersions}
<Badge variant="outline" class="text-2xs px-1 py-0 h-4 flex items-center gap-0.5">
<Loader2 class="w-2 h-2 animate-spin text-muted-foreground" />
</Badge>
{:else if scannerAvailability.trivy && scannerVersions.trivy}
<Badge variant="outline" class="text-2xs px-1 py-0 h-4 bg-green-500/10 text-green-600 border-green-500/30">v{scannerVersions.trivy}</Badge>
{:else if scannerAvailability.trivy}
<Badge variant="outline" class="text-2xs px-1 py-0 h-4 bg-green-500/10 text-green-600 border-green-500/30">Ready</Badge>
{:else}
<Badge variant="outline" class="text-2xs px-1 py-0 h-4 bg-amber-500/10 text-amber-600 border-amber-500/30">Not installed</Badge>
{/if}
{#if !loadingScannerVersions}
{#if !scannerAvailability.trivy}
<ImagePullProgressPopover imageName="aquasec/trivy:latest" envId={environment?.id} onComplete={() => loadScannerSettings(environment?.id)}>
<button class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
<Download class="w-2.5 h-2.5 mr-0.5" />
Pull
</button>
</ImagePullProgressPopover>
{:else}
<button
class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border border-destructive/30 bg-destructive/10 hover:bg-destructive/20 text-destructive transition-colors disabled:opacity-50"
onclick={() => removeTrivy(environment?.id)}
disabled={removingTrivy}
>
{#if removingTrivy}
<Loader2 class="w-2.5 h-2.5 mr-0.5 animate-spin" />
{:else}
<Trash2 class="w-2.5 h-2.5 mr-0.5" />
{/if}
Remove
</button>
{#if trivyUpdateStatus === 'up-to-date'}
<span class="inline-flex items-center text-2xs px-1.5 py-0 h-4 text-green-600">
<CheckCircle2 class="w-2.5 h-2.5 mr-0.5" />
Latest
</span>
{:else if trivyUpdateStatus === 'update-available' || pullingTrivy}
<button
class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border border-amber-500/30 bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 transition-colors disabled:opacity-50"
onclick={() => pullTrivyImage()}
disabled={pullingTrivy}
>
{#if pullingTrivy}
<Loader2 class="w-2.5 h-2.5 mr-0.5 animate-spin" />
Pulling
{:else}
<Download class="w-2.5 h-2.5 mr-0.5" />
Update
{/if}
</button>
{:else}
<button
class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
onclick={() => checkTrivyUpdate()}
disabled={checkingTrivyUpdate}
>
{#if checkingTrivyUpdate}
<Loader2 class="w-2.5 h-2.5 mr-0.5 animate-spin" />
Checking
{:else}
<RefreshCw class="w-2.5 h-2.5 mr-0.5" />
Check
{/if}
</button>
{/if}
{/if}
{/if}
</div>
</div>
{/if}
<!-- Info about automatic download -->
{#if ((selectedScanner === 'grype' || selectedScanner === 'both') && !scannerAvailability.grype) || ((selectedScanner === 'trivy' || selectedScanner === 'both') && !scannerAvailability.trivy)}
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md p-2 flex items-start gap-2">
<Info class="w-3 h-3 mt-0.5 shrink-0" />
<span>Scanner images will be pulled automatically on first scan. Vulnerability databases are cached in Docker volumes for faster subsequent scans.</span>
</div>
{/if}
</div>
{/if}
{/if}
</div>
</Tabs.Content>
<!-- Notifications Tab -->
<Tabs.Content value="notifications" class="mt-0 h-full flex flex-col">
<div class="flex items-center gap-2 text-sm font-medium flex-shrink-0">
<Bell class="w-4 h-4" />
Notification channels
</div>
{#if !isEditing}
<!-- Add mode - show available channels to select -->
<p class="text-xs text-muted-foreground mt-2 flex-shrink-0">
Select which notification channels should send alerts for events from this environment.
</p>
{#if notifications.length === 0}
<div class="flex-1 flex flex-col items-center justify-center py-8 text-center">
<Bell class="w-10 h-10 text-muted-foreground mb-3 opacity-50" />
<p class="text-sm text-muted-foreground">No notification channels configured yet.</p>
<p class="text-xs text-muted-foreground mt-1">Create notification channels in the Notifications settings tab first.</p>
</div>
{:else}
<div class="space-y-2 mt-3 flex-1 overflow-y-auto min-h-0">
{#each notifications as channel (channel.id)}
{@const isSelected = formSelectedNotifications.some(n => n.id === channel.id)}
{@const selectedNotif = formSelectedNotifications.find(n => n.id === channel.id)}
<div class="p-2 rounded-md border bg-card {isSelected ? 'border-primary/50' : ''}">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
{#if channel.type === 'smtp'}
<Mail class="w-3.5 h-3.5 shrink-0 text-blue-500" />
{:else}
<Send class="w-3.5 h-3.5 shrink-0 text-purple-500" />
{/if}
<span class="text-xs font-medium truncate">{channel.name} <span class="text-2xs text-muted-foreground capitalize font-normal">({channel.type})</span></span>
</div>
<div class="flex items-center gap-1 shrink-0">
<TogglePill
checked={isSelected}
disabled={!channel.enabled}
onchange={() => {
if (isSelected) {
formSelectedNotifications = formSelectedNotifications.filter(n => n.id !== channel.id);
} else {
formSelectedNotifications = [...formSelectedNotifications, { id: channel.id, eventTypes: NOTIFICATION_EVENT_TYPES.map(e => e.id) }];
}
}}
/>
</div>
</div>
{#if !channel.enabled}
<p class="text-2xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle class="w-2.5 h-2.5" />
Channel disabled globally
</p>
{/if}
<!-- Event Types (only show if selected) -->
{#if isSelected && selectedNotif}
{@const isCollapsed = collapsedChannels.has(channel.id)}
<div class="mt-2 pt-2 border-t">
<div
class="flex items-center gap-1 cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5 -mx-1"
role="button"
tabindex="0"
onclick={() => toggleChannelCollapse(channel.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleChannelCollapse(channel.id); } }}
>
{#if isCollapsed}
<ChevronRight class="w-3 h-3 text-muted-foreground" />
{:else}
<ChevronDown class="w-3 h-3 text-muted-foreground" />
{/if}
<span class="text-xs text-muted-foreground">Event types ({selectedNotif.eventTypes.length})</span>
</div>
{#if !isCollapsed}
<EventTypesEditor
selectedEventTypes={selectedNotif.eventTypes}
onchange={(newTypes) => {
formSelectedNotifications = formSelectedNotifications.map(n =>
n.id === channel.id ? { ...n, eventTypes: newTypes } : n
);
}}
/>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<p class="text-xs text-muted-foreground mt-2 flex-shrink-0">
Configure which notification channels should send alerts for events from this environment.
{#if environment && !environment.collectActivity}
<span class="text-amber-500">Activity collection will be enabled automatically when you add a channel.</span>
{/if}
</p>
{#if envNotifLoading}
<div class="flex items-center justify-center py-8 flex-1">
<RefreshCw class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
{:else}
<!-- Configured Channels - scrollable area -->
{#if envNotifications.length > 0}
<div class="space-y-2 mt-3 flex-1 overflow-y-auto min-h-0">
{#each envNotifications as notif (notif.id)}
<div class="rounded-md border bg-card overflow-hidden">
<!-- Channel Header - Clickable to collapse -->
<div
class="flex items-center justify-between gap-2 p-2 cursor-pointer hover:bg-muted/30 transition-colors"
role="button"
tabindex="0"
onclick={() => toggleChannelCollapse(notif.notificationId)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleChannelCollapse(notif.notificationId); } }}
>
<div class="flex items-center gap-2 min-w-0">
{#if collapsedChannels.has(notif.notificationId)}
<ChevronRight class="w-4 h-4 text-muted-foreground shrink-0" />
{:else}
<ChevronDown class="w-4 h-4 text-muted-foreground shrink-0" />
{/if}
{#if notif.channelType === 'smtp'}
<Mail class="w-4 h-4 shrink-0 text-blue-500" />
{:else}
<Send class="w-4 h-4 shrink-0 text-purple-500" />
{/if}
<span class="text-sm font-medium truncate">{notif.channelName}</span>
<span class="text-xs text-muted-foreground">({notif.eventTypes.length} events)</span>
</div>
<div class="flex items-center gap-1 shrink-0" role="group" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<TogglePill
checked={notif.enabled}
disabled={!notif.channelEnabled}
onchange={() => environment && updateEnvNotification(environment.id, notif.notificationId, { enabled: !notif.enabled, eventTypes: notif.eventTypes })}
/>
<Button
variant="ghost"
size="sm"
class="h-6 w-6 p-0 text-destructive hover:text-destructive"
onclick={() => environment && deleteEnvNotification(environment.id, notif.notificationId)}
>
<Trash2 class="w-3 h-3" />
</Button>
</div>
</div>
{#if !notif.channelEnabled}
<div class="px-2 pb-2">
<p class="text-2xs text-amber-600 flex items-center gap-1">
<AlertCircle class="w-2.5 h-2.5" />
Channel disabled globally
</p>
</div>
{/if}
<!-- Event Types - collapsible content -->
{#if !collapsedChannels.has(notif.notificationId)}
<div class="px-2 pb-2 pt-1 border-t">
<EventTypesEditor
selectedEventTypes={notif.eventTypes}
onchange={(newTypes) => {
environment && updateEnvNotification(environment.id, notif.notificationId, { enabled: notif.enabled, eventTypes: newTypes });
}}
/>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-center py-6 text-muted-foreground">
<Bell class="w-8 h-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">No notification channels configured</p>
<p class="text-xs mt-1">Add a channel below to receive alerts for this environment</p>
</div>
{/if}
<!-- Add Channel - fixed at bottom -->
{@const availableChannels = notifications.filter(n => !envNotifications.some(en => en.notificationId === n.id))}
{#if availableChannels.length > 0}
<div class="pt-3 border-t flex-shrink-0 mt-4">
<Label class="text-xs text-muted-foreground mb-2 block">Add notification channel:</Label>
<div class="flex flex-wrap gap-2">
{#each availableChannels as channel}
<button
class="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border bg-muted/30 hover:bg-muted transition-colors"
onclick={() => environment && addEnvNotification(environment.id, channel.id)}
>
{#if channel.type === 'smtp'}
<Mail class="w-3 h-3 text-blue-500" />
{:else}
<Send class="w-3 h-3 text-purple-500" />
{/if}
{channel.name}
<Plus class="w-3 h-3 ml-1" />
</button>
{/each}
</div>
</div>
{:else if notifications.length === 0}
<div class="p-3 rounded-md bg-muted/30 text-xs text-muted-foreground flex items-start gap-2 flex-shrink-0 mt-4">
<Info class="w-3.5 h-3.5 mt-0.5 shrink-0" />
{#if !$licenseStore.isEnterprise || $canAccess('notifications', 'create')}
<span>No notification channels have been created yet. <a href="/settings?tab=notifications" class="text-primary hover:underline" onclick={onClose}>Go to Settings Notifications</a> to add channels first.</span>
{:else}
<span>No notification channels have been created yet. Contact your administrator to configure notification channels.</span>
{/if}
</div>
{/if}
{/if}
{/if}
</Tabs.Content>
</div>
</Tabs.Root>
<Dialog.Footer class="flex-shrink-0 border-t pt-4">
<div class="flex items-center gap-2 w-full">
<!-- Test connection button (left side) -->
<Button
variant="outline"
onclick={testConnection}
disabled={testingConnection || formSaving}
class="mr-auto"
>
{#if testingConnection}
<Loader2 class="w-4 h-4 mr-1 animate-spin" />
Testing...
{:else if testResult?.success}
<CheckCircle2 class="w-4 h-4 mr-1 text-green-500" />
Test connection
{:else if testResult && !testResult.success}
<AlertCircle class="w-4 h-4 mr-1 text-red-500" />
Test connection
{:else}
<Wifi class="w-4 h-4 mr-1" />
Test connection
{/if}
</Button>
{#if !isEditing}
<!-- Add mode -->
<Button variant="outline" onclick={onClose}>
Cancel
</Button>
<Button onclick={createEnvironment} disabled={formSaving}>
{#if formSaving}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<Plus class="w-4 h-4 mr-1" />
{/if}
Add
</Button>
{:else}
<!-- Edit mode -->
<Button variant="outline" onclick={onClose}>
Cancel
</Button>
<Button onclick={updateEnvironment} disabled={formSaving}>
{#if formSaving}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
<Check class="w-4 h-4 mr-1" />
{/if}
Save
</Button>
{/if}
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>