Lagt till diagnostic och mer inställningar
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "1780397326074-s7tw",
|
||||
"timestamp": "2026-06-02T10:48:46.074Z",
|
||||
"provider": "cpanel",
|
||||
"operation": "GET /execute/DomainInfo/list_domains",
|
||||
"method": "GET",
|
||||
"url": "https://cpsrv32.misshosting.com/execute/DomainInfo/list_domains",
|
||||
"status": 200,
|
||||
"latency": 17,
|
||||
"ok": true,
|
||||
"error": null
|
||||
},
|
||||
{
|
||||
"id": "1780397326055-i077",
|
||||
"timestamp": "2026-06-02T10:48:46.055Z",
|
||||
"provider": "cpanel",
|
||||
"operation": "GET /execute/DNS/list_zones",
|
||||
"method": "GET",
|
||||
"url": "https://cpsrv32.misshosting.com/execute/DNS/list_zones",
|
||||
"status": 200,
|
||||
"latency": 124,
|
||||
"ok": true,
|
||||
"error": null
|
||||
}
|
||||
]
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
const ARM_BASE = 'https://management.azure.com';
|
||||
const API_VERSION = '2018-05-01';
|
||||
const diagFetch = require('../diagFetch');
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -52,7 +53,8 @@ async function armFetch(path, options = {}) {
|
||||
const token = await getToken();
|
||||
const url = `${ARM_BASE}${path}${path.includes('?') ? '&' : '?'}api-version=${API_VERSION}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
const operation = `${options.method ?? 'GET'} ${path.split('?')[0].replace(/\/subscriptions\/[^/]+/, '/subscriptions/***')}`;
|
||||
const res = await diagFetch('azure', operation, url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Requires env: CLOUDFLARE_API_TOKEN
|
||||
*/
|
||||
|
||||
const BASE = 'https://api.cloudflare.com/client/v4';
|
||||
const BASE = 'https://api.cloudflare.com/client/v4';
|
||||
const diagFetch = require('../diagFetch');
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
@@ -13,7 +14,8 @@ function headers() {
|
||||
}
|
||||
|
||||
async function cfFetch(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, { ...options, headers: headers() });
|
||||
const operation = `${options.method ?? 'GET'} ${path.split('?')[0]}`;
|
||||
const res = await diagFetch('cloudflare', operation, `${BASE}${path}`, { ...options, headers: headers() });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
const msg = data.errors?.map(e => e.message).join(', ') || 'Cloudflare API error';
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const diag = require('../diag');
|
||||
|
||||
function base() {
|
||||
return process.env.CPANEL_URL.replace(/\/$/, '');
|
||||
@@ -26,6 +27,8 @@ function authHeader() {
|
||||
* Avoids undici/fetch version conflicts and supports self-signed certs.
|
||||
*/
|
||||
function request(url, options = {}) {
|
||||
const start = Date.now();
|
||||
const method = options.method || 'GET';
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const insecure = process.env.CPANEL_INSECURE === 'true';
|
||||
@@ -44,10 +47,17 @@ function request(url, options = {}) {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => { body += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, text: () => body }));
|
||||
res.on('end', () => {
|
||||
const latency = Date.now() - start;
|
||||
diag.logRequest('cpanel', `${method} ${parsed.pathname}`, method, url, res.statusCode, latency);
|
||||
resolve({ status: res.statusCode, text: () => body });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('error', err => {
|
||||
diag.logRequest('cpanel', `${method} ${parsed.pathname}`, method, url, null, Date.now() - start, err.message);
|
||||
reject(err);
|
||||
});
|
||||
if (options.body) req.write(options.body);
|
||||
req.end();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const { parseStringPromise } = require('xml2js');
|
||||
|
||||
const ENDPOINT = 'https://api.loopia.se/RPCSERV';
|
||||
const diag = require('../diag');
|
||||
|
||||
// ─── XML-RPC builder ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -86,8 +87,7 @@ function parseValue(node) {
|
||||
|
||||
async function xmlRpc(method, args) {
|
||||
const body = buildRequest(method, args);
|
||||
console.log(`[loopia] → ${method}`);
|
||||
|
||||
const start = Date.now();
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
|
||||
@@ -95,20 +95,24 @@ async function xmlRpc(method, args) {
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const latency = Date.now() - start;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseStringPromise(text, { explicitArray: true });
|
||||
} catch (e) {
|
||||
diag.logRequest('loopia', method, 'POST', ENDPOINT, res.status, latency, 'Invalid XML response');
|
||||
throw new Error(`Loopia returned invalid XML: ${text.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const fault = parsed?.methodResponse?.fault;
|
||||
if (fault) {
|
||||
const fv = parseValue(fault[0].value[0]);
|
||||
diag.logRequest('loopia', method, 'POST', ENDPOINT, res.status, latency, `Fault ${fv.faultCode}: ${fv.faultString}`);
|
||||
throw new Error(`Loopia fault ${fv.faultCode}: ${fv.faultString}`);
|
||||
}
|
||||
|
||||
diag.logRequest('loopia', method, 'POST', ENDPOINT, res.status, latency);
|
||||
const param = parsed?.methodResponse?.params?.[0]?.param?.[0]?.value?.[0];
|
||||
const result = param ? parseValue(param) : null;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* treated as a single zone. Record types are limited to A, AAAA, CNAME.
|
||||
*/
|
||||
|
||||
const diagFetch = require('../diagFetch');
|
||||
let cachedSid = null;
|
||||
let sidExpiry = 0;
|
||||
|
||||
@@ -46,7 +47,7 @@ async function getSession() {
|
||||
|
||||
async function phFetch(path, options = {}) {
|
||||
const sid = await getSession();
|
||||
const res = await fetch(`${base()}${path}`, {
|
||||
const res = await diagFetch('pihole', `${options.method ?? 'GET'} ${path}`, `${base()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Provider diagnostic log — records every API call made to DNS providers.
|
||||
* Keeps the last 200 entries in memory and on disk.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DIAG_PATH = process.env.DIAG_PATH || path.join(__dirname, '..', 'diag-log.json');
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
// In-memory buffer so high-frequency reads don't hammer the disk
|
||||
let buffer = null;
|
||||
|
||||
function load() {
|
||||
if (buffer) return buffer;
|
||||
try { buffer = JSON.parse(fs.readFileSync(DIAG_PATH, 'utf8')); }
|
||||
catch { buffer = []; }
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function save() {
|
||||
try { fs.writeFileSync(DIAG_PATH, JSON.stringify(buffer, null, 2), 'utf8'); }
|
||||
catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise a URL — strip tokens, passwords, and API keys from query strings
|
||||
* and path segments so credentials are never stored in the log.
|
||||
*/
|
||||
function sanitiseUrl(url) {
|
||||
if (!url) return '—';
|
||||
try {
|
||||
const u = new URL(url);
|
||||
// Remove common credential query params
|
||||
['token', 'api_token', 'key', 'password', 'secret', 'auth'].forEach(p => {
|
||||
if (u.searchParams.has(p)) u.searchParams.set(p, '***');
|
||||
});
|
||||
// Mask Azure subscription IDs and resource group names in paths
|
||||
let p = u.pathname.replace(
|
||||
/\/subscriptions\/[a-f0-9-]{36}/gi,
|
||||
'/subscriptions/***'
|
||||
);
|
||||
return `${u.protocol}//${u.hostname}${p}${u.search}`;
|
||||
} catch {
|
||||
return url.replace(/token=[^&]+/gi, 'token=***');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an API call.
|
||||
*
|
||||
* @param {string} provider - 'cloudflare' | 'loopia' | 'pihole' | 'azure' | 'cpanel'
|
||||
* @param {string} operation - human label, e.g. 'listZones', 'addRecord'
|
||||
* @param {string} method - HTTP method
|
||||
* @param {string} url - full URL (will be sanitised before storage)
|
||||
* @param {number|null} status - HTTP status code, or null on network error
|
||||
* @param {number} latency - milliseconds
|
||||
* @param {string|null} error - error message if the call failed
|
||||
*/
|
||||
function logRequest(provider, operation, method, url, status, latency, error = null) {
|
||||
load();
|
||||
buffer.unshift({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
operation,
|
||||
method: method.toUpperCase(),
|
||||
url: sanitiseUrl(url),
|
||||
status,
|
||||
latency,
|
||||
ok: !error && status >= 200 && status < 300,
|
||||
error,
|
||||
});
|
||||
if (buffer.length > MAX_ENTRIES) buffer.splice(MAX_ENTRIES);
|
||||
save();
|
||||
}
|
||||
|
||||
function getEntries({ provider, ok, limit = 100, offset = 0 } = {}) {
|
||||
const entries = load();
|
||||
let filtered = entries;
|
||||
if (provider) filtered = filtered.filter(e => e.provider === provider);
|
||||
if (ok !== undefined) filtered = filtered.filter(e => e.ok === ok);
|
||||
return { total: filtered.length, entries: filtered.slice(offset, offset + limit) };
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
buffer = [];
|
||||
save();
|
||||
}
|
||||
|
||||
module.exports = { logRequest, getEntries, clearLog };
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Thin fetch wrapper that logs every request to the diagnostic log.
|
||||
* Use this instead of plain fetch() inside adapters.
|
||||
*/
|
||||
const diag = require('./diag');
|
||||
|
||||
async function diagFetch(provider, operation, url, options = {}) {
|
||||
const method = options.method ?? 'GET';
|
||||
const start = Date.now();
|
||||
let status = null;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, options);
|
||||
status = res.status;
|
||||
const latency = Date.now() - start;
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
error = `HTTP ${res.status}: ${body.slice(0, 200)}`;
|
||||
diag.logRequest(provider, operation, method, url, status, latency, error);
|
||||
// Re-create a Response-like object since we consumed the body
|
||||
return new Response(body, { status: res.status, headers: res.headers });
|
||||
}
|
||||
|
||||
diag.logRequest(provider, operation, method, url, status, latency);
|
||||
return res;
|
||||
} catch (err) {
|
||||
const latency = Date.now() - start;
|
||||
error = err.message;
|
||||
diag.logRequest(provider, operation, method, url, null, latency, error);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = diagFetch;
|
||||
+43
-7
@@ -4,7 +4,8 @@ const cors = require('cors');
|
||||
|
||||
const { requireAuth } = require('./auth');
|
||||
const { ensureDefaultAdmin } = require('./users');
|
||||
const audit = require('./audit');
|
||||
const audit = require('./audit');
|
||||
const settings = require('./settings');
|
||||
const secrets = require('./secrets');
|
||||
const schedule = require('node-schedule');
|
||||
|
||||
@@ -12,6 +13,7 @@ const zonesRouter = require('./routes/zones');
|
||||
const secretsRouter = require('./routes/secrets');
|
||||
const ipamRouter = require('./routes/ipam');
|
||||
const healthRouter = require('./routes/health');
|
||||
const diag = require('./diag');
|
||||
const recordsRouter = require('./routes/records');
|
||||
const settingsRouter = require('./routes/settings');
|
||||
const authRouter = require('./routes/auth');
|
||||
@@ -36,7 +38,22 @@ app.use('/api/settings', requireAuth, settingsRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/secrets', secretsRouter);
|
||||
app.use('/api/ipam', ipamRouter);
|
||||
app.use('/api/health', healthRouter); // requireAuth applied inside router
|
||||
app.use('/api/health', healthRouter);
|
||||
|
||||
app.get('/api/diag', requireAuth, (req, res) => {
|
||||
const { provider, ok, limit, offset } = req.query;
|
||||
res.json(diag.getEntries({
|
||||
provider,
|
||||
ok: ok !== undefined ? ok === 'true' : undefined,
|
||||
limit: Math.min(parseInt(limit) || 100, 200),
|
||||
offset: parseInt(offset) || 0,
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/api/diag', requireAuth, (req, res) => {
|
||||
diag.clearLog();
|
||||
res.json({ success: true });
|
||||
}); // requireAuth applied inside router
|
||||
|
||||
app.get('/api/audit', requireAuth, (req, res) => {
|
||||
const { limit, offset, user, action, provider, category } = req.query;
|
||||
@@ -107,12 +124,31 @@ async function checkSecretExpiryOnce() {
|
||||
await checkSecretExpiry();
|
||||
}
|
||||
|
||||
// Run at startup only if not already checked today (persisted to disk), then every day at 08:00
|
||||
// Build cron string from HH:MM setting
|
||||
function getSecretCron() {
|
||||
const time = settings.get().notifications?.secret_check_time ?? '08:00';
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
return `${m ?? 0} ${h ?? 8} * * *`;
|
||||
}
|
||||
|
||||
let secretJob = null;
|
||||
|
||||
function scheduleSecretCheck() {
|
||||
if (secretJob) secretJob.cancel();
|
||||
const notif = settings.get().notifications ?? {};
|
||||
const tz = notif.timezone || 'UTC';
|
||||
secretJob = schedule.scheduleJob({ rule: getSecretCron(), tz }, () => {
|
||||
saveLastCheckDate('');
|
||||
const { notifications } = settings.get();
|
||||
if (notifications?.secret_check !== false) checkSecretExpiry();
|
||||
});
|
||||
console.log(`Secret check scheduled at ${notif.secret_check_time ?? '08:00'} (${tz})`);
|
||||
}
|
||||
|
||||
// Run at startup only if not already checked today, then on schedule
|
||||
checkSecretExpiryOnce();
|
||||
schedule.scheduleJob('0 8 * * *', () => {
|
||||
saveLastCheckDate(''); // clear so the scheduled job always fires
|
||||
checkSecretExpiry();
|
||||
});
|
||||
scheduleSecretCheck();
|
||||
module.exports = { scheduleSecretCheck };
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => console.log(`Sloth Manager backend running on port ${PORT}`));
|
||||
|
||||
@@ -34,7 +34,13 @@ async function notify(title, message) {
|
||||
|
||||
// ─── Convenience helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function eventEnabled(key) {
|
||||
const { notifications } = settings.get();
|
||||
return notifications?.[key] !== false;
|
||||
}
|
||||
|
||||
function recordAdded(provider, zoneId, record) {
|
||||
if (!eventEnabled('dns_add')) return;
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Added`,
|
||||
@@ -43,6 +49,7 @@ function recordAdded(provider, zoneId, record) {
|
||||
}
|
||||
|
||||
function recordUpdated(provider, zoneId, oldRecord, newRecord) {
|
||||
if (!eventEnabled('dns_update')) return;
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Updated`,
|
||||
@@ -51,6 +58,7 @@ function recordUpdated(provider, zoneId, oldRecord, newRecord) {
|
||||
}
|
||||
|
||||
function recordDeleted(provider, zoneId, record) {
|
||||
if (!eventEnabled('dns_delete')) return;
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Deleted`,
|
||||
|
||||
@@ -12,6 +12,10 @@ router.get('/', (req, res) => {
|
||||
router.put('/', (req, res) => {
|
||||
try {
|
||||
const updated = settings.update(req.body);
|
||||
// Reschedule secret check if the notification time changed
|
||||
if (req.body.notifications?.secret_check_time) {
|
||||
try { require('../index').scheduleSecretCheck(); } catch { /* safe to ignore */ }
|
||||
}
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
|
||||
@@ -10,6 +10,14 @@ const DEFAULTS = {
|
||||
token: '',
|
||||
priority: 5,
|
||||
},
|
||||
notifications: {
|
||||
dns_add: true,
|
||||
dns_update: true,
|
||||
dns_delete: true,
|
||||
secret_check: true,
|
||||
secret_check_time: '08:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
providerColors: {
|
||||
cloudflare: '#f6821f',
|
||||
loopia: '#2ecc71',
|
||||
@@ -27,6 +35,7 @@ function load() {
|
||||
...DEFAULTS,
|
||||
...raw,
|
||||
gotify: { ...DEFAULTS.gotify, ...(raw.gotify ?? {}) },
|
||||
notifications: { ...DEFAULTS.notifications, ...(raw.notifications ?? {}) },
|
||||
providerColors: { ...DEFAULTS.providerColors, ...(raw.providerColors ?? {}) },
|
||||
};
|
||||
} catch {
|
||||
@@ -48,6 +57,7 @@ function update(partial) {
|
||||
...current,
|
||||
...partial,
|
||||
gotify: { ...current.gotify, ...(partial.gotify ?? {}) },
|
||||
notifications: { ...current.notifications, ...(partial.notifications ?? {}) },
|
||||
providerColors: { ...current.providerColors, ...(partial.providerColors ?? {}) },
|
||||
};
|
||||
save(merged);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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() }));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user