1777 lines
99 KiB
Svelte
1777 lines
99 KiB
Svelte
<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>
|