Lagt till diagnostic och mer inställningar

This commit is contained in:
2026-06-02 13:25:40 +02:00
parent 229a3c7383
commit 8491fe1386
17 changed files with 498 additions and 17 deletions
+4
View File
@@ -260,6 +260,10 @@ body {
color: #fb923c;
}
/* ===== Diagnostics ===== */
.diag-row-error td { background: rgba(239,68,68,0.04); }
.diag-row-error:hover td { background: rgba(239,68,68,0.08) !important; }
/* ===== Provider health status ===== */
.health-badge {
display: inline-block;
+8
View File
@@ -10,6 +10,7 @@ import ProfilePage from './components/ProfilePage';
import AuditPage from './components/AuditPage';
import SecretsPage from './components/SecretsPage';
import IpamPage from './components/IpamPage';
import DiagnosticsPage from './components/DiagnosticsPage';
import DomainsPage from './components/DomainsPage';
import { useProviderColors, providerBadgeStyle } from './context/ProviderColors';
import { useTheme } from './context/Theme';
@@ -223,6 +224,12 @@ function AppShell({ currentUser, onLogout }) {
>
📋 Audit Log
</button>
<button
className={`sidebar-item ${view === 'diag' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('diag'); }}
>
🔬 Diagnostics
</button>
<button
className={`sidebar-item ${view === 'settings' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('settings'); }}
@@ -264,6 +271,7 @@ function AppShell({ currentUser, onLogout }) {
{!selectedProvider && view === 'secrets' && <SecretsPage />}
{!selectedProvider && view === 'ipam' && <IpamPage />}
{!selectedProvider && view === 'audit' && <AuditPage />}
{!selectedProvider && view === 'diag' && <DiagnosticsPage />}
{!selectedProvider && view === 'dashboard' && <Dashboard />}
{selectedProvider && !selectedZone && (
+13
View File
@@ -145,6 +145,19 @@ export async function getAuditLog({ limit, offset, user, action, provider, categ
return handleResponse(await fetch(`${BASE}/audit?${qs}`, { headers: authHeaders() }));
}
export async function getDiagLog({ provider, ok, limit, offset } = {}) {
const qs = new URLSearchParams();
if (provider !== undefined) qs.set('provider', provider);
if (ok !== undefined) qs.set('ok', ok);
if (limit) qs.set('limit', limit);
if (offset) qs.set('offset', offset);
return handleResponse(await fetch(`${BASE}/diag?${qs}`, { headers: authHeaders() }));
}
export async function clearDiagLog() {
return handleResponse(await fetch(`${BASE}/diag`, { method: 'DELETE', headers: authHeaders() }));
}
export async function getProviderHealth() {
return handleResponse(await fetch(`${BASE}/health/providers`, { headers: authHeaders() }));
}
+157
View File
@@ -0,0 +1,157 @@
import { useState, useEffect, useCallback } from 'react';
import { getDiagLog, clearDiagLog } from '../api/dns';
import ConfirmDialog from './ConfirmDialog';
import { exportCsv } from '../utils/exportCsv';
const PAGE_SIZE = 50;
function formatDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`;
}
export default function DiagnosticsPage() {
const [entries, setEntries] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [filterProvider, setFilterProvider] = useState('');
const [filterOk, setFilterOk] = useState('');
const [confirmClear, setConfirmClear] = useState(false);
const load = useCallback((pg = 0) => {
setLoading(true); setError('');
getDiagLog({
provider: filterProvider || undefined,
ok: filterOk !== '' ? filterOk : undefined,
limit: PAGE_SIZE,
offset: pg * PAGE_SIZE,
})
.then(({ entries, total }) => { setEntries(entries); setTotal(total); })
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [filterProvider, filterOk]);
useEffect(() => { setPage(0); load(0); }, [load]);
function goToPage(pg) { setPage(pg); load(pg); }
async function handleClear() {
await clearDiagLog();
setConfirmClear(false);
setEntries([]); setTotal(0);
}
const totalPages = Math.ceil(total / PAGE_SIZE);
const errorCount = entries.filter(e => !e.ok).length;
return (
<div className="dashboard">
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2>Provider Diagnostics</h2>
<p className="dashboard-hint">
Every API call made to DNS providers last 200 entries. Use this to troubleshoot connectivity issues.
</p>
</div>
<button className="btn-danger" onClick={() => setConfirmClear(true)}>Clear Log</button>
</div>
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
<div className="records-actions" style={{ marginBottom: 16 }}>
<select className="filter-input" value={filterProvider} onChange={e => setFilterProvider(e.target.value)}>
<option value="">All providers</option>
<option value="cloudflare">Cloudflare</option>
<option value="loopia">Loopia</option>
<option value="pihole">Pi-hole</option>
<option value="azure">Azure DNS</option>
<option value="cpanel">cPanel</option>
</select>
<select className="filter-input" value={filterOk} onChange={e => setFilterOk(e.target.value)}>
<option value="">All results</option>
<option value="false">Errors only</option>
<option value="true">Success only</option>
</select>
<button className="btn-secondary" onClick={() => load(page)}> Refresh</button>
<button className="btn-export" onClick={() =>
exportCsv(
entries.map(e => ({ timestamp: e.timestamp, provider: e.provider, operation: e.operation, method: e.method, url: e.url, status: e.status ?? '', latency: e.latency, ok: e.ok ? 'yes' : 'no', error: e.error ?? '' })),
['timestamp','provider','operation','method','url','status','latency','ok','error'],
{ timestamp:'Time', provider:'Provider', operation:'Operation', method:'Method', url:'URL', status:'Status', latency:'Latency (ms)', ok:'OK', error:'Error' },
'diagnostics.csv'
)
}> Export CSV</button>
<span className="record-count">{total} entries{errorCount > 0 && <span style={{ color: '#ef4444', marginLeft: 8 }}>· {errorCount} errors on this page</span>}</span>
</div>
{loading ? <p className="hint">Loading</p> : entries.length === 0 ? (
<div className="empty-state">
<p>No diagnostic entries yet make a DNS operation (sync, add, delete) to populate the log.</p>
</div>
) : (
<>
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
<th>Time</th>
<th>Provider</th>
<th>Operation</th>
<th>Status</th>
<th>Latency</th>
<th>URL</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id} className={!e.ok ? 'diag-row-error' : ''}>
<td style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{formatDate(e.timestamp)}</td>
<td style={{ textTransform: 'capitalize', fontWeight: 500 }}>{e.provider}</td>
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{e.operation}</td>
<td>
<span className={`health-badge ${e.ok ? 'health-ok' : 'health-error'}`}>
{e.status ?? 'ERR'}
</span>
</td>
<td style={{ fontFamily: 'monospace', fontSize: 12, color: e.latency > 2000 ? '#f59e0b' : 'var(--text-muted)' }}>
{e.latency}ms
</td>
<td style={{ fontFamily: 'monospace', fontSize: 11, color: 'var(--text-muted)', maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={e.url}>
{e.url}
</td>
<td style={{ fontSize: 12, color: '#fca5a5', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={e.error ?? ''}>
{e.error ?? ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="audit-pagination">
<button className="btn-secondary" onClick={() => goToPage(page - 1)} disabled={page === 0}> Prev</button>
<span className="record-count">Page {page + 1} of {totalPages}</span>
<button className="btn-secondary" onClick={() => goToPage(page + 1)} disabled={page >= totalPages - 1}>Next </button>
</div>
)}
</>
)}
{confirmClear && (
<ConfirmDialog
title="Clear Diagnostic Log"
message="This will delete all diagnostic entries. This cannot be undone."
confirmLabel="Clear Log"
danger
onConfirm={handleClear}
onCancel={() => setConfirmClear(false)}
/>
)}
</div>
);
}
+70 -2
View File
@@ -16,6 +16,7 @@ const TABS = [
function NotificationsTab() {
const [form, setForm] = useState({ enabled: false, url: '', token: '', priority: 5 });
const [events, setEvents] = useState({ dns_add: true, dns_update: true, dns_delete: true, secret_check: true, secret_check_time: '08:00', timezone: 'UTC' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
@@ -25,7 +26,10 @@ function NotificationsTab() {
useEffect(() => {
getSettings()
.then(s => setForm({ ...s.gotify }))
.then(s => {
setForm({ ...s.gotify });
setEvents({ ...{ dns_add: true, dns_update: true, dns_delete: true, secret_check: true, secret_check_time: '08:00', timezone: 'UTC' }, ...s.notifications });
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
@@ -37,11 +41,17 @@ function NotificationsTab() {
setTestResult(null);
}
function handleEventChange(e) {
const { name, value, type, checked } = e.target;
setEvents(ev => ({ ...ev, [name]: type === 'checkbox' ? checked : value }));
setSaved(false);
}
async function handleSave(e) {
e.preventDefault();
setSaving(true); setError(''); setSaved(false);
try {
await saveSettings({ gotify: { ...form, priority: Number(form.priority) } });
await saveSettings({ gotify: { ...form, priority: Number(form.priority) }, notifications: events });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) { setError(err.message); }
@@ -82,6 +92,64 @@ function NotificationsTab() {
<label>Priority <span className="settings-hint">(1 = low, 10 = high)</span>
<input name="priority" type="number" min={1} max={10} value={form.priority} onChange={handleChange} disabled={!form.enabled} />
</label>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<p style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 12 }}>Notify on</p>
{[
{ key: 'dns_add', label: 'DNS record added' },
{ key: 'dns_update', label: 'DNS record updated' },
{ key: 'dns_delete', label: 'DNS record deleted' },
{ key: 'secret_check', label: 'Secret expiry reminder' },
].map(({ key, label }) => (
<label key={key} className="toggle-row" style={{ marginBottom: 10, color: 'var(--text)', textTransform: 'none', letterSpacing: 0, fontSize: 14, fontWeight: 500 }}>
<span>{label}</span>
<input type="checkbox" name={key} checked={!!events[key]} onChange={handleEventChange} className="toggle" disabled={!form.enabled} />
</label>
))}
<div style={{ display: 'flex', gap: 12, marginTop: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Daily reminder time
<input type="time" name="secret_check_time" value={events.secret_check_time} onChange={handleEventChange}
disabled={!form.enabled || !events.secret_check}
style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', color: 'var(--text)', padding: '8px 12px', fontSize: 14, fontFamily: 'inherit', width: 140 }} />
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 12, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', flex: 1, minWidth: 180 }}>
Timezone
<select name="timezone" value={events.timezone} onChange={handleEventChange}
disabled={!form.enabled || !events.secret_check}
style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', color: 'var(--text)', padding: '8px 12px', fontSize: 14, fontFamily: 'inherit' }}>
<optgroup label="UTC">
<option value="UTC">UTC</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/Stockholm">Europe/Stockholm</option>
<option value="Europe/London">Europe/London</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Europe/Oslo">Europe/Oslo</option>
<option value="Europe/Helsinki">Europe/Helsinki</option>
<option value="Europe/Amsterdam">Europe/Amsterdam</option>
<option value="Europe/Copenhagen">Europe/Copenhagen</option>
</optgroup>
<optgroup label="Americas">
<option value="America/New_York">America/New_York</option>
<option value="America/Chicago">America/Chicago</option>
<option value="America/Denver">America/Denver</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="America/Toronto">America/Toronto</option>
<option value="America/Sao_Paulo">America/Sao_Paulo</option>
</optgroup>
<optgroup label="Asia / Pacific">
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Asia/Singapore">Asia/Singapore</option>
<option value="Asia/Dubai">Asia/Dubai</option>
<option value="Australia/Sydney">Australia/Sydney</option>
</optgroup>
</select>
</label>
</div>
</div>
{testResult && (
<p className={testResult.ok ? 'test-ok' : 'test-fail'}>
{testResult.ok ? '✓' : '✕'} {testResult.message}