Add DNS module ported from Sloth Manager
Ports zone/record management across Cloudflare, Loopia, Pi-hole, Azure DNS, cPanel, and Technitium onto the new stack. Unlike the original (one instance per provider configured via env vars), providers are now configured through the UI and support multiple named instances per type, with API credentials encrypted at rest via the integration_credentials table. - server/src/dns/adapters/*: each provider ported to a config-based factory (no more process.env reads), preserving each provider's original quirks (Pi-hole session auth, Azure record-set merging, Loopia XML-RPC, cPanel UAPI/API2 fallbacks, Technitium composite record IDs). - server/src/routes/dns.ts: provider CRUD (admin), a "test connection" endpoint, and zone/record browsing+sync+CRUD (operator+), all audit-logged. - dns_zones_cache/dns_records_cache tables replace Sloth Manager's dns-cache.json file, keeping the same "cache is the source of truth for display, sync fetches fresh from the provider" behavior. - web: a DNS page with provider management, zone browsing, and a record editor, plus a reusable dynamic provider-config form. Verified: full build (tsc + vite) passes; a scripted HTTP-layer test against a running server exercises auth, role gating (403 for viewer), validation (400 on missing config fields), provider CRUD, credential non-leakage in list responses, and adapter error propagation (502 against an unreachable host) — all passing. Real provider connectivity still needs to be checked against the user's actual DNS accounts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89919fdbff
commit
39b1d1fe2e
24 files changed
+2726
-33
No files matched your search
+2
-1
@@ -7,6 +7,7 @@ import Users from "./pages/Users";
|
||||
import AuditLog from "./pages/AuditLog";
|
||||
import Secrets from "./pages/Secrets";
|
||||
import Ipam from "./pages/Ipam";
|
||||
import Dns from "./pages/Dns";
|
||||
import ComingSoon from "./pages/ComingSoon";
|
||||
import AppShell from "./layout/AppShell";
|
||||
|
||||
@@ -57,7 +58,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard user={user} />} />
|
||||
<Route path="/servers" element={<ComingSoon title="Servers & Tasks" />} />
|
||||
<Route path="/dns" element={<ComingSoon title="DNS" />} />
|
||||
<Route path="/dns" element={<Dns user={user} />} />
|
||||
<Route path="/ipam" element={<Ipam user={user} />} />
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
<Route path="/integrations" element={<ComingSoon title="Integrations" />} />
|
||||
|
||||
@@ -76,6 +76,50 @@ export interface IpamInput {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium";
|
||||
|
||||
export interface DnsProviderField {
|
||||
key: string;
|
||||
label: string;
|
||||
secret: boolean;
|
||||
type?: "text" | "password" | "checkbox";
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface DnsProviderSummary {
|
||||
id: number;
|
||||
providerType: DnsProviderType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DnsZone {
|
||||
id: string;
|
||||
name: string;
|
||||
syncedAt: string | null;
|
||||
recordCount: number;
|
||||
}
|
||||
|
||||
export interface DnsRecord {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number | null;
|
||||
priority: number | null;
|
||||
proxied?: boolean | null;
|
||||
}
|
||||
|
||||
export interface DnsRecordInput {
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl?: number;
|
||||
priority?: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
export class ForbiddenError extends Error {}
|
||||
|
||||
@@ -131,4 +175,56 @@ export const api = {
|
||||
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
dns: {
|
||||
providerFields: () =>
|
||||
request<{ fields: Record<DnsProviderType, DnsProviderField[]> }>("/api/dns/provider-fields"),
|
||||
providers: {
|
||||
list: () => request<{ providers: DnsProviderSummary[] }>("/api/dns/providers"),
|
||||
create: (data: { providerType: DnsProviderType; name: string; config: Record<string, string | boolean> }) =>
|
||||
request<{ provider: DnsProviderSummary }>("/api/dns/providers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
update: (id: number, data: { name?: string; enabled?: boolean; config?: Record<string, string | boolean> }) =>
|
||||
request<{ provider: DnsProviderSummary }>(`/api/dns/providers/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
remove: (id: number) => request<void>(`/api/dns/providers/${id}`, { method: "DELETE" }),
|
||||
test: (data: { providerType: DnsProviderType; config: Record<string, string | boolean> }) =>
|
||||
request<{ ok: boolean; zoneCount?: number; error?: string }>("/api/dns/providers/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
},
|
||||
zones: {
|
||||
list: (providerId: number) => request<{ zones: DnsZone[] }>(`/api/dns/providers/${providerId}/zones`),
|
||||
},
|
||||
records: {
|
||||
list: (providerId: number, zoneId: string) =>
|
||||
request<{ records: DnsRecord[]; syncedAt: string | null }>(
|
||||
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`,
|
||||
),
|
||||
sync: (providerId: number, zoneId: string, zoneName?: string) =>
|
||||
request<{ records: DnsRecord[]; syncedAt: string }>(
|
||||
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/sync`,
|
||||
{ method: "POST", body: JSON.stringify({ zoneName }) },
|
||||
),
|
||||
create: (providerId: number, zoneId: string, data: DnsRecordInput) =>
|
||||
request<{ record: DnsRecord }>(
|
||||
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records`,
|
||||
{ method: "POST", body: JSON.stringify(data) },
|
||||
),
|
||||
update: (providerId: number, zoneId: string, recordId: string, data: DnsRecordInput) =>
|
||||
request<{ record: DnsRecord }>(
|
||||
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`,
|
||||
{ method: "PUT", body: JSON.stringify(data) },
|
||||
),
|
||||
remove: (providerId: number, zoneId: string, recordId: string) =>
|
||||
request<void>(
|
||||
`/api/dns/providers/${providerId}/zones/${encodeURIComponent(zoneId)}/records/${encodeURIComponent(recordId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type DnsProviderField, type DnsProviderType } from "../api/client";
|
||||
|
||||
const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
loopia: "Loopia",
|
||||
pihole: "Pi-hole",
|
||||
azure: "Azure DNS",
|
||||
cpanel: "cPanel",
|
||||
technitium: "Technitium",
|
||||
};
|
||||
|
||||
export default function DnsProviderForm({
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreated: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [allFields, setAllFields] = useState<Record<DnsProviderType, DnsProviderField[]> | null>(null);
|
||||
const [providerType, setProviderType] = useState<DnsProviderType>("cloudflare");
|
||||
const [name, setName] = useState("");
|
||||
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; zoneCount?: number; error?: string } | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.dns.providerFields().then((res) => setAllFields(res.fields));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setValues({});
|
||||
setTestResult(null);
|
||||
}, [providerType]);
|
||||
|
||||
const fields = allFields?.[providerType] ?? [];
|
||||
|
||||
function setField(key: string, value: string | boolean) {
|
||||
setValues((v) => ({ ...v, [key]: value }));
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await api.dns.providers.test({ providerType, config: values });
|
||||
setTestResult(result);
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.dns.providers.create({ providerType, name, config: values });
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Add a DNS provider</h3>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="card-body row g-3">
|
||||
{error && (
|
||||
<div className="col-12">
|
||||
<div className="alert alert-danger">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Provider</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={providerType}
|
||||
onChange={(e) => setProviderType(e.target.value as DnsProviderType)}
|
||||
>
|
||||
{Object.entries(PROVIDER_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Display name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="e.g. Main Cloudflare account"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fields.map((field) => (
|
||||
<div className="col-md-4" key={field.key}>
|
||||
{field.type === "checkbox" ? (
|
||||
<label className="form-check form-check-single mt-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={Boolean(values[field.key])}
|
||||
onChange={(e) => setField(field.key, e.target.checked)}
|
||||
/>
|
||||
<span className="form-check-label">{field.label}</span>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<label className="form-label">{field.label}</label>
|
||||
<input
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
className="form-control"
|
||||
placeholder={field.placeholder}
|
||||
required
|
||||
value={(values[field.key] as string) ?? ""}
|
||||
onChange={(e) => setField(field.key, e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{testResult && (
|
||||
<div className="col-12">
|
||||
{testResult.ok ? (
|
||||
<div className="alert alert-success mb-0">
|
||||
Connection OK — found {testResult.zoneCount} zone{testResult.zoneCount === 1 ? "" : "s"}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="alert alert-danger mb-0">Connection failed: {testResult.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={testConnection} disabled={testing}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
Add provider
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
type DnsProviderSummary,
|
||||
type DnsProviderType,
|
||||
type DnsRecord,
|
||||
type DnsRecordInput,
|
||||
type DnsZone,
|
||||
} from "../api/client";
|
||||
import DnsProviderForm from "../components/DnsProviderForm";
|
||||
|
||||
const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
loopia: "Loopia",
|
||||
pihole: "Pi-hole",
|
||||
azure: "Azure DNS",
|
||||
cpanel: "cPanel",
|
||||
technitium: "Technitium",
|
||||
};
|
||||
|
||||
const emptyRecordForm: DnsRecordInput = { type: "A", name: "", content: "", ttl: 3600, priority: undefined, proxied: undefined };
|
||||
|
||||
export default function Dns({ user }: { user: CurrentUser }) {
|
||||
const isAdmin = user.role === "admin";
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [providers, setProviders] = useState<DnsProviderSummary[] | null>(null);
|
||||
const [managingProviders, setManagingProviders] = useState(false);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [zones, setZones] = useState<DnsZone[] | null>(null);
|
||||
const [zonesError, setZonesError] = useState<string | null>(null);
|
||||
const [selectedZone, setSelectedZone] = useState<DnsZone | null>(null);
|
||||
|
||||
const [records, setRecords] = useState<DnsRecord[] | null>(null);
|
||||
const [syncedAt, setSyncedAt] = useState<string | null>(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const [recordForm, setRecordForm] = useState<DnsRecordInput | null>(null);
|
||||
const [editingRecordId, setEditingRecordId] = useState<string | null>(null);
|
||||
const [savingRecord, setSavingRecord] = useState(false);
|
||||
|
||||
function loadProviders() {
|
||||
api.dns.providers
|
||||
.list()
|
||||
.then((res) => {
|
||||
setProviders(res.providers);
|
||||
if (!selectedProviderId && res.providers.length > 0) setSelectedProviderId(res.providers[0].id);
|
||||
})
|
||||
.catch((err) => setError(String(err)));
|
||||
}
|
||||
|
||||
useEffect(loadProviders, []);
|
||||
|
||||
function loadZones(providerId: number) {
|
||||
setZonesError(null);
|
||||
setZones(null);
|
||||
setSelectedZone(null);
|
||||
setRecords(null);
|
||||
api.dns.zones
|
||||
.list(providerId)
|
||||
.then((res) => setZones(res.zones))
|
||||
.catch((err) => setZonesError(err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProviderId && !managingProviders) loadZones(selectedProviderId);
|
||||
}, [selectedProviderId, managingProviders]);
|
||||
|
||||
function loadRecords(providerId: number, zone: DnsZone) {
|
||||
api.dns.records
|
||||
.list(providerId, zone.id)
|
||||
.then((res) => {
|
||||
setRecords(res.records);
|
||||
setSyncedAt(res.syncedAt);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}
|
||||
|
||||
function selectZone(zone: DnsZone) {
|
||||
setSelectedZone(zone);
|
||||
setRecordForm(null);
|
||||
setEditingRecordId(null);
|
||||
if (selectedProviderId) loadRecords(selectedProviderId, zone);
|
||||
}
|
||||
|
||||
async function syncZone() {
|
||||
if (!selectedProviderId || !selectedZone) return;
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.dns.records.sync(selectedProviderId, selectedZone.id, selectedZone.name);
|
||||
setRecords(res.records);
|
||||
setSyncedAt(res.syncedAt);
|
||||
loadZones(selectedProviderId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startAddRecord() {
|
||||
setEditingRecordId(null);
|
||||
setRecordForm({ ...emptyRecordForm });
|
||||
}
|
||||
|
||||
function startEditRecord(r: DnsRecord) {
|
||||
setEditingRecordId(r.id);
|
||||
setRecordForm({
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
content: r.content,
|
||||
ttl: r.ttl ?? undefined,
|
||||
priority: r.priority ?? undefined,
|
||||
proxied: r.proxied ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function submitRecord(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedProviderId || !selectedZone || !recordForm) return;
|
||||
setSavingRecord(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (editingRecordId) {
|
||||
await api.dns.records.update(selectedProviderId, selectedZone.id, editingRecordId, recordForm);
|
||||
} else {
|
||||
await api.dns.records.create(selectedProviderId, selectedZone.id, recordForm);
|
||||
}
|
||||
setRecordForm(null);
|
||||
setEditingRecordId(null);
|
||||
loadRecords(selectedProviderId, selectedZone);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSavingRecord(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRecord(r: DnsRecord) {
|
||||
if (!selectedProviderId || !selectedZone) return;
|
||||
if (!confirm(`Delete ${r.type} record "${r.name}"?`)) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id);
|
||||
loadRecords(selectedProviderId, selectedZone);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleProviderEnabled(p: DnsProviderSummary) {
|
||||
try {
|
||||
await api.dns.providers.update(p.id, { enabled: !p.enabled });
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProvider(p: DnsProviderSummary) {
|
||||
if (!confirm(`Delete provider "${p.name}"? This removes its cached zones and records too.`)) return;
|
||||
try {
|
||||
await api.dns.providers.remove(p.id);
|
||||
if (selectedProviderId === p.id) setSelectedProviderId(null);
|
||||
loadProviders();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProvider = providers?.find((p) => p.id === selectedProviderId) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<h2 className="page-title mb-0">DNS</h2>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="btn btn-outline-secondary ms-auto"
|
||||
onClick={() => setManagingProviders((v) => !v)}
|
||||
>
|
||||
{managingProviders ? "Back to browsing" : "Manage providers"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{managingProviders ? (
|
||||
<>
|
||||
<DnsProviderForm
|
||||
onCreated={() => {
|
||||
loadProviders();
|
||||
}}
|
||||
onCancel={() => setManagingProviders(false)}
|
||||
/>
|
||||
<div className="card">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Provider</th>
|
||||
<th>Status</th>
|
||||
<th className="w-1">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{providers?.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.name}</td>
|
||||
<td>{PROVIDER_LABELS[p.providerType]}</td>
|
||||
<td>
|
||||
<button
|
||||
className={`badge ${p.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
|
||||
onClick={() => toggleProviderEnabled(p)}
|
||||
>
|
||||
{p.enabled ? "Enabled" : "Disabled"}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => removeProvider(p)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{providers?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-secondary text-center">
|
||||
No DNS providers configured yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{providers?.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
No DNS providers configured yet.
|
||||
{isAdmin && (
|
||||
<>
|
||||
{" "}
|
||||
<button className="btn btn-link p-0 align-baseline" onClick={() => setManagingProviders(true)}>
|
||||
Add one
|
||||
</button>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3" style={{ maxWidth: 320 }}>
|
||||
<select
|
||||
className="form-select"
|
||||
value={selectedProviderId ?? ""}
|
||||
onChange={(e) => setSelectedProviderId(Number(e.target.value))}
|
||||
>
|
||||
{providers?.map((p) => (
|
||||
<option key={p.id} value={p.id} disabled={!p.enabled}>
|
||||
{p.name} ({PROVIDER_LABELS[p.providerType]}){!p.enabled ? " — disabled" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-md-5">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Zones</h3>
|
||||
</div>
|
||||
{zonesError && <div className="alert alert-danger m-3 mb-0">{zonesError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone</th>
|
||||
<th>Records</th>
|
||||
<th>Synced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zones?.map((z) => (
|
||||
<tr
|
||||
key={z.id}
|
||||
role="button"
|
||||
className={selectedZone?.id === z.id ? "table-active" : ""}
|
||||
onClick={() => selectZone(z)}
|
||||
>
|
||||
<td>{z.name}</td>
|
||||
<td>{z.recordCount}</td>
|
||||
<td className="text-secondary">
|
||||
{z.syncedAt ? new Date(z.syncedAt).toLocaleString() : "never"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{zones && zones.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-secondary text-center">
|
||||
No zones found for this provider.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-7">
|
||||
{selectedZone ? (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{selectedZone.name}</h3>
|
||||
<div className="card-actions">
|
||||
{canEdit && (
|
||||
<button className="btn btn-sm btn-outline-secondary me-2" onClick={syncZone} disabled={syncing}>
|
||||
{syncing ? "Syncing…" : "Sync now"}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && !recordForm && (
|
||||
<button className="btn btn-sm btn-primary" onClick={startAddRecord}>
|
||||
Add record
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recordForm && (
|
||||
<form onSubmit={submitRecord}>
|
||||
<div className="card-body row g-2">
|
||||
<div className="col-2">
|
||||
<label className="form-label">Type</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
list="dns-record-types"
|
||||
value={recordForm.type}
|
||||
onChange={(e) => setRecordForm({ ...recordForm, type: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
<datalist id="dns-record-types">
|
||||
{["A", "AAAA", "CNAME", "MX", "TXT", "NS", "PTR"].map((t) => (
|
||||
<option key={t} value={t} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="col-3">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
value={recordForm.name}
|
||||
onChange={(e) => setRecordForm({ ...recordForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-3">
|
||||
<label className="form-label">Content</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
value={recordForm.content}
|
||||
onChange={(e) => setRecordForm({ ...recordForm, content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-2">
|
||||
<label className="form-label">TTL</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
value={recordForm.ttl ?? ""}
|
||||
onChange={(e) => setRecordForm({ ...recordForm, ttl: Number(e.target.value) || undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-2">
|
||||
<label className="form-label">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
value={recordForm.priority ?? ""}
|
||||
onChange={(e) =>
|
||||
setRecordForm({ ...recordForm, priority: e.target.value === "" ? undefined : Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{selectedProvider?.providerType === "cloudflare" && (
|
||||
<div className="col-12">
|
||||
<label className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={Boolean(recordForm.proxied)}
|
||||
onChange={(e) => setRecordForm({ ...recordForm, proxied: e.target.checked })}
|
||||
/>
|
||||
<span className="form-check-label">Proxied through Cloudflare</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={savingRecord}>
|
||||
{editingRecordId ? "Save changes" : "Add record"}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => setRecordForm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Content</th>
|
||||
<th>TTL</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records?.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<span className="badge bg-blue-lt">{r.type}</span>
|
||||
</td>
|
||||
<td>{r.name}</td>
|
||||
<td className="text-secondary">{r.content}</td>
|
||||
<td>{r.ttl ?? "—"}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => startEditRecord(r)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => removeRecord(r)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{records?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
|
||||
No cached records.{" "}
|
||||
{syncedAt === null ? "Sync this zone to fetch them." : ""}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">Select a zone to view its records.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/layout/appshell.tsx","./src/pages/auditlog.tsx","./src/pages/comingsoon.tsx","./src/pages/dashboard.tsx","./src/pages/ipam.tsx","./src/pages/login.tsx","./src/pages/secrets.tsx","./src/pages/users.tsx","./src/utils/csv.ts"],"version":"5.9.3"}
|
||||
Reference in new issue
Block a user