feat: production-ready domain control dashboard
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
9c60eaf3fe
commit
df7516e7ab
45 files changed
+7932
-1
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,216 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
)
|
||||
|
||||
func TestInputNormalizers(t *testing.T) {
|
||||
t.Parallel()
|
||||
username, err := normalizeUsername("Alice_Admin")
|
||||
if err != nil || username != "alice_admin" {
|
||||
t.Fatalf("username normalization failed: %q, %v", username, err)
|
||||
}
|
||||
if _, err := normalizeUsername("not allowed!"); err == nil {
|
||||
t.Fatal("invalid username accepted")
|
||||
}
|
||||
zone, err := normalizeZone("BÜCHER.example.")
|
||||
if err != nil || zone != "xn--bcher-kva.example" {
|
||||
t.Fatalf("zone normalization failed: %q, %v", zone, err)
|
||||
}
|
||||
record, fqdn, err := normalizeRecord("home", "example.de")
|
||||
if err != nil || record != "home" || fqdn != "home.example.de" {
|
||||
t.Fatalf("record normalization failed: %q %q %v", record, fqdn, err)
|
||||
}
|
||||
destination, err := normalizeCNAMEDestination("BÜCHER.example.")
|
||||
if err != nil || destination != "xn--bcher-kva.example." {
|
||||
t.Fatalf("CNAME destination normalization failed: %q %v", destination, err)
|
||||
}
|
||||
if _, err := normalizeCNAMEDestination("198.51.100.20"); err == nil {
|
||||
t.Fatal("IP address accepted as CNAME destination")
|
||||
}
|
||||
if ttl, err := parseTTL("300"); err != nil || ttl != 300 {
|
||||
t.Fatalf("TTL parsing failed: %d %v", ttl, err)
|
||||
}
|
||||
if _, err := parseTTL("120"); err == nil {
|
||||
t.Fatal("unsupported TTL accepted")
|
||||
}
|
||||
if ttl, err := parseEditableTTL("120", 120); err != nil || ttl != 120 {
|
||||
t.Fatalf("existing unsupported TTL could not be preserved: %d %v", ttl, err)
|
||||
}
|
||||
staticIPv4, err := normalizeStaticIP("203.0.113.10", 4)
|
||||
if err != nil || staticIPv4 != "203.0.113.10" {
|
||||
t.Fatalf("static IPv4 normalization failed: %q %v", staticIPv4, err)
|
||||
}
|
||||
if _, err := normalizeStaticIP("203.0.113.10", 6); err == nil {
|
||||
t.Fatal("IPv4 accepted as static IPv6")
|
||||
}
|
||||
missingStaticIP := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4="))
|
||||
missingStaticIP.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if _, _, err := parseStaticTargetIPs(missingStaticIP, true, false); err == nil {
|
||||
t.Fatal("inactive DynDNS target accepted enabled IPv4 without a static address")
|
||||
}
|
||||
staticAddresses := httptest.NewRequest(http.MethodPost, "/targets", strings.NewReader("static_ipv4=203.0.113.20&static_ipv6=2001%3Adb8%3A%3A20"))
|
||||
staticAddresses.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
ipv4, ipv6, err := parseStaticTargetIPs(staticAddresses, true, true)
|
||||
if err != nil || ipv4 != "203.0.113.20" || ipv6 != "2001:db8::20" {
|
||||
t.Fatalf("valid static addresses rejected: ipv4=%q ipv6=%q err=%v", ipv4, ipv6, err)
|
||||
}
|
||||
if targetDynDNSActive(true, false, false) {
|
||||
t.Fatal("DynDNS remained active without an address family")
|
||||
}
|
||||
if !targetDynDNSActive(true, true, false) {
|
||||
t.Fatal("IPv4-only DynDNS was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnixSecondsUsesZeroForUnscheduledRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := unixSeconds(time.Time{}); got != 0 {
|
||||
t.Fatalf("zero time must be exposed as unix 0, got %d", got)
|
||||
}
|
||||
value := time.Date(2026, time.July, 11, 12, 30, 0, 0, time.UTC)
|
||||
if got := unixSeconds(value); got != value.Unix() {
|
||||
t.Fatalf("unexpected unix time: got %d, want %d", got, value.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoticeUsesValidatedKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := httptest.NewRequest(http.MethodGet, "/login?notice=Passwort+nicht+korrekt.¬ice_kind=error", nil)
|
||||
got := notice(request)
|
||||
if got == nil || got.Message != "Passwort nicht korrekt." || got.Kind != "error" {
|
||||
t.Fatalf("unexpected notice: %#v", got)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/login?notice=Hinweis¬ice_kind=not-a-valid-kind", nil)
|
||||
got = notice(request)
|
||||
if got == nil || got.Kind != "info" {
|
||||
t.Fatalf("invalid kind must fall back to info: %#v", got)
|
||||
}
|
||||
|
||||
if got := notice(httptest.NewRequest(http.MethodGet, "/login", nil)); got != nil {
|
||||
t.Fatalf("empty notice must not render a toast: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedirectNoticePreservesQueryAndAddsKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/settings", nil)
|
||||
server := &Server{}
|
||||
server.redirectNotice(response, request, "/domains?view=active", "Gespeichert.")
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unexpected status: got %d, want %d", response.Code, http.StatusSeeOther)
|
||||
}
|
||||
location, err := response.Result().Location()
|
||||
if err != nil {
|
||||
t.Fatalf("parse redirect location: %v", err)
|
||||
}
|
||||
if got := location.Query().Get("view"); got != "active" {
|
||||
t.Fatalf("existing query lost: %q", got)
|
||||
}
|
||||
if got := location.Query().Get("notice"); got != "Gespeichert." {
|
||||
t.Fatalf("unexpected notice: %q", got)
|
||||
}
|
||||
if got := location.Query().Get("notice_kind"); got != "success" {
|
||||
t.Fatalf("unexpected notice kind: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginErrorRendersAsToast(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "Eingabe prüfen",
|
||||
Page: "login",
|
||||
Flash: newFlash("Passwort nicht korrekt.", "error"),
|
||||
}
|
||||
if err := templates["login"].ExecuteTemplate(&rendered, "login", data); err != nil {
|
||||
t.Fatalf("render login: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{`class="toast toast-error"`, `data-toast`, `role="alert"`, `Passwort nicht korrekt.`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("rendered login is missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `class="flash"`) {
|
||||
t.Error("legacy in-flow flash message is still rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainDetailPlacesStatusAfterTimeAndRendersTTLAndCNAMEControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "example.de",
|
||||
Page: "domains",
|
||||
CSRF: "csrf",
|
||||
IsAdmin: true,
|
||||
Zone: model.Zone{ID: 1, Name: "example.de"},
|
||||
Settings: model.Settings{IPv6Enabled: true},
|
||||
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, Active: true, TTL: 300, LastStatus: "A unverändert", LastUpdatedAt: time.Date(2026, time.July, 11, 10, 26, 0, 0, time.UTC)}},
|
||||
CNAMERecords: []model.CNAMERecord{{ID: 3, Active: true, FQDN: "www.example.de", Destination: "home.example.de.", TTL: 600}},
|
||||
}
|
||||
if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil {
|
||||
t.Fatalf("render domain detail: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
statusAt := strings.Index(body, `A unverändert`)
|
||||
timeAt := strings.Index(body, `class="target-meta"><time>`)
|
||||
if timeAt < 0 || statusAt < timeAt {
|
||||
t.Fatalf("target time must be rendered before the status pill: %s", body)
|
||||
}
|
||||
for _, expected := range []string{`name="ttl"`, `value="300"`, `action="/cnames"`, `action="/domains/1/ttl"`, `action="/domains/1/delete"`, `action="/cnames/3/remove"`, `action="/cnames/3/delete"`, `data-static-ip-fields`, `data-static-ipv4`, `data-target-requirement`, `data-ipv6-enabled="true"`, `TTL für alle setzen`, `DynDNS aktivieren`, `Ziel vollständig löschen`, `Domain vollständig löschen`, `CNAME aktiv`, `Aus WebUI entfernen`, `cname-edit-grid`, `CNAME-Aliase`, `home.example.de.`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("domain detail is missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainDetailDisablesIPv6ControlsWhenGloballyDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
templates, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("load templates: %v", err)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
data := viewData{
|
||||
Title: "example.de",
|
||||
Page: "domains",
|
||||
CSRF: "csrf",
|
||||
IsAdmin: true,
|
||||
Zone: model.Zone{ID: 1, Name: "example.de"},
|
||||
Settings: model.Settings{IPv6Enabled: false},
|
||||
Targets: []model.Target{{ID: 2, FQDN: "home.example.de", UpdateIPv4: true, UpdateIPv6: true, TTL: 60}},
|
||||
}
|
||||
if err := templates["domain_detail"].ExecuteTemplate(&rendered, "domain_detail", data); err != nil {
|
||||
t.Fatalf("render domain detail: %v", err)
|
||||
}
|
||||
body := rendered.String()
|
||||
for _, expected := range []string{`data-ipv6-enabled="false"`, `IPv6 / AAAA pausiert`, `data-static-ipv6`} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Errorf("globally-disabled IPv6 UI is missing %q", expected)
|
||||
}
|
||||
}
|
||||
inputAt := strings.Index(body, `name="update_ipv6"`)
|
||||
if inputAt < 0 || !strings.Contains(body[inputAt:inputAt+min(100, len(body)-inputAt)], "disabled") {
|
||||
t.Error("globally-disabled IPv6 checkbox remained enabled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
(() => {
|
||||
const toast = document.querySelector('[data-toast]');
|
||||
if (toast) {
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const region = toast.closest('.toast-region');
|
||||
const duration = toast.classList.contains('toast-error') ? 6500 : 4800;
|
||||
let remaining = duration;
|
||||
let startedAt = 0;
|
||||
let dismissTimer;
|
||||
let timerRunning = false;
|
||||
|
||||
const dismiss = () => {
|
||||
if (!toast.isConnected || toast.classList.contains('is-leaving')) return;
|
||||
window.clearTimeout(dismissTimer);
|
||||
timerRunning = false;
|
||||
toast.classList.add('is-leaving');
|
||||
if (reducedMotion) {
|
||||
region?.remove();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => region?.remove(), 220);
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (timerRunning || toast.classList.contains('is-leaving')) return;
|
||||
toast.classList.remove('is-paused');
|
||||
startedAt = performance.now();
|
||||
timerRunning = true;
|
||||
dismissTimer = window.setTimeout(dismiss, remaining);
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (!timerRunning || toast.classList.contains('is-leaving')) return;
|
||||
remaining = Math.max(0, remaining - (performance.now() - startedAt));
|
||||
window.clearTimeout(dismissTimer);
|
||||
timerRunning = false;
|
||||
toast.classList.add('is-paused');
|
||||
};
|
||||
|
||||
toast.querySelector('[data-toast-close]')?.addEventListener('click', dismiss);
|
||||
toast.addEventListener('pointerenter', pause);
|
||||
toast.addEventListener('pointerleave', () => {
|
||||
if (!toast.contains(document.activeElement)) resume();
|
||||
});
|
||||
toast.addEventListener('focusin', pause);
|
||||
toast.addEventListener('focusout', () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!toast.matches(':hover') && !toast.contains(document.activeElement)) resume();
|
||||
});
|
||||
});
|
||||
resume();
|
||||
}
|
||||
|
||||
const currentURL = new URL(window.location.href);
|
||||
if (currentURL.searchParams.has('notice')) {
|
||||
currentURL.searchParams.delete('notice');
|
||||
currentURL.searchParams.delete('notice_kind');
|
||||
const cleanLocation = `${currentURL.pathname}${currentURL.search}${currentURL.hash}`;
|
||||
window.history.replaceState(window.history.state, '', cleanLocation);
|
||||
}
|
||||
|
||||
const settingsForm = document.querySelector('[data-settings-form]');
|
||||
if (settingsForm) {
|
||||
const updaterToggle = settingsForm.querySelector('[data-updater-toggle]');
|
||||
const ipv6Toggle = settingsForm.querySelector('[data-ipv6-toggle]');
|
||||
const ipv6Settings = settingsForm.querySelector('[data-ipv6-settings]');
|
||||
|
||||
const syncToggleCard = (input) => {
|
||||
if (!input) return;
|
||||
const card = input.closest('.setting-switch-card');
|
||||
card?.classList.toggle('is-enabled', input.checked);
|
||||
const label = input.closest('.setting-switch')?.querySelector('b');
|
||||
if (label) label.textContent = input.checked ? 'Aktiv' : 'Inaktiv';
|
||||
};
|
||||
|
||||
const syncIPv6Settings = () => {
|
||||
if (!ipv6Toggle || !ipv6Settings) return;
|
||||
ipv6Settings.classList.toggle('is-disabled', !ipv6Toggle.checked);
|
||||
ipv6Settings.setAttribute('aria-disabled', String(!ipv6Toggle.checked));
|
||||
const textarea = ipv6Settings.querySelector('textarea');
|
||||
if (textarea) textarea.required = ipv6Toggle.checked;
|
||||
};
|
||||
|
||||
updaterToggle?.addEventListener('change', () => syncToggleCard(updaterToggle));
|
||||
ipv6Toggle?.addEventListener('change', () => {
|
||||
syncToggleCard(ipv6Toggle);
|
||||
syncIPv6Settings();
|
||||
});
|
||||
syncToggleCard(updaterToggle);
|
||||
syncToggleCard(ipv6Toggle);
|
||||
syncIPv6Settings();
|
||||
}
|
||||
|
||||
for (const form of document.querySelectorAll('[data-target-form]')) {
|
||||
const active = form.querySelector('[data-target-active]');
|
||||
const staticFields = form.querySelector('[data-static-ip-fields]');
|
||||
const ipv4Toggle = form.querySelector('[name="update_ipv4"]');
|
||||
const ipv6Toggle = form.querySelector('[name="update_ipv6"]');
|
||||
const staticIPv4 = form.querySelector('[data-static-ipv4]');
|
||||
const staticIPv6 = form.querySelector('[data-static-ipv6]');
|
||||
const requirement = form.querySelector('[data-target-requirement]');
|
||||
const submit = form.querySelector('button[type="submit"]');
|
||||
const ipv6Enabled = form.dataset.ipv6Enabled !== 'false';
|
||||
const ipv4Selected = () => ipv4Toggle ? ipv4Toggle.checked : form.dataset.updateIpv4 === 'true';
|
||||
const ipv6Selected = () => ipv6Enabled && (ipv6Toggle ? ipv6Toggle.checked : form.dataset.updateIpv6 === 'true');
|
||||
|
||||
const syncStaticFields = () => {
|
||||
if (!active || !staticFields) return;
|
||||
const useIPv4 = ipv4Selected();
|
||||
const useIPv6 = ipv6Selected();
|
||||
const hasFamily = useIPv4 || useIPv6;
|
||||
if (active.checked && !hasFamily) active.checked = false;
|
||||
|
||||
active.disabled = !hasFamily;
|
||||
active.closest('.switch')?.classList.toggle('switch-disabled', !hasFamily);
|
||||
|
||||
const useStatic = !active.checked && hasFamily;
|
||||
staticFields.hidden = !useStatic;
|
||||
staticFields.setAttribute('aria-hidden', String(!useStatic));
|
||||
if (staticIPv4) {
|
||||
staticIPv4.disabled = !useStatic || !useIPv4;
|
||||
staticIPv4.required = useStatic && useIPv4;
|
||||
}
|
||||
if (staticIPv6) {
|
||||
staticIPv6.disabled = !useStatic || !useIPv6;
|
||||
staticIPv6.required = useStatic && useIPv6;
|
||||
}
|
||||
|
||||
const missingStaticIP = useStatic && (
|
||||
(useIPv4 && !staticIPv4?.value.trim()) ||
|
||||
(useIPv6 && !staticIPv6?.value.trim())
|
||||
);
|
||||
if (submit) submit.disabled = missingStaticIP;
|
||||
if (requirement) requirement.hidden = !missingStaticIP;
|
||||
};
|
||||
|
||||
active?.addEventListener('change', syncStaticFields);
|
||||
for (const familyToggle of [ipv4Toggle, ipv6Toggle]) {
|
||||
familyToggle?.addEventListener('change', () => {
|
||||
if (!familyToggle.checked && active) active.checked = false;
|
||||
syncStaticFields();
|
||||
});
|
||||
}
|
||||
staticIPv4?.addEventListener('input', syncStaticFields);
|
||||
staticIPv6?.addEventListener('input', syncStaticFields);
|
||||
syncStaticFields();
|
||||
}
|
||||
|
||||
for (const zone of document.querySelectorAll('[data-access-zone]')) {
|
||||
const access = zone.querySelector('[data-zone-access]');
|
||||
const manage = zone.querySelector('[data-zone-manage]');
|
||||
const create = zone.querySelector('[data-zone-create]');
|
||||
access?.addEventListener('change', () => {
|
||||
if (!access.checked) {
|
||||
if (manage) manage.checked = false;
|
||||
if (create) create.checked = false;
|
||||
}
|
||||
});
|
||||
manage?.addEventListener('change', () => {
|
||||
if (manage.checked && access) access.checked = true;
|
||||
if (!manage.checked && create) create.checked = false;
|
||||
});
|
||||
create?.addEventListener('change', () => {
|
||||
if (create.checked) {
|
||||
if (access) access.checked = true;
|
||||
if (manage) manage.checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const target of document.querySelectorAll('[data-access-target]')) {
|
||||
const access = target.querySelector('[data-target-access]');
|
||||
const manage = target.querySelector('[data-target-manage]');
|
||||
access?.addEventListener('change', () => {
|
||||
if (!access.checked && manage) manage.checked = false;
|
||||
});
|
||||
manage?.addEventListener('change', () => {
|
||||
if (manage.checked && access) access.checked = true;
|
||||
});
|
||||
}
|
||||
|
||||
const copyButtons = document.querySelectorAll('[data-copy-target]');
|
||||
for (const button of copyButtons) {
|
||||
button.addEventListener('click', async () => {
|
||||
const target = document.getElementById(button.dataset.copyTarget);
|
||||
if (!target) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(target.textContent);
|
||||
const original = button.textContent;
|
||||
button.textContent = 'Kopiert';
|
||||
window.setTimeout(() => { button.textContent = original; }, 2000);
|
||||
} catch {
|
||||
button.textContent = 'Manuell kopieren';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const countdowns = [...document.querySelectorAll('[data-countdown]')];
|
||||
if (countdowns.length === 0) return;
|
||||
|
||||
let nextRun = Number(countdowns[0].dataset.countdown || 0);
|
||||
let running = countdowns[0].dataset.running === 'true';
|
||||
let enabled = countdowns[0].dataset.enabled !== 'false';
|
||||
let lastRefresh = 0;
|
||||
|
||||
const formatRemaining = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const rest = seconds % 60;
|
||||
if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const refreshSchedule = async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastRefresh < 2500) return;
|
||||
lastRefresh = now;
|
||||
try {
|
||||
const response = await fetch('/api/v1/scheduler', {headers: {'Accept': 'application/json'}, cache: 'no-store'});
|
||||
if (!response.ok) return;
|
||||
const state = await response.json();
|
||||
nextRun = Number(state.next_run_unix || 0);
|
||||
running = Boolean(state.running);
|
||||
enabled = state.enabled !== false;
|
||||
} catch {
|
||||
// The visible countdown keeps its last known state when the status request fails.
|
||||
}
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (!enabled) {
|
||||
for (const node of countdowns) {
|
||||
node.textContent = 'Inaktiv';
|
||||
node.setAttribute('aria-label', 'Automatischer Updater ist deaktiviert');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const remaining = Math.max(0, nextRun - now);
|
||||
const label = running ? 'Update läuft' : (nextRun > 0 ? formatRemaining(remaining) : '--:--');
|
||||
for (const node of countdowns) {
|
||||
node.textContent = label;
|
||||
node.setAttribute('aria-label', running ? 'Update läuft' : `Nächste Aktualisierung in ${remaining} Sekunden`);
|
||||
}
|
||||
if (running || nextRun === 0 || remaining === 0) refreshSchedule();
|
||||
};
|
||||
|
||||
tick();
|
||||
refreshSchedule();
|
||||
window.setInterval(tick, 1000);
|
||||
window.setInterval(refreshSchedule, 30000);
|
||||
})();
|
||||
@@ -0,0 +1,396 @@
|
||||
:root {
|
||||
--bg: #f3f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-subtle: #f8fafc;
|
||||
--sidebar: #101827;
|
||||
--sidebar-hover: #1b2739;
|
||||
--text: #182230;
|
||||
--muted: #647386;
|
||||
--line: #dce2e8;
|
||||
--line-strong: #c8d1db;
|
||||
--accent: #2563eb;
|
||||
--accent-dark: #1d4ed8;
|
||||
--accent-soft: #eaf1ff;
|
||||
--success: #0f766e;
|
||||
--success-soft: #e5f7f3;
|
||||
--warning: #9a6700;
|
||||
--warning-soft: #fff6d8;
|
||||
--danger: #b4233c;
|
||||
--danger-soft: #fff0f2;
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 39, .04), 0 8px 24px rgba(16, 24, 39, .035);
|
||||
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; min-height: 100%; background: var(--bg); }
|
||||
body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button, summary { cursor: pointer; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a:hover { color: var(--accent); }
|
||||
h1, h2, h3, p, dl, dd { margin-top: 0; }
|
||||
h1 { margin-bottom: 6px; font-size: clamp(25px, 3vw, 31px); line-height: 1.15; letter-spacing: -.035em; }
|
||||
h2 { margin-bottom: 0; font-size: 16px; line-height: 1.3; letter-spacing: -.015em; }
|
||||
h3 { margin-bottom: 2px; font-size: 14px; line-height: 1.35; }
|
||||
|
||||
.skip-link { position: fixed; top: -50px; left: 12px; z-index: 100; padding: 8px 12px; border-radius: 6px; background: #fff; color: var(--text); box-shadow: var(--shadow); }
|
||||
.skip-link:focus { top: 12px; }
|
||||
|
||||
.admin-layout { display: grid; min-height: 100vh; grid-template-columns: 232px minmax(0, 1fr); }
|
||||
.sidebar { position: sticky; top: 0; display: flex; height: 100vh; flex-direction: column; padding: 18px 14px 14px; background: var(--sidebar); color: #d7deea; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 3px 5px 17px; color: #fff; }
|
||||
.brand:hover { color: #fff; }
|
||||
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid #41506a; border-radius: 8px; background: #192438; color: #8eb3ff; font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { font-size: 13px; line-height: 1.2; }
|
||||
.brand small { margin-top: 2px; color: #7f8ca1; font-family: var(--mono); font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.instance-state { display: flex; align-items: center; gap: 7px; margin: 0 5px 17px; padding: 7px 9px; border: 1px solid #26344a; border-radius: 6px; color: #9eabc0; font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
|
||||
.instance-state span { width: 7px; height: 7px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 3px rgba(52, 211, 153, .12); }
|
||||
.instance-state.is-inactive { border-color: #3a3540; color: #958b98; }
|
||||
.instance-state.is-inactive span { background: #7d8796; box-shadow: 0 0 0 3px rgba(125,135,150,.12); }
|
||||
.sidebar nav { display: grid; gap: 3px; }
|
||||
.sidebar nav a { display: flex; min-height: 38px; align-items: center; gap: 11px; padding: 0 10px; border-radius: 6px; color: #9eabc0; font-size: 12px; font-weight: 650; }
|
||||
.sidebar nav a:hover { background: var(--sidebar-hover); color: #fff; }
|
||||
.sidebar nav a[aria-current="page"] { background: #21304a; color: #fff; box-shadow: inset 2px 0 #6d9fff; }
|
||||
.nav-icon { color: #68768c; font-family: var(--mono); font-size: 9px; }
|
||||
.sidebar nav a[aria-current="page"] .nav-icon { color: #8eb3ff; }
|
||||
.sidebar-account { display: grid; grid-template-columns: 32px minmax(0, 1fr) 28px; gap: 9px; align-items: center; margin-top: auto; padding: 12px 5px 2px; border-top: 1px solid #26344a; }
|
||||
.account-avatar { display: grid; width: 32px; height: 32px; place-items: center; border-radius: 7px; background: #28364b; color: #dce8ff; font-size: 12px; font-weight: 800; }
|
||||
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-account strong { color: #e8edf5; font-size: 11px; }
|
||||
.sidebar-account small { color: #748298; font-size: 9px; }
|
||||
.sidebar-account form { display: contents; }
|
||||
.sidebar-account button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 5px; background: transparent; color: #7e8da3; }
|
||||
.sidebar-account button:hover { background: #26344a; color: #fff; }
|
||||
|
||||
.workspace { min-width: 0; }
|
||||
.workspace-bar { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 clamp(18px, 3vw, 36px); border-bottom: 1px solid var(--line); background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }
|
||||
.workspace-bar div { display: flex; gap: 6px; color: var(--muted); font-family: var(--mono); font-size: 10px; text-transform: uppercase; }
|
||||
.workspace-bar strong { color: var(--text); font-weight: 700; }
|
||||
.environment-badge { padding: 4px 7px; border: 1px solid #efd18a; border-radius: 4px; background: var(--warning-soft); color: #7b5707; font-family: var(--mono); font-size: 9px; font-weight: 700; }
|
||||
.environment-badge.secure { border-color: #9fd5ca; background: var(--success-soft); color: var(--success); }
|
||||
.app-shell { width: min(1280px, calc(100% - clamp(32px, 6vw, 72px))); margin: 0 auto; padding: 30px 0 52px; }
|
||||
.auth-shell { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: linear-gradient(135deg, #eef2f6 0%, #f8fafc 52%, #edf2f7 100%); }
|
||||
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 22px; }
|
||||
.page-heading p { max-width: 720px; margin-bottom: 0; color: var(--muted); font-size: 13px; }
|
||||
.page-kicker, .section-label, .metric-label { display: block; margin-bottom: 5px; color: var(--muted); font-family: var(--mono); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.back-link { display: inline-block; margin-bottom: 12px; color: var(--muted); font-size: 11px; font-weight: 650; }
|
||||
.back-link:hover { color: var(--accent); }
|
||||
.toast-region { position: fixed; z-index: 1000; bottom: max(18px, env(safe-area-inset-bottom)); left: 50%; width: min(460px, calc(100% - 28px)); pointer-events: none; transform: translateX(-50%); }
|
||||
.toast { --toast-tone: #60a5fa; --toast-tone-soft: rgba(96, 165, 250, .14); position: relative; display: grid; grid-template-columns: 34px minmax(0, 1fr) 30px; gap: 11px; align-items: center; min-height: 62px; overflow: hidden; padding: 11px 10px 11px 12px; border: 1px solid rgba(255,255,255,.13); border-radius: 14px; background: rgba(16, 24, 39, .96); color: #f8fafc; box-shadow: 0 20px 45px rgba(15, 23, 42, .24), 0 4px 12px rgba(15, 23, 42, .14); backdrop-filter: blur(16px); pointer-events: auto; animation: toast-in .42s cubic-bezier(.21, 1, .36, 1) both; }
|
||||
.toast::before { position: absolute; inset: 0 0 auto; height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,.28), transparent); content: ""; }
|
||||
.toast-success { --toast-tone: #34d399; --toast-tone-soft: rgba(52, 211, 153, .14); }
|
||||
.toast-error { --toast-tone: #fb7185; --toast-tone-soft: rgba(251, 113, 133, .15); }
|
||||
.toast-icon { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid color-mix(in srgb, var(--toast-tone) 35%, transparent); border-radius: 10px; background: var(--toast-tone-soft); color: var(--toast-tone); font-family: var(--mono); font-size: 13px; font-weight: 900; }
|
||||
.toast-copy { min-width: 0; }
|
||||
.toast-eyebrow { display: block; margin-bottom: 2px; color: var(--toast-tone); font-family: var(--mono); font-size: 8px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.toast-copy p { margin: 0; color: #eef2f7; font-size: 12px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.toast-close { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: #8e9caf; font-size: 19px; line-height: 1; transition: background .14s, color .14s; }
|
||||
.toast-close:hover { background: rgba(255,255,255,.08); color: #fff; }
|
||||
.toast-close:focus-visible { outline: 3px solid rgba(96, 165, 250, .32); outline-offset: 1px; }
|
||||
.toast-progress { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: var(--toast-tone); transform-origin: left; animation: toast-progress 4.8s linear both; }
|
||||
.toast-error .toast-progress { animation-duration: 6.5s; }
|
||||
.toast.is-paused .toast-progress { animation-play-state: paused; }
|
||||
.toast.is-leaving { animation: toast-out .2s ease-in both; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(18px) scale(.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes toast-out { to { opacity: 0; transform: translateY(10px) scale(.98); } }
|
||||
@keyframes toast-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
|
||||
.button { display: inline-flex; min-height: 38px; align-items: center; justify-content: center; gap: 7px; padding: 0 13px; border: 1px solid transparent; border-radius: 6px; background: var(--surface-subtle); color: var(--text); font-size: 12px; font-weight: 700; line-height: 1; transition: border-color .14s, background .14s, box-shadow .14s; }
|
||||
.button:hover { color: var(--text); box-shadow: 0 2px 6px rgba(16,24,39,.08); }
|
||||
.button:disabled { border-color: var(--line); background: #e8edf2; color: #8995a3; box-shadow: none; cursor: not-allowed; }
|
||||
.button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid rgba(37, 99, 235, .22); outline-offset: 2px; }
|
||||
.button-primary { border-color: var(--accent); background: var(--accent); color: #fff; }
|
||||
.button-primary:hover { border-color: var(--accent-dark); background: var(--accent-dark); color: #fff; }
|
||||
.button-quiet { border-color: var(--line-strong); background: #fff; }
|
||||
.button-danger, .button-danger-soft { border-color: #efb8c2; background: var(--danger-soft); color: var(--danger); }
|
||||
.button-danger:hover, .button-danger-soft:hover { border-color: #dc8c9b; background: #ffe4e8; color: #961b31; }
|
||||
.text-danger { color: var(--danger); font-size: 12px; font-weight: 700; }
|
||||
|
||||
.metric-grid { display: grid; grid-template-columns: .72fr 1.15fr 1.15fr .9fr; gap: 10px; margin-bottom: 12px; }
|
||||
.metric-card { display: flex; min-height: 122px; flex-direction: column; padding: 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.metric-card strong { margin-top: auto; overflow: hidden; color: var(--text); font-size: 26px; line-height: 1.15; letter-spacing: -.04em; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metric-card small { margin-top: 5px; color: var(--muted); font-size: 10px; }
|
||||
.metric-card .address-value { font-size: clamp(12px, 1.45vw, 18px); letter-spacing: -.035em; }
|
||||
.metric-card .address-value.compact { font-size: clamp(10px, 1.12vw, 14px); }
|
||||
.metric-card-disabled { border-color: #d7dde4; background: #f1f3f6; box-shadow: none; }
|
||||
.metric-card-disabled strong { color: #788596; }
|
||||
.countdown-card { border-top: 3px solid var(--accent); padding-top: 13px; }
|
||||
.countdown-card.metric-card-disabled { border-top-color: #9aa5b2; }
|
||||
.countdown { font-size: 28px !important; font-variant-numeric: tabular-nums; }
|
||||
.mono { font-family: var(--mono); }
|
||||
|
||||
.panel, .form-panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.panel { padding: 17px; }
|
||||
.form-panel { padding: 22px; }
|
||||
.compact-panel { max-width: 930px; }
|
||||
.panel-heading, .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.panel-heading a { color: var(--accent); font-size: 11px; font-weight: 700; }
|
||||
.panel-links, .heading-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
|
||||
.panel-links { justify-content: flex-end; }
|
||||
.panel-description { max-width: 760px; margin: -4px 0 15px; color: var(--muted); font-size: 12px; }
|
||||
.panel-divider { height: 1px; margin: 18px 0; background: var(--line); }
|
||||
.dashboard-grid, .split-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 12px; margin-bottom: 12px; }
|
||||
.split-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.data-list { display: grid; }
|
||||
.data-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
|
||||
.data-row:first-child { border-top: 0; }
|
||||
.data-row strong, .data-row small { display: block; }
|
||||
.data-row strong { font-size: 12px; font-weight: 650; }
|
||||
.data-row small { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: #a8b2bf; }
|
||||
.state-dot.status-good { background: var(--success); }
|
||||
.state-dot.status-warn { background: var(--warning); }
|
||||
.state-dot.status-bad { background: var(--danger); }
|
||||
.status-pill { display: inline-flex; width: fit-content; min-height: 22px; align-items: center; padding: 0 7px; border-radius: 99px; background: #eef1f4; color: #556476; font-size: 9px; font-weight: 800; white-space: nowrap; }
|
||||
.status-good { color: var(--success) !important; }
|
||||
.status-warn { color: var(--warning) !important; }
|
||||
.status-bad { color: var(--danger) !important; }
|
||||
.status-pill.status-good { background: var(--success-soft); }
|
||||
.status-pill.status-warn { background: var(--warning-soft); }
|
||||
.status-pill.status-bad { background: var(--danger-soft); }
|
||||
|
||||
.domain-list { display: grid; }
|
||||
.domain-list > a { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 0; border-top: 1px solid #edf0f3; }
|
||||
.domain-list > a:first-child { border-top: 0; }
|
||||
.domain-list strong, .domain-list small { display: block; }
|
||||
.domain-list strong { font-size: 12px; }
|
||||
.domain-list small { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.zone-glyph, .empty-icon { display: grid; width: 34px; height: 28px; place-items: center; border: 1px solid #bfd0ea; border-radius: 5px; background: var(--accent-soft); color: #315eaa; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.audit-panel { margin-top: 12px; }
|
||||
.audit-list { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.audit-list li { display: grid; grid-template-columns: 140px minmax(0,1fr) auto; gap: 12px; align-items: center; padding: 9px 0; border-top: 1px solid #edf0f3; }
|
||||
.audit-list li:first-child { border-top: 0; }
|
||||
.audit-list time, .audit-list code { color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
||||
.audit-list strong { font-size: 11px; font-weight: 600; }
|
||||
|
||||
.domain-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.zone-card { display: flex; min-height: 220px; flex-direction: column; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.zone-card:hover { border-color: #b6c4d4; }
|
||||
.zone-card-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.zone-card h2 { margin: 26px 0 12px; overflow-wrap: anywhere; font-size: 17px; }
|
||||
.zone-card dl { display: grid; gap: 5px; margin-bottom: 14px; }
|
||||
.zone-card dl div { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.zone-card dt, .zone-card dd { color: var(--muted); font-size: 10px; }
|
||||
.zone-card dd { color: var(--text); text-align: right; }
|
||||
.inline-error { margin-bottom: 12px; color: var(--danger); font-size: 10px; }
|
||||
.card-link { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 10px; border-top: 1px solid var(--line); color: var(--accent); font-size: 11px; font-weight: 750; }
|
||||
.empty-board { grid-column: 1 / -1; padding: 54px 20px; border: 1px dashed var(--line-strong); border-radius: var(--radius); background: rgba(255,255,255,.55); text-align: center; }
|
||||
.empty-board .empty-icon { width: 46px; height: 38px; margin: 0 auto 13px; }
|
||||
.empty-board h2 { margin-bottom: 5px; }
|
||||
.empty-board p { max-width: 560px; margin: 0 auto 16px; color: var(--muted); font-size: 12px; }
|
||||
.empty-state { padding: 32px 12px; color: var(--muted); font-size: 11px; text-align: center; }
|
||||
.empty-state a { color: var(--accent); font-weight: 700; }
|
||||
|
||||
.zone-summary { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); margin-bottom: 22px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.zone-summary div { padding: 12px 14px; border-left: 1px solid var(--line); }
|
||||
.zone-summary div:first-child { border-left: 0; }
|
||||
.zone-summary span, .zone-summary strong { display: block; }
|
||||
.zone-summary span { color: var(--muted); font-family: var(--mono); font-size: 8px; text-transform: uppercase; }
|
||||
.zone-summary strong { margin-top: 4px; font-size: 11px; }
|
||||
.bulk-ttl-panel { margin: -10px 0 18px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-subtle); }
|
||||
.bulk-ttl-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(150px, 190px) auto; gap: 12px; align-items: end; }
|
||||
.bulk-ttl-form > div { display: grid; gap: 3px; }
|
||||
.bulk-ttl-form strong { font-size: 11px; }
|
||||
.bulk-ttl-form small { color: var(--muted); font-size: 9px; }
|
||||
.targets-section { margin-bottom: 18px; }
|
||||
.record-count { color: var(--muted); font-family: var(--mono); font-size: 10px; }
|
||||
.target-list { display: grid; gap: 7px; }
|
||||
.target-row { display: grid; grid-template-columns: minmax(0,1fr) auto; overflow: hidden; border: 1px solid var(--line); border-left: 3px solid #b9c3ce; border-radius: var(--radius-sm); background: var(--surface); }
|
||||
.target-row.is-active { border-left-color: var(--success); }
|
||||
.target-main { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 12px 14px; }
|
||||
.target-main > div { min-width: 0; }
|
||||
.target-status { width: 8px; height: 8px; flex: none; border-radius: 50%; background: #aeb8c3; }
|
||||
.target-status.on { background: var(--success); box-shadow: 0 0 0 3px rgba(15,118,110,.1); }
|
||||
.target-main h3 { overflow-wrap: anywhere; }
|
||||
.target-main p { display: flex; gap: 7px; align-items: center; overflow: hidden; margin: 0; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.family-chip { display: inline-flex; flex: none; padding: 2px 5px; border-radius: 4px; background: #e9eef4; color: #45576b; font-family: var(--mono); font-size: 8px; font-weight: 800; }
|
||||
.target-meta { display: flex; min-width: 210px; align-items: center; justify-content: flex-end; gap: 10px; padding: 12px 14px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
||||
.target-edit { grid-column: 1 / -1; border-top: 1px solid var(--line); }
|
||||
.target-edit > summary { display: flex; align-items: center; justify-content: space-between; padding: 7px 14px; color: #52647a; font-size: 10px; font-weight: 700; list-style: none; }
|
||||
.target-edit > summary::-webkit-details-marker, details > summary::-webkit-details-marker { display: none; }
|
||||
.target-edit[open] > summary { background: #f8fafc; }
|
||||
.edit-inner { display: grid; grid-template-columns: minmax(0,1fr) 260px; gap: 14px; padding: 14px; border-top: 1px solid var(--line); background: var(--surface-subtle); }
|
||||
.target-form, .unlink-form { display: grid; gap: 11px; }
|
||||
.danger-actions { display: grid; align-content: start; gap: 11px; }
|
||||
.unlink-form { align-content: start; padding: 12px; border: 1px solid #efc6cd; border-radius: var(--radius-sm); background: #fffafb; }
|
||||
.unlink-form strong { color: var(--danger); font-size: 11px; }
|
||||
.unlink-form p { margin-bottom: 0; color: #865764; font-size: 10px; }
|
||||
.target-requirement { margin: 0; padding: 8px 10px; border-left: 3px solid var(--danger); background: #fff3f5; color: #8f2f42; font-size: 10px; }
|
||||
.target-requirement[hidden] { display: none; }
|
||||
.row-error { grid-column: 1 / -1; margin: 0; padding: 8px 14px; border-top: 1px solid #f3d5da; background: #fff8f9; color: var(--danger); font-size: 10px; }
|
||||
.archive-panel { margin-bottom: 18px; border-left: 3px solid #8a98a9; }
|
||||
.archive-list { display: grid; }
|
||||
.archive-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-top: 1px solid #edf0f3; }
|
||||
.archive-row:first-child { border-top: 0; }
|
||||
.archive-row strong, .archive-row small { display: block; }
|
||||
.archive-row strong { font-size: 11px; }
|
||||
.archive-row small { margin-top: 2px; color: var(--muted); font-family: var(--mono); font-size: 9px; }
|
||||
.admin-actions-grid { margin-top: 18px; }
|
||||
|
||||
.stacked-form { display: grid; gap: 13px; }
|
||||
.wide-form { width: 100%; max-width: 850px; }
|
||||
.settings-form { display: grid; gap: 12px; }
|
||||
.settings-mode-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; max-width: 930px; }
|
||||
.setting-switch-card { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 18px; align-items: center; min-height: 116px; padding: 17px; border: 1px solid var(--line); border-left: 3px solid #aab4c0; border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); cursor: pointer; transition: border-color .18s, background .18s, transform .18s; }
|
||||
.setting-switch-card:hover { border-color: #bcc7d3; transform: translateY(-1px); }
|
||||
.setting-switch-card.is-enabled { border-left-color: var(--success); background: linear-gradient(110deg, #fff 65%, #f0faf7); }
|
||||
.setting-switch-copy, .setting-switch-copy strong, .setting-switch-copy small { display: block; }
|
||||
.setting-switch-copy strong { margin-bottom: 4px; color: var(--text); font-size: 15px; }
|
||||
.setting-switch-copy small { max-width: 430px; color: var(--muted); font-size: 10px; font-weight: 500; line-height: 1.45; }
|
||||
.setting-switch { flex-direction: column; gap: 5px; }
|
||||
.setting-switch b { min-width: 48px; color: var(--muted); font-family: var(--mono); font-size: 8px; text-align: center; text-transform: uppercase; }
|
||||
.setting-switch-card.is-enabled .setting-switch b { color: var(--success); }
|
||||
.settings-fields { max-width: 930px; }
|
||||
.endpoint-setting { transition: opacity .18s; }
|
||||
.endpoint-setting.is-disabled { opacity: .48; }
|
||||
.endpoint-setting.is-disabled textarea { background: #f0f2f5; }
|
||||
label { display: grid; gap: 5px; color: #37475a; font-size: 11px; font-weight: 700; }
|
||||
label small { color: var(--muted); font-size: 9px; font-weight: 500; line-height: 1.45; }
|
||||
input, textarea, select { width: 100%; min-height: 39px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 6px; outline: none; background: #fff; color: var(--text); font-size: 12px; transition: border-color .14s, box-shadow .14s; }
|
||||
textarea { resize: vertical; font-family: var(--mono); font-size: 10px; line-height: 1.55; }
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
|
||||
.cname-edit-grid { align-items: start; }
|
||||
.field-note { display: block; margin-top: -6px; color: var(--muted); font-size: 9px; line-height: 1.45; }
|
||||
.form-actions { display: flex; flex-wrap: wrap; gap: 8px; padding-top: 2px; }
|
||||
.toggle-row { display: flex; flex-wrap: wrap; gap: 11px 16px; align-items: center; }
|
||||
.switch { display: inline-flex; width: auto; align-items: center; gap: 7px; color: var(--text); font-size: 10px; font-weight: 700; }
|
||||
.switch input { position: absolute; width: 1px; min-height: 1px; opacity: 0; }
|
||||
.switch > span { position: relative; width: 28px; height: 16px; border-radius: 99px; background: #b9c3cf; transition: background .14s; }
|
||||
.switch > span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 10px; height: 10px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.2); transition: transform .14s; }
|
||||
.switch input:checked + span { background: var(--success); }
|
||||
.switch input:checked + span::after { transform: translateX(12px); }
|
||||
.switch input:focus-visible + span { outline: 3px solid rgba(37,99,235,.2); outline-offset: 2px; }
|
||||
.switch-disabled { color: var(--muted); opacity: .65; }
|
||||
.action-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: 12px; }
|
||||
.info-box { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 11px 12px; border: 1px solid #bfd1e8; border-radius: 6px; background: #f2f7fd; font-size: 10px; }
|
||||
.info-box strong { color: #315e8b; }
|
||||
.info-box span { color: #526d86; }
|
||||
.technical-summary { display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; align-content: center; padding: 11px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-subtle); }
|
||||
.technical-summary span { color: var(--muted); font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
|
||||
.technical-summary strong { font-size: 10px; text-align: right; }
|
||||
.inset-form { margin-top: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-subtle); }
|
||||
|
||||
.auth-card, .token-card { width: min(500px, 100%); padding: 28px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: 0 18px 45px rgba(16,24,39,.1); }
|
||||
.auth-card h1, .token-card h1 { margin: 20px 0 7px; font-size: 25px; }
|
||||
.auth-card .lede, .token-card > p { margin-bottom: 20px; color: var(--muted); font-size: 12px; }
|
||||
.auth-brand { display: flex; align-items: center; gap: 9px; color: var(--text); font-size: 12px; font-weight: 800; }
|
||||
.auth-brand span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 6px; background: var(--sidebar); color: #9dbbff; font-family: var(--mono); font-size: 9px; }
|
||||
.security-strip, .login-meta { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 9px; }
|
||||
.security-strip span { color: var(--success); }
|
||||
.login-meta { text-align: center; }
|
||||
.token-card { width: min(680px, 100%); }
|
||||
.token-value { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; align-items: center; margin: 17px 0; padding: 10px; border: 1px solid #b6cbe8; border-radius: 6px; background: #f2f7fd; }
|
||||
.token-value code { overflow: auto; font-family: var(--mono); font-size: 10px; white-space: nowrap; }
|
||||
.warning-box { margin-top: 14px; padding: 10px 11px; border: 1px solid #ead18e; border-radius: 6px; background: var(--warning-soft); color: #74540b; font-size: 10px; }
|
||||
|
||||
.security-facts ul { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; }
|
||||
.security-facts li { display: grid; gap: 2px; padding-left: 10px; border-left: 2px solid #9fcac4; }
|
||||
.security-facts strong { font-size: 11px; }
|
||||
.security-facts span { color: var(--muted); font-size: 10px; }
|
||||
.user-table { display: grid; }
|
||||
.user-table-head, .user-table-row { display: grid; grid-template-columns: minmax(160px,1.2fr) .55fr .55fr minmax(120px,.8fr) 20px; gap: 10px; align-items: center; }
|
||||
.user-table-head { padding: 4px 7px 8px; color: var(--muted); font-family: var(--mono); font-size: 8px; font-weight: 800; text-transform: uppercase; }
|
||||
.user-table-row { padding: 10px 7px; border-top: 1px solid #edf0f3; }
|
||||
.user-table-row:hover { background: var(--surface-subtle); color: var(--text); }
|
||||
.user-table-row strong, .user-table-row small { display: block; }
|
||||
.user-table-row strong { font-size: 11px; }
|
||||
.user-table-row small, .muted-cell { color: var(--muted); font-size: 9px; }
|
||||
.role-chip { display: inline-flex; width: fit-content; padding: 3px 6px; border-radius: 4px; background: #edf1f5; color: #536477; font-size: 9px; font-weight: 800; }
|
||||
.role-chip.admin { background: var(--accent-soft); color: #315eaa; }
|
||||
.row-arrow { color: var(--accent); }
|
||||
.access-panel { margin-top: 12px; }
|
||||
.access-intro { max-width: 780px; margin: -3px 0 14px; color: var(--muted); font-size: 10px; }
|
||||
.access-list { display: grid; gap: 9px; }
|
||||
.access-zone { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; }
|
||||
.access-zone-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; padding: 12px; background: var(--surface-subtle); }
|
||||
.access-zone-head small { color: var(--muted); font-size: 9px; }
|
||||
.access-zone-controls { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px 14px; }
|
||||
.access-targets { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 1px; border-top: 1px solid var(--line); background: var(--line); }
|
||||
.access-target { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 10px; align-items: center; min-height: 62px; padding: 8px 10px; background: #fff; color: var(--text); font-size: 10px; font-weight: 600; }
|
||||
.access-target-main { display: flex; width: auto; grid-template-columns: none; align-items: center; gap: 8px; }
|
||||
.access-target-main input { width: 15px; min-height: 15px; flex: none; accent-color: var(--accent); }
|
||||
.access-target strong, .access-target small { display: block; }
|
||||
.access-target small { color: var(--muted); font-size: 8px; }
|
||||
.access-manage-switch { white-space: nowrap; }
|
||||
|
||||
.error-summary-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.error-summary-grid article { display: flex; align-items: center; justify-content: space-between; padding: 13px 15px; border: 1px solid #efc7ce; border-radius: var(--radius-sm); background: #fff8f9; }
|
||||
.error-summary-grid span { color: #865764; font-family: var(--mono); font-size: 9px; text-transform: uppercase; }
|
||||
.error-summary-grid strong { color: var(--danger); font-size: 20px; }
|
||||
.error-section { margin-bottom: 12px; }
|
||||
.error-list { display: grid; }
|
||||
.error-item { display: grid; grid-template-columns: 26px minmax(0,1fr) auto; gap: 10px; align-items: start; padding: 11px 0; border-top: 1px solid #edf0f3; }
|
||||
.error-item:first-child { border-top: 0; }
|
||||
.error-item > div { min-width: 0; }
|
||||
.error-item strong, .error-item small { display: block; }
|
||||
.error-item strong { font-size: 11px; }
|
||||
.error-item p { margin: 3px 0; color: var(--danger); font-size: 10px; overflow-wrap: anywhere; }
|
||||
.error-item small { color: var(--muted); font-family: var(--mono); font-size: 8px; }
|
||||
.error-item > a { color: var(--accent); font-size: 10px; font-weight: 700; }
|
||||
.error-mark { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 7px; background: var(--danger-soft); color: var(--danger); font-family: var(--mono); font-size: 10px; font-weight: 900; }
|
||||
.run-error-list .error-item { grid-template-columns: 10px minmax(0,1fr); align-items: center; }
|
||||
.error-empty { display: flex; max-width: 720px; align-items: center; gap: 14px; }
|
||||
.error-empty-icon { display: grid; width: 38px; height: 38px; flex: none; place-items: center; border-radius: 10px; background: var(--success-soft); color: var(--success); font-weight: 900; }
|
||||
.error-empty h2 { margin-bottom: 3px; }
|
||||
.error-empty p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.admin-layout { grid-template-columns: 190px minmax(0,1fr); }
|
||||
.sidebar { padding-inline: 10px; }
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.dashboard-grid, .split-grid { grid-template-columns: 1fr; }
|
||||
.edit-inner { grid-template-columns: 1fr; }
|
||||
.settings-mode-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-layout { display: block; }
|
||||
.sidebar { position: static; height: auto; padding: 10px 12px; }
|
||||
.brand { padding-bottom: 9px; }
|
||||
.instance-state { display: none; }
|
||||
.sidebar nav { display: flex; overflow-x: auto; padding-bottom: 2px; }
|
||||
.sidebar nav a { min-width: fit-content; }
|
||||
.sidebar-account { display: none; }
|
||||
.workspace-bar { min-height: 45px; padding-inline: 14px; }
|
||||
.app-shell { width: min(100% - 24px, 1280px); padding-top: 20px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; gap: 12px; }
|
||||
.page-heading > form, .page-heading > .button { width: 100%; }
|
||||
.page-heading .button { width: 100%; }
|
||||
.heading-actions { width: 100%; }
|
||||
.heading-actions form, .heading-actions .button { width: 100%; }
|
||||
.form-grid, .metric-grid, .zone-summary { grid-template-columns: 1fr; }
|
||||
.bulk-ttl-form { grid-template-columns: 1fr; }
|
||||
.bulk-ttl-form .button { width: 100%; }
|
||||
.zone-summary div { border-top: 1px solid var(--line); border-left: 0; }
|
||||
.zone-summary div:first-child { border-top: 0; }
|
||||
.target-row { grid-template-columns: 1fr; }
|
||||
.target-meta { min-width: 0; justify-content: space-between; padding-top: 0; }
|
||||
.target-edit { grid-column: 1; }
|
||||
.audit-list li { grid-template-columns: 1fr auto; gap: 3px 10px; }
|
||||
.audit-list strong { grid-column: 1 / -1; grid-row: 1; }
|
||||
.user-table-head { display: none; }
|
||||
.user-table-row { grid-template-columns: minmax(0,1fr) auto auto; gap: 7px; }
|
||||
.user-table-row > :nth-child(4) { display: none; }
|
||||
.archive-row { align-items: flex-start; flex-direction: column; }
|
||||
.archive-row .button { width: 100%; }
|
||||
.info-box { grid-template-columns: 1fr; }
|
||||
.access-zone-head, .access-zone-controls { align-items: flex-start; flex-direction: column; }
|
||||
.access-zone-controls { width: 100%; }
|
||||
.access-target { grid-template-columns: 1fr; }
|
||||
.error-summary-grid { grid-template-columns: 1fr; }
|
||||
.error-item { grid-template-columns: 26px minmax(0,1fr); }
|
||||
.error-item > a { grid-column: 2; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; }
|
||||
.toast, .toast-progress { animation: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{define "dashboard"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<span class="page-kicker">Systemstatus</span>
|
||||
<h1>Übersicht</h1>
|
||||
<p>Status der verbundenen DNS-Zonen und des automatischen Update-Dienstes.</p>
|
||||
</div>
|
||||
{{if .IsAdmin}}
|
||||
<div class="heading-actions">
|
||||
<form action="/run" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-primary" type="submit" {{if not .Dashboard.Settings.UpdaterEnabled}}disabled title="Updater ist deaktiviert"{{end}}>Update jetzt ausführen</button>
|
||||
</form>
|
||||
{{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" aria-label="DynDNS-Status">
|
||||
<article class="metric-card"><span class="metric-label">Aktive Ziele</span><strong>{{.Dashboard.ActiveTargets}}</strong><small>{{len .Dashboard.Zones}} sichtbare DNS-Zone(n)</small></article>
|
||||
<article class="metric-card"><span class="metric-label">Öffentliche IPv4</span><strong class="mono address-value">{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}</strong><small>Quelle: externer IP-Resolver</small></article>
|
||||
<article class="metric-card {{if not .Dashboard.Settings.IPv6Enabled}}metric-card-disabled{{end}}"><span class="metric-label">Öffentliche IPv6</span><strong class="mono address-value compact">{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}</strong><small>{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}</small></article>
|
||||
<article class="metric-card countdown-card {{if not .Dashboard.Settings.UpdaterEnabled}}metric-card-disabled{{end}}"><span class="metric-label">Nächste Aktualisierung</span><strong class="mono countdown" data-countdown="{{unixSeconds .Dashboard.NextRunAt}}" data-running="{{.Dashboard.SchedulerRunning}}" data-enabled="{{.Dashboard.Settings.UpdaterEnabled}}">{{if .Dashboard.Settings.UpdaterEnabled}}--:--{{else}}Inaktiv{{end}}</strong><small>{{if .Dashboard.Settings.UpdaterEnabled}}Intervall: {{.Dashboard.Settings.UpdateIntervalSeconds}} Sekunden{{else}}Dienst in den Einstellungen deaktiviert{{end}}</small></article>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
{{if .IsAdmin}}
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
|
||||
{{if .Dashboard.RecentRuns}}
|
||||
<div class="data-list">
|
||||
{{range .Dashboard.RecentRuns}}
|
||||
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}}</small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">DNS-Zonen</span><h2>Verbundene Domains</h2></div><a href="/domains">Verwalten</a></div>
|
||||
{{if .Dashboard.Zones}}
|
||||
<div class="domain-list">
|
||||
{{range .Dashboard.Zones}}
|
||||
<a href="/domains/{{.ID}}"><span class="zone-glyph">DNS</span><span><strong>{{.Name}}</strong><small>Synchronisiert: {{formatTime .LastSyncAt}}</small></span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span></a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">{{if .IsAdmin}}Keine Domain verbunden. <a href="/domains/new">Domain hinzufügen</a>{{else}}Keine Domain zugewiesen.{{end}}</div>{{end}}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{if and .IsAdmin .Dashboard.RecentEvents}}
|
||||
<section class="panel audit-panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div>
|
||||
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time>{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,128 @@
|
||||
{{define "domain_detail"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><a class="back-link" href="/domains">← Domains</a><span class="page-kicker">DNS-Zone</span><h1>{{.Zone.Name}}</h1><p>{{if .IsAdmin}}DynDNS-Ziele und statische CNAME-Aliase dieser Zone.{{else}}Dir zugewiesene DynDNS-Ziele dieser Zone.{{end}}</p></div>
|
||||
{{if .IsAdmin}}<form action="/domains/{{.Zone.ID}}/import" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet" type="submit">Records neu einlesen</button></form>{{end}}
|
||||
</header>
|
||||
|
||||
<section class="zone-summary" aria-label="Zonenstatus">
|
||||
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div>
|
||||
<div><span>Letzter Import</span><strong>{{formatTime .Zone.LastSyncAt}}</strong></div>
|
||||
<div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
|
||||
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
|
||||
</section>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<section class="bulk-ttl-panel" aria-label="TTL für verwaltete Records setzen">
|
||||
<form action="/domains/{{.Zone.ID}}/ttl" method="post" class="bulk-ttl-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div><span class="section-label">Sammelaktion</span><strong>TTL für alle verwalteten Records</strong><small>A-/AAAA-Ziele und CNAMEs dieser Oberfläche; andere Hetzner-Records bleiben unverändert.</small></div><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label><button class="button button-quiet" type="submit">TTL für alle setzen</button></form>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="targets-section">
|
||||
<div class="section-heading"><div><span class="section-label">Verknüpfte Records</span><h2>Subdomains und Root-Record</h2></div><span class="record-count">{{len .Targets}} Einträge</span></div>
|
||||
{{if .Targets}}
|
||||
<div class="target-list">
|
||||
{{range .Targets}}
|
||||
{{$target := .}}
|
||||
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
|
||||
<article class="target-row {{if .Active}}is-active{{end}}">
|
||||
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">{{families .}}</span><span>{{if .Description}}{{.Description}}{{else}}Keine Beschreibung{{end}}</span></p></div></div>
|
||||
<div class="target-meta"><time>{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
|
||||
{{if $canManage}}<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
|
||||
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active {{if .Active}}checked{{end}}><span></span> DynDNS aktiv</label>{{if $.IsAdmin}}<label class="switch"><input type="checkbox" name="update_ipv4" {{if .UpdateIPv4}}checked{{end}}><span></span> IPv4 / A</label><label class="switch {{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if and .UpdateIPv6 $.Settings.IPv6Enabled}}checked{{end}} {{if not $.Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6 / AAAA{{if not $.Settings.IPv6Enabled}} pausiert{{end}}</label>{{end}}</div>
|
||||
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 value="{{.StaticIPv4}}" inputmode="decimal" placeholder="203.0.113.10" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
|
||||
<p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p>
|
||||
<div class="form-grid"><label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $target.TTL)}}<option value="{{$target.TTL}}" selected>{{$target.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $target.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
{{if $.IsAdmin}}
|
||||
<div class="danger-actions"><form action="/targets/{{.ID}}/archive" method="post" class="unlink-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<strong>Verknüpfung trennen</strong>
|
||||
<p>Entfernt nur die lokale DynDNS-Verknüpfung. Die DNS-Records bleiben erhalten.</p>
|
||||
<button class="button button-danger-soft" type="submit">Verknüpfung trennen</button>
|
||||
</form><form action="/targets/{{.ID}}/delete" method="post" class="unlink-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<strong>Ziel vollständig löschen</strong>
|
||||
<p>Löscht das Ziel und alle zugehörigen A-/AAAA-Records bei Hetzner.</p>
|
||||
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
||||
</form></div>
|
||||
{{end}}
|
||||
</div></details>{{end}}
|
||||
{{if .LastError}}<p class="row-error">{{.LastError}}</p>{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">Keine verknüpften A-/AAAA-Records vorhanden. {{if or .IsAdmin .CanCreateTargets}}Lege unten eine neue Subdomain an.{{end}}</div>{{end}}
|
||||
</section>
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<section class="targets-section">
|
||||
<div class="section-heading"><div><span class="section-label">Statische Records</span><h2>CNAME-Aliase</h2></div><span class="record-count">{{len .CNAMERecords}} Einträge</span></div>
|
||||
{{if .CNAMERecords}}
|
||||
<div class="target-list">
|
||||
{{range .CNAMERecords}}
|
||||
{{$cname := .}}
|
||||
<article class="target-row {{if .Active}}is-active{{end}}">
|
||||
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">CNAME</span><span>→ {{.Destination}}</span>{{if .Description}}<span>· {{.Description}}</span>{{end}}</p></div></div>
|
||||
<div class="target-meta"><span class="status-pill {{if .Active}}status-good{{end}}">{{if .Active}}Aktiv{{else}}Inaktiv{{end}}</span></div>
|
||||
<details class="target-edit"><summary>Bearbeiten <span>⌄</span></summary><div class="edit-inner">
|
||||
<form action="/cnames/{{.ID}}/save" method="post" class="target-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" {{if .Active}}checked{{end}}><span></span> CNAME aktiv</label></div>
|
||||
<div class="form-grid cname-edit-grid"><label>CNAME-Ziel <input name="destination" value="{{.Destination}}" maxlength="253" required></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $cname.TTL)}}<option value="{{$cname.TTL}}" selected>{{$cname.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $cname.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><small class="field-note">Vollständiger Domainname; der abschließende Punkt wird ergänzt.</small>
|
||||
<label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
<div class="danger-actions"><form action="/cnames/{{.ID}}/remove" method="post" class="unlink-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<strong>Aus WebUI entfernen</strong>
|
||||
<p>Entfernt nur die lokale Verknüpfung. Der CNAME-Record bei Hetzner bleibt unverändert.</p>
|
||||
<button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button>
|
||||
</form><form action="/cnames/{{.ID}}/delete" method="post" class="unlink-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<strong>Ziel vollständig löschen</strong>
|
||||
<p>Löscht das Ziel aus der WebUI und den zugehörigen CNAME-Record bei Hetzner.</p>
|
||||
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
|
||||
</form></div>
|
||||
</div></details>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty-state">Keine CNAME-Aliase vorhanden. Lege unten einen neuen Alias an.</div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if and .IsAdmin .ArchivedTargets}}
|
||||
<section class="panel archive-panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Archiv</span><h2>Getrennte Ziele</h2></div><span class="record-count">{{len .ArchivedTargets}} Einträge</span></div>
|
||||
<p class="panel-description">Diese Einträge sind nicht mehr mit dem Updater verknüpft. Die zugehörigen Hetzner-DNS-Records wurden beim Trennen nicht gelöscht.</p>
|
||||
<div class="archive-list">
|
||||
{{range .ArchivedTargets}}
|
||||
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if or .IsAdmin .CanCreateTargets}}
|
||||
<section class="action-grid admin-actions-grid">
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Record anlegen</span><h2>Neue Subdomain</h2></div></div>
|
||||
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 inputmode="decimal" placeholder="203.0.113.10" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
|
||||
</article>
|
||||
{{if .IsAdmin}}
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Statischer Record</span><h2>Neuen CNAME anlegen</h2></div></div>
|
||||
<form action="/cnames" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><div class="form-grid"><label>Alias <input name="record_name" required placeholder="www"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><label>CNAME-Ziel <input name="destination" required placeholder="app.example.net."><small>Vollständiger Domainname; ein abschließender Punkt ist optional.</small></label></div><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" checked><span></span> CNAME aktivieren</label></div><button class="button button-primary" type="submit">CNAME anlegen</button></form>
|
||||
</article>
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Zonenzugang</span><h2>Verbindung verwalten</h2></div></div>
|
||||
<p class="panel-description">Der API-Token bleibt verschlüsselt. Eine Anzeige erfordert erneut das Admin-Passwort.</p>
|
||||
<details><summary class="button button-quiet">API-Token anzeigen</summary><form action="/domains/{{.Zone.ID}}/token" method="post" class="stacked-form inset-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Admin-Passwort <input type="password" name="password" autocomplete="current-password" required></label><button class="button button-danger-soft" type="submit">Token entschlüsseln</button></form></details>
|
||||
<div class="panel-divider"></div>
|
||||
<details><summary class="text-danger">Domain-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/domains/{{.Zone.ID}}/remove" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Nur aus der WebUI entfernen</strong><p>Löscht ausschließlich die lokale Konfiguration. Alle DNS-Records bei Hetzner bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/domains/{{.Zone.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Domain vollständig löschen</strong><p>Löscht alle hier verwalteten A-, AAAA- und CNAME-Einträge aus der WebUI sowie die zugehörigen Records bei Hetzner. Andere Record-Typen bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Domain und Records löschen</button></form></div></details>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "domain_new"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><a class="back-link" href="/domains">← Domains</a><span class="page-kicker">Hetzner-Verbindung</span><h1>Domain hinzufügen</h1><p>Nach erfolgreicher Anmeldung werden alle vorhandenen A- und AAAA-Records der Zone automatisch importiert und als inaktive DynDNS-Ziele angezeigt.</p></div></header>
|
||||
<section class="form-panel compact-panel">
|
||||
<form action="/domains" method="post" class="stacked-form wide-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
|
||||
<label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label>
|
||||
<div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "domains"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><span class="page-kicker">DNS-Verwaltung</span><h1>Domains</h1><p>{{if .IsAdmin}}Hetzner-Zonen, Zugangszustand und DynDNS-Ziele verwalten.{{else}}Freigegebene Domains und Subdomains verwalten.{{end}}</p></div>
|
||||
{{if .IsAdmin}}<a class="button button-primary" href="/domains/new">Domain hinzufügen</a>{{end}}
|
||||
</header>
|
||||
<section class="domain-board">
|
||||
{{if .Zones}}
|
||||
{{range .Zones}}
|
||||
<article class="zone-card">
|
||||
<div class="zone-card-top"><span class="zone-glyph">DNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
|
||||
<h2>{{.Name}}</h2>
|
||||
<dl><div><dt>Letzter Import</dt><dd>{{formatTime .LastSyncAt}}</dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
|
||||
{{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
|
||||
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
|
||||
</article>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty-board"><span class="empty-icon">DNS</span><h2>{{if .IsAdmin}}Keine Domain verbunden{{else}}Keine Berechtigung zugewiesen{{end}}</h2><p>{{if .IsAdmin}}Füge eine Hetzner-Zone hinzu. A- und AAAA-Records werden beim Verbinden automatisch importiert.{{else}}Ein Admin kann dir eine vollständige Domain oder einzelne Subdomains freigeben.{{end}}</p>{{if .IsAdmin}}<a class="button button-primary" href="/domains/new">Domain hinzufügen</a>{{end}}</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,53 @@
|
||||
{{define "errors"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle Ziel- und Zonenfehler sowie alle gespeicherten fehlgeschlagenen oder partiellen Update-Läufe.</p></div>
|
||||
<form action="/errors/clear" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{{if eq .ErrorCount 0}}
|
||||
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder Ziel- oder Zonenfehler noch problematische Update-Läufe vorhanden.</p></div></section>
|
||||
{{else}}
|
||||
<section class="error-summary-grid" aria-label="Fehlerübersicht">
|
||||
<article><span>Ziele</span><strong>{{len .ErrorTargets}}</strong></article>
|
||||
<article><span>Zonen</span><strong>{{len .ErrorZones}}</strong></article>
|
||||
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
|
||||
</section>
|
||||
|
||||
{{if .ErrorTargets}}
|
||||
<section class="panel error-section">
|
||||
<div class="panel-heading"><div><span class="section-label">Aktuell</span><h2>Zielprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorTargets}}</span></div>
|
||||
<div class="error-list">
|
||||
{{range .ErrorTargets}}
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich {{formatTime .LastUpdatedAt}}</small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .ErrorZones}}
|
||||
<section class="panel error-section">
|
||||
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
|
||||
<div class="error-list">
|
||||
{{range .ErrorZones}}
|
||||
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch {{formatTime .LastSyncAt}}</small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .ErrorRuns}}
|
||||
<section class="panel error-section">
|
||||
<div class="panel-heading"><div><span class="section-label">Historie</span><h2>Problematische Update-Läufe</h2></div><span class="status-pill status-bad">{{len .ErrorRuns}}</span></div>
|
||||
<div class="error-list run-error-list">
|
||||
{{range .ErrorRuns}}
|
||||
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}} · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,71 @@
|
||||
{{define "layout"}}
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{.Title}} · Hetzner DynDNS</title>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=features-5">
|
||||
<script src="/static/app.js?v=features-5" defer></script>
|
||||
</head>
|
||||
<body class="page-{{.Page}}">
|
||||
<a class="skip-link" href="#main">Zum Inhalt springen</a>
|
||||
{{if and (ne .Page "setup") (ne .Page "login")}}
|
||||
<div class="admin-layout">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/" aria-label="Hetzner DynDNS Startseite">
|
||||
<span class="brand-mark">DD</span>
|
||||
<span><strong>DynDNS Console</strong><small>Hetzner DNS</small></span>
|
||||
</a>
|
||||
<div class="instance-state {{if not .UpdaterEnabled}}is-inactive{{end}}"><span></span> Dienst {{if .UpdaterEnabled}}aktiv{{else}}inaktiv{{end}}</div>
|
||||
<nav aria-label="Hauptnavigation">
|
||||
<a href="/" {{if eq .Page "dashboard"}}aria-current="page"{{end}}><span class="nav-icon">01</span>Übersicht</a>
|
||||
<a href="/domains" {{if eq .Page "domains"}}aria-current="page"{{end}}><span class="nav-icon">02</span>Domains</a>
|
||||
{{if .IsAdmin}}
|
||||
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Updater</a>
|
||||
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Fehler</a>
|
||||
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Benutzer</a>
|
||||
{{end}}
|
||||
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}06{{else}}03{{end}}</span>Sicherheit</a>
|
||||
</nav>
|
||||
<div class="sidebar-account">
|
||||
<span class="account-avatar">{{initial .User.DisplayName}}</span>
|
||||
<span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span>
|
||||
<form action="/logout" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<header class="workspace-bar">
|
||||
<div><span>DynDNS</span><strong>/ {{.Title}}</strong></div>
|
||||
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
|
||||
</header>
|
||||
<main id="main" class="app-shell">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<main id="main" class="auth-shell">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
{{end}}
|
||||
{{if .Flash}}
|
||||
<div class="toast-region">
|
||||
<div class="toast toast-{{.Flash.Kind}}" data-toast role="{{if eq .Flash.Kind "error"}}alert{{else}}status{{end}}" aria-atomic="true">
|
||||
<span class="toast-icon" aria-hidden="true">{{if eq .Flash.Kind "error"}}!{{else if eq .Flash.Kind "info"}}i{{else}}✓{{end}}</span>
|
||||
<div class="toast-copy">
|
||||
<span class="toast-eyebrow">{{if eq .Flash.Kind "error"}}Bitte prüfen{{else if eq .Flash.Kind "info"}}Hinweis{{else}}Erledigt{{end}}</span>
|
||||
<p>{{.Flash.Message}}</p>
|
||||
</div>
|
||||
<button class="toast-close" type="button" data-toast-close aria-label="Meldung schließen">×</button>
|
||||
<span class="toast-progress" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,18 @@
|
||||
{{define "login"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
||||
<h1>Anmelden</h1>
|
||||
<form action="/login" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Benutzername
|
||||
<input name="username" type="text" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Anmelden <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
<div class="login-meta">Sitzungen laufen nach acht Stunden automatisch ab.</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "security"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><span class="page-kicker">Konto und Laufzeit</span><h1>Sicherheit</h1><p>Eigenes Passwort ändern und aktive Sicherheitsmechanismen der Instanz prüfen.</p></div></header>
|
||||
<section class="split-grid">
|
||||
<article class="panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort ändern</h2></div></div><form action="/security/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" required></label><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label>Neues Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></article>
|
||||
<article class="panel security-facts"><div class="panel-heading"><div><span class="section-label">Instanzschutz</span><h2>Aktive Maßnahmen</h2></div></div><ul><li><strong>Argon2id</strong><span>Passwörter werden ausschließlich als gehärtete Hashes gespeichert.</span></li><li><strong>XChaCha20-Poly1305</strong><span>Hetzner-Tokens sind authentifiziert verschlüsselt.</span></li><li><strong>Rollen und Grants</strong><span>Mitglieder sehen nur explizit freigegebene Domains oder Hosts.</span></li><li><strong>CSRF und Sessions</strong><span>Schreibzugriffe sind tokengebunden; Sitzungen laufen automatisch ab.</span></li></ul>{{if eq .Mode "lan"}}<div class="warning-box">HTTP-LAN-Modus aktiv. Nur in einem isolierten, vertrauenswürdigen Netzwerk verwenden.</div>{{end}}</article>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "settings"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div><span class="page-kicker">Scheduler</span><h1>Updater-Konfiguration</h1><p>Dienststatus, Intervall und IP-Familien zentral steuern.</p></div>
|
||||
</header>
|
||||
|
||||
<form action="/settings" method="post" class="settings-form" data-settings-form>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<section class="settings-mode-grid" aria-label="Updater-Schalter">
|
||||
<label class="setting-switch-card {{if .Settings.UpdaterEnabled}}is-enabled{{end}}">
|
||||
<span class="setting-switch-copy"><span class="section-label">Automatik</span><strong>DynDNS-Dienst</strong><small>Stoppt geplante und automatisch ausgelöste Aktualisierungen vollständig.</small></span>
|
||||
<span class="switch setting-switch"><input type="checkbox" name="updater_enabled" data-updater-toggle {{if .Settings.UpdaterEnabled}}checked{{end}}><span></span><b>{{if .Settings.UpdaterEnabled}}Aktiv{{else}}Inaktiv{{end}}</b></span>
|
||||
</label>
|
||||
<label class="setting-switch-card {{if .Settings.IPv6Enabled}}is-enabled{{end}}">
|
||||
<span class="setting-switch-copy"><span class="section-label">IP-Familie</span><strong>IPv6-Aktualisierung</strong><small>Deaktiviert IPv6-Ermittlung und AAAA-Updates, ohne Zielkonfigurationen zu löschen.</small></span>
|
||||
<span class="switch setting-switch"><input type="checkbox" name="ipv6_enabled" data-ipv6-toggle {{if .Settings.IPv6Enabled}}checked{{end}}><span></span><b>{{if .Settings.IPv6Enabled}}Aktiv{{else}}Inaktiv{{end}}</b></span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="form-panel compact-panel settings-fields">
|
||||
<div class="stacked-form wide-form">
|
||||
<div class="form-grid">
|
||||
<label>Abfrageintervall in Sekunden
|
||||
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required>
|
||||
<small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
|
||||
</label>
|
||||
<div class="technical-summary"><span>Letzter Lauf</span><strong>{{formatTime .Settings.LastRunAt}}</strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
|
||||
</div>
|
||||
<div class="form-grid endpoint-grid">
|
||||
<label>IPv4-Endpunkte
|
||||
<textarea name="ipv4_endpoints" rows="4" spellcheck="false" required>{{formatEndpoints .Settings.IPv4Endpoints}}</textarea>
|
||||
<small>Eine HTTPS-URL pro Zeile; Reihenfolge entspricht der Fallback-Reihenfolge.</small>
|
||||
</label>
|
||||
<label class="endpoint-setting {{if not .Settings.IPv6Enabled}}is-disabled{{end}}" data-ipv6-settings aria-disabled="{{if .Settings.IPv6Enabled}}false{{else}}true{{end}}">IPv6-Endpunkte
|
||||
<textarea name="ipv6_endpoints" rows="4" spellcheck="false" {{if .Settings.IPv6Enabled}}required{{end}}>{{formatEndpoints .Settings.IPv6Endpoints}}</textarea>
|
||||
<small>Werden nur verwendet, wenn IPv6 oben aktiviert ist.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Konfiguration speichern</button><a class="button button-quiet" href="/">Zur Übersicht</a></div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
{{end}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{define "setup"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card">
|
||||
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
|
||||
<h1>System einrichten</h1>
|
||||
<p class="lede">Erstelle den ersten Administrator. Weitere Benutzer und Zugriffsrechte können anschließend in der Verwaltung angelegt werden.</p>
|
||||
<form action="/setup" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Admin-Benutzername
|
||||
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required autofocus>
|
||||
</label>
|
||||
<label>Anzeigename
|
||||
<input name="display_name" type="text" value="{{index .Form "display_name"}}" maxlength="80" required>
|
||||
</label></div>
|
||||
<label>Admin-Passwort
|
||||
<input name="password" type="password" autocomplete="new-password" minlength="12" required>
|
||||
<small>Mindestens 12 Zeichen; dieses Passwort kann in V1 nicht über die Oberfläche zurückgesetzt werden.</small>
|
||||
</label>
|
||||
<label>Passwort wiederholen
|
||||
<input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Sichere Einrichtung abschließen <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
<div class="security-strip"><span>✓</span> Argon2id-Passworthash · verschlüsselte API-Tokens · lokale Datenhaltung</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{define "token"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>API-Token</h1><p>Zone: <strong>{{.Zone.Name}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="api-token">{{.Token}}</code><button class="button button-primary" type="button" data-copy-target="api-token">Kopieren</button></div><p class="warning-box">Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.</p><a class="button button-quiet" href="/domains/{{.Zone.ID}}">Zur Domain zurückkehren</a></section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,73 @@
|
||||
{{define "user_detail"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<a class="back-link" href="/users">← Benutzer</a>
|
||||
<span class="page-kicker">Benutzerprofil</span>
|
||||
<h1>{{.ManagedUser.DisplayName}}</h1>
|
||||
<p>@{{.ManagedUser.Username}} · {{if eq .ManagedUser.Role "admin"}}Admin mit vollständigem Zugriff{{else}}Mitglied mit gezielten Freigaben{{end}}</p>
|
||||
</div>
|
||||
<span class="status-pill {{if .ManagedUser.Active}}status-good{{else}}status-bad{{end}}">{{if .ManagedUser.Active}}aktiv{{else}}deaktiviert{{end}}</span>
|
||||
</header>
|
||||
|
||||
<section class="split-grid">
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Konto</span><h2>Rolle und Status</h2></div></div>
|
||||
<form action="/users/{{.ManagedUser.ID}}/state" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Anzeigename <input name="display_name" value="{{.ManagedUser.DisplayName}}" maxlength="80" required></label>
|
||||
<label>Rolle <select name="role"><option value="member" {{if eq .ManagedUser.Role "member"}}selected{{end}}>Mitglied</option><option value="admin" {{if eq .ManagedUser.Role "admin"}}selected{{end}}>Admin</option></select></label>
|
||||
<label class="switch"><input type="checkbox" name="active" {{if .ManagedUser.Active}}checked{{end}}><span></span> Konto aktiv</label>
|
||||
<button class="button button-primary" type="submit">Kontostatus speichern</button>
|
||||
</form>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort zurücksetzen</h2></div></div>
|
||||
<form action="/users/{{.ManagedUser.ID}}/password" method="post" class="stacked-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Neues Passwort <input name="password" type="password" minlength="12" autocomplete="new-password" required></label>
|
||||
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="12" autocomplete="new-password" required></label>
|
||||
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
|
||||
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{{if eq .ManagedUser.Role "member"}}
|
||||
<section class="panel access-panel">
|
||||
<div class="panel-heading"><div><span class="section-label">Berechtigungen</span><h2>Domain- und Subdomain-Zugriffe</h2></div></div>
|
||||
<p class="access-intro">Lege getrennt fest, was sichtbar ist, was bearbeitet werden darf und in welchen Domains das Mitglied selbst neue Subdomains anlegen kann.</p>
|
||||
<form action="/users/{{.ManagedUser.ID}}/access" method="post">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="access-list">
|
||||
{{range .AccessZones}}
|
||||
{{$accessZone := .}}
|
||||
<article class="access-zone" data-access-zone>
|
||||
<div class="access-zone-head">
|
||||
<div><h3>{{$accessZone.Zone.Name}}</h3><small>{{len $accessZone.Targets}} bekannte DynDNS-Ziele</small></div>
|
||||
<div class="access-zone-controls">
|
||||
<label class="switch"><input type="checkbox" name="zone_{{$accessZone.Zone.ID}}" data-zone-access {{if $accessZone.DomainGranted}}checked{{end}}><span></span> Ganze Domain sehen</label>
|
||||
<label class="switch"><input type="checkbox" name="manage_zone_{{$accessZone.Zone.ID}}" data-zone-manage {{if $accessZone.DomainManaged}}checked{{end}}><span></span> Alle Ziele bearbeiten</label>
|
||||
<label class="switch"><input type="checkbox" name="create_zone_{{$accessZone.Zone.ID}}" data-zone-create {{if $accessZone.CanCreateTargets}}checked{{end}}><span></span> Subdomains anlegen</label>
|
||||
</div>
|
||||
</div>
|
||||
{{if $accessZone.Targets}}
|
||||
<div class="access-targets">
|
||||
{{range $accessZone.Targets}}
|
||||
<div class="access-target" data-access-target>
|
||||
<label class="access-target-main"><input type="checkbox" name="target_{{.ID}}" data-target-access {{if index $accessZone.GrantedTargetIDs .ID}}checked{{end}}><span><strong>{{.FQDN}}</strong><small>{{families .}} · {{if .Active}}aktiv{{else}}inaktiv{{end}}</small></span></label>
|
||||
<label class="switch access-manage-switch"><input type="checkbox" name="manage_target_{{.ID}}" data-target-manage {{if index $accessZone.ManagedTargetIDs .ID}}checked{{end}}><span></span> Bearbeiten</label>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<p class="muted-cell">Keine A-/AAAA-Records importiert.</p>{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Zugriffsrechte speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="panel access-panel"><div class="warning-box">Dieser Benutzer ist Admin und hat deshalb automatisch vollen Zugriff auf alle Domains, Hosts, Tokens und Einstellungen.</div></section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{define "user_new"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><a class="back-link" href="/users">← Benutzer</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten erhalten zunächst keine Domain- oder Host-Berechtigungen.</p></div></header>
|
||||
<section class="form-panel compact-panel">
|
||||
<form action="/users" method="post" class="stacked-form wide-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<div class="form-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required></label></div>
|
||||
<div class="form-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" minlength="12" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required></label></div>
|
||||
<label>Rolle <select name="role"><option value="member" {{if eq (index .Form "role") "member"}}selected{{end}}>Mitglied – nur explizit freigegebene Domains/Hosts</option><option value="admin" {{if eq (index .Form "role") "admin"}}selected{{end}}>Admin – vollständige Verwaltung</option></select><small>Admins dürfen Tokens sehen, Einstellungen ändern und weitere Benutzer verwalten.</small></label>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/users">Abbrechen</a></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,5 @@
|
||||
{{define "users"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<header class="page-heading"><div><span class="page-kicker">Zugriffssteuerung</span><h1>Benutzer</h1><p>Konten, Rollen sowie Domain- und Subdomain-Berechtigungen verwalten.</p></div><a class="button button-primary" href="/users/new">Benutzer anlegen</a></header>
|
||||
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell">{{formatTime .LastLoginAt}}</span><span class="row-arrow">→</span></a>{{end}}</div></section>
|
||||
{{end}}
|
||||
Reference in new issue
Block a user