feat: full UI overhaul — dark theme with custom design system
All checks were successful
Deploy / deploy (push) Successful in 39s
All checks were successful
Deploy / deploy (push) Successful in 39s
Replace Pico CSS with a custom dark design system: - Deep navy background (#090b12) with violet-indigo accent (#7b6cf5) - Outfit font for UI, JetBrains Mono for code/URLs - Custom nav with SVG icons and active-state highlighting - Pill badges with colored status dots - Uppercase letter-spaced table headers - Stat cards on replica detail page - h3 section headings with extending rule line - Refined dialog/modal styling with blur backdrop - Terminal-style SSE log stream - Progress bar with accent gradient animation - Page entrance fade-in animation - All Pico CSS variable references replaced with custom tokens Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,55 +1,553 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="light">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}pngx-controller{% endblock %}</title>
|
<title>{% block title %}pngx-controller{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
||||||
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
||||||
<style>
|
<style>
|
||||||
nav { padding: 0.5rem 1rem; }
|
/* ── TOKENS ── */
|
||||||
.badge { display: inline-block; padding: 0.15em 0.6em; border-radius: 1em; font-size: 0.8em; font-weight: 600; }
|
:root {
|
||||||
.badge-synced { background: #d1fae5; color: #065f46; }
|
--bg: #090b12;
|
||||||
.badge-syncing { background: #dbeafe; color: #1e40af; }
|
--surface: #0f1220;
|
||||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
--surface-2: #161926;
|
||||||
.badge-suspended { background: #fef3c7; color: #92400e; }
|
--surface-3: #1d2235;
|
||||||
.badge-pending { background: #f3f4f6; color: #374151; }
|
--border: #252840;
|
||||||
.badge-ok { background: #d1fae5; color: #065f46; }
|
--border-hi: #333653;
|
||||||
.badge-info { background: #eff6ff; color: #1d4ed8; }
|
|
||||||
.badge-warning { background: #fffbeb; color: #b45309; }
|
--accent: #7b6cf5;
|
||||||
small.muted { color: var(--pico-muted-color); }
|
--accent-soft: rgba(123,108,245,.13);
|
||||||
|
--accent-hi: #9588f7;
|
||||||
|
|
||||||
|
--ok: #3dd68c;
|
||||||
|
--ok-soft: rgba(61,214,140,.1);
|
||||||
|
--warn: #f9c443;
|
||||||
|
--warn-soft: rgba(249,196,67,.1);
|
||||||
|
--err: #f47067;
|
||||||
|
--err-soft: rgba(244,112,103,.1);
|
||||||
|
--sus: #ff8c42;
|
||||||
|
--sus-soft: rgba(255,140,66,.1);
|
||||||
|
--info: #5499e4;
|
||||||
|
--info-soft: rgba(84,153,228,.1);
|
||||||
|
|
||||||
|
--text: #dce0f0;
|
||||||
|
--text-2: #8892b0;
|
||||||
|
--text-3: #4a526e;
|
||||||
|
|
||||||
|
--r: 6px;
|
||||||
|
--r2: 4px;
|
||||||
|
--r3: 10px;
|
||||||
|
--font: 'Outfit', system-ui, sans-serif;
|
||||||
|
--mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── RESET ── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
|
||||||
|
|
||||||
|
/* ── BASE ── */
|
||||||
|
body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; line-height: 1.6; }
|
||||||
|
a { color: var(--accent); text-decoration: none; transition: color .15s; }
|
||||||
|
a:hover { color: var(--accent-hi); }
|
||||||
|
strong { font-weight: 600; }
|
||||||
|
hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
||||||
|
p { color: var(--text-2); margin-bottom: .75rem; }
|
||||||
|
small { font-size: .8rem; }
|
||||||
|
code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .82em;
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--accent-hi);
|
||||||
|
padding: .15em .4em;
|
||||||
|
border-radius: var(--r2);
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .8em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: var(--surface-3);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
border-radius: var(--r2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TYPOGRAPHY ── */
|
||||||
|
h2 { font-size: 1.35rem; font-weight: 700; letter-spacing: -.02em; color: var(--text); }
|
||||||
|
h3 {
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 2rem 0 .9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
h3::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
|
||||||
|
/* ── NAV ── */
|
||||||
|
.app-nav {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.nav-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: .92rem;
|
||||||
|
color: var(--text) !important;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-brand svg { color: var(--accent); flex-shrink: 0; }
|
||||||
|
.nav-brand-dim { color: var(--text-3); font-weight: 400; }
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .15rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.nav-links a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .35rem .7rem;
|
||||||
|
border-radius: var(--r2);
|
||||||
|
font-size: .83rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-2);
|
||||||
|
transition: all .15s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-links a svg { opacity: .7; }
|
||||||
|
.nav-links a:hover { color: var(--text); background: var(--surface-2); }
|
||||||
|
.nav-links a:hover svg { opacity: 1; }
|
||||||
|
.nav-links a.active { color: var(--text); background: var(--surface-3); }
|
||||||
|
.nav-links a.active svg { opacity: 1; color: var(--accent); }
|
||||||
|
.nav-meta { display: flex; gap: .9rem; }
|
||||||
|
.nav-meta a { font-size: .72rem; color: var(--text-3); }
|
||||||
|
.nav-meta a:hover { color: var(--text-2); }
|
||||||
|
|
||||||
|
/* ── LAYOUT ── */
|
||||||
|
.container { max-width: 1180px; margin: 0 auto; padding: 2rem 1.5rem; animation: fadein .2s ease; }
|
||||||
|
@keyframes fadein { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
.page-hd {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.page-hd h2 { margin: 0; }
|
||||||
|
.page-hd-sub { color: var(--text-3); font-size: .8rem; margin-top: .2rem; }
|
||||||
|
.page-actions { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
/* ── ARTICLE / CARD ── */
|
||||||
|
article {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
article > header:first-child {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: .85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TABLE ── */
|
||||||
|
.overflow-auto { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: .875rem; }
|
||||||
|
thead th {
|
||||||
|
padding: .55rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
font-size: .68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .07em;
|
||||||
|
color: var(--text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tbody td {
|
||||||
|
padding: .7rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
tbody tr:last-child td { border-bottom: none; }
|
||||||
|
tbody tr:hover td { background: var(--surface-2); }
|
||||||
|
.actions { white-space: nowrap; display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── BUTTONS ── */
|
||||||
|
button, a[role="button"] {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .45rem .95rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: .83rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--r2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
button:hover, a[role="button"]:hover { background: var(--accent-hi); border-color: var(--accent-hi); color: #fff; }
|
||||||
|
|
||||||
|
button.secondary { background: var(--surface-3); color: var(--text-2); border-color: var(--border-hi); }
|
||||||
|
button.secondary:hover { border-color: var(--accent); color: var(--text); background: var(--surface-3); }
|
||||||
|
|
||||||
|
button.outline { background: transparent; color: var(--text-2); border-color: var(--border-hi); }
|
||||||
|
button.outline:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||||
|
|
||||||
|
button.secondary.outline, a.secondary.outline[role="button"] {
|
||||||
|
background: transparent; color: var(--text-2); border-color: var(--border-hi);
|
||||||
|
}
|
||||||
|
button.secondary.outline:hover, a.secondary.outline[role="button"]:hover {
|
||||||
|
border-color: var(--accent); color: var(--accent); background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.contrast { background: var(--err-soft); color: var(--err); border-color: var(--err); }
|
||||||
|
button.contrast:hover { background: var(--err); color: #fff; }
|
||||||
|
button.contrast.outline { background: transparent; color: var(--err); border-color: var(--err); }
|
||||||
|
button.contrast.outline:hover { background: var(--err-soft); }
|
||||||
|
|
||||||
|
button[disabled], button[disabled]:hover { opacity: .4; cursor: not-allowed; pointer-events: none; }
|
||||||
|
button[aria-busy="true"] { opacity: .7; pointer-events: none; }
|
||||||
|
button[aria-busy="true"]::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 11px; height: 11px;
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .55s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* dialog close button */
|
||||||
|
button[rel="prev"], button[aria-label="Close"] {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
color: var(--text-3) !important;
|
||||||
|
padding: .2rem .4rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
float: right;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
button[rel="prev"]::before { content: '×'; }
|
||||||
|
button[rel="prev"]:hover, button[aria-label="Close"]:hover { color: var(--text) !important; }
|
||||||
|
|
||||||
|
/* ── BADGES ── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .28rem;
|
||||||
|
padding: .18em .55em;
|
||||||
|
border-radius: 2em;
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge::before { content: ''; width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.badge-synced, .badge-ok { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.badge-synced::before, .badge-ok::before { background: var(--ok); }
|
||||||
|
|
||||||
|
.badge-syncing, .badge-info { background: var(--info-soft); color: var(--info); }
|
||||||
|
.badge-syncing::before, .badge-info::before { background: var(--info); }
|
||||||
|
|
||||||
|
.badge-error { background: var(--err-soft); color: var(--err); }
|
||||||
|
.badge-error::before { background: var(--err); }
|
||||||
|
|
||||||
|
.badge-suspended, .badge-warning { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.badge-suspended::before, .badge-warning::before { background: var(--warn); }
|
||||||
|
|
||||||
|
.badge-pending { background: var(--surface-3); color: var(--text-2); }
|
||||||
|
.badge-pending::before { background: var(--text-3); }
|
||||||
|
|
||||||
|
/* ── FORMS ── */
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
input[type="text"], input[type="url"], input[type="number"],
|
||||||
|
input[type="password"], input[type="email"], select, textarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-hi);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
padding: .45rem .7rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: .85rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: .3rem;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
input::placeholder { color: var(--text-3); }
|
||||||
|
select option { background: var(--surface-2); }
|
||||||
|
input[type="checkbox"] { width: 1rem; height: 1rem; accent-color: var(--accent); cursor: pointer; display: inline; margin: 0; margin-top: 0 !important; }
|
||||||
|
label small { display: block; margin-top: .25rem; color: var(--text-3); font-size: .74rem; }
|
||||||
|
.muted, small.muted { color: var(--text-3); }
|
||||||
|
|
||||||
|
/* ── PROGRESS ── */
|
||||||
|
progress {
|
||||||
|
display: block; width: 100%; height: 4px;
|
||||||
|
border-radius: 2px; border: none;
|
||||||
|
background: var(--surface-3); overflow: hidden; margin: .5rem 0;
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
}
|
||||||
|
progress::-webkit-progress-bar { background: var(--surface-3); }
|
||||||
|
progress::-webkit-progress-value { background: linear-gradient(90deg, var(--accent), var(--accent-hi)); border-radius: 2px; transition: width .3s; }
|
||||||
|
progress::-moz-progress-bar { background: linear-gradient(90deg, var(--accent), var(--accent-hi)); border-radius: 2px; }
|
||||||
|
progress:not([value])::after {
|
||||||
|
/* shimmer for indeterminate */
|
||||||
|
}
|
||||||
#progress-bar { display: none; }
|
#progress-bar { display: none; }
|
||||||
#progress-bar.active { display: block; }
|
#progress-bar.active { display: block; }
|
||||||
.log-entry-error td { background: #fff5f5; }
|
#progress-bar article {
|
||||||
.log-entry-warning td { background: #fffbeb; }
|
border-left: 3px solid var(--accent);
|
||||||
pre { white-space: pre-wrap; font-size: 0.8em; }
|
background: var(--surface-2);
|
||||||
table { font-size: 0.9em; }
|
padding: .85rem 1.1rem;
|
||||||
.actions { white-space: nowrap; }
|
}
|
||||||
details summary { cursor: pointer; }
|
|
||||||
|
/* ── DIALOG ── */
|
||||||
|
dialog {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-hi);
|
||||||
|
border-radius: var(--r3);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0;
|
||||||
|
max-width: 540px;
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
box-shadow: 0 24px 60px rgba(0,0,0,.7);
|
||||||
|
}
|
||||||
|
dialog::backdrop { background: rgba(0,0,0,.75); backdrop-filter: blur(3px); }
|
||||||
|
dialog article { border: none; border-radius: var(--r3); margin: 0; padding: 0; }
|
||||||
|
dialog article > header:first-child {
|
||||||
|
padding: 1.15rem 1.4rem .9rem;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
dialog article h3 { margin: 0; font-size: .95rem; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--text); }
|
||||||
|
dialog article h3::after { display: none; }
|
||||||
|
dialog article > :not(header):not(footer) { padding: 1.15rem 1.4rem; }
|
||||||
|
dialog article footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: .9rem 1.4rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
/* form > footer inside dialog */
|
||||||
|
dialog form footer { border-top: 1px solid var(--border); padding: .9rem 1.4rem; margin: 0; }
|
||||||
|
|
||||||
|
/* ── DETAILS / SUMMARY ── */
|
||||||
|
details { border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; margin-top: 1.5rem; }
|
||||||
|
summary {
|
||||||
|
padding: .7rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-size: .83rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-2);
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
summary::-webkit-details-marker { display: none; }
|
||||||
|
summary::before { content: '▶'; font-size: .6rem; color: var(--text-3); transition: transform .18s; }
|
||||||
|
details[open] summary::before { transform: rotate(90deg); }
|
||||||
|
details > :not(summary) { padding: 0 1rem 1rem; }
|
||||||
|
details[open] summary { border-bottom: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ── BREADCRUMB ── */
|
||||||
|
nav[aria-label="breadcrumb"] ul {
|
||||||
|
display: flex; align-items: center; gap: .4rem;
|
||||||
|
list-style: none; margin: 0 0 1.25rem; font-size: .78rem; color: var(--text-3);
|
||||||
|
}
|
||||||
|
nav[aria-label="breadcrumb"] li { display: flex; align-items: center; gap: .4rem; }
|
||||||
|
nav[aria-label="breadcrumb"] li:not(:last-child)::after { content: '/'; color: var(--border-hi); }
|
||||||
|
nav[aria-label="breadcrumb"] li a { color: var(--text-2); }
|
||||||
|
nav[aria-label="breadcrumb"] li a:hover { color: var(--accent); }
|
||||||
|
nav[aria-label="breadcrumb"] li:last-child { color: var(--text); }
|
||||||
|
|
||||||
|
/* ── FLASH / ALERT ── */
|
||||||
|
[role="alert"] {
|
||||||
|
padding: .7rem 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
background: var(--info-soft);
|
||||||
|
color: var(--info);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
border-left: 3px solid var(--info);
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── LOG TABLE ── */
|
||||||
|
.log-entry-error td { background: rgba(244,112,103,.04); }
|
||||||
|
.log-entry-warning td { background: rgba(249,196,67,.04); }
|
||||||
|
|
||||||
|
/* ── SSE LOG ── */
|
||||||
|
.sse-terminal {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .78rem;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--surface-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: .75rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GRIDS ── */
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
.grid-auto { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
|
||||||
|
.grid-form-sidebar { display: grid; grid-template-columns: 1fr 340px; gap: 2rem; align-items: start; }
|
||||||
|
|
||||||
|
/* ── STAT CARDS ── */
|
||||||
|
.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: .85rem; margin: 1.25rem 0; }
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
.stat-card-label { font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--text-3); margin-bottom: .35rem; }
|
||||||
|
.stat-card-value { font-size: 1rem; font-weight: 600; color: var(--text); }
|
||||||
|
.stat-card-sub { font-size: .75rem; color: var(--text-3); margin-top: .2rem; }
|
||||||
|
|
||||||
|
/* ── DANGER ZONE ── */
|
||||||
|
.danger-zone { border: 1px solid rgba(244,112,103,.3); border-radius: var(--r); padding: 1rem 1.1rem; }
|
||||||
|
.danger-zone p { font-size: .83rem; color: var(--text-2); margin-bottom: .75rem; }
|
||||||
|
|
||||||
|
/* ── ENV DETECTION CARD ── */
|
||||||
|
.env-detected {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.env-detected-label {
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .07em;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ASIDE ── */
|
||||||
|
aside article { background: var(--surface-2); }
|
||||||
|
|
||||||
|
/* ── MISC ── */
|
||||||
|
.info-banner {
|
||||||
|
padding: .7rem 1rem;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: var(--r2);
|
||||||
|
font-size: .83rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid-form-sidebar { grid-template-columns: 1fr; }
|
||||||
|
.grid-2 { grid-template-columns: 1fr; }
|
||||||
|
.app-nav { padding: 0 1rem; }
|
||||||
|
.container { padding: 1.25rem 1rem; }
|
||||||
|
.nav-meta { display: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="container-fluid">
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li><strong><a href="/" style="text-decoration:none;">📄 pngx-controller</a></strong></li>
|
|
||||||
</ul>
|
|
||||||
<ul>
|
|
||||||
<li><a href="/">Dashboard</a></li>
|
|
||||||
<li><a href="/replicas">Replicas</a></li>
|
|
||||||
<li><a href="/logs">Logs</a></li>
|
|
||||||
<li><a href="/settings">Settings</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container">
|
<header class="app-nav">
|
||||||
{% block content %}{% endblock %}
|
<a href="/" class="nav-brand">
|
||||||
</main>
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
|
<path d="M10 2H4.5A1.5 1.5 0 003 3.5v11A1.5 1.5 0 004.5 16h9a1.5 1.5 0 001.5-1.5V7l-5-5z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M10 2v5h4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 11.5l2 2 2-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 13.5v-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span>pngx<span class="nav-brand-dim">-controller</span></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<footer class="container-fluid" style="text-align:center; padding:1rem; color:var(--pico-muted-color); font-size:0.8em;">
|
<ul class="nav-links">
|
||||||
pngx-controller — <a href="/healthz">health</a> — <a href="/metrics">metrics</a>
|
<li><a href="/">
|
||||||
</footer>
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><rect x=".75" y=".75" width="4.5" height="4.5" rx=".75" stroke="currentColor" stroke-width="1.3"/><rect x="7.75" y=".75" width="4.5" height="4.5" rx=".75" stroke="currentColor" stroke-width="1.3"/><rect x=".75" y="7.75" width="4.5" height="4.5" rx=".75" stroke="currentColor" stroke-width="1.3"/><rect x="7.75" y="7.75" width="4.5" height="4.5" rx=".75" stroke="currentColor" stroke-width="1.3"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a></li>
|
||||||
|
<li><a href="/replicas">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><rect x=".75" y="1.75" width="11.5" height="3.5" rx=".75" stroke="currentColor" stroke-width="1.3"/><rect x=".75" y="7.75" width="11.5" height="3.5" rx=".75" stroke="currentColor" stroke-width="1.3"/><circle cx="10.25" cy="3.5" r=".75" fill="currentColor"/><circle cx="10.25" cy="9.5" r=".75" fill="currentColor"/></svg>
|
||||||
|
Replicas
|
||||||
|
</a></li>
|
||||||
|
<li><a href="/logs">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M1.5 3h10M1.5 6.5h7M1.5 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
Logs
|
||||||
|
</a></li>
|
||||||
|
<li><a href="/settings">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><circle cx="6.5" cy="6.5" r="1.75" stroke="currentColor" stroke-width="1.3"/><path d="M6.5 1v1.25m0 8.5V12M1 6.5h1.25m8.5 0H12M2.87 2.87l.88.88m5 5 .88.88M2.87 10.13l.88-.88m5-5 .88-.88" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||||
|
Settings
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="nav-meta">
|
||||||
|
<a href="/healthz">health</a>
|
||||||
|
<a href="/metrics">metrics</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
document.querySelectorAll('.nav-links a').forEach(function(a) {
|
||||||
|
const href = a.getAttribute('href');
|
||||||
|
if (href === '/' ? path === '/' : path.startsWith(href)) {
|
||||||
|
a.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,37 +2,41 @@
|
|||||||
{% block title %}Dashboard — pngx-controller{% endblock %}
|
{% block title %}Dashboard — pngx-controller{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:1rem; margin-bottom:1rem;">
|
<div class="page-hd">
|
||||||
<h2 style="margin:0;">Dashboard</h2>
|
<div>
|
||||||
<div style="display:flex; gap:0.5rem; align-items:center;">
|
<h2>Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
<div class="page-actions">
|
||||||
<button id="sync-btn"
|
<button id="sync-btn"
|
||||||
hx-post="/api/sync"
|
hx-post="/api/sync"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::after-request="startPolling()"
|
hx-on::after-request="startPolling()"
|
||||||
class="{% if progress.running %}secondary{% endif %}">
|
class="{% if progress.running %}secondary{% endif %}">
|
||||||
{% if progress.running %}Syncing…{% else %}⟳ Sync Now{% endif %}
|
{% if progress.running %}
|
||||||
|
Syncing…
|
||||||
|
{% else %}
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M11 6.5A4.5 4.5 0 112.5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2.5 1.5V4H5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Sync Now
|
||||||
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if flash %}
|
{% if flash %}
|
||||||
<div role="alert" style="padding:0.6rem 1rem; margin-bottom:1rem; background:#eff6ff; color:#1d4ed8; border-radius:0.4rem; border-left:3px solid #1d4ed8; font-size:0.95em;">
|
<div role="alert">{{ flash }}</div>
|
||||||
{{ flash }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Progress bar -->
|
|
||||||
<div id="progress-bar" class="{% if progress.running %}active{% endif %}"
|
<div id="progress-bar" class="{% if progress.running %}active{% endif %}"
|
||||||
hx-get="/api/sync/running"
|
hx-get="/api/sync/running"
|
||||||
hx-trigger="every 2s"
|
hx-trigger="every 2s"
|
||||||
hx-target="#progress-inner"
|
hx-target="#progress-inner"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<article style="padding:0.75rem;">
|
<article>
|
||||||
<div id="progress-inner">
|
<div id="progress-inner">
|
||||||
{% if progress.running %}
|
{% if progress.running %}
|
||||||
<strong>{{ progress.phase }}</strong>
|
<strong style="font-size:.85rem;">{{ progress.phase }}</strong>
|
||||||
{% if progress.docs_total > 0 %}
|
{% if progress.docs_total > 0 %}
|
||||||
— {{ progress.docs_done }} / {{ progress.docs_total }} documents
|
<span style="color:var(--text-3); font-size:.8rem; margin-left:.5rem;">{{ progress.docs_done }} / {{ progress.docs_total }} documents</span>
|
||||||
<progress value="{{ progress.docs_done }}" max="{{ progress.docs_total }}"></progress>
|
<progress value="{{ progress.docs_done }}" max="{{ progress.docs_total }}"></progress>
|
||||||
{% else %}
|
{% else %}
|
||||||
<progress></progress>
|
<progress></progress>
|
||||||
@@ -42,33 +46,31 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Last sync summary -->
|
|
||||||
{% if last_run %}
|
{% if last_run %}
|
||||||
<details>
|
<details style="margin-bottom:1.25rem;">
|
||||||
<summary>
|
<summary>
|
||||||
Last sync run:
|
Last sync run —
|
||||||
{% if last_run.finished_at %}
|
{% if last_run.finished_at %}
|
||||||
finished {{ last_run.finished_at.strftime('%Y-%m-%d %H:%M:%S') }} UTC
|
{{ last_run.finished_at.strftime('%Y-%m-%d %H:%M') }} UTC
|
||||||
— {{ last_run.docs_synced }} synced, {{ last_run.docs_failed }} failed
|
· {{ last_run.docs_synced }} synced, {{ last_run.docs_failed }} failed
|
||||||
{% if last_run.timed_out %}<span class="badge badge-error">timed out</span>{% endif %}
|
{% if last_run.timed_out %} <span class="badge badge-warning">timed out</span>{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
running…
|
running…
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</summary>
|
</summary>
|
||||||
<p>Triggered by: <code>{{ last_run.triggered_by }}</code> — Run #{{ last_run.id }}</p>
|
<p style="font-size:.83rem; color:var(--text-2); padding-top:.5rem;">Triggered by: <code>{{ last_run.triggered_by }}</code> — Run #{{ last_run.id }}</p>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Live doc counts -->
|
|
||||||
<div id="doc-counts"
|
<div id="doc-counts"
|
||||||
hx-get="/ui/doc-counts"
|
hx-get="/ui/doc-counts"
|
||||||
hx-trigger="load, every 120s"
|
hx-trigger="load, every 120s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<small class="muted">Loading document counts…</small>
|
<small class="muted">Loading document counts…</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replica table -->
|
|
||||||
<h3>Replicas</h3>
|
<h3>Replicas</h3>
|
||||||
|
|
||||||
{% if replica_rows %}
|
{% if replica_rows %}
|
||||||
<div class="overflow-auto">
|
<div class="overflow-auto">
|
||||||
<table>
|
<table>
|
||||||
@@ -85,29 +87,31 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for row in replica_rows %}
|
{% for row in replica_rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/replicas/{{ row.replica.id }}">{{ row.replica.name }}</a></td>
|
<td><a href="/replicas/{{ row.replica.id }}" style="font-weight:500;">{{ row.replica.name }}</a></td>
|
||||||
<td><small>{{ row.replica.url }}</small></td>
|
<td><code>{{ row.replica.url }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-{{ row.status }}">{{ row.status }}</span>
|
<span class="badge badge-{{ row.status }}">{{ row.status }}</span>
|
||||||
{% if row.replica.suspended_at %}
|
{% if row.replica.suspended_at %}
|
||||||
<br><small class="muted">{{ row.replica.consecutive_failures }} failures</small>
|
<br><small class="muted">{{ row.replica.consecutive_failures }} failures</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.lag }}</td>
|
<td style="color:var(--text-2); font-size:.82rem;">{{ row.lag }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if row.last_run %}
|
{% if row.last_run %}
|
||||||
<small>
|
<small>
|
||||||
✓ {{ row.last_run.docs_synced }}
|
<span style="color:var(--ok);">✓</span> {{ row.last_run.docs_synced }}
|
||||||
{% if row.last_run.docs_failed %} · ✗ <a href="/logs?replica_id={{ row.replica.id }}">{{ row.last_run.docs_failed }}</a>{% endif %}
|
{% if row.last_run.docs_failed %}
|
||||||
|
<span style="color:var(--err);">✗</span> <a href="/logs?replica_id={{ row.replica.id }}" style="color:var(--err);">{{ row.last_run.docs_failed }}</a>
|
||||||
|
{% endif %}
|
||||||
</small>
|
</small>
|
||||||
{% else %}
|
{% else %}
|
||||||
<small class="muted">never</small>
|
<small class="muted">never</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a href="/replicas/{{ row.replica.id }}" role="button" class="secondary outline" style="padding:0.2em 0.6em; font-size:0.8em;">Details</a>
|
<a href="/replicas/{{ row.replica.id }}" role="button" class="secondary outline" style="padding:.25em .65em; font-size:.78rem;">Details</a>
|
||||||
{% if row.replica.suspended_at %}
|
{% if row.replica.suspended_at %}
|
||||||
<button class="contrast outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
<button class="contrast outline" style="padding:.25em .65em; font-size:.78rem;"
|
||||||
hx-post="/api/replicas/{{ row.replica.id }}/unsuspend"
|
hx-post="/api/replicas/{{ row.replica.id }}/unsuspend"
|
||||||
hx-on::after-request="window.location.reload()">
|
hx-on::after-request="window.location.reload()">
|
||||||
Re-enable
|
Re-enable
|
||||||
@@ -120,27 +124,25 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No replicas configured. <a href="/replicas">Add one →</a></p>
|
<p>No replicas configured. <a href="/replicas">Add one →</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
document.getElementById('progress-bar').classList.add('active');
|
document.getElementById('progress-bar').classList.add('active');
|
||||||
document.getElementById('sync-btn').textContent = 'Syncing…';
|
const btn = document.getElementById('sync-btn');
|
||||||
document.getElementById('sync-btn').setAttribute('disabled', true);
|
btn.textContent = 'Syncing\u2026';
|
||||||
// polling is driven by hx-trigger="every 2s" on the progress bar
|
btn.setAttribute('disabled', true);
|
||||||
// stop when running=false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch progress updates to hide bar when done
|
|
||||||
document.body.addEventListener('htmx:afterSettle', function(evt) {
|
document.body.addEventListener('htmx:afterSettle', function(evt) {
|
||||||
if (evt.detail.target && evt.detail.target.id === 'progress-inner') {
|
if (evt.detail.target && evt.detail.target.id === 'progress-inner') {
|
||||||
// Re-read progress via a quick fetch
|
|
||||||
fetch('/api/sync/running').then(r => r.json()).then(data => {
|
fetch('/api/sync/running').then(r => r.json()).then(data => {
|
||||||
if (!data.running) {
|
if (!data.running) {
|
||||||
document.getElementById('progress-bar').classList.remove('active');
|
document.getElementById('progress-bar').classList.remove('active');
|
||||||
document.getElementById('sync-btn').textContent = '⟳ Sync Now';
|
const btn = document.getElementById('sync-btn');
|
||||||
document.getElementById('sync-btn').removeAttribute('disabled');
|
btn.innerHTML = '<svg width="13" height="13" viewBox="0 0 13 13" fill="none"><path d="M11 6.5A4.5 4.5 0 112.5 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2.5 1.5V4H5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> Sync Now';
|
||||||
|
btn.removeAttribute('disabled');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,56 +2,64 @@
|
|||||||
{% block title %}Logs — pngx-controller{% endblock %}
|
{% block title %}Logs — pngx-controller{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h2>Logs</h2>
|
<div class="page-hd">
|
||||||
|
<h2>Logs</h2>
|
||||||
|
<div class="page-actions">
|
||||||
|
<button class="secondary outline"
|
||||||
|
hx-delete="/api/logs?older_than_days=90"
|
||||||
|
hx-confirm="Delete logs older than 90 days?"
|
||||||
|
hx-on::after-request="window.location.reload()">
|
||||||
|
Clear old logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:flex-end; margin-bottom:1rem;">
|
<div style="display:flex; gap:.85rem; flex-wrap:wrap; align-items:flex-end; margin-bottom:1.25rem;">
|
||||||
<label style="flex:1; min-width:150px;">
|
<label style="flex:1; min-width:140px; margin:0;">
|
||||||
Replica
|
Replica
|
||||||
<select id="filter-replica" onchange="applyFilters()">
|
<select id="filter-replica" onchange="applyFilters()">
|
||||||
<option value="">All</option>
|
<option value="">All replicas</option>
|
||||||
{% for r in replicas %}
|
{% for r in replicas %}
|
||||||
<option value="{{ r.id }}">{{ r.name }}</option>
|
<option value="{{ r.id }}">{{ r.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label style="flex:1; min-width:120px;">
|
<label style="flex:1; min-width:120px; margin:0;">
|
||||||
Level
|
Level
|
||||||
<select id="filter-level" onchange="applyFilters()">
|
<select id="filter-level" onchange="applyFilters()">
|
||||||
<option value="">All</option>
|
<option value="">All levels</option>
|
||||||
<option value="info">Info</option>
|
<option value="info">Info</option>
|
||||||
<option value="warning">Warning</option>
|
<option value="warning">Warning</option>
|
||||||
<option value="error">Error</option>
|
<option value="error">Error</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label style="flex:2; min-width:200px;">
|
<label style="flex:2; min-width:180px; margin:0;">
|
||||||
Search
|
Search
|
||||||
<input type="text" id="filter-q" placeholder="Full-text search…" oninput="debounceFilter()">
|
<input type="text" id="filter-q" placeholder="Full-text search…" oninput="debounceFilter()">
|
||||||
</label>
|
</label>
|
||||||
<button class="secondary outline" onclick="applyFilters()">Filter</button>
|
<button class="secondary outline" onclick="applyFilters()" style="align-self:flex-end; margin-bottom:0;">
|
||||||
<button class="contrast outline"
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><circle cx="5" cy="5" r="3.5" stroke="currentColor" stroke-width="1.4"/><path d="M8.5 8.5L11 11" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
|
||||||
hx-delete="/api/logs?older_than_days=90"
|
Filter
|
||||||
hx-confirm="Delete logs older than 90 days?"
|
|
||||||
hx-on::after-request="window.location.reload()">
|
|
||||||
Clear old logs
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live SSE tail -->
|
<h3>Live stream</h3>
|
||||||
|
|
||||||
<details open id="live-section">
|
<details open id="live-section">
|
||||||
<summary>Live log stream</summary>
|
<summary>Live log stream</summary>
|
||||||
<div id="sse-log"
|
<div id="sse-log"
|
||||||
hx-ext="sse"
|
hx-ext="sse"
|
||||||
sse-connect="/api/logs/stream"
|
sse-connect="/api/logs/stream"
|
||||||
style="font-family:monospace; font-size:0.8em; max-height:300px; overflow-y:auto; border:1px solid var(--pico-muted-border-color); padding:0.5rem; border-radius:0.5rem; background:var(--pico-code-background-color);">
|
class="sse-terminal"
|
||||||
|
style="margin-top:.75rem;">
|
||||||
<div sse-swap="message" hx-swap="afterbegin">
|
<div sse-swap="message" hx-swap="afterbegin">
|
||||||
<span class="muted">Connecting to log stream…</span>
|
<span class="muted">Connecting to log stream…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<hr>
|
<h3>Log history</h3>
|
||||||
|
|
||||||
<!-- Static log table -->
|
|
||||||
<div id="log-table-area">
|
<div id="log-table-area">
|
||||||
{% include "partials/log_table.html" %}
|
{% include "partials/log_table.html" %}
|
||||||
</div>
|
</div>
|
||||||
@@ -76,39 +84,41 @@ function applyFilters() {
|
|||||||
target: '#log-table-area',
|
target: '#log-table-area',
|
||||||
swap: 'innerHTML',
|
swap: 'innerHTML',
|
||||||
handler: function(elt, info) {
|
handler: function(elt, info) {
|
||||||
// render JSON as table
|
|
||||||
const data = JSON.parse(info.xhr.responseText);
|
const data = JSON.parse(info.xhr.responseText);
|
||||||
const rows = data.map(l => `
|
const rows = data.map(l => `
|
||||||
<tr class="log-entry-${l.level || 'info'}">
|
<tr class="log-entry-${l.level || 'info'}">
|
||||||
<td><small>${l.created_at || ''}</small></td>
|
<td><small style="font-family:var(--mono);">${l.created_at || ''}</small></td>
|
||||||
<td><span class="badge badge-${l.level}">${l.level || ''}</span></td>
|
<td><span class="badge badge-${l.level}">${l.level || ''}</span></td>
|
||||||
<td>${l.replica_id || ''}</td>
|
<td style="font-size:.82rem;">${l.replica_id || ''}</td>
|
||||||
<td>${l.doc_id || ''}</td>
|
<td style="font-family:var(--mono); font-size:.8rem;">${l.doc_id || ''}</td>
|
||||||
<td>${l.message || ''}</td>
|
<td style="font-size:.83rem;">${l.message || ''}</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
elt.innerHTML = `<table><thead><tr><th>Time</th><th>Level</th><th>Replica</th><th>Doc</th><th>Message</th></tr></thead><tbody>${rows}</tbody></table>`;
|
elt.innerHTML = `<div class="overflow-auto"><table><thead><tr><th>Time</th><th>Level</th><th>Replica</th><th>Doc</th><th>Message</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pretty-print SSE messages
|
|
||||||
document.body.addEventListener('htmx:sseMessage', function(e) {
|
document.body.addEventListener('htmx:sseMessage', function(e) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.detail.data);
|
const data = JSON.parse(e.detail.data);
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
const color = data.level === 'error' ? '#f87171' : data.level === 'warning' ? '#fbbf24' : '#6ee7b7';
|
const color = data.level === 'error' ? 'var(--err)' : data.level === 'warning' ? 'var(--warn)' : 'var(--ok)';
|
||||||
el.style.color = color;
|
el.style.color = color;
|
||||||
el.innerHTML = `[${data.ts}] ${data.level?.toUpperCase()} ${data.replica_id ? '[r:'+data.replica_id+']' : ''} ${data.doc_id ? '[d:'+data.doc_id+']' : ''} ${data.message}`;
|
el.style.marginBottom = '.1rem';
|
||||||
|
const ts = data.ts || '';
|
||||||
|
const lvl = (data.level || 'info').toUpperCase();
|
||||||
|
const rep = data.replica_id ? ' [r:' + data.replica_id + ']' : '';
|
||||||
|
const doc = data.doc_id ? ' [d:' + data.doc_id + ']' : '';
|
||||||
|
el.textContent = '[' + ts + '] ' + lvl + rep + doc + ' ' + (data.message || '');
|
||||||
const container = document.querySelector('#sse-log div[sse-swap]');
|
const container = document.querySelector('#sse-log div[sse-swap]');
|
||||||
if (container) {
|
if (container) {
|
||||||
const first = container.querySelector('span.muted');
|
const first = container.querySelector('span.muted');
|
||||||
if (first) first.remove();
|
if (first) first.remove();
|
||||||
container.insertBefore(el, container.firstChild);
|
container.insertBefore(el, container.firstChild);
|
||||||
// Keep max 200 entries
|
|
||||||
while (container.children.length > 200) container.removeChild(container.lastChild);
|
while (container.children.length > 200) container.removeChild(container.lastChild);
|
||||||
}
|
}
|
||||||
} catch(err) {}
|
} catch(err) {}
|
||||||
return false; // prevent default HTMX swap
|
return false;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr class="log-entry-{{ log.level or 'info' }}">
|
<tr class="log-entry-{{ log.level or 'info' }}">
|
||||||
<td><small>{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') if log.created_at else '' }}</small></td>
|
<td><small style="font-family:var(--mono);">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') if log.created_at else '' }}</small></td>
|
||||||
<td><span class="badge badge-{{ log.level or 'info' }}">{{ log.level or 'info' }}</span></td>
|
<td><span class="badge badge-{{ log.level or 'info' }}">{{ log.level or 'info' }}</span></td>
|
||||||
<td>{{ log.replica_id or '' }}</td>
|
<td style="font-size:.82rem;">{{ log.replica_id or '' }}</td>
|
||||||
<td>{{ log.doc_id or '' }}</td>
|
<td style="font-family:var(--mono); font-size:.8rem;">{{ log.doc_id or '' }}</td>
|
||||||
<td>{{ log.message or '' }}</td>
|
<td style="font-size:.83rem;">{{ log.message or '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:1rem;">
|
<div class="page-hd">
|
||||||
<h2 style="margin:0;">{{ replica.name }}</h2>
|
<h2>{{ replica.name }}</h2>
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
<div class="page-actions">
|
||||||
<button class="secondary outline"
|
<button class="secondary outline"
|
||||||
hx-post="/api/replicas/{{ replica.id }}/test"
|
hx-post="/api/replicas/{{ replica.id }}/test"
|
||||||
hx-target="#conn-result" hx-swap="innerHTML">
|
hx-target="#conn-result" hx-swap="innerHTML">
|
||||||
@@ -21,7 +21,8 @@
|
|||||||
hx-post="/api/sync?replica_id={{ replica.id }}"
|
hx-post="/api/sync?replica_id={{ replica.id }}"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::after-request="window.location.reload()">
|
hx-on::after-request="window.location.reload()">
|
||||||
⟳ Sync Now
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6A4 4 0 112 3.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><path d="M2 1.5V3.5H4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Sync Now
|
||||||
</button>
|
</button>
|
||||||
{% if replica.suspended_at %}
|
{% if replica.suspended_at %}
|
||||||
<button class="contrast"
|
<button class="contrast"
|
||||||
@@ -31,44 +32,48 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if replica.enabled and not replica.suspended_at %}
|
{% if replica.enabled and not replica.suspended_at %}
|
||||||
<button class="outline" style="margin-left:auto;" onclick="openPromoteDialog()">
|
<button class="secondary outline" onclick="openPromoteDialog()">
|
||||||
Promote to Master
|
Promote to Master
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span id="conn-result" style="margin-left:0.5rem;"></span>
|
<span id="conn-result"></span>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:1rem; margin: 1.5rem 0;">
|
<div class="stat-cards">
|
||||||
<article style="padding:1rem;">
|
<div class="stat-card">
|
||||||
<header style="margin-bottom:0.25rem;"><small class="muted">URL</small></header>
|
<div class="stat-card-label">URL</div>
|
||||||
<code>{{ replica.url }}</code>
|
<div class="stat-card-value"><code style="font-size:.78rem;">{{ replica.url }}</code></div>
|
||||||
</article>
|
</div>
|
||||||
<article style="padding:1rem;">
|
<div class="stat-card">
|
||||||
<header style="margin-bottom:0.25rem;"><small class="muted">Status</small></header>
|
<div class="stat-card-label">Status</div>
|
||||||
{% if replica.suspended_at %}
|
<div class="stat-card-value">
|
||||||
<span class="badge badge-suspended">suspended</span>
|
{% if replica.suspended_at %}
|
||||||
<br><small class="muted">{{ replica.consecutive_failures }} consecutive failures</small>
|
<span class="badge badge-suspended">suspended</span>
|
||||||
{% elif replica.last_sync_ts %}
|
<div class="stat-card-sub">{{ replica.consecutive_failures }} consecutive failures</div>
|
||||||
<span class="badge badge-synced">synced</span>
|
{% elif replica.last_sync_ts %}
|
||||||
{% else %}
|
<span class="badge badge-synced">synced</span>
|
||||||
<span class="badge badge-pending">pending</span>
|
{% else %}
|
||||||
{% endif %}
|
<span class="badge badge-pending">pending</span>
|
||||||
</article>
|
{% endif %}
|
||||||
<article style="padding:1rem;">
|
</div>
|
||||||
<header style="margin-bottom:0.25rem;"><small class="muted">Last sync</small></header>
|
</div>
|
||||||
{% if replica.last_sync_ts %}
|
<div class="stat-card">
|
||||||
{{ replica.last_sync_ts.strftime('%Y-%m-%d %H:%M:%S') }} UTC
|
<div class="stat-card-label">Last sync</div>
|
||||||
<br><small class="muted">{{ lag }}</small>
|
<div class="stat-card-value" style="font-size:.88rem;">
|
||||||
{% else %}
|
{% if replica.last_sync_ts %}
|
||||||
<small class="muted">never</small>
|
{{ replica.last_sync_ts.strftime('%Y-%m-%d %H:%M:%S') }} UTC
|
||||||
{% endif %}
|
<div class="stat-card-sub">{{ lag }}</div>
|
||||||
</article>
|
{% else %}
|
||||||
<article style="padding:1rem;">
|
<span class="muted">never</span>
|
||||||
<header style="margin-bottom:0.25rem;"><small class="muted">Interval</small></header>
|
{% endif %}
|
||||||
{% if replica.sync_interval_seconds %}{{ replica.sync_interval_seconds }}s{% else %}global{% endif %}
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-label">Interval</div>
|
||||||
|
<div class="stat-card-value">{% if replica.sync_interval_seconds %}{{ replica.sync_interval_seconds }}s{% else %}<span style="color:var(--text-2);">global</span>{% endif %}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Sync Run History (last 20)</h3>
|
<h3>Sync Run History (last 20)</h3>
|
||||||
@@ -81,22 +86,22 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for run in recent_runs %}
|
{% for run in recent_runs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ run.id }}</td>
|
<td style="color:var(--text-3); font-size:.8rem;">{{ run.id }}</td>
|
||||||
<td>{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S') if run.started_at else '—' }}</td>
|
<td style="font-size:.82rem;">{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S') if run.started_at else '—' }}</td>
|
||||||
<td>
|
<td style="font-size:.82rem; color:var(--text-2);">
|
||||||
{% if run.started_at and run.finished_at %}
|
{% if run.started_at and run.finished_at %}
|
||||||
{% set dur = (run.finished_at - run.started_at).total_seconds()|int %}
|
{% set dur = (run.finished_at - run.started_at).total_seconds()|int %}
|
||||||
{% if dur < 60 %}{{ dur }}s
|
{% if dur < 60 %}{{ dur }}s
|
||||||
{% else %}{{ dur // 60 }}m {{ dur % 60 }}s{% endif %}
|
{% else %}{{ dur // 60 }}m {{ dur % 60 }}s{% endif %}
|
||||||
{% elif run.started_at %}
|
{% elif run.started_at %}
|
||||||
running…
|
<span class="badge badge-syncing">running</span>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ run.docs_synced }}</td>
|
<td style="color:var(--ok);">{{ run.docs_synced }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if run.docs_failed %}
|
{% if run.docs_failed %}
|
||||||
<a href="/logs?replica_id={{ replica.id }}" class="badge badge-error">{{ run.docs_failed }}</a>
|
<a href="/logs?replica_id={{ replica.id }}" class="badge badge-error">{{ run.docs_failed }}</a>
|
||||||
{% else %}0{% endif %}
|
{% else %}<span style="color:var(--text-3);">0</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td><code>{{ run.triggered_by }}</code></td>
|
<td><code>{{ run.triggered_by }}</code></td>
|
||||||
<td>{% if run.timed_out %}<span class="badge badge-warning">timed out</span>{% endif %}</td>
|
<td>{% if run.timed_out %}<span class="badge badge-warning">timed out</span>{% endif %}</td>
|
||||||
@@ -119,12 +124,12 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in sync_map_page %}
|
{% for entry in sync_map_page %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ entry.master_doc_id }}</td>
|
<td style="font-family:var(--mono); font-size:.82rem;">{{ entry.master_doc_id }}</td>
|
||||||
<td>{{ entry.replica_doc_id or '—' }}</td>
|
<td style="font-family:var(--mono); font-size:.82rem;">{{ entry.replica_doc_id or '—' }}</td>
|
||||||
<td><span class="badge badge-{{ entry.status }}">{{ entry.status }}</span></td>
|
<td><span class="badge badge-{{ entry.status }}">{{ entry.status }}</span></td>
|
||||||
<td>{{ entry.last_synced.strftime('%Y-%m-%d %H:%M') if entry.last_synced else '—' }}</td>
|
<td style="font-size:.82rem; color:var(--text-2);">{{ entry.last_synced.strftime('%Y-%m-%d %H:%M') if entry.last_synced else '—' }}</td>
|
||||||
<td>{{ entry.retry_count }}</td>
|
<td style="color:var(--text-3);">{{ entry.retry_count }}</td>
|
||||||
<td><small>{{ entry.error_msg or '' }}</small></td>
|
<td><small style="color:var(--text-2);">{{ entry.error_msg or '' }}</small></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -134,23 +139,25 @@
|
|||||||
<p><small class="muted">No sync map entries yet.</small></p>
|
<p><small class="muted">No sync map entries yet.</small></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<details style="margin-top:2rem;">
|
<details>
|
||||||
<summary>Danger Zone</summary>
|
<summary>Danger Zone</summary>
|
||||||
<div style="padding:1rem; border:1px solid var(--pico-del-color); border-radius:0.5rem; margin-top:0.5rem;">
|
<div class="danger-zone" style="margin-top:.75rem;">
|
||||||
<p>Full resync wipes the sync map for this replica and re-syncs everything from scratch.</p>
|
<p>Full resync wipes the sync map for this replica and re-syncs everything from scratch.</p>
|
||||||
<button class="contrast"
|
<div style="display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
|
||||||
hx-post="/api/replicas/{{ replica.id }}/resync"
|
<button class="contrast"
|
||||||
hx-confirm="Wipe sync map and trigger full resync for {{ replica.name }}?"
|
hx-post="/api/replicas/{{ replica.id }}/resync"
|
||||||
hx-on::after-request="window.location.reload()">
|
hx-confirm="Wipe sync map and trigger full resync for {{ replica.name }}?"
|
||||||
Full Resync
|
hx-on::after-request="window.location.reload()">
|
||||||
</button>
|
Full Resync
|
||||||
<button class="secondary"
|
</button>
|
||||||
hx-post="/api/replicas/{{ replica.id }}/reconcile"
|
<button class="secondary"
|
||||||
hx-confirm="Run reconcile for {{ replica.name }}? This matches existing documents without re-uploading."
|
hx-post="/api/replicas/{{ replica.id }}/reconcile"
|
||||||
hx-target="#reconcile-result" hx-swap="innerHTML">
|
hx-confirm="Run reconcile for {{ replica.name }}? This matches existing documents without re-uploading."
|
||||||
Reconcile
|
hx-target="#reconcile-result" hx-swap="innerHTML">
|
||||||
</button>
|
Reconcile
|
||||||
<span id="reconcile-result" style="margin-left:0.5rem;"></span>
|
</button>
|
||||||
|
<span id="reconcile-result"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -158,19 +165,17 @@
|
|||||||
<dialog id="promote-dialog">
|
<dialog id="promote-dialog">
|
||||||
<article>
|
<article>
|
||||||
<header>
|
<header>
|
||||||
<button aria-label="Close" rel="prev" onclick="document.getElementById('promote-dialog').close()"></button>
|
<button aria-label="Close" onclick="document.getElementById('promote-dialog').close()"></button>
|
||||||
<h3 style="margin:0;">Promote <strong>{{ replica.name }}</strong> to Master</h3>
|
<h3>Promote <strong>{{ replica.name }}</strong> to Master</h3>
|
||||||
<p style="margin:0.25rem 0 0; font-size:0.875em; color:var(--pico-muted-color);">
|
<p style="margin:.2rem 0 0; font-size:.78rem; color:var(--text-3);">{{ replica.url }}</p>
|
||||||
{{ replica.url }}
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="promote-preflight-content" style="min-height:6rem;">
|
<div id="promote-preflight-content" style="min-height:6rem; padding:1.15rem 1.4rem;">
|
||||||
<progress></progress>
|
<progress></progress>
|
||||||
<p class="muted">Checking pre-flight conditions…</p>
|
<p class="muted" style="font-size:.83rem;">Checking pre-flight conditions…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer style="display:flex; justify-content:flex-end; gap:0.5rem; padding:0; margin-top:1.25rem;">
|
<footer style="display:flex; justify-content:flex-end; gap:.5rem;">
|
||||||
<button class="secondary outline" onclick="document.getElementById('promote-dialog').close()">Cancel</button>
|
<button class="secondary outline" onclick="document.getElementById('promote-dialog').close()">Cancel</button>
|
||||||
<button id="promote-confirm-btn" disabled onclick="doPromote()">Promote to Master</button>
|
<button id="promote-confirm-btn" disabled onclick="doPromote()">Promote to Master</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -186,7 +191,7 @@ async function openPromoteDialog() {
|
|||||||
const dialog = document.getElementById('promote-dialog');
|
const dialog = document.getElementById('promote-dialog');
|
||||||
const content = document.getElementById('promote-preflight-content');
|
const content = document.getElementById('promote-preflight-content');
|
||||||
const btn = document.getElementById('promote-confirm-btn');
|
const btn = document.getElementById('promote-confirm-btn');
|
||||||
content.innerHTML = '<progress></progress><p class="muted">Checking pre-flight conditions\u2026</p>';
|
content.innerHTML = '<progress></progress><p class="muted" style="font-size:.83rem;">Checking pre-flight conditions\u2026</p>';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
_preflightData = null;
|
_preflightData = null;
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
@@ -206,9 +211,9 @@ function renderPreflight(data) {
|
|||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
if (!data.can_promote) {
|
if (!data.can_promote) {
|
||||||
html += '<div style="padding:0.75rem 1rem; background:#fff5f5; border-radius:0.4rem; border-left:3px solid var(--pico-del-color);">';
|
html += '<div style="padding:.75rem 1rem; background:rgba(244,112,103,.08); border-radius:4px; border-left:3px solid #f47067;">';
|
||||||
html += '<strong>' + (data.error || 'Cannot promote') + '</strong><br>';
|
html += '<strong>' + (data.error || 'Cannot promote') + '</strong><br>';
|
||||||
html += '<small>' + (data.detail || '') + '</small>';
|
html += '<small style="color:var(--text-2);">' + (data.detail || '') + '</small>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -216,33 +221,36 @@ function renderPreflight(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Summary cards
|
// Summary cards
|
||||||
html += '<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:0.75rem; margin-bottom:1rem;">';
|
html += '<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:.65rem; margin-bottom:1rem;">';
|
||||||
html += '<div style="padding:0.6rem 0.75rem; background:var(--pico-card-sectionning-background-color); border-radius:0.4rem; text-align:center;">';
|
[
|
||||||
html += '<small class="muted">Master docs</small><br><strong>' + (data.master_doc_count ?? '?') + '</strong></div>';
|
['Master docs', data.master_doc_count ?? '?'],
|
||||||
html += '<div style="padding:0.6rem 0.75rem; background:var(--pico-card-sectionning-background-color); border-radius:0.4rem; text-align:center;">';
|
['Replica docs', data.replica_doc_count ?? '?'],
|
||||||
html += '<small class="muted">Replica docs</small><br><strong>' + (data.replica_doc_count ?? '?') + '</strong></div>';
|
['Mappings to rebuild', data.ok_entries],
|
||||||
html += '<div style="padding:0.6rem 0.75rem; background:var(--pico-card-sectionning-background-color); border-radius:0.4rem; text-align:center;">';
|
].forEach(function([label, val]) {
|
||||||
html += '<small class="muted">Mappings to rebuild</small><br><strong>' + data.ok_entries + '</strong></div>';
|
html += '<div style="padding:.55rem .75rem; background:#1d2235; border-radius:4px; text-align:center;">';
|
||||||
|
html += '<div style="font-size:.68rem; text-transform:uppercase; letter-spacing:.07em; color:var(--text-3); margin-bottom:.25rem;">' + label + '</div>';
|
||||||
|
html += '<strong style="font-size:1.1rem;">' + val + '</strong></div>';
|
||||||
|
});
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Old master name field
|
// Old master name
|
||||||
html += '<label style="margin-bottom:0.75rem; display:block;">Name for the current master (it becomes a new replica)<input type="text" id="old-master-name" value="old-master" required oninput="updateConfirmBtn()" style="margin-top:0.25rem;"></label>';
|
html += '<label style="display:block; margin-bottom:.75rem;">Name for the current master (it becomes a new replica)<input type="text" id="old-master-name" value="old-master" required oninput="updateConfirmBtn()"></label>';
|
||||||
|
|
||||||
// Warnings with ack checkboxes
|
// Ack checkboxes
|
||||||
if (data.pending_entries > 0) {
|
if (data.pending_entries > 0) {
|
||||||
html += '<label style="display:flex; gap:0.5rem; align-items:flex-start; margin-bottom:0.5rem; cursor:pointer;">';
|
html += '<label style="display:flex; gap:.5rem; align-items:flex-start; margin-bottom:.5rem; cursor:pointer; font-size:.85rem;">';
|
||||||
html += '<input type="checkbox" id="ack-pending" onchange="updateConfirmBtn()" style="margin-top:0.1rem; flex-shrink:0;">';
|
html += '<input type="checkbox" id="ack-pending" onchange="updateConfirmBtn()" style="margin-top:.15rem; flex-shrink:0;">';
|
||||||
html += '<span><strong>' + data.pending_entries + ' pending entries</strong> \u2014 these documents may be re-uploaded after promotion.</span>';
|
html += '<span><strong>' + data.pending_entries + ' pending entries</strong> \u2014 these documents may be re-uploaded after promotion.</span>';
|
||||||
html += '</label>';
|
html += '</label>';
|
||||||
}
|
}
|
||||||
if (data.error_entries > 0) {
|
if (data.error_entries > 0) {
|
||||||
html += '<label style="display:flex; gap:0.5rem; align-items:flex-start; margin-bottom:0.5rem; cursor:pointer;">';
|
html += '<label style="display:flex; gap:.5rem; align-items:flex-start; margin-bottom:.5rem; cursor:pointer; font-size:.85rem;">';
|
||||||
html += '<input type="checkbox" id="ack-errors" onchange="updateConfirmBtn()" style="margin-top:0.1rem; flex-shrink:0;">';
|
html += '<input type="checkbox" id="ack-errors" onchange="updateConfirmBtn()" style="margin-top:.15rem; flex-shrink:0;">';
|
||||||
html += '<span><strong>' + data.error_entries + ' error entries</strong> \u2014 these documents will be re-synced after promotion.</span>';
|
html += '<span><strong>' + data.error_entries + ' error entries</strong> \u2014 these documents will be re-synced after promotion.</span>';
|
||||||
html += '</label>';
|
html += '</label>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '<p style="margin:0.75rem 0 0; font-size:0.875em; color:var(--pico-muted-color);">The current master will be added as a new replica and will sync from <strong>' + REPLICA_NAME + '</strong> going forward.</p>';
|
html += '<p style="margin:.75rem 0 0; font-size:.8rem; color:var(--text-3);">The current master will be added as a new replica and will sync from <strong style="color:var(--text-2);">' + REPLICA_NAME + '</strong> going forward.</p>';
|
||||||
|
|
||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
updateConfirmBtn();
|
updateConfirmBtn();
|
||||||
@@ -287,15 +295,15 @@ async function doPromote() {
|
|||||||
} else {
|
} else {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
const detail = err.detail?.detail || err.detail || 'Unknown error';
|
const detail = err.detail?.detail || err.detail || 'Unknown error';
|
||||||
const content = document.getElementById('promote-preflight-content');
|
document.getElementById('promote-preflight-content').innerHTML +=
|
||||||
content.innerHTML += '<p style="margin-top:0.75rem;"><span class="badge badge-error">Error</span> ' + detail + '</p>';
|
'<p style="margin-top:.75rem;"><span class="badge badge-error">Error</span> ' + detail + '</p>';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.removeAttribute('aria-busy');
|
btn.removeAttribute('aria-busy');
|
||||||
btn.textContent = 'Promote to Master';
|
btn.textContent = 'Promote to Master';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const content = document.getElementById('promote-preflight-content');
|
document.getElementById('promote-preflight-content').innerHTML +=
|
||||||
content.innerHTML += '<p style="margin-top:0.75rem;"><span class="badge badge-error">Network error</span> ' + err + '</p>';
|
'<p style="margin-top:.75rem;"><span class="badge badge-error">Network error</span> ' + err + '</p>';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.removeAttribute('aria-busy');
|
btn.removeAttribute('aria-busy');
|
||||||
btn.textContent = 'Promote to Master';
|
btn.textContent = 'Promote to Master';
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
{% block title %}Replicas — pngx-controller{% endblock %}
|
{% block title %}Replicas — pngx-controller{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:1rem; margin-bottom:1.5rem;">
|
<div class="page-hd">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="margin:0;">Replicas</h2>
|
<h2>Replicas</h2>
|
||||||
<small class="muted">Paperless-ngx instances that receive documents from the master.</small>
|
<div class="page-hd-sub">Paperless-ngx instances that receive documents from the master.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for r in replicas %}
|
{% for r in replicas %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/replicas/{{ r.id }}">{{ r.name }}</a></td>
|
<td><a href="/replicas/{{ r.id }}" style="font-weight:500;">{{ r.name }}</a></td>
|
||||||
<td><small><code>{{ r.url }}</code></small></td>
|
<td><code>{{ r.url }}</code></td>
|
||||||
<td>
|
<td>
|
||||||
{% if r.suspended_at %}
|
{% if r.suspended_at %}
|
||||||
<span class="badge badge-suspended">suspended</span>
|
<span class="badge badge-suspended">suspended</span>
|
||||||
@@ -37,38 +37,38 @@
|
|||||||
<span class="badge badge-ok">enabled</span>
|
<span class="badge badge-ok">enabled</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td style="color:var(--text-2); font-size:.82rem;">
|
||||||
{% if r.sync_interval_seconds %}{{ r.sync_interval_seconds }}s{% else %}<small class="muted">global</small>{% endif %}
|
{% if r.sync_interval_seconds %}{{ r.sync_interval_seconds }}s{% else %}<small class="muted">global</small>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td style="color:var(--text-2); font-size:.82rem;">
|
||||||
{% if r.last_sync_ts %}{{ r.last_sync_ts.strftime('%Y-%m-%d %H:%M') }} UTC{% else %}<small class="muted">never</small>{% endif %}
|
{% if r.last_sync_ts %}{{ r.last_sync_ts.strftime('%Y-%m-%d %H:%M') }} UTC{% else %}<small class="muted">never</small>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<a href="/replicas/{{ r.id }}" role="button" class="secondary outline" style="padding:0.2em 0.6em; font-size:0.8em;">Detail</a>
|
<a href="/replicas/{{ r.id }}" role="button" class="secondary outline" style="padding:.25em .65em; font-size:.78rem;">Detail</a>
|
||||||
<button class="outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
<button class="outline" style="padding:.25em .65em; font-size:.78rem;"
|
||||||
hx-post="/api/replicas/{{ r.id }}/test"
|
hx-post="/api/replicas/{{ r.id }}/test"
|
||||||
hx-target="#test-result-{{ r.id }}"
|
hx-target="#test-result-{{ r.id }}"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
Test
|
Test
|
||||||
</button>
|
</button>
|
||||||
{% if r.suspended_at %}
|
{% if r.suspended_at %}
|
||||||
<button class="contrast outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
<button class="contrast outline" style="padding:.25em .65em; font-size:.78rem;"
|
||||||
hx-post="/api/replicas/{{ r.id }}/unsuspend"
|
hx-post="/api/replicas/{{ r.id }}/unsuspend"
|
||||||
hx-on::after-request="window.location.reload()">
|
hx-on::after-request="window.location.reload()">
|
||||||
Re-enable
|
Re-enable
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="secondary outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
<button class="secondary outline" style="padding:.25em .65em; font-size:.78rem;"
|
||||||
onclick="openEditModal({{ r.id }}, '{{ r.name }}', '{{ r.url }}', {{ r.enabled|lower }}, {{ r.sync_interval_seconds or 'null' }})">
|
onclick="openEditModal({{ r.id }}, '{{ r.name }}', '{{ r.url }}', {{ r.enabled|lower }}, {{ r.sync_interval_seconds or 'null' }})">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button class="contrast outline" style="padding:0.2em 0.6em; font-size:0.8em;"
|
<button class="contrast outline" style="padding:.25em .65em; font-size:.78rem;"
|
||||||
hx-delete="/api/replicas/{{ r.id }}"
|
hx-delete="/api/replicas/{{ r.id }}"
|
||||||
hx-confirm="Delete replica {{ r.name }}? This also removes its sync map."
|
hx-confirm="Delete replica {{ r.name }}? This also removes its sync map."
|
||||||
hx-on::after-request="window.location.reload()">
|
hx-on::after-request="window.location.reload()">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<span id="test-result-{{ r.id }}" style="font-size:0.8em;"></span>
|
<span id="test-result-{{ r.id }}" style="font-size:.78rem;"></span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -79,102 +79,94 @@
|
|||||||
<p class="muted" style="margin-bottom:1.5rem;">No replicas configured yet.</p>
|
<p class="muted" style="margin-bottom:1.5rem;">No replicas configured yet.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr style="margin:2rem 0;">
|
<h3>Add Replica</h3>
|
||||||
|
|
||||||
<h3 style="margin-bottom:0.25rem;">Add Replica</h3>
|
|
||||||
<p style="margin-top:0; margin-bottom:1.5rem;"><small class="muted">The controller will test the connection before saving.</small></p>
|
|
||||||
|
|
||||||
{% if env_replicas %}
|
{% if env_replicas %}
|
||||||
<div style="margin-bottom:1.25rem; padding:0.75rem 1rem; background:var(--pico-card-background-color); border:1px solid var(--pico-muted-border-color); border-radius:var(--pico-border-radius);">
|
<div class="env-detected">
|
||||||
<small style="font-weight:600; text-transform:uppercase; letter-spacing:0.05em; color:var(--pico-muted-color);">Detected in pngx.env — not yet added</small>
|
<div class="env-detected-label">Detected in pngx.env — not yet added</div>
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-top:0.5rem;">
|
<div style="display:flex; flex-wrap:wrap; gap:.4rem; margin-top:.4rem;">
|
||||||
{% for er in env_replicas %}
|
{% for er in env_replicas %}
|
||||||
{% set er_name = er.safe | lower | replace('_', '-') %}
|
{% set er_name = er.safe | lower | replace('_', '-') %}
|
||||||
<button type="button" class="outline" style="font-size:0.85em; padding:0.3em 0.8em;"
|
<button type="button" class="outline" style="font-size:.8rem; padding:.3em .75em;"
|
||||||
onclick="prefillAdd('{{ er_name }}', '{{ er.url }}', '{{ er.token }}')">
|
onclick="prefillAdd('{{ er_name }}', '{{ er.url }}', '{{ er.token }}')">
|
||||||
+ {{ er.safe }} <small style="opacity:0.6; margin-left:0.25em;">{{ er.url }}</small>
|
+ {{ er.safe }} <span style="opacity:.5; margin-left:.2em; font-size:.75em;">{{ er.url }}</span>
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns:1fr 360px; gap:2rem; align-items:start;">
|
<div class="grid-form-sidebar">
|
||||||
|
|
||||||
<!-- Add form -->
|
<article style="margin:0;">
|
||||||
<article style="margin:0; padding:1.5rem;">
|
<p style="margin-bottom:1rem; font-size:.8rem; color:var(--text-3);">The controller will test the connection before saving.</p>
|
||||||
<form id="add-replica-form" onsubmit="submitAddReplica(event)">
|
<form id="add-replica-form" onsubmit="submitAddReplica(event)">
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
<div class="grid-2">
|
||||||
<label>
|
<label>
|
||||||
Display name
|
Display name
|
||||||
<input type="text" name="name" required placeholder="e.g. berlin-office">
|
<input type="text" name="name" required placeholder="e.g. berlin-office">
|
||||||
<small class="muted">Short identifier shown in the dashboard.</small>
|
<small>Short identifier shown in the dashboard.</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Instance URL
|
Instance URL
|
||||||
<input type="url" name="url" required placeholder="http://100.x.x.x:8000">
|
<input type="url" name="url" required placeholder="http://100.x.x.x:8000">
|
||||||
<small class="muted">Must be reachable from this controller.</small>
|
<small>Must be reachable from this controller.</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin-top:0.5rem;">
|
<div class="grid-2" style="margin-top:.75rem;">
|
||||||
<label>
|
<label>
|
||||||
API token
|
API token
|
||||||
<input type="password" name="api_token" required autocomplete="off">
|
<input type="password" name="api_token" required autocomplete="off">
|
||||||
<small class="muted">From Paperless → Settings → API → Auth Tokens.</small>
|
<small>From Paperless → Settings → API → Auth Tokens.</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Sync interval
|
Sync interval
|
||||||
<input type="number" name="sync_interval_seconds" placeholder="Leave blank to use global" min="60">
|
<input type="number" name="sync_interval_seconds" placeholder="Leave blank to use global" min="60">
|
||||||
<small class="muted">Seconds between syncs. Overrides the global setting.</small>
|
<small>Seconds between syncs. Overrides the global setting.</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:1rem;">
|
<div style="margin-top:.9rem;">
|
||||||
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
<label style="display:flex; align-items:center; gap:.5rem; cursor:pointer; font-size:.85rem;">
|
||||||
<input type="checkbox" name="enabled" checked style="margin:0;">
|
<input type="checkbox" name="enabled" checked>
|
||||||
Enable immediately after adding
|
Enable immediately after adding
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="add-test-result" style="margin:0.75rem 0; min-height:1.5em;"></div>
|
<div id="add-test-result" style="margin:.75rem 0; min-height:1.5em;"></div>
|
||||||
<button type="submit" style="margin-bottom:0;">Add Replica</button>
|
<button type="submit" style="margin-bottom:0;">Add Replica</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<!-- Help sidebar -->
|
|
||||||
<aside>
|
<aside>
|
||||||
<article style="margin:0; padding:1.25rem; background:var(--pico-card-sectionning-background-color);">
|
<article style="margin:0; padding:1.1rem;">
|
||||||
<p style="margin:0 0 1rem; font-weight:600; font-size:0.95em;">Before you add a replica</p>
|
<p style="margin:0 0 .9rem; font-weight:600; font-size:.875rem; color:var(--text);">Before you add a replica</p>
|
||||||
|
<ol style="margin:0 0 1.1rem; padding-left:1.25rem; display:flex; flex-direction:column; gap:.65rem;">
|
||||||
<ol style="margin:0 0 1.25rem; padding-left:1.25rem; display:flex; flex-direction:column; gap:0.75rem;">
|
<li style="font-size:.83rem; color:var(--text-2);">
|
||||||
<li>
|
<strong style="color:var(--text); display:block;">Verify reachability</strong>
|
||||||
<strong>Verify reachability</strong><br>
|
The replica’s URL must be reachable from this controller. On Tailscale, use the Tailscale IP and port.
|
||||||
<small class="muted">The replica's URL must be reachable from this controller. On Tailscale, use the replica's Tailscale IP and port.</small>
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li style="font-size:.83rem; color:var(--text-2);">
|
||||||
<strong>Generate an API token</strong><br>
|
<strong style="color:var(--text); display:block;">Generate an API token</strong>
|
||||||
<small class="muted">In the replica's Paperless UI go to <strong>Settings → API → Auth Tokens</strong> and create a token for a superuser account.</small>
|
In the replica’s Paperless UI go to <strong>Settings → API → Auth Tokens</strong> and create a token for a superuser account.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li style="font-size:.83rem; color:var(--text-2);">
|
||||||
<strong>Decide on sync interval</strong><br>
|
<strong style="color:var(--text); display:block;">Decide on sync interval</strong>
|
||||||
<small class="muted">Leave blank to use the global interval (set in Settings). Override per-replica if this instance needs a different cadence.</small>
|
Leave blank to use the global interval (set in Settings). Override per-replica if this instance needs a different cadence.
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<hr style="margin:.85rem 0;">
|
||||||
<hr style="margin:1rem 0;">
|
<p style="margin:0 0 .4rem; font-weight:600; font-size:.83rem; color:var(--text);">Replica already has documents?</p>
|
||||||
|
<p style="margin:0; font-size:.8rem; color:var(--text-2);">
|
||||||
<p style="margin:0 0 0.5rem; font-weight:600; font-size:0.9em;">Replica already has documents?</p>
|
Add it normally — then open its <strong>Detail</strong> page and run
|
||||||
<p style="margin:0; font-size:0.875em; color:var(--pico-muted-color);">
|
|
||||||
Add it normally — then open its <strong>Detail</strong> page and run
|
|
||||||
<strong>Reconcile</strong> before the first sync. Reconcile matches
|
<strong>Reconcile</strong> before the first sync. Reconcile matches
|
||||||
existing documents to the master without re-uploading them, preventing
|
existing documents to the master without re-uploading them, preventing duplicates.
|
||||||
duplicates.
|
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article style="margin:0.75rem 0 0; padding:1rem 1.25rem; border-left:3px solid var(--pico-primary);">
|
<article style="margin:.75rem 0 0; padding:.9rem 1.1rem; border-left:3px solid var(--accent);">
|
||||||
<p style="margin:0; font-size:0.875em;">
|
<p style="margin:0; font-size:.8rem; color:var(--text-2);">
|
||||||
<strong>What gets synced?</strong><br>
|
<strong style="color:var(--text); display:block; margin-bottom:.2rem;">What gets synced?</strong>
|
||||||
<span class="muted">Documents, tags, correspondents, document types, and custom fields.
|
Documents, tags, correspondents, document types, and custom fields.
|
||||||
The master always wins — replicas are read-only by convention.</span>
|
The master always wins — replicas are read-only by convention.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -185,26 +177,29 @@
|
|||||||
<dialog id="edit-modal">
|
<dialog id="edit-modal">
|
||||||
<article>
|
<article>
|
||||||
<header>
|
<header>
|
||||||
<h3 style="margin:0;">Edit Replica</h3>
|
<button aria-label="Close" onclick="document.getElementById('edit-modal').close()"></button>
|
||||||
|
<h3>Edit Replica</h3>
|
||||||
</header>
|
</header>
|
||||||
<form id="edit-replica-form" onsubmit="submitEditReplica(event)">
|
<form id="edit-replica-form" onsubmit="submitEditReplica(event)" style="padding:0;">
|
||||||
<input type="hidden" name="id">
|
<div style="padding:1.15rem 1.4rem; display:flex; flex-direction:column; gap:.75rem;">
|
||||||
<label>Display name <input type="text" name="name" required></label>
|
<input type="hidden" name="id">
|
||||||
<label>Instance URL <input type="url" name="url" required></label>
|
<label>Display name <input type="text" name="name" required></label>
|
||||||
<label>
|
<label>Instance URL <input type="url" name="url" required></label>
|
||||||
API Token
|
<label>
|
||||||
<input type="password" name="api_token" placeholder="Leave blank to keep current" autocomplete="off">
|
API Token
|
||||||
<small class="muted">Only fill this if you want to update the token.</small>
|
<input type="password" name="api_token" placeholder="Leave blank to keep current" autocomplete="off">
|
||||||
</label>
|
<small>Only fill this if you want to update the token.</small>
|
||||||
<label>
|
</label>
|
||||||
Sync interval (seconds)
|
<label>
|
||||||
<input type="number" name="sync_interval_seconds" min="60" placeholder="Leave blank to use global">
|
Sync interval (seconds)
|
||||||
</label>
|
<input type="number" name="sync_interval_seconds" min="60" placeholder="Leave blank to use global">
|
||||||
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
</label>
|
||||||
<input type="checkbox" name="enabled" style="margin:0;"> Enabled
|
<label style="display:flex; align-items:center; gap:.5rem; cursor:pointer; font-size:.85rem; margin-top:.1rem;">
|
||||||
</label>
|
<input type="checkbox" name="enabled"> Enabled
|
||||||
<div id="edit-test-result" style="margin:0.5rem 0; min-height:1.5em;"></div>
|
</label>
|
||||||
<footer style="display:flex; justify-content:flex-end; gap:0.5rem; padding:0; margin-top:1rem;">
|
<div id="edit-test-result" style="min-height:1.5em;"></div>
|
||||||
|
</div>
|
||||||
|
<footer style="display:flex; justify-content:flex-end; gap:.5rem;">
|
||||||
<button type="button" class="secondary outline" onclick="document.getElementById('edit-modal').close()">Cancel</button>
|
<button type="button" class="secondary outline" onclick="document.getElementById('edit-modal').close()">Cancel</button>
|
||||||
<button type="submit">Save Changes</button>
|
<button type="submit">Save Changes</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -233,7 +228,7 @@ async function submitAddReplica(e) {
|
|||||||
const resultEl = document.getElementById('add-test-result');
|
const resultEl = document.getElementById('add-test-result');
|
||||||
const btn = form.querySelector('button[type=submit]');
|
const btn = form.querySelector('button[type=submit]');
|
||||||
btn.setAttribute('aria-busy', 'true');
|
btn.setAttribute('aria-busy', 'true');
|
||||||
btn.textContent = 'Testing connection…';
|
btn.textContent = 'Testing connection\u2026';
|
||||||
const r = await fetch('/api/replicas', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
const r = await fetch('/api/replicas', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
btn.removeAttribute('aria-busy');
|
btn.removeAttribute('aria-busy');
|
||||||
@@ -271,7 +266,7 @@ async function submitEditReplica(e) {
|
|||||||
const resultEl = document.getElementById('edit-test-result');
|
const resultEl = document.getElementById('edit-test-result');
|
||||||
const btn = form.querySelector('button[type=submit]');
|
const btn = form.querySelector('button[type=submit]');
|
||||||
btn.setAttribute('aria-busy', 'true');
|
btn.setAttribute('aria-busy', 'true');
|
||||||
btn.textContent = 'Saving…';
|
btn.textContent = 'Saving\u2026';
|
||||||
const r = await fetch('/api/replicas/' + id, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
const r = await fetch('/api/replicas/' + id, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
btn.removeAttribute('aria-busy');
|
btn.removeAttribute('aria-busy');
|
||||||
|
|||||||
@@ -2,84 +2,107 @@
|
|||||||
{% block title %}Settings — pngx-controller{% endblock %}
|
{% block title %}Settings — pngx-controller{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h2>Settings</h2>
|
<div class="page-hd">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onsubmit="saveSettings(event)">
|
<form onsubmit="saveSettings(event)">
|
||||||
|
|
||||||
<h3>Master Instance</h3>
|
<h3>Master Instance</h3>
|
||||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
<article style="margin-bottom:1.5rem;">
|
||||||
<label>
|
<div class="grid-2">
|
||||||
Master URL
|
<label>
|
||||||
<input type="url" name="master_url" value="{{ settings.get('master_url', '') }}" placeholder="http://100.x.x.x:8000">
|
Master URL
|
||||||
</label>
|
<input type="url" name="master_url" value="{{ settings.get('master_url', '') }}" placeholder="http://100.x.x.x:8000">
|
||||||
<label>
|
<small>Paperless-ngx instance that acts as the source of truth.</small>
|
||||||
Master API Token
|
</label>
|
||||||
<input type="password" name="master_token" value="{{ env_master_token }}" placeholder="leave blank to keep current">
|
<label>
|
||||||
</label>
|
Master API Token
|
||||||
</div>
|
<input type="password" name="master_token" value="{{ env_master_token }}" placeholder="leave blank to keep current">
|
||||||
|
<small>From Paperless → Settings → API → Auth Tokens.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<h3>Sync Engine</h3>
|
<h3>Sync Engine</h3>
|
||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:1rem;">
|
<article style="margin-bottom:1.5rem;">
|
||||||
<label>
|
<div class="grid-auto">
|
||||||
Sync Interval (seconds)
|
<label>
|
||||||
<input type="number" name="sync_interval_seconds" value="{{ settings.get('sync_interval_seconds', '900') }}" min="60">
|
Sync Interval (seconds)
|
||||||
</label>
|
<input type="number" name="sync_interval_seconds" value="{{ settings.get('sync_interval_seconds', '900') }}" min="60">
|
||||||
<label>
|
<small>How often the scheduler runs a sync cycle.</small>
|
||||||
Cycle Timeout (seconds)
|
</label>
|
||||||
<input type="number" name="sync_cycle_timeout_seconds" value="{{ settings.get('sync_cycle_timeout_seconds', '1800') }}" min="60">
|
<label>
|
||||||
</label>
|
Cycle Timeout (seconds)
|
||||||
<label>
|
<input type="number" name="sync_cycle_timeout_seconds" value="{{ settings.get('sync_cycle_timeout_seconds', '1800') }}" min="60">
|
||||||
Task Poll Timeout (seconds)
|
<small>Maximum wall-clock time for a full sync cycle.</small>
|
||||||
<input type="number" name="task_poll_timeout_seconds" value="{{ settings.get('task_poll_timeout_seconds', '600') }}" min="30">
|
</label>
|
||||||
</label>
|
<label>
|
||||||
<label>
|
Task Poll Timeout (seconds)
|
||||||
Max Concurrent Requests
|
<input type="number" name="task_poll_timeout_seconds" value="{{ settings.get('task_poll_timeout_seconds', '600') }}" min="30">
|
||||||
<input type="number" name="max_concurrent_requests" value="{{ settings.get('max_concurrent_requests', '4') }}" min="1" max="20">
|
<small>Timeout waiting for Paperless task completion.</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Suspend Threshold (consecutive failures)
|
Max Concurrent Requests
|
||||||
<input type="number" name="replica_suspend_threshold" value="{{ settings.get('replica_suspend_threshold', '5') }}" min="1">
|
<input type="number" name="max_concurrent_requests" value="{{ settings.get('max_concurrent_requests', '4') }}" min="1" max="20">
|
||||||
</label>
|
<small>Semaphore limit for parallel API calls.</small>
|
||||||
<label>
|
</label>
|
||||||
Log Retention (days)
|
<label>
|
||||||
<input type="number" name="log_retention_days" value="{{ settings.get('log_retention_days', '90') }}" min="1">
|
Suspend Threshold
|
||||||
</label>
|
<input type="number" name="replica_suspend_threshold" value="{{ settings.get('replica_suspend_threshold', '5') }}" min="1">
|
||||||
</div>
|
<small>Consecutive failures before a replica is suspended.</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Log Retention (days)
|
||||||
|
<input type="number" name="log_retention_days" value="{{ settings.get('log_retention_days', '90') }}" min="1">
|
||||||
|
<small>Automatically prune log entries older than this.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<h3>Notifications</h3>
|
<h3>Notifications</h3>
|
||||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:1rem;">
|
<article style="margin-bottom:1.5rem;">
|
||||||
<label>
|
<div class="grid-auto">
|
||||||
Alert Target
|
<label>
|
||||||
<select name="alert_target_type">
|
Alert Target
|
||||||
<option value="" {% if not settings.get('alert_target_type') %}selected{% endif %}>Disabled</option>
|
<select name="alert_target_type">
|
||||||
<option value="gotify" {% if settings.get('alert_target_type') == 'gotify' %}selected{% endif %}>Gotify</option>
|
<option value="" {% if not settings.get('alert_target_type') %}selected{% endif %}>Disabled</option>
|
||||||
<option value="webhook" {% if settings.get('alert_target_type') == 'webhook' %}selected{% endif %}>Webhook</option>
|
<option value="gotify" {% if settings.get('alert_target_type') == 'gotify' %}selected{% endif %}>Gotify</option>
|
||||||
</select>
|
<option value="webhook" {% if settings.get('alert_target_type') == 'webhook' %}selected{% endif %}>Webhook</option>
|
||||||
</label>
|
</select>
|
||||||
<label>
|
<small>Where to send sync failure alerts.</small>
|
||||||
Alert URL
|
</label>
|
||||||
<input type="url" name="alert_target_url" value="{{ settings.get('alert_target_url', '') }}" placeholder="https://…">
|
<label>
|
||||||
</label>
|
Alert URL
|
||||||
<label>
|
<input type="url" name="alert_target_url" value="{{ settings.get('alert_target_url', '') }}" placeholder="https://…">
|
||||||
Alert Token / Auth Header
|
<small>Gotify server URL or webhook endpoint.</small>
|
||||||
<input type="password" name="alert_target_token" value="{{ env_alert_token }}" placeholder="leave blank to keep current">
|
</label>
|
||||||
</label>
|
<label>
|
||||||
<label>
|
Alert Token / Auth Header
|
||||||
Alert Error Threshold (docs failed)
|
<input type="password" name="alert_target_token" value="{{ env_alert_token }}" placeholder="leave blank to keep current">
|
||||||
<input type="number" name="alert_error_threshold" value="{{ settings.get('alert_error_threshold', '5') }}" min="1">
|
<small>Gotify token or Authorization header value.</small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Alert Cooldown (seconds)
|
Alert Error Threshold
|
||||||
<input type="number" name="alert_cooldown_seconds" value="{{ settings.get('alert_cooldown_seconds', '3600') }}" min="60">
|
<input type="number" name="alert_error_threshold" value="{{ settings.get('alert_error_threshold', '5') }}" min="1">
|
||||||
</label>
|
<small>Number of failed docs before an alert is sent.</small>
|
||||||
</div>
|
</label>
|
||||||
|
<label>
|
||||||
|
Alert Cooldown (seconds)
|
||||||
|
<input type="number" name="alert_cooldown_seconds" value="{{ settings.get('alert_cooldown_seconds', '3600') }}" min="60">
|
||||||
|
<small>Minimum time between repeated alerts.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<div style="margin-top:1.5rem; display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap;">
|
<div style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||||
<button type="submit" id="save-btn">Save Settings</button>
|
<button type="submit" id="save-btn">Save Settings</button>
|
||||||
<button type="button" id="test-btn" class="secondary outline" onclick="testConnection()">
|
<button type="button" id="test-btn" class="secondary outline" onclick="testConnection()">
|
||||||
Test Connection
|
Test Connection
|
||||||
</button>
|
</button>
|
||||||
<span id="settings-result"></span>
|
<span id="settings-result"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -97,9 +120,7 @@ function formBody() {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setResult(html) {
|
function setResult(html) { document.getElementById('settings-result').innerHTML = html; }
|
||||||
document.getElementById('settings-result').innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLoading(btn, label) {
|
function setLoading(btn, label) {
|
||||||
btn.setAttribute('aria-busy', 'true');
|
btn.setAttribute('aria-busy', 'true');
|
||||||
@@ -117,7 +138,7 @@ function clearLoading(btn) {
|
|||||||
async function saveSettings(e) {
|
async function saveSettings(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const btn = document.getElementById('save-btn');
|
const btn = document.getElementById('save-btn');
|
||||||
setLoading(btn, 'Saving…');
|
setLoading(btn, 'Saving\u2026');
|
||||||
setResult('');
|
setResult('');
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/settings', {
|
const r = await fetch('/api/settings', {
|
||||||
@@ -127,13 +148,13 @@ async function saveSettings(e) {
|
|||||||
});
|
});
|
||||||
const json = await r.json();
|
const json = await r.json();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
setResult('<span class="badge badge-ok">✓ Settings saved</span>');
|
setResult('<span class="badge badge-ok">\u2713 Settings saved</span>');
|
||||||
} else {
|
} else {
|
||||||
const detail = typeof json.detail === 'string' ? json.detail : JSON.stringify(json.detail);
|
const detail = typeof json.detail === 'string' ? json.detail : JSON.stringify(json.detail);
|
||||||
setResult('<span class="badge badge-error">✗ ' + detail + '</span>');
|
setResult('<span class="badge badge-error">\u2717 ' + detail + '</span>');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResult('<span class="badge badge-error">✗ Network error: ' + err.message + '</span>');
|
setResult('<span class="badge badge-error">\u2717 Network error: ' + err.message + '</span>');
|
||||||
} finally {
|
} finally {
|
||||||
clearLoading(btn);
|
clearLoading(btn);
|
||||||
}
|
}
|
||||||
@@ -141,7 +162,7 @@ async function saveSettings(e) {
|
|||||||
|
|
||||||
async function testConnection() {
|
async function testConnection() {
|
||||||
const btn = document.getElementById('test-btn');
|
const btn = document.getElementById('test-btn');
|
||||||
setLoading(btn, 'Testing…');
|
setLoading(btn, 'Testing\u2026');
|
||||||
setResult('');
|
setResult('');
|
||||||
try {
|
try {
|
||||||
const form = document.querySelector('form');
|
const form = document.querySelector('form');
|
||||||
@@ -154,12 +175,12 @@ async function testConnection() {
|
|||||||
});
|
});
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
setResult('<span class="badge badge-ok">✓ Connected — ' + data.doc_count + ' docs · ' + data.latency_ms + 'ms</span>');
|
setResult('<span class="badge badge-ok">\u2713 Connected — ' + data.doc_count + ' docs · ' + data.latency_ms + 'ms</span>');
|
||||||
} else {
|
} else {
|
||||||
setResult('<span class="badge badge-error">✗ ' + (data.error || 'Connection failed') + '</span>');
|
setResult('<span class="badge badge-error">\u2717 ' + (data.error || 'Connection failed') + '</span>');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResult('<span class="badge badge-error">✗ ' + err.message + '</span>');
|
setResult('<span class="badge badge-error">\u2717 ' + err.message + '</span>');
|
||||||
} finally {
|
} finally {
|
||||||
clearLoading(btn);
|
clearLoading(btn);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user