Files
helix-proxy/ui/src/routes/+page.svelte
T
s1d3sw1ped 1cc94f2c99
Format / gofmt (push) Failing after 27s
CI / Build (push) Successful in 51s
CI / Go Tests (push) Failing after 21s
initial commit
2026-06-06 07:54:44 -05:00

1777 lines
99 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import Modal from '$lib/Modal.svelte';
import FlashBanner from '$lib/FlashBanner.svelte';
import {
accessLabelById,
auditLogSummary,
certLabel,
certLabelById,
domainListLabel,
ownerDisplay,
providerLabel,
redirLabel,
sslCertLabel,
streamLabel,
streamStatusDisplay,
} from '$lib/labels';
let token = $state(localStorage.getItem('pm_token') || '');
let user = $state<any>(null);
let currentTab = $state('dashboard');
let loading = $state(false);
let error = $state('');
let formError = $state('');
let success = $state('');
// Data states
let dashboard = $state<any>({});
let proxyHosts = $state<any[]>([]);
let certificates = $state<any[]>([]);
let accessLists = $state<any[]>([]);
let auditLogs = $state<any[]>([]);
let streams = $state<any[]>([]);
let redirs = $state<any[]>([]);
let deads = $state<any[]>([]);
let settingsData = $state<any>({});
let appVersion = $state('');
// Form states for new items (simple)
let newProxy = $state({
id: 0,
domainNames: '',
forwardScheme: 'http',
forwardHost: '127.0.0.1',
forwardPort: 80,
accessListId: 0,
certificateId: 0,
sslForced: false,
blockExploits: false,
hstsEnabled: false,
enabled: true,
allowWebsocketUpgrade: false,
trustForwardedProto: false,
cachingEnabled: false,
http2Support: false,
hstsSubdomains: false,
locations: [] as any[], // [{path, forwardScheme, forwardHost, forwardPort, forwardPath}]
requestHeaders: [] as { name: string; value: string }[],
responseHeaders: [] as { name: string; value: string }[],
hideHeaders: [] as string[],
createdOn: ''
});
let newCert = $state({ domainNames: '', provider: 'letsencrypt' });
let newCustomCert = $state({ niceName: '', domainNames: '', cert: '', key: '' });
let newAccess = $state({ id: 0, name: '', clients: '127.0.0.1/32 allow', items: 'user:pass', createdOn: '', satisfyAny: false, passAuth: false });
let newStream = $state({ id: 0, incomingPort: 9000, forwardingHost: '127.0.0.1', forwardingPort: 9000, tcpForwarding: true, udpForwarding: false, certificateId: 0, enabled: true, createdOn: '' });
let newRedir = $state({ id: 0, domainNames: '', forwardDomainName: 'example.com', forwardHttpCode: 301, forwardScheme: 'https', preservePath: false, enabled: true, createdOn: '' });
let newDead = $state({ id: 0, domainNames: '', enabled: true, customHtml: '', createdOn: '' });
// single admin password (default "password" until changed; forced on first login)
let mustChangePassword = $state(false);
let showChangePw = $state(false);
let pwForm = $state({ current: '', new: '', confirm: '' });
let changingPw = $state(false);
// Modal state for form-based CRUD (streams/redir/dead) and confirm dialogs
let openFormModal = $state(false);
let formModalType = $state<string | null>(null);
let formModalTitle = $state('');
let confirmState = $state({ open: false, message: '', onConfirm: null as any });
const API = ''; // same origin
function saveToken(t: string) {
token = t;
localStorage.setItem('pm_token', t);
}
function clearAuth() {
token = '';
user = null;
mustChangePassword = false;
showChangePw = false;
pwForm = { current: '', new: '', confirm: '' };
localStorage.removeItem('pm_token');
currentTab = 'dashboard';
openFormModal = false;
formModalType = null;
proxyFormTab = 'details';
searchProxy = ''; searchCerts = ''; searchAccess = ''; searchStreams = ''; searchRedir = ''; searchDead = ''; searchAudit = '';
confirmState.open = false;
confirmState.message = '';
confirmState.onConfirm = null;
showUserMenu = false;
showHostsMenu = false;
}
async function apiFetch(path: string, opts: RequestInit = {}) {
const headers: any = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(API + path, { ...opts, headers });
if (res.status === 401) {
clearAuth();
throw new Error('Session expired, please login again');
}
const txt = await res.text();
if (!res.ok) {
throw new Error(txt || res.statusText);
}
if (!txt) return null;
return JSON.parse(txt);
}
// shared helper to wrap async side-effect handlers (toggles, loads, refreshes) for consistent error surfacing
async function safeCall(fn: () => Promise<void>, okMsg?: string) {
error = '';
try {
await fn();
if (okMsg) success = okMsg;
} catch (err: any) {
error = err.message || 'Failed';
}
}
async function login(e: Event) {
e.preventDefault();
error = ''; success = ''; loading = true;
const form = e.target as HTMLFormElement;
const password = (form.querySelector('#password') as HTMLInputElement).value || '';
try {
const data = await apiFetch('/api/login', { method: 'POST', body: JSON.stringify({ password }) });
saveToken(data.token);
user = data.user;
mustChangePassword = !!data.mustChangePassword;
showChangePw = mustChangePassword;
success = mustChangePassword ? 'Login successful — please set a new admin password' : 'Logged in';
pwForm = { current: '', new: '', confirm: '' };
if (!mustChangePassword) {
await loadAll();
currentTab = 'dashboard';
}
} catch (err: any) {
error = err.message || 'Login failed';
} finally { loading = false; }
}
async function changeAdminPassword(e?: Event) {
if (e) e.preventDefault();
error = ''; success = ''; changingPw = true;
if (!mustChangePassword) showChangePw = true; // keep form visible for manual change
if (pwForm.new !== pwForm.confirm) {
error = 'New passwords do not match';
changingPw = false;
return;
}
if (!pwForm.new || pwForm.new.length < 4) {
error = 'New password must be at least 4 characters';
changingPw = false;
return;
}
try {
const body: any = { newPassword: pwForm.new };
if (!mustChangePassword || pwForm.current) {
body.currentPassword = pwForm.current || (mustChangePassword ? 'password' : '');
}
await apiFetch('/api/users/me/password', { method: 'POST', body: JSON.stringify(body) });
success = 'Password changed successfully';
mustChangePassword = false;
showChangePw = false;
pwForm = { current: '', new: '', confirm: '' };
// after forced first change, load the app
await loadAll();
currentTab = 'dashboard';
} catch (err: any) {
error = err.message || 'Failed to change password';
} finally {
changingPw = false;
}
}
async function loadDashboard() {
dashboard = await apiFetch('/api/dashboard');
}
async function loadProxyHosts() {
proxyHosts = (await apiFetch('/api/proxy-hosts')) ?? [];
}
async function loadCerts() {
certificates = (await apiFetch('/api/certificates')) ?? [];
}
async function loadAccess() {
accessLists = (await apiFetch('/api/access-lists')) ?? [];
}
async function loadAudit() {
auditLogs = (await apiFetch('/api/audit-logs')) ?? [];
}
async function loadStreams() { streams = (await apiFetch('/api/streams')) ?? []; }
async function loadRedirs() { redirs = (await apiFetch('/api/redirection-hosts')) ?? []; }
async function loadDeads() { deads = (await apiFetch('/api/dead-hosts')) ?? []; }
async function loadSettings() {
settingsData = await apiFetch('/api/settings');
}
async function loadVersion() {
try {
const data = await apiFetch('/api/version');
appVersion = data?.version || '';
} catch {
appVersion = '';
}
}
// search states for list views (client-side filter)
let searchProxy = $state('');
let searchCerts = $state('');
let searchAccess = $state('');
let searchStreams = $state('');
let searchRedir = $state('');
let searchDead = $state('');
let searchAudit = $state('');
// internal tab for proxy form modal (Details / Custom Locations / SSL / Headers)
let proxyFormTab = $state<'details'|'locations'|'ssl'|'headers'>('details');
function parseLegacyAdvancedConfig(adv: string) {
const requestHeaders: { name: string; value: string }[] = [];
const responseHeaders: { name: string; value: string }[] = [];
const hideHeaders: string[] = [];
if (!adv) return { requestHeaders, responseHeaders, hideHeaders };
for (const raw of adv.replace(/;/g, '\n').split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const lower = line.toLowerCase();
const parts = line.split(/\s+/);
if (lower.startsWith('proxy_set_header ') && parts.length >= 3) {
requestHeaders.push({ name: parts[1], value: parts.slice(2).join(' ') });
} else if (lower.startsWith('add_header ') && parts.length >= 3) {
const val = parts.slice(2).join(' ').replace(/^["']|["';]+$/g, '');
responseHeaders.push({ name: parts[1], value: val });
} else if (lower.startsWith('proxy_hide_header ') && parts.length >= 2) {
hideHeaders.push(parts[1]);
}
}
return { requestHeaders, responseHeaders, hideHeaders };
}
function headerFieldsFromHost(h: any) {
const requestHeaders = (h.requestHeaders || []).map((r: any) => ({ name: r.name || '', value: r.value || '' }));
const responseHeaders = (h.responseHeaders || []).map((r: any) => ({ name: r.name || '', value: r.value || '' }));
const hideHeaders = [...(h.hideHeaders || [])];
if (!requestHeaders.length && !responseHeaders.length && !hideHeaders.length && h.advancedConfig) {
return parseLegacyAdvancedConfig(h.advancedConfig);
}
return { requestHeaders, responseHeaders, hideHeaders };
}
// user menu and hosts dropdown state
let showUserMenu = $state(false);
let showHostsMenu = $state(false);
// client-side filtered lists (per spec, no backend change)
const filteredProxy = $derived(
proxyHosts.filter((h: any) => {
const q = searchProxy.toLowerCase();
if (!q) return true;
const domains = (h.domainNames || []).join(' ').toLowerCase();
return domains.includes(q) || (h.forwardHost || '').toLowerCase().includes(q);
})
);
const filteredCerts = $derived(
certificates.filter((c: any) => {
const q = searchCerts.toLowerCase();
if (!q) return true;
const domains = (c.domainNames || []).join(' ').toLowerCase();
const nice = (c.niceName || '').toLowerCase();
return domains.includes(q) || nice.includes(q) || (c.provider || '').toLowerCase().includes(q);
})
);
const filteredAccess = $derived(
accessLists.filter((al: any) => {
const q = searchAccess.toLowerCase();
if (!q) return true;
return (al.name || '').toLowerCase().includes(q);
})
);
const filteredStreams = $derived(
streams.filter((s: any) => {
const q = searchStreams.toLowerCase();
if (!q) return true;
return String(s.incomingPort).toLowerCase().includes(q) || (s.forwardingHost || '').toLowerCase().includes(q);
})
);
const filteredRedirs = $derived(
redirs.filter((r: any) => {
const q = searchRedir.toLowerCase();
if (!q) return true;
const doms = (r.domainNames || []).join(' ').toLowerCase();
return doms.includes(q) || (r.forwardDomainName || '').toLowerCase().includes(q);
})
);
const filteredDeads = $derived(
deads.filter((d: any) => {
const q = searchDead.toLowerCase();
if (!q) return true;
return (d.domainNames || []).join(' ').toLowerCase().includes(q);
})
);
const filteredAudit = $derived(
auditLogs.filter((l: any) => {
const q = searchAudit.toLowerCase();
if (!q) return true;
const summary = auditLogSummary(l, user).toLowerCase();
return summary.includes(q)
|| (l.action || '').toLowerCase().includes(q)
|| (l.objectType || '').toLowerCase().includes(q)
|| String(l.objectId || '').includes(q)
|| String(l.userId || '').includes(q);
})
);
const owner = $derived(ownerDisplay(user));
function isCertInUse(certId: number): boolean {
if (!certId) return false;
return proxyHosts.some((h: any) => h.certificateId === certId) || streams.some((s: any) => s.certificateId === certId);
}
async function loadAll() {
if (!token) return;
loading = true; error = '';
try {
await Promise.all([
loadDashboard(),
loadProxyHosts(),
loadCerts(),
loadAccess(),
loadAudit(),
loadStreams(),
loadRedirs(),
loadDeads(),
loadSettings(),
loadVersion()
]);
if (!user) {
try { user = await apiFetch('/api/users/me'); } catch {}
}
} catch (err: any) {
error = err.message;
} finally { loading = false; }
}
async function toggleHost(id: number, enable: boolean) {
const path = `/api/proxy-hosts/${id}/${enable ? 'enable' : 'disable'}`;
await safeCall(async () => {
await apiFetch(path, { method: 'POST' });
await loadProxyHosts();
await loadDashboard();
}, `Host ${enable ? 'enabled' : 'disabled'}`);
}
async function deleteHost(id: number) {
await safeCall(async () => {
await apiFetch(`/api/proxy-hosts/${id}`, { method: 'DELETE' });
await loadProxyHosts();
await loadCerts();
await loadAccess();
await loadDashboard();
}, 'Host deleted');
}
function sourceDomainConflict(domains: string[], existing: any[], selfId: number): string | null {
const domainKeys = domains.map(d => d.toLowerCase());
if (new Set(domainKeys).size !== domainKeys.length) {
return 'Duplicate source domains are not allowed';
}
for (const item of existing) {
if (selfId && item.id === selfId) continue;
for (const d of (item.domainNames || [])) {
if (domainKeys.includes(String(d).toLowerCase())) {
return `Source domain already in use: ${d}`;
}
}
}
return null;
}
function streamPortConflict(port: number, selfId: number): string | null {
for (const s of streams) {
if (selfId && s.id === selfId) continue;
if (s.incomingPort === port) {
return `Incoming port already in use: ${port}`;
}
}
return null;
}
async function createProxy(e: Event) {
e.preventDefault();
formError = ''; error = ''; success = '';
const domains = newProxy.domainNames.split(',').map(s => s.trim()).filter(Boolean);
if (!domains.length) { formError = 'Enter at least one domain'; return; }
const domainErr = sourceDomainConflict(domains, proxyHosts, newProxy.id);
if (domainErr) { formError = domainErr; proxyFormTab = 'details'; return; }
let certificateId = newProxy.certificateId || 0;
let autoCertMsg = '';
if (certificateId === -1) {
// Convenience: auto-request a new LE cert for the domains entered here (one-flow like original NPM).
// Mirrors the separate certs tab flow but uses the proxy's domains and attaches the result.
try {
const certPayload = { provider: 'letsencrypt', domainNames: domains };
const created = await apiFetch('/api/certificates', { method: 'POST', body: JSON.stringify(certPayload) });
certificateId = created?.id || 0;
autoCertMsg = 'Certificate requested and ';
await loadCerts();
} catch (err: any) {
formError = err.message || 'Failed to request LE certificate';
return;
}
}
const locs = (newProxy.locations || []).filter(l => l.path).map(l => ({
path: l.path,
forwardScheme: l.forwardScheme || 'http',
forwardHost: l.forwardHost || '',
forwardPort: parseInt(String(l.forwardPort)) || 80,
forwardPath: l.forwardPath || ''
}));
const payload: any = {
domainNames: domains,
forwardScheme: newProxy.forwardScheme,
forwardHost: newProxy.forwardHost,
forwardPort: parseInt(String(newProxy.forwardPort)) || 80,
accessListId: newProxy.accessListId || 0,
certificateId,
sslForced: newProxy.sslForced,
blockExploits: newProxy.blockExploits,
hstsEnabled: newProxy.hstsEnabled,
enabled: newProxy.enabled,
allowWebsocketUpgrade: newProxy.allowWebsocketUpgrade,
trustForwardedProto: newProxy.trustForwardedProto,
cachingEnabled: newProxy.cachingEnabled,
http2Support: newProxy.http2Support,
hstsSubdomains: newProxy.hstsSubdomains,
locations: locs,
requestHeaders: (newProxy.requestHeaders || []).filter(r => (r.name || '').trim()).map(r => ({ name: r.name.trim(), value: r.value || '' })),
responseHeaders: (newProxy.responseHeaders || []).filter(r => (r.name || '').trim()).map(r => ({ name: r.name.trim(), value: r.value || '' })),
hideHeaders: (newProxy.hideHeaders || []).map(s => s.trim()).filter(Boolean),
advancedConfig: '',
createdOn: newProxy.createdOn || ''
};
const method = newProxy.id ? 'PUT' : 'POST';
const url = newProxy.id ? `/api/proxy-hosts/${newProxy.id}` : '/api/proxy-hosts';
try {
await apiFetch(url, { method, body: JSON.stringify(payload) });
success = autoCertMsg + (newProxy.id ? 'Proxy host updated' : 'Proxy host created');
resetProxyForm();
await loadProxyHosts();
await loadCerts();
await loadAccess();
await loadDashboard();
} catch (err: any) {
formError = err.message || 'Failed';
}
}
function resetProxyForm() {
newProxy = {
id: 0,
domainNames: '',
forwardScheme: 'http',
forwardHost: '127.0.0.1',
forwardPort: 80,
accessListId: 0,
certificateId: 0,
sslForced: false,
blockExploits: false,
hstsEnabled: false,
enabled: true,
allowWebsocketUpgrade: false,
trustForwardedProto: false,
cachingEnabled: false,
http2Support: false,
hstsSubdomains: false,
locations: [] as any[],
requestHeaders: [] as { name: string; value: string }[],
responseHeaders: [] as { name: string; value: string }[],
hideHeaders: [] as string[],
createdOn: ''
};
}
function closeForm() {
if (formModalType === 'proxy' && !newProxy.id) {
resetProxyForm();
}
proxyFormTab = 'details';
formModalType = null;
formError = '';
openFormModal = false;
}
function editHost(h: any) {
const headers = headerFieldsFromHost(h);
newProxy = {
id: h.id,
domainNames: (h.domainNames || []).join(', '),
forwardScheme: h.forwardScheme || 'http',
forwardHost: h.forwardHost || '',
forwardPort: h.forwardPort || 80,
accessListId: h.accessListId || 0,
certificateId: h.certificateId || 0,
sslForced: !!h.sslForced,
blockExploits: !!h.blockExploits,
hstsEnabled: !!h.hstsEnabled,
enabled: !!h.enabled,
allowWebsocketUpgrade: !!h.allowWebsocketUpgrade,
trustForwardedProto: !!h.trustForwardedProto,
cachingEnabled: !!h.cachingEnabled,
http2Support: !!h.http2Support,
hstsSubdomains: !!h.hstsSubdomains,
locations: (h.locations || []).map((l: any) => ({...l})),
requestHeaders: headers.requestHeaders,
responseHeaders: headers.responseHeaders,
hideHeaders: headers.hideHeaders,
createdOn: h.createdOn || ''
};
// switch to proxy tab if not
currentTab = 'proxy';
}
async function createCert(e: Event) {
e.preventDefault();
formError = ''; error = ''; success = '';
const domains = newCert.domainNames.split(',').map(s => s.trim()).filter(Boolean);
if (!domains.length) { formError = 'Enter domains'; return; }
const payload = { provider: newCert.provider, domainNames: domains };
try {
await apiFetch('/api/certificates', { method: 'POST', body: JSON.stringify(payload) });
success = 'Certificate requested';
newCert = { domainNames: '', provider: 'letsencrypt' };
await loadCerts();
await loadProxyHosts();
await loadStreams();
await loadDashboard();
} catch (err: any) {
formError = err.message || 'Failed';
}
}
async function createCustomCert(e: Event) {
e.preventDefault();
formError = ''; error = ''; success = '';
const domains = newCustomCert.domainNames.split(',').map(s => s.trim()).filter(Boolean);
if (!domains.length || !newCustomCert.cert || !newCustomCert.key) { formError = 'Domains + cert + key required'; return; }
const payload = {
provider: 'other',
niceName: newCustomCert.niceName || domains[0],
domainNames: domains,
meta: { certificate: newCustomCert.cert, certificate_key: newCustomCert.key, cert: newCustomCert.cert, key: newCustomCert.key }
};
try {
await apiFetch('/api/certificates', { method: 'POST', body: JSON.stringify(payload) });
success = 'Custom certificate uploaded';
newCustomCert = { niceName: '', domainNames: '', cert: '', key: '' };
await loadCerts();
await loadProxyHosts();
await loadStreams();
await loadDashboard();
} catch (err: any) {
formError = err.message || 'Failed';
}
}
async function deleteCert(id: number) {
await safeCall(async () => {
await apiFetch(`/api/certificates/${id}`, { method: 'DELETE' });
await loadCerts();
await loadProxyHosts();
await loadStreams();
await loadDashboard();
}, 'Certificate deleted');
}
async function renewCert(id: number) {
error = ''; success = '';
try {
await apiFetch(`/api/certificates/${id}/renew`, { method: 'POST' });
success = 'Certificate renewal requested (LE will re-issue)';
await loadCerts();
await loadProxyHosts();
await loadStreams();
await loadDashboard();
} catch (err: any) {
error = err.message || 'Renew failed';
}
}
async function createAccess(e: Event) {
e.preventDefault();
formError = ''; error = ''; success = '';
const name = (newAccess.name || '').trim();
if (!name) { formError = 'Enter a name for the access list'; return; }
// very simple parse for demo
const clientsStr = newAccess.clients;
const clients = clientsStr.split(',').map((p: string) => {
const [addr, dir = 'allow'] = p.trim().split(' ');
return { address: addr, directive: dir };
});
const itemsStr = newAccess.items;
const items = itemsStr.split(',').map((p: string) => {
const [u, pw] = p.trim().split(':');
return { username: u, password: pw || 'pass' };
});
const payload: any = { name, clients, items, satisfyAny: newAccess.satisfyAny, passAuth: newAccess.passAuth, createdOn: newAccess.createdOn || '' };
const method = newAccess.id ? 'PUT' : 'POST';
const url = newAccess.id ? `/api/access-lists/${newAccess.id}` : '/api/access-lists';
try {
await apiFetch(url, { method, body: JSON.stringify(payload) });
success = newAccess.id ? 'Access list updated' : 'Access list created';
resetAccessForm();
await loadAccess();
await loadProxyHosts(); // for Proxy Hosts count in access table if viewing it
} catch (err: any) {
formError = err.message || 'Failed';
}
}
function resetAccessForm() {
newAccess = { id: 0, name: '', clients: '127.0.0.1/32 allow', items: 'user:pass', createdOn: '', satisfyAny: false, passAuth: false };
}
function editAccess(al: any) {
const clientsStr = (al.clients || []).map((c: any) => `${c.address} ${c.directive}`).join(', ');
const itemsStr = (al.items || []).map((i: any) => `${i.username}:${i.password || ''}`).join(', ');
newAccess = { id: al.id, name: al.name || '', clients: clientsStr || '127.0.0.1/32 allow', items: itemsStr || 'user:pass', createdOn: al.createdOn || '', satisfyAny: !!al.satisfyAny, passAuth: !!al.passAuth };
}
async function deleteAccess(id: number) {
await safeCall(async () => {
await apiFetch(`/api/access-lists/${id}`, { method: 'DELETE' });
await loadAccess();
await loadProxyHosts(); // for symmetry with createAccess + Proxy Hosts count / name lookups in proxy table if viewing it
}, 'Access list deleted');
}
async function createStream(e: Event) {
e.preventDefault();
formError = ''; error = ''; success = '';
const payload: any = {
incomingPort: parseInt(String(newStream.incomingPort)) || 0,
forwardingHost: newStream.forwardingHost,
forwardingPort: parseInt(String(newStream.forwardingPort)) || 0,
tcpForwarding: newStream.tcpForwarding,
udpForwarding: newStream.udpForwarding,
certificateId: newStream.certificateId || 0,
enabled: newStream.enabled,
createdOn: newStream.createdOn || ''
};
if (!payload.incomingPort) { formError = 'Incoming port required'; return; }
const portErr = streamPortConflict(payload.incomingPort, newStream.id);
if (portErr) { formError = portErr; return; }
const method = newStream.id ? 'PUT' : 'POST';
const url = newStream.id ? `/api/streams/${newStream.id}` : '/api/streams';
try {
await apiFetch(url, { method, body: JSON.stringify(payload) });
success = newStream.id ? 'Stream updated' : 'Stream created' + (payload.certificateId ? ' (with SSL termination)' : '');
resetStreamForm();
await loadStreams();
await loadCerts();
await loadDashboard();
} catch (err: any) {
formError = err.message || 'Failed';
}
}
function resetStreamForm() {
newStream = { id: 0, incomingPort: 9000, forwardingHost: '127.0.0.1', forwardingPort: 9000, tcpForwarding: true, udpForwarding: false, certificateId: 0, enabled: true, createdOn: '' };
}
function editStream(s: any) {
newStream = { id: s.id || 0, incomingPort: s.incomingPort || 9000, forwardingHost: s.forwardingHost || '', forwardingPort: s.forwardingPort || 9000, tcpForwarding: !!s.tcpForwarding, udpForwarding: !!s.udpForwarding, certificateId: s.certificateId || 0, enabled: !!s.enabled, createdOn: s.createdOn || '' };
}
async function deleteStream(id: number) {
await safeCall(async () => {
await apiFetch(`/api/streams/${id}`, { method: 'DELETE' });
await loadStreams();
await loadCerts();
await loadDashboard();
}, 'Stream deleted');
}
async function createRedir(e: Event) {
e.preventDefault();
formError = ''; error = ''; success = '';
const domains = newRedir.domainNames.split(',').map(s => s.trim()).filter(Boolean);
if (!domains.length) { formError = 'Enter domains'; return; }
const domainErr = sourceDomainConflict(domains, redirs, newRedir.id);
if (domainErr) { formError = domainErr; return; }
const payload: any = {
domainNames: domains,
forwardDomainName: newRedir.forwardDomainName,
forwardHttpCode: newRedir.forwardHttpCode || 301,
forwardScheme: newRedir.forwardScheme || 'https',
preservePath: newRedir.preservePath,
enabled: newRedir.enabled,
createdOn: newRedir.createdOn || ''
};
const method = newRedir.id ? 'PUT' : 'POST';
const url = newRedir.id ? `/api/redirection-hosts/${newRedir.id}` : '/api/redirection-hosts';
try {
await apiFetch(url, { method, body: JSON.stringify(payload) });
success = newRedir.id ? 'Redirection host updated' : 'Redirection host created';
resetRedirForm();
await loadRedirs();
await loadDashboard();
} catch (err: any) {
formError = err.message || 'Failed';
}
}
function resetRedirForm() {
newRedir = { id: 0, domainNames: '', forwardDomainName: 'example.com', forwardHttpCode: 301, forwardScheme: 'https', preservePath: false, enabled: true, createdOn: '' };
}
function editRedir(r: any) {
newRedir = {
id: r.id || 0,
domainNames: (r.domainNames || []).join(', '),
forwardDomainName: r.forwardDomainName || 'example.com',
forwardHttpCode: r.forwardHttpCode || 301,
forwardScheme: r.forwardScheme || 'https',
preservePath: !!r.preservePath,
enabled: !!r.enabled,
createdOn: r.createdOn || ''
};
}
async function deleteRedir(id: number) {
await safeCall(async () => {
await apiFetch(`/api/redirection-hosts/${id}`, { method: 'DELETE' });
await loadRedirs();
await loadDashboard();
}, 'Redirection host deleted');
}
async function createDead(e: Event) {
e.preventDefault();
formError = ''; error = ''; success = '';
const domains = newDead.domainNames.split(',').map(s => s.trim()).filter(Boolean);
if (!domains.length) { formError = 'Enter domains'; return; }
const domainErr = sourceDomainConflict(domains, deads, newDead.id);
if (domainErr) { formError = domainErr; return; }
const payload: any = {
domainNames: domains,
enabled: newDead.enabled,
meta: newDead.customHtml ? { html: newDead.customHtml } : {},
createdOn: newDead.createdOn || ''
};
const method = newDead.id ? 'PUT' : 'POST';
const url = newDead.id ? `/api/dead-hosts/${newDead.id}` : '/api/dead-hosts';
try {
await apiFetch(url, { method, body: JSON.stringify(payload) });
success = newDead.id ? '404 host updated' : '404 host created';
resetDeadForm();
await loadDeads();
await loadDashboard();
} catch (err: any) {
formError = err.message || 'Failed';
}
}
function resetDeadForm() {
newDead = { id: 0, domainNames: '', enabled: true, customHtml: '', createdOn: '' };
}
function editDead(d: any) {
newDead = {
id: d.id || 0,
domainNames: (d.domainNames || []).join(', '),
enabled: !!d.enabled,
customHtml: (d.meta && d.meta.html) || '',
createdOn: d.createdOn || ''
};
}
async function deleteDead(id: number) {
await safeCall(async () => {
await apiFetch(`/api/dead-hosts/${id}`, { method: 'DELETE' });
await loadDeads();
await loadDashboard();
}, '404 host deleted');
}
async function toggleRedir(id: number, en: boolean) {
await safeCall(async () => {
await apiFetch(`/api/redirection-hosts/${id}/${en?'enable':'disable'}`, {method:'POST'});
await loadRedirs();
}, `Host ${en ? 'enabled' : 'disabled'}`);
}
async function toggleDead(id: number, en: boolean) {
await safeCall(async () => {
await apiFetch(`/api/dead-hosts/${id}/${en?'enable':'disable'}`, {method:'POST'});
await loadDeads();
}, `Host ${en ? 'enabled' : 'disabled'}`);
}
async function toggleStream(id: number, en: boolean) {
await safeCall(async () => {
await apiFetch(`/api/streams/${id}/${en?'enable':'disable'}`, {method:'POST'});
await loadStreams();
}, `Host ${en ? 'enabled' : 'disabled'}`);
}
// init - onMount avoids Svelte 5 "captures the initial value" warning from top-level if on $state
onMount(() => {
if (token) {
loadAll();
}
});
function logout() {
clearAuth();
proxyHosts = []; certificates = []; /* etc reset if want */
}
function switchTab(tab: string) {
showHostsMenu = false;
showUserMenu = false;
currentTab = tab;
openFormModal = false;
formModalType = null;
proxyFormTab = 'details';
searchProxy = ''; searchCerts = ''; searchAccess = ''; searchStreams = ''; searchRedir = ''; searchDead = ''; searchAudit = '';
confirmState.open = false;
confirmState.message = '';
confirmState.onConfirm = null;
if (tab === 'dashboard') safeCall(loadDashboard);
if (tab === 'proxy') { safeCall(async () => { await loadProxyHosts(); await loadCerts(); await loadAccess(); }); }
if (tab === 'certs') safeCall(async () => { await loadCerts(); await loadProxyHosts(); await loadStreams(); });
if (tab === 'access') safeCall(async () => { await loadAccess(); await loadProxyHosts(); });
if (tab === 'audit') safeCall(loadAudit);
if (tab === 'streams') { safeCall(async () => { await loadStreams(); await loadCerts(); }); }
if (tab === 'redir') safeCall(async () => { await loadRedirs(); await loadDashboard(); });
if (tab === 'dead') safeCall(async () => { await loadDeads(); await loadDashboard(); });
if (tab === 'settings') safeCall(loadSettings);
}
function showConfirm(message: string, onConfirm: () => void | Promise<void>) {
confirmState.message = message;
confirmState.onConfirm = onConfirm;
confirmState.open = true;
}
function handleConfirm() {
const fn = confirmState.onConfirm;
confirmState.open = false;
confirmState.message = '';
confirmState.onConfirm = null;
if (fn) {
Promise.resolve(fn()).catch((e: any) => { error = e?.message || 'Action failed'; });
}
}
function openNewStream() {
resetStreamForm();
formError = '';
formModalType = 'stream';
formModalTitle = 'Add Stream';
openFormModal = true;
}
function editStreamAndOpen(s: any) {
editStream(s);
formError = '';
formModalType = 'stream';
formModalTitle = `Edit Stream ${streamLabel(newStream)}`;
openFormModal = true;
}
function openNewRedir() {
resetRedirForm();
formError = '';
formModalType = 'redir';
formModalTitle = 'Add Redirection Host';
openFormModal = true;
}
function editRedirAndOpen(r: any) {
editRedir(r);
formError = '';
formModalType = 'redir';
formModalTitle = `Edit ${redirLabel(newRedir)}`;
openFormModal = true;
}
function openNewDead() {
resetDeadForm();
formError = '';
formModalType = 'dead';
formModalTitle = 'Add 404 Host';
openFormModal = true;
}
function editDeadAndOpen(d: any) {
editDead(d);
formError = '';
formModalType = 'dead';
formModalTitle = `Edit 404 Host ${domainListLabel(newDead.domainNames, `#${newDead.id}`)}`;
openFormModal = true;
}
function openNewProxy() {
resetProxyForm();
proxyFormTab = 'details';
formError = '';
formModalType = 'proxy';
formModalTitle = 'Add Proxy Host';
openFormModal = true;
}
function editHostAndOpen(h: any) {
editHost(h);
proxyFormTab = 'details';
formError = '';
formModalType = 'proxy';
const domains = (newProxy.domainNames || '').split(',').map((s: string) => s.trim()).filter(Boolean);
formModalTitle = `Edit Proxy Host ${domainListLabel(domains, `#${newProxy.id}`)}`;
openFormModal = true;
}
function openNewCert() {
newCert = { domainNames: '', provider: 'letsencrypt' };
formError = '';
formModalType = 'cert';
formModalTitle = 'Add Certificate';
openFormModal = true;
}
function openNewCustomCert() {
newCustomCert = { niceName: '', domainNames: '', cert: '', key: '' };
formError = '';
formModalType = 'customCert';
formModalTitle = 'Add Custom Certificate';
openFormModal = true;
}
function openNewAccess() {
resetAccessForm();
formError = '';
formModalType = 'access';
formModalTitle = 'Add Access List';
openFormModal = true;
}
function editAccessAndOpen(al: any) {
editAccess(al);
formError = '';
formModalType = 'access';
formModalTitle = `Edit Access List ${newAccess.name || `#${newAccess.id}`}`;
openFormModal = true;
}
</script>
<div class="min-h-screen bg-zinc-950 text-zinc-200">
<!-- Top banner matching live: logo + title, right icons + user avatar menu -->
<header class="border-b border-zinc-800 bg-zinc-900">
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-emerald-500 rounded flex items-center justify-center text-black font-bold">HP</div>
<div class="font-semibold">Helix Proxy</div>
</div>
{#if token}
<div class="flex items-center gap-1 text-sm">
<!-- icon buttons (notifications/help placeholders to match live) -->
<button title="Notifications" class="p-1 text-zinc-400 hover:text-zinc-200">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
</button>
<button title="Help" class="p-1 text-zinc-400 hover:text-zinc-200">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
</button>
<!-- user avatar + dropdown menu -->
<div class="relative">
<button
onclick={() => showUserMenu = !showUserMenu}
class="w-6 h-6 bg-emerald-600 rounded-full flex items-center justify-center text-[10px] text-white font-medium focus:outline-none"
title="Open user menu"
aria-haspopup="menu"
aria-expanded={showUserMenu}
>
A
</button>
{#if showUserMenu}
<div class="absolute right-0 mt-1 w-48 bg-zinc-900 border border-zinc-700 rounded shadow-xl text-sm z-50 py-1">
<div class="px-3 py-1.5 text-[11px] text-zinc-400 border-b border-zinc-800">{user?.email || 'admin'}<div class="text-[10px] text-emerald-400">Administrator</div></div>
<button onclick={() => { showUserMenu=false; pwForm = { current: '', new: '', confirm: '' }; showChangePw = true; }} class="block w-full text-left px-3 py-1 hover:bg-zinc-800 text-xs">Change password</button>
<button onclick={() => { showUserMenu=false; logout(); }} class="block w-full text-left px-3 py-1 hover:bg-zinc-800 text-xs text-red-400">Logout</button>
</div>
{/if}
</div>
</div>
{/if}
</div>
</header>
{#if !token}
<!-- Login -->
<div class="max-w-md mx-auto mt-16 p-8 bg-zinc-900 border border-zinc-800 rounded-xl">
<h1 class="text-2xl font-semibold mb-6">Sign in</h1>
<form onsubmit={login} class="space-y-4">
<div>
<label for="password" class="text-xs text-zinc-400 block mb-1">Password</label>
<input id="password" type="password" placeholder="Password" class="w-full bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm focus:outline-none focus:border-emerald-500" />
</div>
<button type="submit" disabled={loading} class="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-60 py-2 rounded font-medium text-sm transition">
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<FlashBanner bind:message={error} variant="error" compact class="mt-4" />
<FlashBanner bind:message={success} variant="success" compact class="mt-4" />
</div>
{:else}
<!-- Main App -->
<div class="max-w-6xl mx-auto px-4 py-6">
{#if !mustChangePassword}
<!-- Secondary nav bar matching live NPM: Dashboard, Hosts(dropdown), Access Lists, Certificates, Audit Logs, Settings -->
<div class="flex items-center gap-x-5 text-sm border-b border-zinc-800 pb-2 mb-6 -mx-1 px-1 text-zinc-300">
<button onclick={() => switchTab('dashboard')} class="{currentTab === 'dashboard' ? 'text-emerald-400 font-medium' : 'hover:text-white'}">Dashboard</button>
<!-- Hosts dropdown -->
<div class="relative">
<button
onclick={() => showHostsMenu = !showHostsMenu}
class="flex items-center gap-0.5 {['proxy','redir','streams','dead'].includes(currentTab) ? 'text-emerald-400 font-medium' : 'hover:text-white'}"
aria-haspopup="menu"
aria-expanded={showHostsMenu}
>
Hosts <span class="text-xs"></span>
</button>
{#if showHostsMenu}
<div class="absolute left-0 mt-1 w-44 bg-zinc-900 border border-zinc-700 rounded shadow-xl z-50 py-1 text-xs">
<button onclick={() => { switchTab('proxy'); }} class="block w-full text-left px-3 py-1 hover:bg-zinc-800 {currentTab==='proxy' ? 'text-emerald-400' : ''}">Proxy Hosts</button>
<button onclick={() => { switchTab('redir'); }} class="block w-full text-left px-3 py-1 hover:bg-zinc-800 {currentTab==='redir' ? 'text-emerald-400' : ''}">Redirection Hosts</button>
<button onclick={() => { switchTab('streams'); }} class="block w-full text-left px-3 py-1 hover:bg-zinc-800 {currentTab==='streams' ? 'text-emerald-400' : ''}">Streams</button>
<button onclick={() => { switchTab('dead'); }} class="block w-full text-left px-3 py-1 hover:bg-zinc-800 {currentTab==='dead' ? 'text-emerald-400' : ''}">404 Hosts</button>
</div>
{/if}
</div>
<button onclick={() => switchTab('access')} class="{currentTab === 'access' ? 'text-emerald-400 font-medium' : 'hover:text-white'}">Access Lists</button>
<button onclick={() => switchTab('certs')} class="{currentTab === 'certs' ? 'text-emerald-400 font-medium' : 'hover:text-white'}">Certificates</button>
<button onclick={() => switchTab('audit')} class="{currentTab === 'audit' ? 'text-emerald-400 font-medium' : 'hover:text-white'}">Audit Logs</button>
<button onclick={() => switchTab('settings')} class="{currentTab === 'settings' ? 'text-emerald-400 font-medium' : 'hover:text-white'}">Settings</button>
</div>
{/if}
{#if loading}<div class="text-xs text-zinc-500 mb-2">Loading...</div>{/if}
<FlashBanner bind:message={error} variant="error" class="mb-4" />
<FlashBanner bind:message={success} variant="success" class="mb-4" />
{#if mustChangePassword || showChangePw}
<div class="max-w-lg mx-auto mb-6 p-5 bg-zinc-900 border border-emerald-600 rounded-xl">
<div class="font-medium mb-1">{mustChangePassword ? 'First login: set a new admin password' : 'Change Admin Password'}</div>
<p class="text-xs text-zinc-400 mb-3">{mustChangePassword ? 'The default "password" must be changed before using the manager.' : 'Enter your current password and choose a new one.'}</p>
<form onsubmit={changeAdminPassword} class="space-y-2 text-sm">
{#if !mustChangePassword}
<input bind:value={pwForm.current} type="password" placeholder="Current password" title="Your current admin password" class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5" />
{/if}
<input bind:value={pwForm.new} type="password" placeholder="New password (min. 4 characters)" title="At least 4 characters; cannot be the default password" class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5" />
<input bind:value={pwForm.confirm} type="password" placeholder="Confirm new password" class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5" />
<div class="flex gap-2">
<button type="submit" disabled={changingPw} class="bg-emerald-600 hover:bg-emerald-500 px-4 py-1 rounded text-sm flex-1">{changingPw ? 'Saving...' : (mustChangePassword ? 'Set Password & Enter' : 'Update Password')}</button>
{#if !mustChangePassword}
<button type="button" onclick={() => { showChangePw = false; pwForm = {current:'',new:'',confirm:''}; }} class="px-3 py-1 rounded border border-zinc-700 text-xs">Cancel</button>
{/if}
</div>
</form>
</div>
{/if}
<!-- DASHBOARD -->
{#if currentTab === 'dashboard'}
<div class="mb-2 font-semibold">Dashboard</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<button type="button" onclick={() => switchTab('proxy')} class="bg-zinc-900 border border-zinc-800 hover:border-emerald-700 rounded-xl p-4 flex gap-3 items-start cursor-pointer text-left w-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-emerald-400 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
<div>
<div class="text-3xl font-semibold">{dashboard.proxy_hosts ?? 0}</div>
<div class="text-xs text-zinc-400">Proxy Hosts</div>
</div>
</button>
<button type="button" onclick={() => switchTab('redir')} class="bg-zinc-900 border border-zinc-800 hover:border-emerald-700 rounded-xl p-4 flex gap-3 items-start cursor-pointer text-left w-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-emerald-400 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
<div>
<div class="text-3xl font-semibold">{dashboard.redirection_hosts ?? 0}</div>
<div class="text-xs text-zinc-400">Redirection Hosts</div>
</div>
</button>
<button type="button" onclick={() => switchTab('streams')} class="bg-zinc-900 border border-zinc-800 hover:border-emerald-700 rounded-xl p-4 flex gap-3 items-start cursor-pointer text-left w-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-emerald-400 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
<div>
<div class="text-3xl font-semibold">{dashboard.streams ?? 0}</div>
<div class="text-xs text-zinc-400">Streams</div>
</div>
</button>
<button type="button" onclick={() => switchTab('dead')} class="bg-zinc-900 border border-zinc-800 hover:border-emerald-700 rounded-xl p-4 flex gap-3 items-start cursor-pointer text-left w-full focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-emerald-400 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<div>
<div class="text-3xl font-semibold">{dashboard.dead_hosts ?? 0}</div>
<div class="text-xs text-zinc-400">404 Hosts</div>
</div>
</button>
</div>
<!-- note moved to settings per spec -->
{/if}
<!-- PROXY HOSTS -->
{#if currentTab === 'proxy'}
<!-- per-page header with search, refresh icon, Add button matching live -->
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-base">Proxy Hosts</div>
<div class="flex items-center gap-2">
<div class="relative">
<input bind:value={searchProxy} placeholder="Search..." class="bg-zinc-950 border border-zinc-700 rounded pl-8 pr-2 py-1 text-xs w-40 focus:outline-none focus:border-emerald-600" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 absolute left-2 top-1.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button title="Refresh" onclick={() => safeCall(async () => { await loadProxyHosts(); await loadCerts(); await loadAccess(); await loadDashboard(); })} class="p-1 text-zinc-400 hover:text-white bg-zinc-800 rounded">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.058 11H1M12 3v2m0 16v2m9-9H15m-6 0a8 8 0 01-.937-1.5" /></svg>
</button>
<button onclick={openNewProxy} class="text-xs px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
Add Proxy Host
</button>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden mb-6">
<table class="w-full text-sm">
<thead class="bg-zinc-950 text-xs text-zinc-400">
<tr>
<th class="text-left p-3 font-normal">Owner</th>
<th class="text-left p-3 font-normal">Source ↓</th>
<th class="text-left p-3 font-normal">Destination</th>
<th class="text-left p-3 font-normal">SSL</th>
<th class="text-left p-3 font-normal">Access</th>
<th class="p-3 font-normal">Status</th>
<th class="p-3 w-8"></th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
{#each filteredProxy as h}
<tr class="hover:bg-zinc-950/50">
<td class="p-3">
<div class="flex items-center gap-2 text-xs">
<div class="w-5 h-5 bg-emerald-600 text-white rounded-full text-[10px] flex items-center justify-center shrink-0">{owner.initial}</div>
<span>{owner.label}</span>
</div>
</td>
<td class="p-3 text-xs">
<button onclick={() => editHostAndOpen(h)} class="text-emerald-400 hover:underline font-mono">{(h.domainNames || []).join(', ') || '-'}</button>
<div class="text-[10px] text-zinc-500">Created: {h.createdOn || '—'}</div>
</td>
<td class="p-3 text-xs font-mono text-zinc-300">{h.forwardScheme}://{h.forwardHost}:{h.forwardPort}</td>
<td class="p-3 text-xs">
{sslCertLabel(h.certificateId, certificates, h.sslForced)}
</td>
<td class="p-3 text-xs">{h.accessListId ? accessLabelById(h.accessListId, accessLists) : 'Public'}</td>
<td class="p-3"><span class="text-[10px] px-2 py-0.5 rounded-full {h.enabled ? 'bg-emerald-900 text-emerald-400' : 'bg-zinc-700 text-zinc-400'}">{h.enabled ? 'Online' : 'Offline'}</span></td>
<td class="p-3 text-right">
<button title="Edit" onclick={() => editHostAndOpen(h)} class="text-zinc-400 hover:text-white p-0.5"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg></button>
<button title="{h.enabled ? 'Disable' : 'Enable'}" onclick={() => toggleHost(h.id, !h.enabled)} class="text-zinc-400 hover:text-white p-0.5 ml-0.5 text-[10px]">{h.enabled ? '⏻' : '⏻'}</button>
<button title="Delete" onclick={() => showConfirm(`Delete proxy host for ${(h.domainNames||[]).join(', ') || h.id}?`, () => deleteHost(h.id))} class="text-red-400 hover:text-red-300 p-0.5 ml-0.5">×</button>
</td>
</tr>
{:else}
<tr><td colspan="7" class="p-0"><div class="py-12 text-center">
<div class="text-lg">There are no Proxy Hosts</div>
<div class="text-sm text-zinc-400">Why don't you create one?</div>
<button onclick={openNewProxy} class="mt-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-sm rounded">Add Proxy Host</button>
</div></td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- CERTIFICATES -->
{#if currentTab === 'certs'}
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-base">Certificates</div>
<div class="flex items-center gap-2">
<div class="relative">
<input bind:value={searchCerts} placeholder="Search..." class="bg-zinc-950 border border-zinc-700 rounded pl-8 pr-2 py-1 text-xs w-40 focus:outline-none focus:border-emerald-600" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 absolute left-2 top-1.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button title="Refresh" onclick={() => safeCall(async () => { await loadCerts(); await loadProxyHosts(); await loadStreams(); })} class="p-1 text-zinc-400 hover:text-white bg-zinc-800 rounded">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.058 11H1M12 3v2m0 16v2m9-9H15m-6 0a8 8 0 01-.937-1.5" /></svg>
</button>
<button onclick={openNewCert} class="text-xs px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
Add Certificate
</button>
<button onclick={openNewCustomCert} class="text-xs px-2 py-1 bg-emerald-700 hover:bg-emerald-600 rounded">Upload Custom</button>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden mb-4 text-sm">
<table class="w-full">
<thead class="bg-zinc-950"><tr class="text-xs text-zinc-400"><th class="text-left p-3 font-normal">Owner</th><th class="text-left p-3 font-normal">Name ↓</th><th class="text-left p-3 font-normal">Provider</th><th class="p-3 font-normal">Expires</th><th class="p-3 font-normal">Status</th><th class="p-3"></th></tr></thead>
<tbody class="text-xs divide-y divide-zinc-800">
{#each filteredCerts as c}
<tr class="hover:bg-zinc-950/50">
<td class="p-3">
<div class="flex items-center gap-2 text-xs">
<div class="w-5 h-5 bg-emerald-600 text-white rounded-full text-[10px] flex items-center justify-center shrink-0">{owner.initial}</div>
<span>{owner.label}</span>
</div>
</td>
<td class="p-3 font-mono">{certLabel(c)}<div class="text-[10px] text-zinc-500">Created: {c.createdOn || '—'}</div></td>
<td class="p-3">{providerLabel(c.provider)}</td>
<td class="p-3">{c.expiresOn || '-'}</td>
<td class="p-3"><span class="text-[10px] px-2 py-0.5 rounded-full {isCertInUse(c.id) ? 'bg-emerald-900 text-emerald-400' : 'bg-zinc-700 text-zinc-400'}">{isCertInUse(c.id) ? 'In Use' : 'Available'}</span></td>
<td class="p-3 text-right">
<button title="Renew" onclick={() => renewCert(c.id)} class="text-zinc-400 hover:text-white p-0.5">
<!-- renew icon (refresh arrows) to match icon-button style of other lists (no more text "renew") -->
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.058 11H1M12 3v2m0 16v2m9-9H15m-6 0a8 8 0 01-.937-1.5" /></svg>
</button>
<button title="Delete" onclick={() => showConfirm(`Delete certificate "${certLabel(c)}"? (hosts using it will lose SSL)`, () => deleteCert(c.id))} class="text-red-400 hover:text-red-300 p-0.5 ml-0.5">×</button>
</td>
</tr>
{:else}
<tr><td colspan="6" class="p-0"><div class="py-12 text-center">
<div class="text-lg">There are no Certificates</div>
<div class="text-sm text-zinc-400">Why don't you create one?</div>
<button onclick={openNewCert} class="mt-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-sm rounded">Add Certificate</button>
</div></td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- ACCESS LISTS -->
{#if currentTab === 'access'}
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-base">Access Lists</div>
<div class="flex items-center gap-2">
<div class="relative">
<input bind:value={searchAccess} placeholder="Search..." class="bg-zinc-950 border border-zinc-700 rounded pl-8 pr-2 py-1 text-xs w-40 focus:outline-none focus:border-emerald-600" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 absolute left-2 top-1.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button title="Refresh" onclick={() => safeCall(async () => { await loadAccess(); await loadProxyHosts(); })} class="p-1 text-zinc-400 hover:text-white bg-zinc-800 rounded">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.058 11H1M12 3v2m0 16v2m9-9H15m-6 0a8 8 0 01-.937-1.5" /></svg>
</button>
<button onclick={openNewAccess} class="text-xs px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
Add Access List
</button>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden mb-4 text-sm">
<table class="w-full">
<thead class="bg-zinc-950"><tr class="text-xs text-zinc-400"><th class="text-left p-3 font-normal">Owner</th><th class="text-left p-3 font-normal">Name </th><th class="p-3 font-normal">Authorization</th><th class="p-3 font-normal">Access</th><th class="p-3 font-normal">Satisfy</th><th class="p-3 font-normal">Proxy Hosts</th><th class="p-3"></th></tr></thead>
<tbody class="text-xs divide-y divide-zinc-800">
{#each filteredAccess as al}
<tr class="hover:bg-zinc-950/50">
<td class="p-3">
<div class="flex items-center gap-2 text-xs">
<div class="w-5 h-5 bg-emerald-600 text-white rounded-full text-[10px] flex items-center justify-center shrink-0">{owner.initial}</div>
<span>{owner.label}</span>
</div>
</td>
<td class="p-3 font-mono">{al.name || '-'}<div class="text-[10px] text-zinc-500">Created: {al.createdOn || '—'}</div></td>
<td class="p-3">{(al.items || []).length} User</td>
<td class="p-3">{(al.clients || []).length} Rule</td>
<td class="p-3">{al.satisfyAny ? 'Any' : 'All'}</td>
<td class="p-3">{proxyHosts.filter((p:any)=>p.accessListId === al.id).length}</td>
<td class="p-3 text-right space-x-1">
<button title="Edit" onclick={() => editAccessAndOpen(al)} class="text-zinc-400 hover:text-white p-0.5"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg></button>
<button title="Delete" onclick={() => showConfirm(`Delete access list "${al.name}"?`, () => deleteAccess(al.id))} class="text-red-400 hover:text-red-300 p-0.5">×</button>
</td>
</tr>
{:else}
<tr><td colspan="7" class="p-0"><div class="py-12 text-center">
<div class="text-lg">There are no Access Lists</div>
<div class="text-sm text-zinc-400">Why don't you create one?</div>
<button onclick={openNewAccess} class="mt-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-sm rounded">Add Access List</button>
</div></td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- STREAMS (dedicated tab, full table + modal CRUD) -->
{#if currentTab === 'streams'}
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-base">Streams</div>
<div class="flex items-center gap-2">
<div class="relative">
<input bind:value={searchStreams} placeholder="Search..." class="bg-zinc-950 border border-zinc-700 rounded pl-8 pr-2 py-1 text-xs w-40 focus:outline-none focus:border-emerald-600" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 absolute left-2 top-1.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button title="Refresh" onclick={() => safeCall(async () => { await loadStreams(); await loadCerts(); })} class="p-1 text-zinc-400 hover:text-white bg-zinc-800 rounded">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.058 11H1M12 3v2m0 16v2m9-9H15m-6 0a8 8 0 01-.937-1.5" /></svg>
</button>
<button onclick={openNewStream} class="text-xs px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
Add Stream
</button>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden mb-6">
<table class="w-full text-sm">
<thead class="bg-zinc-950 text-xs text-zinc-400">
<tr>
<th class="text-left p-3 font-normal">Owner</th>
<th class="text-left p-3 font-normal">Incoming </th>
<th class="text-left p-3 font-normal">Forward</th>
<th class="p-3 font-normal">SSL</th>
<th class="p-3 font-normal">Status</th>
<th class="p-3 w-8"></th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
{#each filteredStreams as s}
{@const status = streamStatusDisplay(s)}
<tr class="hover:bg-zinc-950/50">
<td class="p-3">
<div class="flex items-center gap-2 text-xs">
<div class="w-5 h-5 bg-emerald-600 text-white rounded-full text-[10px] flex items-center justify-center shrink-0">{owner.initial}</div>
<span>{owner.label}</span>
</div>
</td>
<td class="p-3 text-xs font-mono">{s.incomingPort}<div class="text-[10px] text-zinc-500">Created: {s.createdOn || '—'}</div></td>
<td class="p-3 text-xs font-mono">{s.forwardingHost}:{s.forwardingPort}</td>
<td class="p-3 text-xs">{sslCertLabel(s.certificateId, certificates)} {s.tcpForwarding ? 'TCP' : ''}{s.udpForwarding ? ' UDP' : ''}</td>
<td class="p-3"><span class="text-[10px] px-2 py-0.5 rounded-full {status.className}" title={status.title}>{status.label}</span></td>
<td class="p-3 text-right">
<button title="Edit" onclick={() => editStreamAndOpen(s)} class="text-zinc-400 hover:text-white p-0.5"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg></button>
<button title="{s.enabled ? 'Disable' : 'Enable'}" onclick={() => toggleStream(s.id, !s.enabled)} class="text-zinc-400 hover:text-white p-0.5 ml-0.5 text-[10px]">⏻</button>
<button title="Delete" onclick={() => showConfirm(`Delete stream ${streamLabel(s)}?`, () => deleteStream(s.id))} class="text-red-400 hover:text-red-300 p-0.5 ml-0.5">×</button>
</td>
</tr>
{:else}
<tr><td colspan="6" class="p-0"><div class="py-12 text-center">
<div class="text-lg">There are no Streams</div>
<div class="text-sm text-zinc-400">Why don't you create one?</div>
<button onclick={openNewStream} class="mt-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-sm rounded">Add Stream</button>
</div></td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- REDIRECTION HOSTS (dedicated tab) -->
{#if currentTab === 'redir'}
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-base">Redirection Hosts</div>
<div class="flex items-center gap-2">
<div class="relative">
<input bind:value={searchRedir} placeholder="Search..." class="bg-zinc-950 border border-zinc-700 rounded pl-8 pr-2 py-1 text-xs w-40 focus:outline-none focus:border-emerald-600" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 absolute left-2 top-1.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button title="Refresh" onclick={() => safeCall(async () => { await loadRedirs(); await loadDashboard(); })} class="p-1 text-zinc-400 hover:text-white bg-zinc-800 rounded">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.058 11H1M12 3v2m0 16v2m9-9H15m-6 0a8 8 0 01-.937-1.5" /></svg>
</button>
<button onclick={openNewRedir} class="text-xs px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
Add Redirection Host
</button>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden mb-6">
<table class="w-full text-sm">
<thead class="bg-zinc-950 text-xs text-zinc-400">
<tr>
<th class="text-left p-3 font-normal">Owner</th>
<th class="text-left p-3 font-normal">Source </th>
<th class="text-left p-3 font-normal">Destination</th>
<th class="p-3 font-normal">Status</th>
<th class="p-3 w-8"></th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
{#each filteredRedirs as r}
<tr class="hover:bg-zinc-950/50">
<td class="p-3">
<div class="flex items-center gap-2 text-xs">
<div class="w-5 h-5 bg-emerald-600 text-white rounded-full text-[10px] flex items-center justify-center shrink-0">{owner.initial}</div>
<span>{owner.label}</span>
</div>
</td>
<td class="p-3 text-xs">
<button onclick={() => editRedirAndOpen(r)} class="text-emerald-400 hover:underline font-mono">{(r.domainNames || []).join(', ') || '-'}</button>
<div class="text-[10px] text-zinc-500">Created: {r.createdOn || '—'}</div>
</td>
<td class="p-3 text-xs font-mono">{r.forwardDomainName} ({r.forwardHttpCode} {r.forwardScheme})</td>
<td class="p-3"><span class="text-[10px] px-2 py-0.5 rounded-full {r.enabled ? 'bg-emerald-900 text-emerald-400' : 'bg-zinc-700 text-zinc-400'}">{r.enabled ? 'Online' : 'Offline'}</span></td>
<td class="p-3 text-right">
<button title="Edit" onclick={() => editRedirAndOpen(r)} class="text-zinc-400 hover:text-white p-0.5"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg></button>
<button title="{r.enabled ? 'Disable' : 'Enable'}" onclick={() => toggleRedir(r.id, !r.enabled)} class="text-zinc-400 hover:text-white p-0.5 ml-0.5 text-[10px]">⏻</button>
<button title="Delete" onclick={() => showConfirm(`Delete redirection ${redirLabel(r)}?`, () => deleteRedir(r.id))} class="text-red-400 hover:text-red-300 p-0.5 ml-0.5">×</button>
</td>
</tr>
{:else}
<tr><td colspan="5" class="p-0"><div class="py-12 text-center">
<div class="text-lg">There are no Redirection Hosts</div>
<div class="text-sm text-zinc-400">Why don't you create one?</div>
<button onclick={openNewRedir} class="mt-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-sm rounded">Add Redirection Host</button>
</div></td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- DEAD HOSTS (dedicated tab) -- shown as 404 Hosts to align terminology per review (vars/form/api stay 'dead'/'dead-host') -->
{#if currentTab === 'dead'}
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-base">404 Hosts</div>
<div class="flex items-center gap-2">
<div class="relative">
<input bind:value={searchDead} placeholder="Search..." class="bg-zinc-950 border border-zinc-700 rounded pl-8 pr-2 py-1 text-xs w-40 focus:outline-none focus:border-emerald-600" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 absolute left-2 top-1.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
<button title="Refresh" onclick={() => safeCall(async () => { await loadDeads(); await loadDashboard(); })} class="p-1 text-zinc-400 hover:text-white bg-zinc-800 rounded">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.058 11H1M12 3v2m0 16v2m9-9H15m-6 0a8 8 0 01-.937-1.5" /></svg>
</button>
<button onclick={openNewDead} class="text-xs px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
Add 404 Host
</button>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden mb-6">
<table class="w-full text-sm">
<thead class="bg-zinc-950 text-xs text-zinc-400">
<tr>
<th class="text-left p-3 font-normal">Owner</th>
<th class="text-left p-3 font-normal">Source </th>
<th class="text-left p-3 font-normal">Custom HTML</th>
<th class="p-3 font-normal">Status</th>
<th class="p-3 w-8"></th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
{#each filteredDeads as d}
<tr class="hover:bg-zinc-950/50">
<td class="p-3">
<div class="flex items-center gap-2 text-xs">
<div class="w-5 h-5 bg-emerald-600 text-white rounded-full text-[10px] flex items-center justify-center shrink-0">{owner.initial}</div>
<span>{owner.label}</span>
</div>
</td>
<td class="p-3 text-xs">
<button onclick={() => editDeadAndOpen(d)} class="text-emerald-400 hover:underline font-mono">{(d.domainNames || []).join(', ') || '-'}</button>
<div class="text-[10px] text-zinc-500">Created: {d.createdOn || '—'}</div>
</td>
<td class="p-3 text-xs">{d.meta && d.meta.html ? 'yes (per-host)' : 'default 404'}</td>
<td class="p-3"><span class="text-[10px] px-2 py-0.5 rounded-full {d.enabled ? 'bg-emerald-900 text-emerald-400' : 'bg-zinc-700 text-zinc-400'}">{d.enabled ? 'Online' : 'Offline'}</span></td>
<td class="p-3 text-right">
<button title="Edit" onclick={() => editDeadAndOpen(d)} class="text-zinc-400 hover:text-white p-0.5"><svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg></button>
<button title="{d.enabled ? 'Disable' : 'Enable'}" onclick={() => toggleDead(d.id, !d.enabled)} class="text-zinc-400 hover:text-white p-0.5 ml-0.5 text-[10px]">⏻</button>
<button title="Delete" onclick={() => showConfirm(`Delete 404 host ${domainListLabel(d.domainNames, `#${d.id}`)}?`, () => deleteDead(d.id))} class="text-red-400 hover:text-red-300 p-0.5 ml-0.5">×</button>
</td>
</tr>
{:else}
<tr><td colspan="5" class="p-0"><div class="py-12 text-center">
<div class="text-lg">There are no 404 Hosts</div>
<div class="text-sm text-zinc-400">Why don't you create one?</div>
<button onclick={openNewDead} class="mt-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-sm rounded">Add 404 Host</button>
</div></td></tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- AUDIT -->
{#if currentTab === 'audit'}
<div class="flex items-center justify-between mb-3">
<div class="font-semibold text-base">Audit Logs (recent first)</div>
<div class="relative">
<input bind:value={searchAudit} placeholder="Search..." class="bg-zinc-950 border border-zinc-700 rounded pl-8 pr-2 py-1 text-xs w-40 focus:outline-none focus:border-emerald-600" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 absolute left-2 top-1.5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3 text-xs font-mono space-y-1 max-h-[420px] overflow-auto">
{#each filteredAudit.slice().reverse() as log}
<div class="flex gap-2"><span class="text-zinc-500 w-40 shrink-0">{log.createdOn}</span> <span>{auditLogSummary(log, user)}</span></div>
{:else}
<div class="text-zinc-500">No logs yet</div>
{/each}
</div>
{/if}
<!-- SETTINGS -->
{#if currentTab === 'settings'}
<div class="font-semibold mb-3">Settings</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 text-sm max-w-md">
<div class="mb-2" title="Behavior for requests that do not match any configured host">Default site</div>
<select bind:value={settingsData.default_site} class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 w-full mb-2">
<option value="congratulations">Welcome page</option>
<option value="404">404 Not Found</option>
<option value="444">Close connection</option>
<option value="redirect">Redirect</option>
</select>
{#if settingsData.default_site === 'redirect'}
<input bind:value={settingsData.default_site_redirect} placeholder="https://example.com" title="URL to redirect unmatched hosts to" class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 w-full mb-2" />
{/if}
<div class="mt-2">
<label class="text-xs text-zinc-400 block mb-1" title="Email used when requesting new Let's Encrypt certificates">Let's Encrypt email</label>
<input bind:value={settingsData.letsencrypt_email} placeholder="admin@example.com" title="Email used when requesting new Let's Encrypt certificates" class="bg-zinc-950 border border-zinc-700 rounded px-3 py-1 w-full mb-2 text-xs" />
</div>
<button onclick={async () => {
try {
await apiFetch('/api/settings', {method:'POST', body: JSON.stringify(settingsData)});
success = 'Settings saved';
await loadSettings();
} catch (err: any) {
error = err.message || 'Failed';
}
}} class="bg-emerald-600 px-4 py-1 rounded">Save Settings</button>
</div>
{/if}
<!-- Modals for form CRUD (proxy/streams/redir/dead/certs/access) and confirms. Replaces inline cramped forms + confirm()/prompt(). Placed at end of logged-in app div (after tab contents) so always rendered/available when authenticated (no conditional per tab). -->
<Modal bind:open={openFormModal} bind:error={formError} title={formModalTitle} onClose={() => closeForm()}>
{#if formModalType === 'stream'}
<form onsubmit={async (e) => { await createStream(e); if (!formError) closeForm(); }} class="space-y-3 text-sm">
<div class="flex gap-2">
<input type="number" bind:value={newStream.incomingPort} placeholder="Incoming port" title="Port this proxy listens on" class="w-28 bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<input bind:value={newStream.forwardingHost} placeholder="Forward host" title="Target hostname or IP" class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<input type="number" bind:value={newStream.forwardingPort} placeholder="Port" class="w-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
</div>
<div class="flex gap-3 items-center flex-wrap text-xs">
<label class="flex items-center gap-1" title="Enable TCP forwarding"><input type="checkbox" bind:checked={newStream.tcpForwarding} /> TCP</label>
<label class="flex items-center gap-1" title="Enable UDP forwarding"><input type="checkbox" bind:checked={newStream.udpForwarding} /> UDP</label>
<select bind:value={newStream.certificateId} class="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-xs" title="Optional SSL certificate for incoming connections">
<option value={0}>no SSL</option>
{#each certificates as c}<option value={c.id}>{certLabel(c)}</option>{/each}
</select>
<label class="flex items-center gap-1"><input type="checkbox" bind:checked={newStream.enabled} /> enabled</label>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-1 rounded text-sm flex-1">Save</button>
<button type="button" onclick={() => closeForm()} class="px-3 py-1 rounded border border-zinc-700 text-sm">Cancel</button>
</div>
</form>
{:else if formModalType === 'redir'}
<form onsubmit={async (e) => { await createRedir(e); if (!formError) closeForm(); }} class="space-y-3 text-sm">
<input bind:value={newRedir.domainNames} placeholder="Source domains, comma separated" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<input bind:value={newRedir.forwardDomainName} placeholder="Target domain" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<div class="flex gap-2">
<input type="number" bind:value={newRedir.forwardHttpCode} placeholder="301" title="HTTP redirect status code" class="w-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<select bind:value={newRedir.forwardScheme} class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-2 py-1">
<option value="https">https</option><option value="http">http</option>
</select>
<label class="flex items-center text-xs gap-1 px-2" title="Append the original request path to the redirect target"><input type="checkbox" bind:checked={newRedir.preservePath} /> Preserve path</label>
</div>
<div class="flex items-center gap-2 text-xs">
<label class="flex items-center gap-1"><input type="checkbox" bind:checked={newRedir.enabled} /> enabled</label>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-1 rounded text-sm flex-1">Save</button>
<button type="button" onclick={() => closeForm()} class="px-3 py-1 rounded border border-zinc-700 text-sm">Cancel</button>
</div>
</form>
{:else if formModalType === 'dead'}
<form onsubmit={async (e) => { await createDead(e); if (!formError) closeForm(); }} class="space-y-3 text-sm">
<input bind:value={newDead.domainNames} placeholder="Source domains, comma separated" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<textarea bind:value={newDead.customHtml} placeholder="Custom 404 HTML (optional)" title="Leave empty to use the default 404 page" class="w-full h-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-xs font-mono"></textarea>
<div class="flex items-center gap-2 text-xs">
<label class="flex items-center gap-1"><input type="checkbox" bind:checked={newDead.enabled} /> enabled</label>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-1 rounded text-sm flex-1">Save</button>
<button type="button" onclick={() => closeForm()} class="px-3 py-1 rounded border border-zinc-700 text-sm">Cancel</button>
</div>
</form>
{:else if formModalType === 'proxy'}
<form onsubmit={async (e) => { await createProxy(e); if (!formError) closeForm(); }} class="space-y-3 text-sm">
<!-- internal tabs: Details / Custom Locations / SSL / Headers -->
<div class="flex border-b border-zinc-700 -mx-1 text-xs mb-2">
<button type="button" onclick={() => proxyFormTab='details'} class="px-3 py-1 {proxyFormTab==='details' ? 'border-b-2 border-emerald-500 text-emerald-400 font-medium' : 'text-zinc-400 hover:text-zinc-200'}">Details</button>
<button type="button" onclick={() => proxyFormTab='locations'} class="px-3 py-1 {proxyFormTab==='locations' ? 'border-b-2 border-emerald-500 text-emerald-400 font-medium' : 'text-zinc-400 hover:text-zinc-200'}">Custom Locations</button>
<button type="button" onclick={() => proxyFormTab='ssl'} class="px-3 py-1 {proxyFormTab==='ssl' ? 'border-b-2 border-emerald-500 text-emerald-400 font-medium' : 'text-zinc-400 hover:text-zinc-200'}">SSL</button>
<button type="button" onclick={() => proxyFormTab='headers'} class="px-3 py-1 {proxyFormTab==='headers' ? 'border-b-2 border-emerald-500 text-emerald-400 font-medium' : 'text-zinc-400 hover:text-zinc-200'}">Headers</button>
</div>
{#if proxyFormTab === 'details'}
<input bind:value={newProxy.domainNames} placeholder="Domain names, comma separated" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<div class="flex gap-2">
<select bind:value={newProxy.forwardScheme} class="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 flex-1 text-sm" title="Upstream scheme">
<option value="http">http</option><option value="https">https</option>
</select>
<input bind:value={newProxy.forwardHost} placeholder="Forward hostname or IP" class="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 flex-1" />
<input type="number" bind:value={newProxy.forwardPort} placeholder="Port" class="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 w-20" />
</div>
<div>
<select bind:value={newProxy.accessListId} class="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-xs w-full" title="Restrict access to selected clients and credentials">
<option value={0}>Public</option>
{#each accessLists as al}
<option value={al.id}>{al.name}</option>
{/each}
</select>
</div>
<div class="mt-3 pt-3 border-t border-zinc-800">
<label class="flex items-center gap-2 text-xs">
<input type="checkbox" bind:checked={newProxy.enabled} />
<span class="font-medium">Enabled</span>
</label>
</div>
<div class="mt-2">
<div class="text-xs font-medium mb-1">Options</div>
<div class="flex items-center gap-3 text-xs flex-wrap">
<label class="flex items-center gap-1" title="Cache static assets"><input type="checkbox" bind:checked={newProxy.cachingEnabled} /> Cache assets</label>
<label class="flex items-center gap-1" title="Block common exploit patterns"><input type="checkbox" bind:checked={newProxy.blockExploits} /> Block exploits</label>
<label class="flex items-center gap-1" title="Allow websocket upgrade requests"><input type="checkbox" bind:checked={newProxy.allowWebsocketUpgrade} /> Websockets</label>
<label class="flex items-center gap-1" title="Trust X-Forwarded-Proto from upstream"><input type="checkbox" bind:checked={newProxy.trustForwardedProto} /> Trust forwarded proto</label>
<label class="flex items-center gap-1" title="Enable HTTP/2 to upstream"><input type="checkbox" bind:checked={newProxy.http2Support} /> HTTP/2</label>
</div>
</div>
{:else if proxyFormTab === 'locations'}
<div class="space-y-2">
<div class="text-xs flex items-center gap-2">
Custom locations
<button type="button" title="Add location" onclick={() => { newProxy.locations = [...(newProxy.locations || []), {path: '', forwardScheme: 'http', forwardHost: '', forwardPort: 80, forwardPath: ''}]; }} class="text-emerald-400 text-[10px] underline">+ add</button>
</div>
{#each (newProxy.locations as any[]) as loc, i}
<div class="border border-zinc-800 rounded p-2 space-y-1.5 text-xs">
<input bind:value={loc.path} placeholder="Path (/api)" title="URL path prefix for this location" class="w-full min-w-0 bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<div class="flex gap-1.5">
<select bind:value={loc.forwardScheme} title="Upstream scheme" class="shrink-0 bg-zinc-950 border border-zinc-700 rounded px-1 py-1">
<option value="http">http</option><option value="https">https</option>
</select>
<input bind:value={loc.forwardHost} placeholder="Forward host" title="Upstream hostname or IP" class="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<input type="number" bind:value={loc.forwardPort} placeholder="Port" title="Upstream port" class="w-16 shrink-0 bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
</div>
<div class="flex gap-1.5">
<input bind:value={loc.forwardPath} placeholder="Rewrite path (optional)" title="Replace matched path prefix with this value" class="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<button type="button" title="Remove location" onclick={() => { newProxy.locations = (newProxy.locations || []).filter((_,j) => j !== i); }} class="shrink-0 text-red-400 px-2">×</button>
</div>
</div>
{/each}
</div>
{:else if proxyFormTab === 'ssl'}
<select bind:value={newProxy.certificateId} class="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-xs w-full" title="SSL certificate for these domains">
<option value={0}>No certificate</option>
<option value={-1} title="Request a new Let's Encrypt certificate for the domains on this host">Request new Let's Encrypt certificate</option>
{#each certificates as c}
<option value={c.id}>{certLabel(c)}</option>
{/each}
</select>
<div class="flex items-center gap-3 text-xs flex-wrap mt-2">
<label class="flex items-center gap-1" title="Redirect HTTP to HTTPS"><input type="checkbox" bind:checked={newProxy.sslForced} /> Force SSL</label>
<label class="flex items-center gap-1" title="Send Strict-Transport-Security header"><input type="checkbox" bind:checked={newProxy.hstsEnabled} /> HSTS</label>
<label class="flex items-center gap-1" title="Include subdomains in HSTS"><input type="checkbox" bind:checked={newProxy.hstsSubdomains} /> HSTS subdomains</label>
</div>
{:else if proxyFormTab === 'headers'}
<div class="space-y-3">
<div>
<div class="text-xs mb-1 flex items-center gap-2">
<span title="Headers sent to the upstream server">Request headers</span>
<button type="button" title="Add request header" onclick={() => { newProxy.requestHeaders = [...(newProxy.requestHeaders || []), { name: '', value: '' }]; }} class="text-emerald-400 text-[10px] underline">+ add</button>
</div>
{#each (newProxy.requestHeaders || []) as hdr, i}
<div class="grid grid-cols-[1fr_1fr_auto] gap-1 mb-1 text-xs">
<input bind:value={hdr.name} placeholder="Header name" class="bg-zinc-950 border border-zinc-700 rounded px-1 py-0.5" />
<input bind:value={hdr.value} placeholder="Value" class="bg-zinc-950 border border-zinc-700 rounded px-1 py-0.5" />
<button type="button" title="Remove header" onclick={() => { newProxy.requestHeaders = (newProxy.requestHeaders || []).filter((_, j) => j !== i); }} class="text-red-400 px-1">×</button>
</div>
{/each}
</div>
<div>
<div class="text-xs mb-1 flex items-center gap-2">
<span title="Headers added to responses sent to clients">Response headers</span>
<button type="button" title="Add response header" onclick={() => { newProxy.responseHeaders = [...(newProxy.responseHeaders || []), { name: '', value: '' }]; }} class="text-emerald-400 text-[10px] underline">+ add</button>
</div>
{#each (newProxy.responseHeaders || []) as hdr, i}
<div class="grid grid-cols-[1fr_1fr_auto] gap-1 mb-1 text-xs">
<input bind:value={hdr.name} placeholder="Header name" class="bg-zinc-950 border border-zinc-700 rounded px-1 py-0.5" />
<input bind:value={hdr.value} placeholder="Value" class="bg-zinc-950 border border-zinc-700 rounded px-1 py-0.5" />
<button type="button" title="Remove header" onclick={() => { newProxy.responseHeaders = (newProxy.responseHeaders || []).filter((_, j) => j !== i); }} class="text-red-400 px-1">×</button>
</div>
{/each}
</div>
<div>
<div class="text-xs mb-1 flex items-center gap-2">
<span title="Upstream response headers to remove before sending to clients">Hide headers</span>
<button type="button" title="Add hidden header" onclick={() => { newProxy.hideHeaders = [...(newProxy.hideHeaders || []), '']; }} class="text-emerald-400 text-[10px] underline">+ add</button>
</div>
{#each (newProxy.hideHeaders || []) as name, i}
<div class="grid grid-cols-[1fr_auto] gap-1 mb-1 text-xs">
<input bind:value={newProxy.hideHeaders[i]} placeholder="Header name" class="bg-zinc-950 border border-zinc-700 rounded px-1 py-0.5" />
<button type="button" title="Remove" onclick={() => { newProxy.hideHeaders = (newProxy.hideHeaders || []).filter((_, j) => j !== i); }} class="text-red-400 px-1">×</button>
</div>
{/each}
</div>
</div>
{/if}
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-1 rounded text-sm flex-1">Save</button>
<button type="button" onclick={() => closeForm()} class="px-3 py-1 rounded border border-zinc-700 text-sm">Cancel</button>
</div>
</form>
{:else if formModalType === 'cert'}
<form onsubmit={async (e) => { await createCert(e); if (!formError) closeForm(); }} class="space-y-3 text-sm">
<input bind:value={newCert.domainNames} placeholder="example.com, www.example.com" title="Domains must resolve publicly for Let's Encrypt validation" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-1 rounded text-sm flex-1">Save</button>
<button type="button" onclick={() => closeForm()} class="px-3 py-1 rounded border border-zinc-700 text-sm">Cancel</button>
</div>
</form>
{:else if formModalType === 'customCert'}
<form onsubmit={async (e) => { await createCustomCert(e); if (!formError) closeForm(); }} class="space-y-3 text-sm">
<input bind:value={newCustomCert.niceName} placeholder="Display name (optional)" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<input bind:value={newCustomCert.domainNames} placeholder="Domains, comma separated" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<textarea bind:value={newCustomCert.cert} placeholder="-----BEGIN CERTIFICATE-----\n..." class="w-full h-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-xs font-mono"></textarea>
<textarea bind:value={newCustomCert.key} placeholder="-----BEGIN PRIVATE KEY-----\n..." class="w-full h-20 bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-xs font-mono"></textarea>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-1 rounded text-sm flex-1">Save</button>
<button type="button" onclick={() => closeForm()} class="px-3 py-1 rounded border border-zinc-700 text-sm">Cancel</button>
</div>
</form>
{:else if formModalType === 'access'}
<form onsubmit={async (e) => { await createAccess(e); if (!formError) closeForm(); }} class="space-y-3 text-sm">
<input bind:value={newAccess.name} placeholder="Name" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1" />
<input bind:value={newAccess.clients} placeholder="127.0.0.1/32 allow, 10.0.0.0/8 deny" title="Client CIDR rules with allow or deny" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-xs" />
<input bind:value={newAccess.items} placeholder="user:password, admin:secret" title="Basic auth credentials" class="w-full bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-xs" />
<div class="flex items-center gap-3 text-xs">
<label class="flex items-center gap-1" title="Any matching client rule is sufficient"><input type="checkbox" bind:checked={newAccess.satisfyAny} /> Satisfy any</label>
<label class="flex items-center gap-1" title="Forward authorization to the upstream"><input type="checkbox" bind:checked={newAccess.passAuth} /> Pass auth</label>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="bg-emerald-600 hover:bg-emerald-500 px-4 py-1 rounded text-sm flex-1">Save</button>
<button type="button" onclick={() => closeForm()} class="px-3 py-1 rounded border border-zinc-700 text-sm">Cancel</button>
</div>
</form>
{:else}
<div class="text-xs text-zinc-500">No form selected.</div>
{/if}
</Modal>
<Modal bind:open={confirmState.open} title="Confirm" onClose={() => { confirmState.message = ''; confirmState.onConfirm = null; }}>
<p class="text-sm mb-4 text-zinc-300">{confirmState.message}</p>
<div class="flex gap-2 justify-end">
<button type="button" onclick={() => { confirmState.open = false; confirmState.message = ''; confirmState.onConfirm = null; }} class="px-4 py-1.5 text-sm rounded border border-zinc-700 hover:bg-zinc-800">Cancel</button>
<button type="button" onclick={handleConfirm} class="px-4 py-1.5 text-sm rounded bg-red-600 hover:bg-red-500">Confirm Delete</button>
</div>
</Modal>
<!-- footer matching live reference -->
<footer class="text-[10px] text-zinc-500 mt-8 py-4 border-t border-zinc-800 flex flex-wrap gap-x-4">
<a href="https://github.com/rrd/helix-proxy" class="hover:text-zinc-300">GitHub</a>
<span>© 2026</span>
<span>{appVersion || 'dev'}</span>
</footer>
</div>
{/if}
</div>
<style>
/* minimal overrides if needed */
</style>