Routes now load immediately from DB without waiting for the Caddy Admin API status check (which can take up to 2s timeout). A dedicated useEffect on caddyEnabled replaces the unreliable fire-and-forget call inside loadSettings.
1198 lines
52 KiB
TypeScript
1198 lines
52 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { authFetch } from '../lib/auth';
|
||
import { User } from '../types';
|
||
import {
|
||
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
|
||
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw, Terminal,
|
||
Network, Trash2, Plus, HardDrive, Download, Upload, AlertTriangle, Plug, Server, Pencil, X,
|
||
} from 'lucide-react';
|
||
|
||
const SECRET_SENTINEL = '__SET__';
|
||
|
||
interface RawSettings {
|
||
azure_enabled: string;
|
||
azure_client_id: string;
|
||
azure_tenant_id: string;
|
||
azure_client_secret: string;
|
||
azure_redirect_uri: string;
|
||
azure_allowed_group: string;
|
||
checkmk_enabled: string;
|
||
checkmk_api_url: string;
|
||
checkmk_api_user: string;
|
||
checkmk_api_secret: string;
|
||
checkmk_sync_interval_ms: string;
|
||
semaphore_enabled: string;
|
||
semaphore_api_url: string;
|
||
semaphore_api_token: string;
|
||
semaphore_project_id: string;
|
||
caddy_enabled: string;
|
||
caddy_admin_url: string;
|
||
}
|
||
|
||
interface CaddyRoute {
|
||
id: number;
|
||
hostname: string;
|
||
upstream: string;
|
||
tls: number;
|
||
compress: number;
|
||
}
|
||
|
||
interface DbInfo {
|
||
sizeBytes: number;
|
||
lastModified: string;
|
||
tables: Record<string, number>;
|
||
path: string;
|
||
}
|
||
|
||
interface SettingsProps {
|
||
currentUser: User;
|
||
}
|
||
|
||
function Label({ children }: { children: React.ReactNode }) {
|
||
return <label className="block text-[11px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5">{children}</label>;
|
||
}
|
||
|
||
function Hint({ children }: { children: React.ReactNode }) {
|
||
return <p className="mt-1 text-[10px] text-slate-500 font-mono leading-relaxed">{children}</p>;
|
||
}
|
||
|
||
function ConfiguredBadge() {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
|
||
<span className="w-1 h-1 rounded-full bg-emerald-400 inline-block" />
|
||
CONFIGURED
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function FieldRow({ label, hint, badge, children }: {
|
||
label: string;
|
||
hint?: string;
|
||
badge?: React.ReactNode;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<div className="flex items-center justify-between">
|
||
<Label>{label}</Label>
|
||
{badge}
|
||
</div>
|
||
{children}
|
||
{hint && <Hint>{hint}</Hint>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Input({ value, onChange, placeholder, monospace, icon }: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
monospace?: boolean;
|
||
icon?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="relative">
|
||
{icon && (
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
|
||
{icon}
|
||
</span>
|
||
)}
|
||
<input
|
||
type="text"
|
||
value={value}
|
||
onChange={e => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
className={`w-full bg-slate-900 border border-slate-700 rounded-lg py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all ${icon ? 'pl-9 pr-3' : 'px-3'} ${monospace ? 'font-mono text-xs' : ''}`}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SecretInput({ value, onChange, alreadySet, show, onToggleShow }: {
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
alreadySet: boolean;
|
||
show: boolean;
|
||
onToggleShow: () => void;
|
||
}) {
|
||
return (
|
||
<div className="relative">
|
||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
|
||
<KeyRound className="w-3.5 h-3.5" />
|
||
</span>
|
||
<input
|
||
type={show ? 'text' : 'password'}
|
||
value={value}
|
||
onChange={e => onChange(e.target.value)}
|
||
placeholder={alreadySet ? '•••••••• (leave blank to keep)' : 'Secret'}
|
||
className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-xs font-mono text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={onToggleShow}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||
tabIndex={-1}
|
||
>
|
||
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionCard({ accentColor, children }: {
|
||
accentColor: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl overflow-hidden">
|
||
<div className={`h-0.5 w-full ${accentColor}`} />
|
||
<div className="p-6 space-y-5">
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [activeSection, setActiveSection] = useState<'integrations' | 'system'>('integrations');
|
||
const [error, setError] = useState('');
|
||
const [successMsg, setSuccessMsg] = useState('');
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const [effectiveRedirectUri, setEffectiveRedirectUri] = useState('');
|
||
|
||
const [azureEnabled, setAzureEnabled] = useState(false);
|
||
const [azureClientId, setAzureClientId] = useState('');
|
||
const [azureTenantId, setAzureTenantId] = useState('');
|
||
const [azureClientSecret, setAzureClientSecret] = useState('');
|
||
const [azureSecretSet, setAzureSecretSet] = useState(false);
|
||
const [azureRedirectUri, setAzureRedirectUri] = useState('');
|
||
const [azureAllowedGroup, setAzureAllowedGroup] = useState('');
|
||
const [showAzureSecret, setShowAzureSecret] = useState(false);
|
||
|
||
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
|
||
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
|
||
const [checkmkApiUser, setCheckmkApiUser] = useState('automation');
|
||
const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
|
||
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
|
||
const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
|
||
const [showCheckmkSecret, setShowCheckmkSecret] = useState(false);
|
||
const [syncing, setSyncing] = useState(false);
|
||
|
||
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
|
||
const [semaphoreApiUrl, setSemaphoreApiUrl] = useState('');
|
||
const [semaphoreApiToken, setSemaphoreApiToken] = useState('');
|
||
const [semaphoreTokenSet, setSemaphoreTokenSet] = useState(false);
|
||
const [semaphoreProjectId, setSemaphoreProjectId] = useState('');
|
||
const [showSemaphoreToken, setShowSemaphoreToken] = useState(false);
|
||
const [semaphoreTesting, setSemaphoreTesting] = useState(false);
|
||
const [semaphoreTestResult, setSemaphoreTestResult] = useState<string | null>(null);
|
||
|
||
const [caddyEnabled, setCaddyEnabled] = useState(false);
|
||
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
|
||
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
|
||
const [caddyRoutes, setCaddyRoutes] = useState<{ system: { hostname: string; upstream: string }[]; custom: CaddyRoute[] } | null>(null);
|
||
const [addingRoute, setAddingRoute] = useState(false);
|
||
const [newHostname, setNewHostname] = useState('');
|
||
const [newUpstream, setNewUpstream] = useState('');
|
||
const [newTls, setNewTls] = useState(true);
|
||
const [newCompress, setNewCompress] = useState(true);
|
||
|
||
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||
const [editHostname, setEditHostname] = useState('');
|
||
const [editUpstream, setEditUpstream] = useState('');
|
||
const [editTls, setEditTls] = useState(true);
|
||
const [editCompress, setEditCompress] = useState(true);
|
||
const [savingRoute, setSavingRoute] = useState(false);
|
||
|
||
const [dbInfo, setDbInfo] = useState<DbInfo | null>(null);
|
||
const [backingUp, setBackingUp] = useState(false);
|
||
const [importFile, setImportFile] = useState<File | null>(null);
|
||
const [importing, setImporting] = useState(false);
|
||
const [importConfirmed, setImportConfirmed] = useState(false);
|
||
const [importResult, setImportResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||
|
||
useEffect(() => {
|
||
loadSettings();
|
||
loadDbInfo();
|
||
fetch('/api/auth/config')
|
||
.then(r => r.json())
|
||
.then(d => { if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); })
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (caddyEnabled) loadCaddyRoutes();
|
||
}, [caddyEnabled]);
|
||
|
||
async function loadSettings() {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const res = await authFetch('/api/settings');
|
||
if (!res.ok) {
|
||
let msg = `Failed to load settings (HTTP ${res.status}).`;
|
||
try { const d = await res.json(); if (d.error) msg = `${d.error} (HTTP ${res.status})`; } catch {}
|
||
setError(msg);
|
||
return;
|
||
}
|
||
const data: RawSettings = await res.json();
|
||
setAzureEnabled(data.azure_enabled === 'true');
|
||
setAzureClientId(data.azure_client_id || '');
|
||
setAzureTenantId(data.azure_tenant_id || '');
|
||
setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL);
|
||
setAzureClientSecret('');
|
||
setAzureRedirectUri(data.azure_redirect_uri || '');
|
||
setAzureAllowedGroup(data.azure_allowed_group || '');
|
||
setCheckmkEnabled(data.checkmk_enabled === 'true');
|
||
setCheckmkApiUrl(data.checkmk_api_url || '');
|
||
setCheckmkApiUser(data.checkmk_api_user || 'automation');
|
||
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
||
setCheckmkApiSecret('');
|
||
setCheckmkSyncInterval(data.checkmk_sync_interval_ms || '60000');
|
||
setSemaphoreEnabled(data.semaphore_enabled === 'true');
|
||
setSemaphoreApiUrl(data.semaphore_api_url || '');
|
||
setSemaphoreTokenSet(data.semaphore_api_token === SECRET_SENTINEL);
|
||
setSemaphoreApiToken('');
|
||
setSemaphoreProjectId(data.semaphore_project_id || '');
|
||
setCaddyEnabled(data.caddy_enabled === 'true');
|
||
setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019');
|
||
} catch {
|
||
setError('Network error loading settings.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function handleSave() {
|
||
setSaving(true);
|
||
setError('');
|
||
setSuccessMsg('');
|
||
const payload: Record<string, string> = {
|
||
azure_enabled: azureEnabled ? 'true' : 'false',
|
||
azure_client_id: azureClientId,
|
||
azure_tenant_id: azureTenantId,
|
||
azure_redirect_uri: azureRedirectUri,
|
||
azure_allowed_group: azureAllowedGroup,
|
||
checkmk_enabled: checkmkEnabled ? 'true' : 'false',
|
||
checkmk_api_url: checkmkApiUrl,
|
||
checkmk_api_user: checkmkApiUser,
|
||
checkmk_sync_interval_ms: checkmkSyncInterval,
|
||
semaphore_enabled: semaphoreEnabled ? 'true' : 'false',
|
||
semaphore_api_url: semaphoreApiUrl,
|
||
semaphore_project_id: semaphoreProjectId,
|
||
caddy_enabled: caddyEnabled ? 'true' : 'false',
|
||
caddy_admin_url: caddyAdminUrl,
|
||
};
|
||
if (azureClientSecret) payload.azure_client_secret = azureClientSecret;
|
||
if (checkmkApiSecret) payload.checkmk_api_secret = checkmkApiSecret;
|
||
if (semaphoreApiToken) payload.semaphore_api_token = semaphoreApiToken;
|
||
try {
|
||
const res = await authFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) });
|
||
if (!res.ok) {
|
||
const d = await res.json();
|
||
setError(d.error || 'Failed to save settings.');
|
||
return;
|
||
}
|
||
const data: RawSettings = await res.json();
|
||
setAzureSecretSet(data.azure_client_secret === SECRET_SENTINEL);
|
||
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
||
setSemaphoreTokenSet(data.semaphore_api_token === SECRET_SENTINEL);
|
||
setAzureClientSecret('');
|
||
setCheckmkApiSecret('');
|
||
setSemaphoreApiToken('');
|
||
await loadCaddyRoutes();
|
||
setSuccessMsg('Settings saved successfully.');
|
||
setTimeout(() => setSuccessMsg(''), 4000);
|
||
} catch {
|
||
setError('Network error saving settings.');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function runSync() {
|
||
setSyncing(true);
|
||
setError('');
|
||
try {
|
||
const res = await authFetch('/api/checkmk/sync', { method: 'POST' });
|
||
if (!res.ok) {
|
||
const d = await res.json();
|
||
setError(d.error || 'Sync failed.');
|
||
return;
|
||
}
|
||
setSuccessMsg('CheckMK sync triggered successfully.');
|
||
setTimeout(() => setSuccessMsg(''), 4000);
|
||
} catch {
|
||
setError('Network error during sync.');
|
||
} finally {
|
||
setSyncing(false);
|
||
}
|
||
}
|
||
|
||
async function testSemaphoreConnection() {
|
||
setSemaphoreTesting(true);
|
||
setSemaphoreTestResult(null);
|
||
try {
|
||
const res = await authFetch('/api/semaphore/templates');
|
||
if (!res.ok) {
|
||
const d = await res.json();
|
||
setSemaphoreTestResult(`Error: ${d.error || `HTTP ${res.status}`}`);
|
||
return;
|
||
}
|
||
const templates = await res.json() as any[];
|
||
setSemaphoreTestResult(`Connected — ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
|
||
} catch {
|
||
setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
|
||
} finally {
|
||
setSemaphoreTesting(false);
|
||
}
|
||
}
|
||
|
||
function copyRedirectUri() {
|
||
if (!effectiveRedirectUri) return;
|
||
navigator.clipboard.writeText(effectiveRedirectUri).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
async function loadCaddyRoutes() {
|
||
// Status check is background — never blocks route display
|
||
authFetch('/api/caddy/status')
|
||
.then(res => res.ok ? res.json() : null)
|
||
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
|
||
.catch(() => setCaddyStatus('unavailable'));
|
||
|
||
// Routes load immediately from DB
|
||
try {
|
||
const res = await authFetch('/api/caddy/routes');
|
||
if (res.ok) setCaddyRoutes(await res.json());
|
||
} catch {}
|
||
}
|
||
|
||
async function handleAddRoute() {
|
||
if (!newHostname.trim() || !newUpstream.trim()) return;
|
||
setAddingRoute(true);
|
||
try {
|
||
const res = await authFetch('/api/caddy/routes', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ hostname: newHostname.trim(), upstream: newUpstream.trim(), tls: newTls, compress: newCompress }),
|
||
});
|
||
if (!res.ok) {
|
||
const d = await res.json();
|
||
setError(d.error || 'Failed to add route.');
|
||
return;
|
||
}
|
||
setNewHostname('');
|
||
setNewUpstream('');
|
||
setNewTls(true);
|
||
setNewCompress(true);
|
||
await loadCaddyRoutes();
|
||
} catch {
|
||
setError('Network error adding route.');
|
||
} finally {
|
||
setAddingRoute(false);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteRoute(id: number) {
|
||
try {
|
||
const res = await authFetch(`/api/caddy/routes/${id}`, { method: 'DELETE' });
|
||
if (!res.ok && res.status !== 204) {
|
||
const d = await res.json().catch(() => ({}));
|
||
setError((d as any).error || 'Failed to delete route.');
|
||
return;
|
||
}
|
||
await loadCaddyRoutes();
|
||
} catch {
|
||
setError('Network error deleting route.');
|
||
}
|
||
}
|
||
|
||
function handleEditStart(r: CaddyRoute) {
|
||
setEditingRouteId(r.id);
|
||
setEditHostname(r.hostname);
|
||
setEditUpstream(r.upstream);
|
||
setEditTls(r.tls === 1);
|
||
setEditCompress(r.compress === 1);
|
||
}
|
||
|
||
function handleEditCancel() {
|
||
setEditingRouteId(null);
|
||
}
|
||
|
||
async function handleEditSave(id: number) {
|
||
if (!editHostname.trim() || !editUpstream.trim()) return;
|
||
setSavingRoute(true);
|
||
try {
|
||
const res = await authFetch(`/api/caddy/routes/${id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress }),
|
||
});
|
||
if (!res.ok) {
|
||
const d = await res.json();
|
||
setError(d.error || 'Failed to update route.');
|
||
return;
|
||
}
|
||
setEditingRouteId(null);
|
||
await loadCaddyRoutes();
|
||
} catch {
|
||
setError('Network error updating route.');
|
||
} finally {
|
||
setSavingRoute(false);
|
||
}
|
||
}
|
||
|
||
async function loadDbInfo() {
|
||
try {
|
||
const res = await authFetch('/api/database/info');
|
||
if (res.ok) setDbInfo(await res.json());
|
||
} catch {}
|
||
}
|
||
|
||
async function handleBackup() {
|
||
setBackingUp(true);
|
||
setError('');
|
||
try {
|
||
const res = await authFetch('/api/database/backup');
|
||
if (!res.ok) {
|
||
const d = await res.json().catch(() => ({}));
|
||
setError((d as any).error || 'Backup failed.');
|
||
return;
|
||
}
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `ghostgrid-backup-${new Date().toISOString().slice(0, 10)}.db`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} catch {
|
||
setError('Network error during backup.');
|
||
} finally {
|
||
setBackingUp(false);
|
||
}
|
||
}
|
||
|
||
async function handleImport() {
|
||
if (!importFile || !importConfirmed) return;
|
||
setImporting(true);
|
||
setImportResult(null);
|
||
try {
|
||
const res = await authFetch('/api/database/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
body: importFile,
|
||
});
|
||
if (!res.ok) {
|
||
const d = await res.json().catch(() => ({}));
|
||
setImportResult({ ok: false, msg: (d as any).error || 'Import failed.' });
|
||
return;
|
||
}
|
||
setImportResult({ ok: true, msg: 'Database imported successfully. Reload the page to reflect all changes.' });
|
||
setImportFile(null);
|
||
setImportConfirmed(false);
|
||
loadDbInfo();
|
||
loadSettings();
|
||
} catch {
|
||
setImportResult({ ok: false, msg: 'Network error during import.' });
|
||
} finally {
|
||
setImporting(false);
|
||
}
|
||
}
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<span className="w-5 h-5 border-2 border-slate-700 border-t-cyan-400 rounded-full animate-spin" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
|
||
{/* Page header */}
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
|
||
<Settings2 className="w-3 h-3" />
|
||
<span>SYSTEM</span>
|
||
<ChevronRight className="w-3 h-3" />
|
||
<span className="text-slate-400">SETTINGS</span>
|
||
</div>
|
||
<h1 className="text-xl font-bold text-white tracking-tight">Settings</h1>
|
||
<p className="text-xs text-slate-500 mt-0.5">Configure integrations and authentication providers.</p>
|
||
</div>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="shrink-0 flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50"
|
||
>
|
||
{saving ? (
|
||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||
) : (
|
||
<Save className="w-4 h-4" />
|
||
)}
|
||
{saving ? 'Saving…' : 'Save Settings'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Feedback banners */}
|
||
{error && (
|
||
<div className="flex items-start gap-2.5 bg-red-950/40 border border-red-900/50 rounded-xl px-4 py-3 text-xs text-red-300">
|
||
<AlertCircle className="w-4 h-4 shrink-0 text-red-400 mt-0.5" />
|
||
<span>{error}</span>
|
||
</div>
|
||
)}
|
||
{successMsg && (
|
||
<div className="flex items-center gap-2.5 bg-emerald-950/40 border border-emerald-900/50 rounded-xl px-4 py-3 text-xs text-emerald-300">
|
||
<CheckCircle className="w-4 h-4 shrink-0 text-emerald-400" />
|
||
{successMsg}
|
||
</div>
|
||
)}
|
||
|
||
{/* Section tabs — switch between Integrations and System to keep the page light */}
|
||
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
|
||
{([
|
||
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
|
||
{ id: 'system', label: 'System', icon: <Server className="w-3.5 h-3.5" /> },
|
||
] as const).map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => setActiveSection(tab.id)}
|
||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||
activeSection === tab.id
|
||
? 'bg-cyan-950/40 border border-cyan-900/50 text-cyan-400'
|
||
: 'border border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-800'
|
||
}`}
|
||
>
|
||
{tab.icon}
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Integrations: Azure in column one, monitoring & automation stacked in column two ── */}
|
||
{activeSection === 'integrations' && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||
|
||
{/* ── Microsoft Entra ID ── */}
|
||
<SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-blue-950/60 border border-blue-900/40 rounded-xl">
|
||
<Shield className="w-4 h-4 text-blue-400" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-sm font-semibold text-white">Microsoft Entra ID</h2>
|
||
{azureEnabled && azureSecretSet && (
|
||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-blue-950/60 border border-blue-900/50 text-blue-400">
|
||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-pulse inline-block" />
|
||
ACTIVE
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2.5">
|
||
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue-400' : 'text-slate-600'}`}>
|
||
{azureEnabled ? 'ENABLED' : 'DISABLED'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setAzureEnabled(v => !v)}
|
||
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${azureEnabled ? 'bg-blue-600 shadow-[0_0_10px_rgba(37,99,235,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
|
||
>
|
||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${azureEnabled ? 'left-5' : 'left-0.5'}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-px bg-slate-800/60" />
|
||
|
||
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||
<Input value={azureTenantId} onChange={setAzureTenantId} placeholder="Tenant ID" monospace />
|
||
</FieldRow>
|
||
<FieldRow label="Client ID (Application ID)" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
|
||
<Input value={azureClientId} onChange={setAzureClientId} placeholder="Client ID" monospace />
|
||
</FieldRow>
|
||
<FieldRow
|
||
label="Client Secret"
|
||
badge={azureSecretSet ? <ConfiguredBadge /> : undefined}
|
||
>
|
||
<SecretInput
|
||
value={azureClientSecret}
|
||
onChange={setAzureClientSecret}
|
||
alreadySet={azureSecretSet}
|
||
show={showAzureSecret}
|
||
onToggleShow={() => setShowAzureSecret(v => !v)}
|
||
/>
|
||
</FieldRow>
|
||
<div className="sm:col-span-2">
|
||
<FieldRow
|
||
label="Redirect URI"
|
||
hint="Must match exactly what is registered in Azure Portal > App registrations > Authentication. Leave blank to auto-derive from APP_URL."
|
||
>
|
||
<Input
|
||
value={azureRedirectUri}
|
||
onChange={setAzureRedirectUri}
|
||
placeholder={effectiveRedirectUri || 'https://…/api/auth/azure/callback'}
|
||
monospace
|
||
/>
|
||
</FieldRow>
|
||
</div>
|
||
<FieldRow
|
||
label="Allowed Group"
|
||
hint="Object ID of an Azure AD group. If set, only members of this group can sign in."
|
||
>
|
||
<Input
|
||
value={azureAllowedGroup}
|
||
onChange={setAzureAllowedGroup}
|
||
placeholder="Leave blank to allow all tenant users"
|
||
monospace
|
||
icon={<Users className="w-3.5 h-3.5" />}
|
||
/>
|
||
</FieldRow>
|
||
</div>
|
||
|
||
{/* Redirect URI – read-only */}
|
||
{effectiveRedirectUri && azureEnabled && (
|
||
<div className="flex items-start gap-3 bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">Required Redirect URI</p>
|
||
<p className="text-[11px] font-mono text-slate-200 break-all">{effectiveRedirectUri}</p>
|
||
<p className="text-[10px] text-slate-500 mt-1">Register this in Azure Portal > App registrations > Authentication > Redirect URIs</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={copyRedirectUri}
|
||
className="shrink-0 p-1.5 rounded-lg text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-all"
|
||
title="Copy to clipboard"
|
||
>
|
||
{copied ? <CheckCircle className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</SectionCard>
|
||
|
||
{/* Monitoring & automation, stacked together in the second column */}
|
||
<div className="space-y-6">
|
||
|
||
{/* ── CheckMK ── */}
|
||
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-emerald-950/60 border border-emerald-900/40 rounded-xl">
|
||
<Activity className="w-4 h-4 text-emerald-400" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-sm font-semibold text-white">CheckMK</h2>
|
||
{checkmkEnabled && checkmkApiUrl && checkmkSecretSet && (
|
||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
|
||
<span className="w-1 h-1 rounded-full bg-emerald-400 animate-pulse inline-block" />
|
||
ACTIVE
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-0.5">Device status sync via CheckMK REST API</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2.5">
|
||
<span className={`text-[10px] font-semibold font-mono ${checkmkEnabled ? 'text-emerald-400' : 'text-slate-600'}`}>
|
||
{checkmkEnabled ? 'ENABLED' : 'DISABLED'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCheckmkEnabled(v => !v)}
|
||
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${checkmkEnabled ? 'bg-emerald-600 shadow-[0_0_10px_rgba(5,150,105,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
|
||
>
|
||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${checkmkEnabled ? 'left-5' : 'left-0.5'}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-px bg-slate-800/60" />
|
||
|
||
<div className={`space-y-5 transition-opacity duration-200 ${!checkmkEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||
<FieldRow label="API URL">
|
||
<Input
|
||
value={checkmkApiUrl}
|
||
onChange={setCheckmkApiUrl}
|
||
placeholder="https://checkmk/<site>/check_mk/api/1.0"
|
||
monospace
|
||
icon={<Globe className="w-3.5 h-3.5" />}
|
||
/>
|
||
</FieldRow>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||
<FieldRow label="Automation User" hint="Setup > Users > Automation user (e.g. automation)">
|
||
<Input
|
||
value={checkmkApiUser}
|
||
onChange={setCheckmkApiUser}
|
||
placeholder="automation"
|
||
monospace
|
||
icon={<KeyRound className="w-3.5 h-3.5" />}
|
||
/>
|
||
</FieldRow>
|
||
<FieldRow
|
||
label="Automation Secret"
|
||
hint="Setup > Users > Automation user > Automation secret"
|
||
badge={checkmkSecretSet ? <ConfiguredBadge /> : undefined}
|
||
>
|
||
<SecretInput
|
||
value={checkmkApiSecret}
|
||
onChange={setCheckmkApiSecret}
|
||
alreadySet={checkmkSecretSet}
|
||
show={showCheckmkSecret}
|
||
onToggleShow={() => setShowCheckmkSecret(v => !v)}
|
||
/>
|
||
</FieldRow>
|
||
<FieldRow label="Sync Interval" hint="Milliseconds between status polls (default: 60000)">
|
||
<Input
|
||
value={checkmkSyncInterval}
|
||
onChange={setCheckmkSyncInterval}
|
||
placeholder="60000"
|
||
monospace
|
||
icon={<Clock className="w-3.5 h-3.5" />}
|
||
/>
|
||
</FieldRow>
|
||
</div>
|
||
{checkmkApiUrl && checkmkSecretSet && (
|
||
<button
|
||
type="button"
|
||
onClick={runSync}
|
||
disabled={syncing}
|
||
className="flex items-center gap-2 bg-emerald-950/60 hover:bg-emerald-900/40 border border-emerald-900/50 text-emerald-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
|
||
{syncing ? 'Syncing…' : 'Run sync now'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</SectionCard>
|
||
|
||
{/* ── Ansible Semaphore ── */}
|
||
<SectionCard accentColor="bg-gradient-to-r from-orange-600 to-amber-600">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-orange-950/60 border border-orange-900/40 rounded-xl">
|
||
<Terminal className="w-4 h-4 text-orange-400" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-sm font-semibold text-white">Ansible Semaphore</h2>
|
||
{semaphoreEnabled && semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
|
||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-orange-950/60 border border-orange-900/50 text-orange-400">
|
||
<span className="w-1 h-1 rounded-full bg-orange-400 animate-pulse inline-block" />
|
||
ACTIVE
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-0.5">Trigger playbooks automatically at booking start and end</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2.5">
|
||
<span className={`text-[10px] font-semibold font-mono ${semaphoreEnabled ? 'text-orange-400' : 'text-slate-600'}`}>
|
||
{semaphoreEnabled ? 'ENABLED' : 'DISABLED'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSemaphoreEnabled(v => !v)}
|
||
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${semaphoreEnabled ? 'bg-orange-600 shadow-[0_0_10px_rgba(234,88,12,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
|
||
>
|
||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${semaphoreEnabled ? 'left-5' : 'left-0.5'}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-px bg-slate-800/60" />
|
||
|
||
<div className={`space-y-5 transition-opacity duration-200 ${!semaphoreEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||
<FieldRow label="API URL">
|
||
<Input
|
||
value={semaphoreApiUrl}
|
||
onChange={setSemaphoreApiUrl}
|
||
placeholder="https://semaphore/api/v1alpha"
|
||
monospace
|
||
icon={<Globe className="w-3.5 h-3.5" />}
|
||
/>
|
||
</FieldRow>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||
<FieldRow
|
||
label="API Token"
|
||
hint="Profile > API Tokens in Semaphore"
|
||
badge={semaphoreTokenSet ? <ConfiguredBadge /> : undefined}
|
||
>
|
||
<SecretInput
|
||
value={semaphoreApiToken}
|
||
onChange={setSemaphoreApiToken}
|
||
alreadySet={semaphoreTokenSet}
|
||
show={showSemaphoreToken}
|
||
onToggleShow={() => setShowSemaphoreToken(v => !v)}
|
||
/>
|
||
</FieldRow>
|
||
<FieldRow label="Project ID" hint="Numeric ID from the Semaphore project URL">
|
||
<Input
|
||
value={semaphoreProjectId}
|
||
onChange={setSemaphoreProjectId}
|
||
placeholder="1"
|
||
monospace
|
||
icon={<KeyRound className="w-3.5 h-3.5" />}
|
||
/>
|
||
</FieldRow>
|
||
</div>
|
||
{semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
|
||
<div className="space-y-2">
|
||
<button
|
||
type="button"
|
||
onClick={testSemaphoreConnection}
|
||
disabled={semaphoreTesting}
|
||
className="flex items-center gap-2 bg-orange-950/60 hover:bg-orange-900/40 border border-orange-900/50 text-orange-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<RefreshCw className={`w-3.5 h-3.5 ${semaphoreTesting ? 'animate-spin' : ''}`} />
|
||
{semaphoreTesting ? 'Testing…' : 'Test connection'}
|
||
</button>
|
||
{semaphoreTestResult && (
|
||
<p className={`text-[11px] font-mono ${semaphoreTestResult.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
|
||
{semaphoreTestResult}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</SectionCard>
|
||
|
||
</div>{/* end second column */}
|
||
|
||
</div>
|
||
)}
|
||
|
||
{/* ── System ── */}
|
||
{activeSection === 'system' && (
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||
|
||
{/* ── Caddy Reverse Proxy ── */}
|
||
<SectionCard accentColor="bg-gradient-to-r from-sky-600 to-cyan-600">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-sky-950/60 border border-sky-900/40 rounded-xl">
|
||
<Network className="w-4 h-4 text-sky-400" />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-sm font-semibold text-white">Caddy Reverse Proxy</h2>
|
||
{caddyEnabled && caddyStatus === 'available' && (
|
||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-sky-950/60 border border-sky-900/50 text-sky-400">
|
||
<span className="w-1 h-1 rounded-full bg-sky-400 animate-pulse inline-block" />
|
||
ACTIVE
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-slate-500 mt-0.5">Manage reverse proxy routes for internal services</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2.5">
|
||
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
|
||
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCaddyEnabled((v: boolean) => !v)}
|
||
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${caddyEnabled ? 'bg-sky-600 shadow-[0_0_10px_rgba(2,132,199,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
|
||
>
|
||
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${caddyEnabled ? 'left-5' : 'left-0.5'}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-px bg-slate-800/60" />
|
||
|
||
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||
<FieldRow label="Caddy Admin URL" hint="Default: http://localhost:2019">
|
||
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
|
||
</FieldRow>
|
||
|
||
{/* Route list */}
|
||
{caddyEnabled && (
|
||
<div className="space-y-2">
|
||
<Label>Proxy Routes</Label>
|
||
|
||
{caddyStatus === 'unavailable' && (
|
||
<p className="text-[11px] font-mono text-amber-400 mb-2">
|
||
Caddy Admin API not reachable — routes will be applied when Caddy starts.
|
||
</p>
|
||
)}
|
||
|
||
{/* Custom routes */}
|
||
{caddyRoutes?.custom.map(r => (
|
||
<div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
|
||
{editingRouteId === r.id ? (
|
||
<div className="flex items-end gap-2">
|
||
<div className="flex-1 min-w-0">
|
||
<Label>Hostname</Label>
|
||
<Input value={editHostname} onChange={setEditHostname} placeholder="hostname" monospace />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<Label>Upstream</Label>
|
||
<Input value={editUpstream} onChange={setEditUpstream} placeholder="127.0.0.1:3000" monospace />
|
||
</div>
|
||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
|
||
<button type="button" onClick={() => setEditTls(v => !v)}
|
||
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editTls ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
|
||
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${editTls ? 'left-4' : 'left-0.5'}`} />
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
|
||
<button type="button" onClick={() => setEditCompress(v => !v)}
|
||
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editCompress ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
|
||
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${editCompress ? 'left-4' : 'left-0.5'}`} />
|
||
</button>
|
||
</div>
|
||
<button type="button" onClick={() => handleEditSave(r.id)} disabled={savingRoute || !editHostname.trim() || !editUpstream.trim()}
|
||
className="flex items-center gap-1 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-2.5 py-2 rounded-lg transition-all disabled:opacity-50 shrink-0">
|
||
<CheckCircle className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button type="button" onClick={handleEditCancel}
|
||
className="p-2 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800 transition-all shrink-0">
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
|
||
<span className="text-slate-600 text-[11px]">></span>
|
||
<span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span>
|
||
{r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null}
|
||
{r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
|
||
</div>
|
||
<div className="flex items-center gap-1 ml-3 shrink-0">
|
||
<button type="button" onClick={() => handleEditStart(r)}
|
||
className="p-1.5 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-950/30 transition-all" title="Edit route">
|
||
<Pencil className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button type="button" onClick={() => handleDeleteRoute(r.id)}
|
||
className="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-950/30 transition-all" title="Remove route">
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{/* Add route form */}
|
||
<div className="flex items-end gap-2 pt-1">
|
||
<div className="flex-1 min-w-0">
|
||
<Label>Hostname</Label>
|
||
<Input value={newHostname} onChange={setNewHostname} placeholder="semaphore" monospace />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<Label>Upstream</Label>
|
||
<Input value={newUpstream} onChange={setNewUpstream} placeholder="127.0.0.1:3000" monospace />
|
||
</div>
|
||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setNewTls(v => !v)}
|
||
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newTls ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}
|
||
>
|
||
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${newTls ? 'left-4' : 'left-0.5'}`} />
|
||
</button>
|
||
</div>
|
||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setNewCompress(v => !v)}
|
||
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newCompress ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}
|
||
>
|
||
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${newCompress ? 'left-4' : 'left-0.5'}`} />
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddRoute}
|
||
disabled={addingRoute || !newHostname.trim() || !newUpstream.trim()}
|
||
className="flex items-center gap-1.5 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
{addingRoute ? 'Adding…' : 'Add'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</SectionCard>
|
||
|
||
{/* ── Database ── */}
|
||
<SectionCard accentColor="bg-gradient-to-r from-violet-600 to-purple-600">
|
||
|
||
{/* Header: icon + title + file size */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-violet-950/60 border border-violet-900/40 rounded-xl">
|
||
<HardDrive className="w-4 h-4 text-violet-400" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-sm font-semibold text-white">Database</h2>
|
||
<p className="text-[11px] text-slate-500 mt-0.5">SQLite · {dbInfo?.path ?? 'ghostgrid.db'}</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-xl font-bold text-white font-mono leading-none">
|
||
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '—'}
|
||
</p>
|
||
<p className="text-[10px] text-slate-500 font-mono mt-1">
|
||
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-px bg-slate-800/60" />
|
||
|
||
{/* Proportional usage bar + table stats */}
|
||
{dbInfo ? (() => {
|
||
const tableEntries = Object.entries(dbInfo.tables) as [string, number][];
|
||
const total = tableEntries.reduce((sum, [, n]) => sum + n, 0);
|
||
const palette: Record<string, string> = {
|
||
users: 'bg-blue-500', devices: 'bg-emerald-500', labs: 'bg-orange-500',
|
||
bookings: 'bg-cyan-500', logs: 'bg-slate-400', links: 'bg-violet-500',
|
||
settings: 'bg-slate-600', caddy: 'bg-sky-500',
|
||
};
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex h-1.5 rounded-full overflow-hidden bg-slate-800 gap-px">
|
||
{total === 0
|
||
? <div className="flex-1 bg-slate-700" />
|
||
: tableEntries.filter(([, n]) => n > 0).map(([t, n]) => (
|
||
<div
|
||
key={t}
|
||
title={`${t}: ${n}`}
|
||
className={`${palette[t] ?? 'bg-slate-500'} transition-all`}
|
||
style={{ width: `${(n / total) * 100}%` }}
|
||
/>
|
||
))
|
||
}
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-1.5">
|
||
{tableEntries.map(([t, n]) => (
|
||
<div key={t} className="bg-slate-900/50 border border-slate-700/60 rounded-lg px-2 py-1.5">
|
||
<div className={`w-1.5 h-1.5 rounded-full mb-1 ${palette[t] ?? 'bg-slate-500'}`} />
|
||
<p className="text-[8px] font-semibold text-slate-600 uppercase tracking-wide truncate">{t}</p>
|
||
<p className="text-sm font-bold text-white font-mono">{n}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})() : (
|
||
<div className="h-16 flex items-center justify-center">
|
||
<span className="w-4 h-4 border-2 border-slate-700 border-t-violet-400 rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
|
||
<div className="h-px bg-slate-800/60" />
|
||
|
||
{/* Actions */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
|
||
{/* Backup */}
|
||
<div className="space-y-2">
|
||
<Label>Backup</Label>
|
||
<button
|
||
type="button"
|
||
onClick={handleBackup}
|
||
disabled={backingUp}
|
||
className="w-full flex items-center justify-center gap-2 bg-violet-950/60 hover:bg-violet-900/40 border border-violet-900/50 text-violet-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<Download className={`w-3.5 h-3.5 ${backingUp ? 'animate-pulse' : ''}`} />
|
||
{backingUp ? 'Creating backup…' : 'Download Backup'}
|
||
</button>
|
||
<Hint>Downloads a consistent snapshot of the SQLite database.</Hint>
|
||
</div>
|
||
|
||
{/* Import */}
|
||
<div className="space-y-2">
|
||
<Label>Import</Label>
|
||
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
|
||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
|
||
<p className="text-[11px] text-amber-300 leading-relaxed">
|
||
<strong>Import overwrites the entire database</strong> — this cannot be undone.
|
||
</p>
|
||
</div>
|
||
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">
|
||
<Upload className="w-3.5 h-3.5 shrink-0" />
|
||
<span className="truncate">{importFile ? importFile.name : 'Select .db file…'}</span>
|
||
<input
|
||
type="file"
|
||
accept=".db"
|
||
className="sr-only"
|
||
onChange={e => {
|
||
setImportFile(e.target.files?.[0] ?? null);
|
||
setImportConfirmed(false);
|
||
setImportResult(null);
|
||
}}
|
||
/>
|
||
</label>
|
||
{importFile && (
|
||
<div className="space-y-2">
|
||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={importConfirmed}
|
||
onChange={e => setImportConfirmed(e.target.checked)}
|
||
className="w-3.5 h-3.5 rounded accent-violet-500"
|
||
/>
|
||
<span className="text-[11px] text-slate-400">I confirm this will overwrite all existing data</span>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={handleImport}
|
||
disabled={importing || !importConfirmed}
|
||
className="w-full flex items-center justify-center gap-2 bg-red-950/60 hover:bg-red-900/40 border border-red-900/50 text-red-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{importing
|
||
? <span className="w-3.5 h-3.5 border-2 border-red-400/30 border-t-red-400 rounded-full animate-spin" />
|
||
: <Upload className="w-3.5 h-3.5" />
|
||
}
|
||
{importing ? 'Importing…' : 'Import Database'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{importResult && (
|
||
<p className={`text-[11px] font-mono ${importResult.ok ? 'text-emerald-400' : 'text-red-400'}`}>
|
||
{importResult.msg}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
</SectionCard>
|
||
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|