initial commit

This commit is contained in:
2026-06-02 01:00:27 +02:00
commit d2a8072a47
64 changed files with 26467 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
build
.env
.env.*
+3
View File
@@ -0,0 +1,3 @@
HOST=127.0.0.1
DANGEROUSLY_DISABLE_HOST_CHECK=true
REACT_APP_API_URL=http://localhost:3001
+22
View File
@@ -0,0 +1,22 @@
# ── Stage 1: build the React app ─────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY public/ ./public/
COPY src/ ./src/
# API calls are proxied by nginx so no REACT_APP_API_URL needed
RUN npm run build
# ── Stage 2: serve with nginx ─────────────────────────────────────────────────
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+34
View File
@@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy API calls to the backend container
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# React app — all other routes serve index.html (client-side routing)
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin" always;
# Cache static assets
location ~* \.(js|css|png|svg|ico|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+17521
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "dns-manager-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-scripts": "5.0.1",
"recharts": "^3.8.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sloth Manager</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
+1002
View File
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
import { useState, useEffect, useCallback } from 'react';
import { getProviders, getZones, getRecords, syncRecords, addRecord, updateRecord, deleteRecord, getMe, setToken } from './api/dns';
import RecordsTable from './components/RecordsTable';
import AddRecordForm from './components/AddRecordForm';
import Dashboard from './components/Dashboard';
import SettingsPage from './components/SettingsPage';
import ProviderOverview from './components/ProviderOverview';
import LoginPage from './components/LoginPage';
import ProfilePage from './components/ProfilePage';
import AuditPage from './components/AuditPage';
import SecretsPage from './components/SecretsPage';
import IpamPage from './components/IpamPage';
import DomainsPage from './components/DomainsPage';
import { useProviderColors, providerBadgeStyle } from './context/ProviderColors';
import { useTheme } from './context/Theme';
import ConfirmDialog from './components/ConfirmDialog';
import './App.css';
function formatSyncedAt(iso) {
if (!iso) return null;
const d = new Date(iso);
const date = d.toLocaleDateString('sv-SE'); // YYYY-MM-DD
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
return `${date} ${time}`;
}
// ─── Main app (shown when authenticated) ─────────────────────────────────────
function AppShell({ currentUser, onLogout }) {
const [providers, setProviders] = useState([]);
const [selectedProvider, setSelectedProvider] = useState(null);
const [zones, setZones] = useState([]);
const [selectedZone, setSelectedZone] = useState(null);
const [records, setRecords] = useState([]);
const [syncedAt, setSyncedAt] = useState(null);
const [loadingZones, setLoadingZones] = useState(false);
const [loadingRecords, setLoadingRecords] = useState(false);
const [syncing, setSyncing] = useState(false);
const [view, setView] = useState('dashboard');
const [showForm, setShowForm] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const { colors: providerColors } = useProviderColors();
const { theme, toggleTheme } = useTheme();
const [deletingId, setDeletingId] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
useEffect(() => {
getProviders()
.then(setProviders)
.catch(e => setError(e.message));
}, []);
useEffect(() => {
if (!selectedProvider) return;
setZones([]);
setSelectedZone(null);
setRecords([]);
setSyncedAt(null);
setError('');
setLoadingZones(true);
getZones(selectedProvider)
.then(setZones)
.catch(e => setError(e.message))
.finally(() => setLoadingZones(false));
}, [selectedProvider]);
const loadRecords = useCallback((provider, zone) => {
setLoadingRecords(true);
setError('');
getRecords(provider, zone.id)
.then(({ records, synced_at }) => {
setRecords(records);
setSyncedAt(synced_at);
})
.catch(e => setError(e.message))
.finally(() => setLoadingRecords(false));
}, []);
function selectZone(zone) {
setSelectedZone(zone);
setFilter('');
loadRecords(selectedProvider, zone);
}
async function handleSync() {
setSyncing(true);
setError('');
try {
const { records, synced_at } = await syncRecords(selectedProvider, selectedZone.id, selectedZone.name);
setRecords(records);
setSyncedAt(synced_at);
} catch (e) {
setError(e.message);
} finally {
setSyncing(false);
}
}
function openAddForm() { setEditingRecord(null); setShowForm(true); }
function openEditForm(record) { setEditingRecord(record); setShowForm(true); }
function closeForm() { setShowForm(false); setEditingRecord(null); }
async function handleFormSubmit(formData) {
if (editingRecord) {
const result = await updateRecord(selectedProvider, selectedZone.id, editingRecord.id, formData);
setRecords(rs => rs.map(r => r.id === editingRecord.id ? { ...formData, id: result.id ?? editingRecord.id } : r));
} else {
const result = await addRecord(selectedProvider, selectedZone.id, formData);
setRecords(rs => [...rs, { ...formData, id: result.id }]);
}
closeForm();
}
function handleDeleteRecord(record) {
setConfirmDelete(record);
}
async function confirmDeleteRecord() {
const record = confirmDelete;
setConfirmDelete(null);
setDeletingId(record.id);
try {
await deleteRecord(selectedProvider, selectedZone.id, record.id);
setRecords(rs => rs.filter(r => r.id !== record.id));
} catch (e) {
setError(e.message);
} finally {
setDeletingId(null);
}
}
const filteredRecords = records.filter(r => {
const q = filter.toLowerCase();
return !q || r.name.toLowerCase().includes(q) || r.content.toLowerCase().includes(q) || r.type.toLowerCase().includes(q);
});
return (
<div className="app">
<header className="app-header">
<h1>🦥 Sloth Manager</h1>
<div className="header-user">
<button className="btn-theme" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
<span className="header-username">{currentUser.username}</span>
<button className="btn-logout" onClick={onLogout}>Sign out</button>
</div>
</header>
<div className="layout">
<aside className="sidebar">
{/* Overview */}
<div className="sidebar-section">
<button
className={`sidebar-item ${view === 'dashboard' && !selectedProvider ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('dashboard'); }}
>
🏠 Overview
</button>
</div>
{/* DNS */}
<div className="sidebar-section">
<h2>DNS</h2>
<button
className={`sidebar-item ${view === 'domains' && !selectedProvider ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('domains'); }}
>
🌍 All Domains
</button>
{providers.length === 0 && <p className="hint">No providers configured.<br />Set up your .env file.</p>}
{providers.map(p => (
<button
key={p.id}
className={`sidebar-item ${selectedProvider === p.id ? 'active' : ''}`}
onClick={() => { setSelectedProvider(p.id); setView('dashboard'); }}
>
{p.name}
</button>
))}
</div>
{selectedProvider && (
<div className="sidebar-section">
<h2>Zones / Domains</h2>
{loadingZones && <p className="hint">Loading</p>}
{zones.map(z => (
<button
key={z.id}
className={`sidebar-item ${selectedZone?.id === z.id ? 'active' : ''}`}
onClick={() => selectZone(z)}
>
{z.name}
</button>
))}
</div>
)}
{/* Tools */}
<div className="sidebar-section">
<h2>Tools</h2>
<button
className={`sidebar-item ${view === 'secrets' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('secrets'); }}
>
🔑 Secrets
</button>
<button
className={`sidebar-item ${view === 'ipam' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('ipam'); }}
>
🖥 IP Addresses
</button>
</div>
{/* System */}
<div className="sidebar-section">
<h2>System</h2>
<button
className={`sidebar-item ${view === 'audit' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('audit'); }}
>
📋 Audit Log
</button>
<button
className={`sidebar-item ${view === 'settings' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('settings'); }}
>
Settings
</button>
<button
className={`sidebar-item ${view === 'profile' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('profile'); }}
>
👤 My Profile
</button>
</div>
<div className="sidebar-footer">
By <a href="https://bobbantech.com" target="_blank" rel="noreferrer">bobbantech</a>
</div>
</aside>
<main className="main">
{error && (
<div className="error-banner">
<strong>Error:</strong> {error}
<button className="dismiss" onClick={() => setError('')}></button>
</div>
)}
{!selectedProvider && view === 'settings' && <SettingsPage currentUser={currentUser} />}
{!selectedProvider && view === 'profile' && <ProfilePage currentUser={currentUser} />}
{!selectedProvider && view === 'domains' && (
<DomainsPage onNavigate={(provider, zoneId, zoneName) => {
setSelectedProvider(provider);
setView('dashboard');
// wait for zones to load then select
setSelectedZone({ id: zoneId, name: zoneName });
loadRecords(provider, { id: zoneId, name: zoneName });
}} />
)}
{!selectedProvider && view === 'secrets' && <SecretsPage />}
{!selectedProvider && view === 'ipam' && <IpamPage />}
{!selectedProvider && view === 'audit' && <AuditPage />}
{!selectedProvider && view === 'dashboard' && <Dashboard />}
{selectedProvider && !selectedZone && (
<ProviderOverview
provider={selectedProvider}
providerMeta={providers.find(p => p.id === selectedProvider)}
zones={zones}
onSelectZone={selectZone}
/>
)}
{selectedZone && (
<>
<div className="records-header">
<div>
<h2>{selectedZone.name}</h2>
<span className="badge" style={providerBadgeStyle(providerColors, selectedProvider)}>{selectedProvider}</span>
<span className="record-count">{records.length} records</span>
{syncedAt
? <span className="synced-at">Last synced: {formatSyncedAt(syncedAt)}</span>
: <span className="synced-at never">Never synced press Sync to load records</span>
}
</div>
<div className="records-actions">
<input
className="filter-input"
placeholder="Filter records…"
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<button className="btn-primary" onClick={openAddForm}>+ Add Record</button>
<button className="btn-sync" onClick={handleSync} disabled={syncing} title="Fetch latest records from provider">
{syncing ? 'Syncing…' : '⟳ Sync'}
</button>
</div>
</div>
{loadingRecords
? <p className="hint">Loading</p>
: !syncedAt
? <div className="empty-state"><p>Press <strong>Sync</strong> to fetch records from {selectedProvider}.</p></div>
: <RecordsTable records={filteredRecords} onDelete={handleDeleteRecord} onEdit={openEditForm} deleting={deletingId} zoneName={selectedZone?.name} />
}
</>
)}
</main>
</div>
{confirmDelete && (
<ConfirmDialog
title="Delete DNS Record"
message={`Delete ${confirmDelete.type} record "${confirmDelete.name}" → ${confirmDelete.content}?`}
confirmLabel="Delete"
danger
onConfirm={confirmDeleteRecord}
onCancel={() => setConfirmDelete(null)}
/>
)}
{showForm && (
<AddRecordForm
provider={selectedProvider}
zone={selectedZone?.name}
existing={editingRecord}
onSubmit={handleFormSubmit}
onCancel={closeForm}
/>
)}
</div>
);
}
// ─── Auth gate (always renders, no hook ordering issues) ──────────────────────
export default function App() {
const [currentUser, setCurrentUser] = useState(null);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
getMe()
.then(user => { setCurrentUser(user); setAuthChecked(true); })
.catch(() => setAuthChecked(true));
}, []);
useEffect(() => {
const handler = () => setCurrentUser(null);
window.addEventListener('auth:logout', handler);
return () => window.removeEventListener('auth:logout', handler);
}, []);
function handleLogin(user) { setCurrentUser(user); }
function handleLogout() {
setToken(null);
setCurrentUser(null);
}
if (!authChecked) return null;
if (!currentUser) return <LoginPage onLogin={handleLogin} />;
return <AppShell currentUser={currentUser} onLogout={handleLogout} />;
}
+219
View File
@@ -0,0 +1,219 @@
const BASE = (process.env.REACT_APP_API_URL || 'http://localhost:3001') + '/api';
export function getToken() {
return localStorage.getItem('dns_token');
}
export function setToken(token) {
if (token) localStorage.setItem('dns_token', token);
else localStorage.removeItem('dns_token');
}
function authHeaders() {
const token = getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function handleResponse(res) {
if (res.status === 401) {
setToken(null);
window.dispatchEvent(new Event('auth:logout'));
throw new Error('Session expired — please log in again');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status} ${res.statusText}`);
}
return res.json();
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
export async function login(username, password) {
const res = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
return handleResponse(res);
}
export async function getMe() {
return handleResponse(await fetch(`${BASE}/auth/me`, { headers: authHeaders() }));
}
export async function changePassword(currentPassword, newPassword) {
return handleResponse(await fetch(`${BASE}/auth/change-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ currentPassword, newPassword }),
}));
}
// ─── Users ────────────────────────────────────────────────────────────────────
export async function getUsers() {
return handleResponse(await fetch(`${BASE}/users`, { headers: authHeaders() }));
}
export async function createUser(username, password) {
return handleResponse(await fetch(`${BASE}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ username, password }),
}));
}
export async function deleteUser(id) {
return handleResponse(await fetch(`${BASE}/users/${id}`, {
method: 'DELETE', headers: authHeaders(),
}));
}
export async function resetUserPassword(id, newPassword) {
return handleResponse(await fetch(`${BASE}/users/${id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ newPassword }),
}));
}
// ─── IPAM ─────────────────────────────────────────────────────────────────────
export async function getIpam() {
return handleResponse(await fetch(`${BASE}/ipam`, { headers: authHeaders() }));
}
export async function createIpEntry(data) {
return handleResponse(await fetch(`${BASE}/ipam`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(data),
}));
}
export async function updateIpEntry(id, data) {
return handleResponse(await fetch(`${BASE}/ipam/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(data),
}));
}
export async function deleteIpEntry(id) {
return handleResponse(await fetch(`${BASE}/ipam/${id}`, {
method: 'DELETE', headers: authHeaders(),
}));
}
// ─── Secrets ──────────────────────────────────────────────────────────────────
export async function getSecrets() {
return handleResponse(await fetch(`${BASE}/secrets`, { headers: authHeaders() }));
}
export async function createSecret(data) {
return handleResponse(await fetch(`${BASE}/secrets`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(data),
}));
}
export async function updateSecret(id, data) {
return handleResponse(await fetch(`${BASE}/secrets/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(data),
}));
}
export async function deleteSecret(id) {
return handleResponse(await fetch(`${BASE}/secrets/${id}`, {
method: 'DELETE', headers: authHeaders(),
}));
}
export async function getAuditLog({ limit, offset, user, action, provider, category } = {}) {
const qs = new URLSearchParams();
if (limit) qs.set('limit', limit);
if (offset) qs.set('offset', offset);
if (user) qs.set('user', user);
if (action) qs.set('action', action);
if (provider) qs.set('provider', provider);
if (category) qs.set('category', category);
return handleResponse(await fetch(`${BASE}/audit?${qs}`, { headers: authHeaders() }));
}
export async function getProviderHealth() {
return handleResponse(await fetch(`${BASE}/health/providers`, { headers: authHeaders() }));
}
export async function getProviders() {
return handleResponse(await fetch(`${BASE}/providers`, { headers: authHeaders() }));
}
export async function getStats() {
return handleResponse(await fetch(`${BASE}/stats`, { headers: authHeaders() }));
}
export async function getAllDomains() {
return handleResponse(await fetch(`${BASE}/domains`, { headers: authHeaders() }));
}
export async function getSettings() {
return handleResponse(await fetch(`${BASE}/settings`, { headers: authHeaders() }));
}
export async function saveSettings(settings) {
return handleResponse(await fetch(`${BASE}/settings`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(settings),
}));
}
export async function clearCache() {
return handleResponse(await fetch(`${BASE}/settings/clear-cache`, { method: 'POST', headers: authHeaders() }));
}
export async function testNotification(gotify) {
return handleResponse(await fetch(`${BASE}/settings/test-notification`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(gotify),
}));
}
export async function getSyncStatus(provider) {
return handleResponse(await fetch(`${BASE}/sync-status/${provider}`, { headers: authHeaders() }));
}
export async function getZones(provider) {
return handleResponse(await fetch(`${BASE}/zones/${provider}`, { headers: authHeaders() }));
}
export async function getRecords(provider, zoneId) {
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}`, { headers: authHeaders() }));
}
export async function syncRecords(provider, zoneId, zoneName) {
return handleResponse(await fetch(`${BASE}/records/sync/${provider}/${encodeURIComponent(zoneId)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ zoneName }),
}));
}
export async function addRecord(provider, zoneId, record) {
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(record),
}));
}
export async function updateRecord(provider, zoneId, recordId, record) {
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}/${encodeURIComponent(recordId)}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(record),
}));
}
export async function deleteRecord(provider, zoneId, recordId) {
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}/${encodeURIComponent(recordId)}`, {
method: 'DELETE', headers: authHeaders(),
}));
}
+81
View File
@@ -0,0 +1,81 @@
import { useState } from 'react';
const ALL_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR'];
const PIHOLE_RECORD_TYPES = ['A', 'AAAA', 'CNAME'];
// Pass `existing` prop to enter edit mode.
export default function AddRecordForm({ provider, zone, onSubmit, onCancel, existing }) {
const editMode = Boolean(existing);
const RECORD_TYPES = provider === 'pihole' ? PIHOLE_RECORD_TYPES : ALL_RECORD_TYPES;
const [form, setForm] = useState({
type: existing?.type || 'A',
name: existing?.name || '',
content: existing?.content || '',
ttl: existing?.ttl ?? 3600,
priority: existing?.priority ?? '',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const needsPriority = ['MX', 'SRV'].includes(form.type);
function handleChange(e) {
const { name, value } = e.target;
setForm(f => ({ ...f, [name]: value }));
}
async function handleSubmit(e) {
e.preventDefault();
setError('');
setLoading(true);
try {
await onSubmit(form);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="modal-overlay">
<div className="modal">
<h3>{editMode ? 'Edit DNS Record' : 'Add DNS Record'}</h3>
<p className="modal-subtitle">
<span className="badge">{provider}</span> {zone}
</p>
<form onSubmit={handleSubmit} className="record-form">
<div className="form-row">
<label>Type
<select name="type" value={form.type} onChange={handleChange} disabled={editMode}>
{RECORD_TYPES.map(t => <option key={t}>{t}</option>)}
</select>
</label>
<label>TTL (seconds)
<input name="ttl" type="number" value={form.ttl} onChange={handleChange} min={1} required />
</label>
{needsPriority && (
<label>Priority
<input name="priority" type="number" value={form.priority} onChange={handleChange} min={0} required />
</label>
)}
</div>
<label>Name / Host
<input name="name" value={form.name} onChange={handleChange} placeholder="@ or subdomain" required />
</label>
<label>Content / Value
<input name="content" value={form.content} onChange={handleChange} placeholder="e.g. 1.2.3.4" required />
</label>
{error && <p className="error">{error}</p>}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={onCancel} disabled={loading}>Cancel</button>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? (editMode ? 'Saving…' : 'Adding…') : (editMode ? 'Save Changes' : 'Add Record')}
</button>
</div>
</form>
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { useState, useEffect, useCallback } from 'react';
import { getAuditLog } from '../api/dns';
import { exportCsv } from '../utils/exportCsv';
const ACTION_LABELS = {
add: { label: 'Added', cls: 'audit-add' },
update: { label: 'Updated', cls: 'audit-update' },
delete: { label: 'Deleted', cls: 'audit-delete' },
};
const CATEGORY_LABELS = {
dns: { label: 'DNS', cls: 'audit-cat-dns' },
secret: { label: 'Secret', cls: 'audit-cat-secret' },
ipam: { label: 'IP Address', cls: 'audit-cat-ipam' },
user: { label: 'User', cls: 'audit-cat-user' },
};
const SECRET_TYPE_LABELS = {
api_token: 'API Token',
ssl_certificate: 'SSL Certificate',
password: 'Password',
generic: 'Generic',
};
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' })}`;
}
function EntryDetail({ entry }) {
const { category, action, record, prev, target } = entry;
if (category === 'dns') {
if (!record?.name) return null;
if (prev && action === 'update') {
const contentChanged = prev.content !== record.content;
return (
<span className="audit-detail">
{record.type} {record.name}
{contentChanged && <> <s style={{ opacity: 0.5 }}>{prev.content}</s> → {record.content}</>}
</span>
);
}
return <span className="audit-detail">{record.type} {record.name} {record.content}</span>;
}
if (category === 'secret') {
if (!target) return null;
const typeLabel = SECRET_TYPE_LABELS[target.type] ?? target.type;
if (prev && action === 'update') {
return <span className="audit-detail">{typeLabel} {prev.name}{prev.name !== target.name ? `${target.name}` : ''}</span>;
}
return <span className="audit-detail">{typeLabel} {target.name}{target.expires ? ` (expires ${target.expires?.slice(0, 10)})` : ''}</span>;
}
if (category === 'user') {
if (!entry.target) return null;
const isSelf = entry.user?.id === entry.target?.id;
if (action === 'update') return <span className="audit-detail">{entry.target.username}{isSelf ? ' (own password)' : ' (password reset)'}</span>;
return <span className="audit-detail">{entry.target.username}</span>;
}
if (category === 'ipam') {
if (!target) return null;
if (prev && action === 'update') {
const addrChanged = prev.address !== target.address;
const labelChanged = prev.label !== target.label;
return (
<span className="audit-detail">
{target.address}
{labelChanged && <> ({prev.label} {target.label})</>}
</span>
);
}
return <span className="audit-detail">{target.address}{target.label ? `${target.label}` : ''}{target.vendor ? ` (${target.vendor})` : ''}</span>;
}
return null;
}
export default function AuditPage() {
const [entries, setEntries] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [filterUser, setFilterUser] = useState('');
const [filterAction, setFilterAction] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [filterProvider, setFilterProvider] = useState('');
const load = useCallback((pg = 0) => {
setLoading(true);
setError('');
getAuditLog({
limit: PAGE_SIZE,
offset: pg * PAGE_SIZE,
user: filterUser || undefined,
action: filterAction || undefined,
category: filterCategory || undefined,
provider: filterProvider || undefined,
})
.then(({ entries, total }) => { setEntries(entries); setTotal(total); })
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [filterUser, filterAction, filterCategory, filterProvider]);
useEffect(() => { setPage(0); load(0); }, [load]);
function goToPage(pg) { setPage(pg); load(pg); }
const totalPages = Math.ceil(total / PAGE_SIZE);
function handleExport() {
exportCsv(
entries.map(e => ({
timestamp: e.timestamp,
user: e.user?.username ?? '',
category: e.category,
action: e.action,
provider: e.provider ?? '',
zone: e.zone ?? '',
detail: e.category === 'dns'
? `${e.record?.type ?? ''} ${e.record?.name ?? ''} ${e.record?.content ?? ''}`
: e.category === 'secret'
? `${e.target?.type ?? ''}${e.target?.name ?? ''}`
: `${e.target?.address ?? ''} ${e.target?.label ?? ''}`,
})),
['timestamp','user','category','action','provider','zone','detail'],
{ timestamp:'Time', user:'User', category:'Category', action:'Action', provider:'Provider', zone:'Zone', detail:'Detail' },
'audit-log.csv'
);
}
return (
<div className="dashboard">
<div className="dashboard-header">
<div>
<h2>Audit Log</h2>
<p className="dashboard-hint">All changes made through Sloth Manager. Newest first.</p>
</div>
</div>
<div className="audit-filters">
<input className="filter-input" placeholder="Filter by user…" value={filterUser} onChange={e => setFilterUser(e.target.value)} />
<select className="filter-input" value={filterCategory} onChange={e => setFilterCategory(e.target.value)}>
<option value="">All categories</option>
<option value="dns">DNS</option>
<option value="secret">Secrets</option>
<option value="ipam">IP Addresses</option>
<option value="user">Users</option>
</select>
<select className="filter-input" value={filterAction} onChange={e => setFilterAction(e.target.value)}>
<option value="">All actions</option>
<option value="add">Added</option>
<option value="update">Updated</option>
<option value="delete">Deleted</option>
</select>
<input className="filter-input" placeholder="Filter by provider…" value={filterProvider} onChange={e => setFilterProvider(e.target.value)} style={{ display: filterCategory && filterCategory !== 'dns' ? 'none' : undefined }} />
<span className="record-count">{total} entries</span>
<button className="btn-export" onClick={handleExport}> Export CSV</button>
</div>
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
{loading ? (
<p className="hint">Loading</p>
) : entries.length === 0 ? (
<div className="empty-state"><p>No audit log entries yet.</p></div>
) : (
<>
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
<th>Time</th>
<th>User</th>
<th>Category</th>
<th>Action</th>
<th>Detail</th>
<th>Provider / Zone</th>
</tr>
</thead>
<tbody>
{entries.map(e => {
const action = ACTION_LABELS[e.action] ?? { label: e.action, cls: '' };
const category = CATEGORY_LABELS[e.category] ?? { label: e.category, cls: '' };
return (
<tr key={e.id}>
<td style={{ color: 'var(--text-muted)', fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(e.timestamp)}</td>
<td><span className="audit-user">{e.user?.username ?? '—'}</span></td>
<td><span className={`audit-badge ${category.cls}`}>{category.label}</span></td>
<td><span className={`audit-badge ${action.cls}`}>{action.label}</span></td>
<td><EntryDetail entry={e} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>
{e.provider && <span style={{ textTransform: 'capitalize' }}>{e.provider}</span>}
{e.zone && <span> / {e.zone}</span>}
</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>
)}
</>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Reusable confirmation dialog.
*
* Props:
* title - dialog heading
* message - body text
* confirmLabel - text for the confirm button (default "Confirm")
* danger - if true, confirm button uses the danger style
* onConfirm - called when the user confirms
* onCancel - called when the user cancels or clicks outside
*/
export default function ConfirmDialog({
title = 'Are you sure?',
message,
confirmLabel = 'Confirm',
danger = false,
onConfirm,
onCancel,
}) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal confirm-dialog" onClick={e => e.stopPropagation()}>
<h3>{title}</h3>
{message && <p className="confirm-message" style={{ whiteSpace: 'pre-line' }}>{message}</p>}
<div className="form-actions" style={{ marginTop: 20 }}>
<button className="btn-secondary" onClick={onCancel}>Cancel</button>
<button
className={danger ? 'btn-danger' : 'btn-primary'}
onClick={onConfirm}
autoFocus
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { getStats, getSecrets } from '../api/dns';
// ─── DNS chart colours ────────────────────────────────────────────────────────
const DNS_TYPE_COLORS = {
A: '#60a5fa',
AAAA: '#93c5fd',
CNAME: '#c084fc',
MX: '#4ade80',
TXT: '#fb923c',
NS: '#94a3b8',
SRV: '#a3e635',
CAA: '#f472b6',
PTR: '#facc15',
};
const SECRET_TYPE_COLORS = {
api_token: '#6366f1',
ssl_certificate: '#22d3ee',
password: '#f472b6',
generic: '#94a3b8',
};
const SECRET_TYPE_LABELS = {
api_token: 'API Token',
ssl_certificate: 'SSL Certificate',
password: 'Password',
generic: 'Generic',
};
function dnsTypeColor(type) { return DNS_TYPE_COLORS[type] ?? '#6366f1'; }
function secretTypeColor(type) { return SECRET_TYPE_COLORS[type] ?? '#6366f1'; }
// ─── Shared components ────────────────────────────────────────────────────────
function StatCard({ label, value, sub, accent }) {
return (
<div className="stat-card">
<div className="stat-value" style={accent ? { color: accent } : {}}>{value}</div>
<div className="stat-label">{label}</div>
{sub && <div className="stat-sub">{sub}</div>}
</div>
);
}
function SectionHeader({ title, hint }) {
return (
<div style={{ marginBottom: 16, marginTop: 8 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{title}</h3>
{hint && <p className="dashboard-hint" style={{ marginBottom: 0 }}>{hint}</p>}
</div>
);
}
const DnsTooltip = ({ active, payload }) => {
if (!active || !payload?.length) return null;
const { type, count } = payload[0].payload;
return (
<div className="chart-tooltip">
<span className="chart-tooltip-type">{type}</span>
<span>{count} record{count !== 1 ? 's' : ''}</span>
</div>
);
};
const SecretTooltip = ({ active, payload }) => {
if (!active || !payload?.length) return null;
const { type, count } = payload[0].payload;
return (
<div className="chart-tooltip">
<span className="chart-tooltip-type">{SECRET_TYPE_LABELS[type] ?? type}</span>
<span>{count} secret{count !== 1 ? 's' : ''}</span>
</div>
);
};
// ─── Dashboard ────────────────────────────────────────────────────────────────
export default function Dashboard() {
const [stats, setStats] = useState(null);
const [secrets, setSecrets] = useState([]);
const [error, setError] = useState('');
useEffect(() => {
getStats().then(setStats).catch(e => setError(e.message));
getSecrets().then(setSecrets).catch(() => {});
}, []);
// DNS stats
const totalRecords = stats?.recordTypes.reduce((s, r) => s + r.count, 0) ?? 0;
// Secrets stats
const totalSecrets = secrets.length;
const expiredCount = secrets.filter(s => s.status === 'expired').length;
const warningCount = secrets.filter(s => s.status === 'warning').length;
const secretsByType = Object.entries(
secrets.reduce((acc, s) => {
acc[s.type] = (acc[s.type] ?? 0) + 1;
return acc;
}, {})
).map(([type, count]) => ({ type, count }));
return (
<div className="dashboard">
<div className="dashboard-header">
<h2>Overview</h2>
<p className="dashboard-hint">DNS statistics are based on locally cached records. Sync a zone to update them.</p>
</div>
{error && <p className="error" style={{ marginBottom: 16 }}>{error}</p>}
<div className="dashboard-grid">
{/* ── DNS Column ── */}
<div>
<SectionHeader
title="DNS"
hint={stats ? `${stats.totalZones} zone${stats.totalZones !== 1 ? 's' : ''} across ${Object.keys(stats.perProvider).length} provider${Object.keys(stats.perProvider).length !== 1 ? 's' : ''}` : ''}
/>
{stats && (
<>
<div className="stat-cards">
<StatCard
label="Domains"
value={stats.totalZones}
sub={Object.entries(stats.perProvider).map(([p, v]) => `${v.zones} on ${p}`).join(' · ')}
/>
<StatCard
label="Total Records"
value={totalRecords}
sub={`${stats.recordTypes.length} type${stats.recordTypes.length !== 1 ? 's' : ''}`}
/>
<StatCard
label="With MX"
value={stats.zonesWithMx}
sub={stats.totalZones > 0 ? `${Math.round((stats.zonesWithMx / stats.totalZones) * 100)}%` : null}
/>
</div>
{stats.recordTypes.length > 0 ? (
<div className="chart-section">
<h3>Record Types</h3>
<div className="chart-wrapper">
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={stats.recordTypes}
dataKey="count"
nameKey="type"
cx="50%"
cy="50%"
outerRadius={85}
innerRadius={42}
paddingAngle={2}
label={({ type, percent }) => percent > 0.05 ? `${type} ${(percent * 100).toFixed(0)}%` : ''}
labelLine={false}
>
{stats.recordTypes.map(entry => (
<Cell key={entry.type} fill={dnsTypeColor(entry.type)} />
))}
</Pie>
<Tooltip content={<DnsTooltip />} />
<Legend formatter={v => <span style={{ color: 'var(--text)', fontSize: 12 }}>{v}</span>} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
) : (
<div className="empty-state" style={{ marginTop: 16 }}>
<p>No records cached yet sync a zone to populate statistics.</p>
</div>
)}
</>
)}
</div>
{/* ── Secrets Column ── */}
<div>
<SectionHeader
title="Secrets"
hint="Expiry tracking for API tokens, certificates, and passwords."
/>
<div className="stat-cards">
<StatCard label="Monitored" value={totalSecrets} sub="total tracked" />
<StatCard
label="Expiring Soon"
value={warningCount}
sub="in warning window"
accent={warningCount > 0 ? '#f59e0b' : undefined}
/>
<StatCard
label="Expired"
value={expiredCount}
sub="need attention"
accent={expiredCount > 0 ? '#ef4444' : undefined}
/>
</div>
{secretsByType.length > 0 ? (
<div className="chart-section">
<h3>Secrets by Type</h3>
<div className="chart-wrapper">
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={secretsByType}
dataKey="count"
nameKey="type"
cx="50%"
cy="50%"
outerRadius={85}
innerRadius={42}
paddingAngle={2}
label={({ type, percent }) =>
percent > 0.05 ? `${(percent * 100).toFixed(0)}%` : ''
}
labelLine={false}
>
{secretsByType.map(entry => (
<Cell key={entry.type} fill={secretTypeColor(entry.type)} />
))}
</Pie>
<Tooltip content={<SecretTooltip />} />
<Legend formatter={v => <span style={{ color: 'var(--text)', fontSize: 12 }}>{SECRET_TYPE_LABELS[v] ?? v}</span>} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
) : (
<div className="empty-state" style={{ marginTop: 16 }}>
<p>No secrets yet go to <strong>🔑 Secrets</strong> to add one.</p>
</div>
)}
</div>
</div>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
import { useState, useEffect } from 'react';
import { getAllDomains } from '../api/dns';
import { exportCsv } from '../utils/exportCsv';
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
function formatDate(iso) {
if (!iso) return <span className="never-synced">Never synced</span>;
const d = new Date(iso);
return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`;
}
export default function DomainsPage({ onNavigate }) {
const { colors } = useProviderColors();
const [domains, setDomains] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [showDupes, setShowDupes] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
getAllDomains()
.then(setDomains)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
const duplicateCount = domains.filter(d => d.duplicate).length;
const filtered = domains.filter(d => {
if (showDupes && !d.duplicate) return false;
if (!filter) return true;
const q = filter.toLowerCase();
return d.name.toLowerCase().includes(q) ||
d.entries.some(e => e.provider.toLowerCase().includes(q));
});
function handleExport() {
const rows = [];
for (const d of filtered) {
for (const e of d.entries) {
rows.push({
domain: d.name,
provider: e.provider,
record_count: e.record_count,
synced_at: e.synced_at ?? '',
duplicate: d.duplicate ? 'Yes' : 'No',
});
}
}
exportCsv(
rows,
['domain', 'provider', 'record_count', 'synced_at', 'duplicate'],
{ domain: 'Domain', provider: 'Provider', record_count: 'Records', synced_at: 'Last Synced', duplicate: 'Duplicate' },
'all-domains.csv'
);
}
return (
<div className="dashboard">
<div className="dashboard-header">
<div>
<h2>All Domains</h2>
<p className="dashboard-hint">
All zones across every provider based on locally cached data. Sync a zone to update it.
</p>
</div>
</div>
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
<div className="records-actions" style={{ marginBottom: 16 }}>
<input
className="filter-input"
placeholder="Filter by domain or provider…"
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<button
className={`btn-secondary ${showDupes ? 'active-filter' : ''}`}
onClick={() => setShowDupes(v => !v)}
title="Show only domains registered on multiple providers"
>
Duplicates {duplicateCount > 0 && <span className="dupe-badge">{duplicateCount}</span>}
</button>
<button className="btn-export" onClick={handleExport} title="Export to CSV"> Export CSV</button>
<span className="record-count">{filtered.length} domains</span>
</div>
{loading ? (
<p className="hint">Loading</p>
) : filtered.length === 0 ? (
<div className="empty-state">
<p>{domains.length === 0 ? 'No domains cached yet — sync a zone first.' : 'No domains match your filter.'}</p>
</div>
) : (
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
<th>Domain</th>
<th>Provider</th>
<th>Records</th>
<th>Last Synced</th>
</tr>
</thead>
<tbody>
{filtered.map(d =>
d.entries.map((e, i) => (
<tr
key={`${d.name}-${e.provider}`}
className={d.duplicate ? 'dupe-row' : ''}
style={{ cursor: 'pointer' }}
onClick={() => onNavigate && onNavigate(e.provider, e.zone_id, d.name)}
title="Click to open this zone"
>
<td className="name-cell">
{i === 0 && (
<>
{d.name}
{d.duplicate && <span className="dupe-tag" title="Same domain on multiple providers"> duplicate</span>}
</>
)}
</td>
<td><span className="badge" style={{ ...providerBadgeStyle(colors, e.provider), textTransform: 'capitalize' }}>{e.provider}</span></td>
<td>{e.record_count}</td>
<td style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatDate(e.synced_at)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
import { useState, useEffect } from 'react';
import { getIpam, createIpEntry, updateIpEntry, deleteIpEntry } from '../api/dns';
import ConfirmDialog from './ConfirmDialog';
import { exportCsv } from '../utils/exportCsv';
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
function isIPv6(address) {
return address.includes(':');
}
function IpForm({ initial, onSave, onCancel }) {
const [form, setForm] = useState({
address: initial?.address ?? '',
label: initial?.label ?? '',
vendor: initial?.vendor ?? '',
location: initial?.location ?? '',
notes: initial?.notes ?? '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
function handleChange(e) {
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
}
async function handleSubmit(e) {
e.preventDefault();
setSaving(true); setError('');
try { await onSave(form); }
catch (err) { setError(err.message); }
finally { setSaving(false); }
}
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h3>{initial ? 'Edit IP Address' : 'Add IP Address'}</h3>
<form onSubmit={handleSubmit} className="record-form">
<div className="form-row">
<label>IP Address
<input name="address" value={form.address} onChange={handleChange} placeholder="1.2.3.4 or 2001:db8::1" required autoFocus disabled={!!initial} />
</label>
<label>Label
<input name="label" value={form.label} onChange={handleChange} placeholder="e.g. Web Server 1" />
</label>
</div>
<div className="form-row">
<label>Vendor / Provider
<input name="vendor" value={form.vendor} onChange={handleChange} placeholder="e.g. Hetzner, DigitalOcean" />
</label>
<label>Location / Region
<input name="location" value={form.location} onChange={handleChange} placeholder="e.g. Frankfurt, US-East" />
</label>
</div>
<label>Notes
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
</label>
{error && <p className="error">{error}</p>}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving…' : initial ? 'Save Changes' : 'Add IP'}
</button>
</div>
</form>
</div>
</div>
);
}
function DnsMatchBadges({ matches, colors }) {
if (!matches || matches.length === 0) return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}></span>;
return (
<div className="ipam-dns-matches">
{matches.map((m, i) => (
<span key={i} className="ipam-dns-match" title={`${m.type} ${m.name}`}>
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>{m.provider}</span>
<span className="ipam-dns-name">{m.name}</span>
</span>
))}
</div>
);
}
export default function IpamPage() {
const { colors } = useProviderColors();
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const [filter, setFilter] = useState('');
const [error, setError] = useState('');
function load() {
setLoading(true);
getIpam()
.then(setEntries)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
async function handleSave(data) {
if (editing) {
const updated = await updateIpEntry(editing.id, data);
setEntries(e => e.map(x => x.id === editing.id ? updated : x));
} else {
const created = await createIpEntry(data);
setEntries(e => [...e, created]);
}
setShowForm(false);
setEditing(null);
}
async function handleDelete() {
await deleteIpEntry(confirmDel.id);
setEntries(e => e.filter(x => x.id !== confirmDel.id));
setConfirmDel(null);
}
const filtered = entries.filter(e => {
if (!filter) return true;
const q = filter.toLowerCase();
return (
e.address.includes(q) ||
(e.label || '').toLowerCase().includes(q) ||
(e.vendor || '').toLowerCase().includes(q) ||
(e.location || '').toLowerCase().includes(q)
);
});
const v4count = entries.filter(e => !isIPv6(e.address)).length;
const v6count = entries.filter(e => isIPv6(e.address)).length;
return (
<div className="dashboard">
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2>IP Addresses</h2>
<p className="dashboard-hint">
{entries.length} address{entries.length !== 1 ? 'es' : ''} stored
{entries.length > 0 && ` · ${v4count} IPv4 · ${v6count} IPv6`}
</p>
</div>
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add IP</button>
</div>
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
<div className="records-actions" style={{ marginBottom: 16 }}>
<input className="filter-input" placeholder="Filter by IP, label, vendor…" value={filter} onChange={e => setFilter(e.target.value)} />
<button className="btn-export" onClick={() =>
exportCsv(
filtered.map(e => ({
address: e.address, label: e.label, vendor: e.vendor,
location: e.location, notes: e.notes,
dns_matches: (e.dnsMatches || []).map(m => `${m.name} (${m.provider})`).join('; '),
})),
['address','label','vendor','location','notes','dns_matches'],
{ address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
'ip-addresses.csv'
)
}> Export CSV</button>
<span className="record-count">{filtered.length} entries</span>
</div>
{loading ? <p className="hint">Loading</p> : filtered.length === 0 ? (
<div className="empty-state">
<p>{entries.length === 0 ? 'No IP addresses stored yet — press + Add IP to get started.' : 'No entries match your filter.'}</p>
</div>
) : (
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
<th>IP Address</th>
<th>Label</th>
<th>Vendor</th>
<th>Location</th>
<th>DNS Records</th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map(e => (
<tr key={e.id}>
<td>
<span className={`ipam-address ${isIPv6(e.address) ? 'ipam-v6' : 'ipam-v4'}`}>{e.address}</span>
</td>
<td>{e.label || <span style={{ color: 'var(--text-muted)' }}></span>}</td>
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{e.vendor || '—'}</td>
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{e.location || '—'}</td>
<td><DnsMatchBadges matches={e.dnsMatches} colors={colors} /></td>
<td className="actions-cell">
{e.notes && <span className="secret-notes" title={e.notes}></span>}
<button className="btn-edit" onClick={() => { setEditing(e); setShowForm(true); }} title="Edit"></button>
<button className="btn-delete" onClick={() => setConfirmDel(e)} title="Delete"></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showForm && (
<IpForm
initial={editing}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditing(null); }}
/>
)}
{confirmDel && (
<ConfirmDialog
title="Delete IP Address"
message={`Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?`}
confirmLabel="Delete"
danger
onConfirm={handleDelete}
onCancel={() => setConfirmDel(null)}
/>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { login, setToken } from '../api/dns';
export default function LoginPage({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e) {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = await login(username, password);
setToken(data.token);
onLogin(data.user);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="login-wrapper">
<div className="login-box">
<div className="login-logo">🦥</div>
<h1 className="login-title">Sloth Manager</h1>
<form onSubmit={handleSubmit} className="login-form">
<label>
Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
autoComplete="username"
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" className="btn-primary login-btn" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="login-footer">By <a href="https://bobbantech.com" target="_blank" rel="noreferrer">bobbantech</a></p>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { changePassword } from '../api/dns';
export default function ProfilePage({ currentUser }) {
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
function handleChange(e) {
const { name, value } = e.target;
setForm(f => ({ ...f, [name]: value }));
setError('');
setSuccess(false);
}
async function handleSubmit(e) {
e.preventDefault();
if (form.next !== form.confirm) { setError('New passwords do not match'); return; }
setSaving(true);
setError('');
try {
await changePassword(form.current, form.next);
setForm({ current: '', next: '', confirm: '' });
setSuccess(true);
setTimeout(() => setSuccess(false), 4000);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
return (
<div className="dashboard" style={{ maxWidth: 480 }}>
<div className="dashboard-header">
<h2>My Profile</h2>
</div>
<section className="settings-section">
<h3>Account</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className="profile-field">
<span className="profile-label">Username</span>
<span className="profile-value">{currentUser.username}</span>
</div>
</div>
</section>
<section className="settings-section">
<h3>Change Password</h3>
<form onSubmit={handleSubmit} className="settings-form">
<label>Current Password
<input
type="password"
name="current"
value={form.current}
onChange={handleChange}
autoComplete="current-password"
required
/>
</label>
<label>New Password
<input
type="password"
name="next"
value={form.next}
onChange={handleChange}
autoComplete="new-password"
required
minLength={6}
/>
</label>
<label>Confirm New Password
<input
type="password"
name="confirm"
value={form.confirm}
onChange={handleChange}
autoComplete="new-password"
required
minLength={6}
/>
</label>
{error && <p className="error">{error}</p>}
{success && <p className="test-ok"> Password changed successfully.</p>}
<div className="form-actions">
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Change Password'}
</button>
</div>
</form>
</section>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react';
import { getSyncStatus, syncRecords } from '../api/dns';
import { exportCsv } from '../utils/exportCsv';
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
const PROVIDER_LINKS = {
cloudflare: { label: 'Open Cloudflare', url: 'https://dash.cloudflare.com/login' },
loopia: { label: 'Open Loopia', url: 'https://customerzone.loopia.se' },
pihole: { label: 'Open Pi-hole', url: null }, // dynamic — uses PIHOLE_URL
azure: { label: 'Open Azure', url: 'https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.Network%2FdnsZones' },
cpanel: { label: 'Open cPanel', url: null }, // dynamic — uses CPANEL_URL
};
function formatSyncedAt(iso) {
if (!iso) return <span className="never-synced">Never synced</span>;
const d = new Date(iso);
const date = d.toLocaleDateString('sv-SE');
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
return `${date} ${time}`;
}
export default function ProviderOverview({ provider, providerMeta, zones, onSelectZone }) {
const { colors } = useProviderColors();
const link = PROVIDER_LINKS[provider];
const externalUrl = (provider === 'pihole' || provider === 'cpanel') ? providerMeta?.url : link?.url;
const [syncStatus, setSyncStatus] = useState({});
const [syncingId, setSyncingId] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
if (!provider) return;
getSyncStatus(provider)
.then(setSyncStatus)
.catch(() => {});
}, [provider]);
async function handleSync(e, zone) {
e.stopPropagation(); // prevent row click opening the zone
setSyncingId(zone.id);
setError('');
try {
await syncRecords(provider, zone.id, zone.name);
// Refresh sync status after sync completes
const updated = await getSyncStatus(provider);
setSyncStatus(updated);
} catch (err) {
setError(`Failed to sync ${zone.name}: ${err.message}`);
} finally {
setSyncingId(null);
}
}
return (
<div className="provider-overview">
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2 style={{ textTransform: 'capitalize' }}>{provider}</h2>
<p className="dashboard-hint">{zones.length} zone{zones.length !== 1 ? 's' : ''} click a row to manage its records, or sync directly from here.</p>
</div>
<button className="btn-export" onClick={() =>
exportCsv(
zones.map(z => ({ domain: z.name, synced_at: syncStatus[z.id]?.synced_at ?? '', records: syncStatus[z.id]?.record_count ?? '' })),
['domain', 'records', 'synced_at'],
{ domain: 'Domain', records: 'Records', synced_at: 'Last Synced' },
`${provider}-domains.csv`
)
} title="Export to CSV"> Export CSV</button>
{externalUrl && (
<a href={externalUrl} target="_blank" rel="noreferrer" className="btn-secondary" style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}>
{link?.label ?? `Open ${provider}`}
</a>
)}
</div>
{error && (
<div className="error-banner" style={{ marginBottom: 16 }}>
<strong>Error:</strong> {error}
<button className="dismiss" onClick={() => setError('')}></button>
</div>
)}
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
<th>Zone / Domain</th>
<th>Records</th>
<th>Last Synced</th>
<th></th>
</tr>
</thead>
<tbody>
{zones.map(z => {
const status = syncStatus[z.id];
const isSyncing = syncingId === z.id;
return (
<tr key={z.id} className="zone-row" onClick={() => onSelectZone(z)}>
<td className="name-cell">{z.name}</td>
<td>{status?.record_count ?? '—'}</td>
<td>{formatSyncedAt(status?.synced_at)}</td>
<td className="actions-cell">
<button
className="btn-sync"
onClick={(e) => handleSync(e, z)}
disabled={isSyncing}
title="Sync records from provider"
>
{isSyncing ? '…' : '⟳'}
</button>
<span className="zone-open" onClick={() => onSelectZone(z)}>Open </span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useState, useMemo } from 'react';
import { exportCsv } from '../utils/exportCsv';
const COLUMNS = [
{ key: 'type', label: 'Type' },
{ key: 'name', label: 'Name' },
{ key: 'content', label: 'Content' },
{ key: 'ttl', label: 'TTL' },
{ key: 'priority', label: 'Priority' },
];
function compareValues(a, b) {
// nulls always last
if (a === null || a === undefined) return 1;
if (b === null || b === undefined) return -1;
// numeric comparison when both are numbers
if (typeof a === 'number' && typeof b === 'number') return a - b;
// natural sort for strings (handles "sub10" > "sub9" correctly)
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
export default function RecordsTable({ records, onDelete, onEdit, deleting, zoneName = 'records' }) {
function handleExport() {
exportCsv(
records,
['type', 'name', 'content', 'ttl', 'priority'],
{ type: 'Type', name: 'Name', content: 'Content', ttl: 'TTL', priority: 'Priority' },
`${zoneName}-dns-records.csv`
);
}
const [sortKey, setSortKey] = useState('name');
const [sortDir, setSortDir] = useState('asc');
function handleSort(key) {
if (key === sortKey) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDir('asc');
}
}
const sorted = useMemo(() => {
const copy = [...records];
copy.sort((a, b) => {
const cmp = compareValues(a[sortKey], b[sortKey]);
return sortDir === 'asc' ? cmp : -cmp;
});
return copy;
}, [records, sortKey, sortDir]);
function indicator(key) {
if (key !== sortKey) return <span className="sort-icon inactive"></span>;
return <span className="sort-icon active">{sortDir === 'asc' ? '↑' : '↓'}</span>;
}
if (records.length === 0) {
return <p className="empty">No DNS records found for this zone.</p>;
}
return (
<>
<div className="table-actions">
<button className="btn-export" onClick={handleExport} title="Export to CSV"> Export CSV</button>
</div>
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
{COLUMNS.map(col => (
<th
key={col.key}
className="sortable"
onClick={() => handleSort(col.key)}
title={`Sort by ${col.label}`}
>
{col.label} {indicator(col.key)}
</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{sorted.map(r => (
<tr key={r.id}>
<td><span className={`type-badge type-${r.type?.toLowerCase() ?? 'unknown'}`}>{r.type ?? '?'}</span></td>
<td className="name-cell">{r.name}</td>
<td className="content-cell">{r.content}</td>
<td>{r.ttl === 1 || r.ttl === null ? 'Auto' : r.ttl}</td>
<td>{r.priority ?? '—'}</td>
<td className="actions-cell">
<button className="btn-edit" onClick={() => onEdit(r)} title="Edit record"></button>
<button
className="btn-delete"
onClick={() => onDelete(r)}
disabled={deleting === r.id}
title="Delete record"
>
{deleting === r.id ? '…' : '✕'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
+246
View File
@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import { getSecrets, createSecret, updateSecret, deleteSecret } from '../api/dns';
import ConfirmDialog from './ConfirmDialog';
import { exportCsv } from '../utils/exportCsv';
const TYPE_LABELS = {
api_token: 'API Token',
ssl_certificate: 'SSL Certificate',
password: 'Password',
generic: 'Generic',
};
const STATUS_CONFIG = {
expired: { label: 'Expired', cls: 'secret-expired' },
warning: { label: 'Expiring', cls: 'secret-warning' },
ok: { label: 'OK', cls: 'secret-ok' },
};
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('sv-SE');
}
function SecretForm({ initial, onSave, onCancel }) {
const [form, setForm] = useState({
name: initial?.name ?? '',
type: initial?.type ?? 'api_token',
description: initial?.description ?? '',
expires_at: initial?.expires_at ? initial.expires_at.slice(0, 10) : '',
warning_days: initial?.warning_days ?? 30,
notes: initial?.notes ?? '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
function handleChange(e) {
const { name, value } = e.target;
setForm(f => ({ ...f, [name]: value }));
}
async function handleSubmit(e) {
e.preventDefault();
setSaving(true); setError('');
try {
await onSave({ ...form, warning_days: Number(form.warning_days) });
} catch (err) { setError(err.message); }
finally { setSaving(false); }
}
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h3>{initial ? 'Edit Secret' : 'Add Secret'}</h3>
<form onSubmit={handleSubmit} className="record-form">
<div className="form-row">
<label>Name
<input name="name" value={form.name} onChange={handleChange} required autoFocus />
</label>
<label>Type
<select name="type" value={form.type} onChange={handleChange}>
{Object.entries(TYPE_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</label>
</div>
<label>Description
<input name="description" value={form.description} onChange={handleChange} placeholder="What is this secret used for?" />
</label>
<div className="form-row">
<label>Expiry Date
<input name="expires_at" type="date" value={form.expires_at} onChange={handleChange} required />
</label>
<label>Warn (days before)
<input name="warning_days" type="number" min={1} value={form.warning_days} onChange={handleChange} required />
</label>
</div>
<label>Notes
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
</label>
{error && <p className="error">{error}</p>}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving…' : initial ? 'Save Changes' : 'Add Secret'}
</button>
</div>
</form>
</div>
</div>
);
}
export default function SecretsPage() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(null);
const [confirmDel, setConfirmDel] = useState(null);
const [filter, setFilter] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [error, setError] = useState('');
function load() {
setLoading(true);
getSecrets()
.then(setItems)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}
useEffect(() => { load(); }, []);
async function handleSave(data) {
if (editing) {
const updated = await updateSecret(editing.id, data);
setItems(i => i.map(x => x.id === editing.id ? { ...updated, daysLeft: x.daysLeft, status: x.status } : x));
} else {
await createSecret(data);
load(); // reload to get fresh status
}
setShowForm(false);
setEditing(null);
}
async function handleDelete() {
await deleteSecret(confirmDel.id);
setItems(i => i.filter(x => x.id !== confirmDel.id));
setConfirmDel(null);
}
const filtered = items.filter(s => {
if (filterStatus && s.status !== filterStatus) return false;
if (!filter) return true;
const q = filter.toLowerCase();
return s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q);
});
const expiredCount = items.filter(s => s.status === 'expired').length;
const warningCount = items.filter(s => s.status === 'warning').length;
return (
<div className="dashboard">
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h2>Secrets</h2>
<p className="dashboard-hint">Track expiry dates for API tokens, certificates, passwords, and other secrets.</p>
</div>
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add Secret</button>
</div>
{(expiredCount > 0 || warningCount > 0) && (
<div className="secrets-alert-banner">
{expiredCount > 0 && <span className="secret-expired-text"> {expiredCount} expired</span>}
{warningCount > 0 && <span className="secret-warning-text"> {warningCount} expiring soon</span>}
</div>
)}
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
<div className="records-actions" style={{ marginBottom: 16 }}>
<input className="filter-input" placeholder="Filter secrets…" value={filter} onChange={e => setFilter(e.target.value)} />
<select className="filter-input" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="">All statuses</option>
<option value="expired">Expired</option>
<option value="warning">Expiring soon</option>
<option value="ok">OK</option>
</select>
<button className="btn-export" onClick={() =>
exportCsv(
filtered.map(s => ({ name: s.name, type: TYPE_LABELS[s.type] ?? s.type, description: s.description, expires_at: s.expires_at?.slice(0,10), warning_days: s.warning_days, status: s.status, days_left: s.daysLeft, notes: s.notes })),
['name','type','description','expires_at','warning_days','status','days_left','notes'],
{ name:'Name', type:'Type', description:'Description', expires_at:'Expires', warning_days:'Warn Days', status:'Status', days_left:'Days Left', notes:'Notes' },
'secrets.csv'
)
}> Export CSV</button>
<span className="record-count">{filtered.length} secrets</span>
</div>
{loading ? <p className="hint">Loading</p> : filtered.length === 0 ? (
<div className="empty-state">
<p>{items.length === 0 ? 'No secrets tracked yet — press + Add Secret to get started.' : 'No secrets match your filter.'}</p>
</div>
) : (
<div className="table-wrapper">
<table className="records-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Expires</th>
<th>Days Left</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{filtered.map(s => {
const sc = STATUS_CONFIG[s.status] ?? STATUS_CONFIG.ok;
return (
<tr key={s.id}>
<td className="name-cell">
{s.name}
{s.notes && <span className="secret-notes" title={s.notes}> </span>}
</td>
<td><span className="type-badge">{TYPE_LABELS[s.type] ?? s.type}</span></td>
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{s.description || '—'}</td>
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{formatDate(s.expires_at)}</td>
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>
{s.daysLeft < 0 ? `${Math.abs(s.daysLeft)}d ago` : `${s.daysLeft}d`}
</td>
<td><span className={`secret-status-badge ${sc.cls}`}>{sc.label}</span></td>
<td className="actions-cell">
<button className="btn-edit" onClick={() => { setEditing(s); setShowForm(true); }} title="Edit"></button>
<button className="btn-delete" onClick={() => setConfirmDel(s)} title="Delete"></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showForm && (
<SecretForm
initial={editing}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditing(null); }}
/>
)}
{confirmDel && (
<ConfirmDialog
title="Delete Secret"
message={`Delete "${confirmDel.name}"? This cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={handleDelete}
onCancel={() => setConfirmDel(null)}
/>
)}
</div>
);
}
+476
View File
@@ -0,0 +1,476 @@
import { useState, useEffect } from 'react';
import { getSettings, saveSettings, testNotification, clearCache, getUsers, createUser, deleteUser, getProviderHealth } from '../api/dns';
import ConfirmDialog from './ConfirmDialog';
import { exportCsv } from '../utils/exportCsv';
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
const TABS = [
{ id: 'notifications', label: 'Notifications' },
{ id: 'providers', label: 'Providers' },
{ id: 'status', label: 'Provider Status' },
{ id: 'users', label: 'Users' },
{ id: 'cache', label: 'Cache' },
];
// ─── Notifications tab ────────────────────────────────────────────────────────
function NotificationsTab() {
const [form, setForm] = useState({ enabled: false, url: '', token: '', priority: 5 });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [saved, setSaved] = useState(false);
const [testResult, setTestResult] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
getSettings()
.then(s => setForm({ ...s.gotify }))
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, []);
function handleChange(e) {
const { name, value, type, checked } = e.target;
setForm(f => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
setSaved(false);
setTestResult(null);
}
async function handleSave(e) {
e.preventDefault();
setSaving(true); setError(''); setSaved(false);
try {
await saveSettings({ gotify: { ...form, priority: Number(form.priority) } });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) { setError(err.message); }
finally { setSaving(false); }
}
async function handleTest() {
setTesting(true); setTestResult(null); setError('');
try {
await testNotification({ url: form.url, token: form.token, priority: Number(form.priority) });
setTestResult({ ok: true, message: 'Notification sent successfully.' });
} catch (err) {
setTestResult({ ok: false, message: err.message });
} finally { setTesting(false); }
}
if (loading) return <p className="hint">Loading</p>;
return (
<div className="settings-grid">
<section className="settings-section">
<h3>Gotify Notifications</h3>
<p className="settings-desc">
Send a notification to a Gotify instance whenever a DNS record is added, updated, or deleted.
</p>
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
<form onSubmit={handleSave} className="settings-form">
<label className="toggle-row">
<span>Enable notifications</span>
<input type="checkbox" name="enabled" checked={form.enabled} onChange={handleChange} className="toggle" />
</label>
<label>Gotify URL
<input name="url" type="url" value={form.url} onChange={handleChange} placeholder="http://gotify.example.com" disabled={!form.enabled} />
</label>
<label>App Token
<input name="token" type="password" value={form.token} onChange={handleChange} placeholder="Your Gotify app token" disabled={!form.enabled} />
</label>
<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>
{testResult && (
<p className={testResult.ok ? 'test-ok' : 'test-fail'}>
{testResult.ok ? '✓' : '✕'} {testResult.message}
</p>
)}
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={handleTest} disabled={testing || !form.url || !form.token}>
{testing ? 'Sending…' : 'Send Test'}
</button>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving…' : saved ? '✓ Saved' : 'Save Settings'}
</button>
</div>
</form>
</section>
<section className="settings-section">
<h3>About Gotify</h3>
<p className="settings-desc" style={{ lineHeight: 1.8 }}>
<a href="https://gotify.net" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>Gotify</a> is a self-hosted push notification server.<br /><br />
<strong>To get started:</strong><br />
1. Log in to your Gotify instance<br />
2. Go to <strong>Apps Create application</strong><br />
3. Copy the generated token and paste it into App Token<br /><br />
Notifications fire on every record add, update, or delete not on sync.
</p>
</section>
</div>
);
}
// ─── Users tab ────────────────────────────────────────────────────────────────
function UsersTab({ currentUser }) {
const [users, setUsers] = useState([]);
const [showForm, setShowForm] = useState(false);
const [newUser, setNewUser] = useState({ username: '', password: '' });
const [creating, setCreating] = useState(false);
const [confirmUser, setConfirmUser] = useState(null);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => { getUsers().then(setUsers).catch(() => {}); }, []);
async function handleCreate(e) {
e.preventDefault();
setError(''); setSuccess('');
setCreating(true);
try {
const user = await createUser(newUser.username, newUser.password);
setUsers(u => [...u, user]);
setNewUser({ username: '', password: '' });
setShowForm(false);
setSuccess(`User "${user.username}" created.`);
setTimeout(() => setSuccess(''), 3000);
} catch (err) { setError(err.message); }
finally { setCreating(false); }
}
function handleCancel() {
setShowForm(false);
setNewUser({ username: '', password: '' });
setError('');
}
async function confirmDeleteUser() {
const user = confirmUser;
setConfirmUser(null);
try {
await deleteUser(user.id);
setUsers(u => u.filter(x => x.id !== user.id));
} catch (err) { setError(err.message); }
}
function formatDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
const date = d.toLocaleDateString('sv-SE');
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
return `${date} ${time}`;
}
return (
<>
{confirmUser && (
<ConfirmDialog
title="Delete User"
message={`Remove "${confirmUser.username}" from Sloth Manager? This cannot be undone.`}
confirmLabel="Delete User"
danger
onConfirm={confirmDeleteUser}
onCancel={() => setConfirmUser(null)}
/>
)}
<div className="records-header" style={{ marginBottom: 16 }}>
<div>
<h3 style={{ fontSize: 15, fontWeight: 600 }}>Users</h3>
<p className="settings-desc" style={{ marginBottom: 0 }}>Manage who can access Sloth Manager.</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn-export" onClick={() =>
exportCsv(users, ['username', 'createdAt'], { username: 'Username', createdAt: 'Created' }, 'users.csv')
} title="Export to CSV"> Export CSV</button>
<button className="btn-primary" onClick={() => setShowForm(true)} disabled={showForm}>+ Add User</button>
</div>
</div>
{success && <p className="test-ok" style={{ marginBottom: 12 }}> {success}</p>}
<div className="table-wrapper" style={{ marginBottom: showForm ? 24 : 0 }}>
<table className="records-table">
<thead>
<tr>
<th>Username</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>
<span className="user-name">{u.username}</span>
{u.id === currentUser?.id && (
<span className="badge" style={{ fontSize: 10, marginLeft: 8 }}>you</span>
)}
</td>
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{formatDate(u.createdAt)}</td>
<td className="actions-cell">
{u.id !== currentUser?.id && (
<button className="btn-delete" onClick={() => setConfirmUser(u)} title="Delete user"></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{showForm && (
<section className="settings-section" style={{ marginTop: 0 }}>
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 16 }}>New User</h4>
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
<form onSubmit={handleCreate} className="settings-form">
<label>Username
<input value={newUser.username} onChange={e => setNewUser(f => ({ ...f, username: e.target.value }))} required autoFocus />
</label>
<label>Password
<input type="password" value={newUser.password} onChange={e => setNewUser(f => ({ ...f, password: e.target.value }))} required minLength={6} />
</label>
<div className="form-actions">
<button type="button" className="btn-secondary" onClick={handleCancel} disabled={creating}>Cancel</button>
<button type="submit" className="btn-primary" disabled={creating}>{creating ? 'Creating…' : 'Create User'}</button>
</div>
</form>
</section>
)}
</>
);
}
// ─── Cache tab ────────────────────────────────────────────────────────────────
function CacheTab() {
const [clearing, setClearing] = useState(false);
const [cleared, setCleared] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const [error, setError] = useState('');
async function doClearCache() {
setConfirmClear(false);
setClearing(true); setError('');
try {
await clearCache();
setCleared(true);
setTimeout(() => setCleared(false), 3000);
} catch (err) { setError(err.message); }
finally { setClearing(false); }
}
return (
<>
{confirmClear && (
<ConfirmDialog
title="Clear Record Cache"
message="This will delete all locally cached DNS records. All zones will need to be re-synced afterwards."
confirmLabel="Clear Cache"
danger
onConfirm={doClearCache}
onCancel={() => setConfirmClear(false)}
/>
)}
<section className="settings-section">
<h3>Record Cache</h3>
<p className="settings-desc">
The record cache stores a local copy of all synced DNS records in <code>dns-cache.json</code>.
Clear it if you experience stale or corrupt data all zones will need to be re-synced afterwards.
</p>
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
<button type="button" className="btn-danger" onClick={() => setConfirmClear(true)} disabled={clearing}>
{clearing ? 'Clearing…' : cleared ? '✓ Cache cleared' : 'Clear Cache'}
</button>
</div>
</section>
</>
);
}
// ─── Providers tab ───────────────────────────────────────────────────────────
const PROVIDER_NAMES = {
cloudflare: 'Cloudflare',
loopia: 'Loopia',
pihole: 'Pi-hole',
azure: 'Azure DNS',
cpanel: 'cPanel',
};
function ProvidersTab() {
const { colors, setColors } = useProviderColors();
const [local, setLocal] = useState({});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState('');
useEffect(() => { setLocal({ ...colors }); }, [colors]);
function handleChange(provider, value) {
setLocal(c => ({ ...c, [provider]: value }));
setSaved(false);
}
async function handleSave() {
setSaving(true); setError(''); setSaved(false);
try {
await saveSettings({ providerColors: local });
setColors(local);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) { setError(err.message); }
finally { setSaving(false); }
}
return (
<section className="settings-section">
<h3>Provider Tag Colors</h3>
<p className="settings-desc">Customize the color of each provider's badge throughout the app.</p>
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
<div className="provider-colors-list">
{Object.entries(PROVIDER_NAMES).map(([id, name]) => (
<div key={id} className="provider-color-row">
<span className="badge" style={providerBadgeStyle(local, id)}>{name}</span>
<label className="color-label">
<input
type="color"
value={local[id] ?? '#6366f1'}
onChange={e => handleChange(id, e.target.value)}
className="color-input"
/>
<span className="color-hex">{local[id] ?? '#6366f1'}</span>
</label>
</div>
))}
</div>
<div className="form-actions" style={{ marginTop: 20 }}>
<button className="btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving' : saved ? ' Saved' : 'Save Colors'}
</button>
</div>
</section>
);
}
// ─── Status tab ──────────────────────────────────────────────────────────────
function StatusTab() {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [lastChecked, setLastChecked] = useState(null);
const [error, setError] = useState('');
async function runCheck() {
setLoading(true); setError('');
try {
const data = await getProviderHealth();
setResults(data);
setLastChecked(new Date());
} catch (err) { setError(err.message); }
finally { setLoading(false); }
}
const STATUS_CONFIG = {
ok: { icon: '', label: 'Connected', cls: 'health-ok' },
error: { icon: '', label: 'Error', cls: 'health-error' },
unconfigured: { icon: '', label: 'Not configured', cls: 'health-unconfigured' },
};
return (
<section className="settings-section" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div>
<h3>Provider Status</h3>
<p className="settings-desc" style={{ marginBottom: 0 }}>
Test connectivity to each configured provider.
{lastChecked && <span> Last checked: {lastChecked.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>}
</p>
</div>
<button className="btn-primary" onClick={runCheck} disabled={loading}>
{loading ? 'Checking' : ' Run Check'}
</button>
</div>
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
{results.length === 0 && !loading && (
<p className="hint" style={{ marginTop: 16 }}>Press <strong>Run Check</strong> to test all provider connections.</p>
)}
{results.length > 0 && (
<div className="table-wrapper" style={{ marginTop: 16 }}>
<table className="records-table">
<thead>
<tr>
<th>Provider</th>
<th>Status</th>
<th>Latency</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{results.map(r => {
const cfg = STATUS_CONFIG[r.status] ?? STATUS_CONFIG.unconfigured;
return (
<tr key={r.id}>
<td style={{ fontWeight: 500 }}>{r.name}</td>
<td>
<span className={`health-badge ${cfg.cls}`}>
{cfg.icon} {cfg.label}
</span>
</td>
<td style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--text-muted)' }}>
{r.latency !== null ? `${r.latency} ms` : ''}
</td>
<td style={{ fontSize: 13, color: r.error ? '#fca5a5' : 'var(--text-muted)' }}>
{r.error ?? (r.status === 'unconfigured' ? 'No credentials in .env' : 'OK')}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
);
}
// ─── Settings page ────────────────────────────────────────────────────────────
export default function SettingsPage({ currentUser }) {
const [activeTab, setActiveTab] = useState('notifications');
return (
<div className="settings-page">
<div className="dashboard-header">
<h2>Settings</h2>
</div>
<div className="settings-tabs">
{TABS.map(t => (
<button
key={t.id}
className={`settings-tab ${activeTab === t.id ? 'active' : ''}`}
onClick={() => setActiveTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div className="settings-tab-content">
{activeTab === 'notifications' && <NotificationsTab />}
{activeTab === 'providers' && <ProvidersTab />}
{activeTab === 'status' && <StatusTab />}
{activeTab === 'users' && <UsersTab currentUser={currentUser} />}
{activeTab === 'cache' && <CacheTab />}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { createContext, useContext, useState, useEffect } from 'react';
import { getSettings } from '../api/dns';
const DEFAULTS = {
cloudflare: '#f6821f',
loopia: '#2ecc71',
pihole: '#96060c',
azure: '#0078d4',
cpanel: '#ff6c2c',
};
const ProviderColorsContext = createContext(DEFAULTS);
export function ProviderColorsProvider({ children }) {
const [colors, setColors] = useState(DEFAULTS);
useEffect(() => {
getSettings()
.then(s => setColors({ ...DEFAULTS, ...(s.providerColors ?? {}) }))
.catch(() => {});
}, []);
return (
<ProviderColorsContext.Provider value={{ colors, setColors }}>
{children}
</ProviderColorsContext.Provider>
);
}
export function useProviderColors() {
return useContext(ProviderColorsContext);
}
export function providerBadgeStyle(colors, provider) {
const color = colors[provider?.toLowerCase()] ?? '#6366f1';
return {
background: `${color}22`,
color,
border: `1px solid ${color}55`,
};
}
+26
View File
@@ -0,0 +1,26 @@
import { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext({ theme: 'dark', toggleTheme: () => {} });
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => localStorage.getItem('dns_theme') || 'dark');
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('dns_theme', theme);
}, [theme]);
function toggleTheme() {
setTheme(t => t === 'dark' ? 'light' : 'dark');
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ProviderColorsProvider } from './context/ProviderColors';
import { ThemeProvider } from './context/Theme';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<ThemeProvider>
<ProviderColorsProvider>
<App />
</ProviderColorsProvider>
</ThemeProvider>
</React.StrictMode>
);
+30
View File
@@ -0,0 +1,30 @@
/**
* Download an array of objects as a CSV file.
* @param {object[]} rows - data rows
* @param {string[]} columns - keys to include (in order)
* @param {object} headers - optional { key: 'Label' } overrides
* @param {string} filename
*/
export function exportCsv(rows, columns, headers = {}, filename = 'export.csv') {
const header = columns.map(c => headers[c] ?? c).join(',');
const body = rows.map(row =>
columns.map(col => {
const val = row[col] ?? '';
const str = String(val);
// Wrap in quotes if the value contains a comma, quote, or newline
return str.includes(',') || str.includes('"') || str.includes('\n')
? `"${str.replace(/"/g, '""')}"`
: str;
}).join(',')
);
const csv = [header, ...body].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}