295 lines
13 KiB
HTML
295 lines
13 KiB
HTML
<!doctype html>
|
|
<html lang="{{LANG_CODE}}" class="h-full">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>{{T_APP_TITLE}}</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<script src="/admin/assets/tailwindcss.js"></script>
|
|
<script src="/admin/assets/htmx.min.js"></script>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
|
.nav-link.active { background: rgb(15 23 42); color: rgb(125 211 252); }
|
|
</style>
|
|
</head>
|
|
<body class="h-full bg-slate-950 text-slate-100">
|
|
<!--
|
|
Single-page shell. The sidebar drives navigation via HTMX:
|
|
each link does an `hx-get` of an HTML fragment URL that returns the
|
|
body of the page. The fragments live under /admin/pages/ and are
|
|
server-rendered Rust handlers that return Html<String>.
|
|
This keeps the UI a flat directory of static files plus a small
|
|
set of fragment endpoints — no SPA, no Node, no build step.
|
|
-->
|
|
<div class="min-h-full flex">
|
|
<aside class="w-56 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col">
|
|
<div class="px-4 py-5 border-b border-slate-800">
|
|
<h1 class="text-base font-semibold">{{T_APP_TITLE}}</h1>
|
|
<p id="me-display" class="text-xs text-slate-500 mt-1" hx-get="/admin/me" hx-trigger="load" hx-swap="innerHTML">…</p>
|
|
</div>
|
|
<nav class="flex-1 px-2 py-3 space-y-1">
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/users" hx-target="#main" hx-push-url="#users">{{T_NAV_USERS}}</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/devices" hx-target="#main" hx-push-url="#devices">{{T_NAV_DEVICES}}</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/groups" hx-target="#main" hx-push-url="#groups">{{T_NAV_GROUPS}}</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/strategies" hx-target="#main" hx-push-url="#strategies">{{T_NAV_STRATEGIES}}</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/address-books" hx-target="#main" hx-push-url="#address-books">{{T_NAV_AB}}</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/audit" hx-target="#main" hx-push-url="#audit">{{T_NAV_AUDIT}}</a>
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-300 hover:bg-slate-800"
|
|
hx-get="/admin/pages/deploy" hx-target="#main" hx-push-url="#deploy">{{T_NAV_DEPLOY}}</a>
|
|
</nav>
|
|
<div class="px-2 py-3 border-t border-slate-800 space-y-1">
|
|
<a class="nav-link block px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
|
hx-get="/admin/pages/profile" hx-target="#main" hx-push-url="#profile">{{T_NAV_PROFILE}}</a>
|
|
<button
|
|
class="w-full text-left px-3 py-1.5 text-sm rounded text-slate-400 hover:bg-slate-800"
|
|
hx-post="/admin/logout"
|
|
hx-on::after-request="window.location.href = '/admin/login.html'"
|
|
>{{T_NAV_SIGNOUT}}</button>
|
|
<div class="pt-2">
|
|
<label class="block text-[10px] uppercase tracking-wide text-slate-600 px-3 mb-1">{{T_LANGUAGE}}</label>
|
|
<select
|
|
class="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300"
|
|
onchange="document.cookie='admin_lang='+this.value+'; path=/; max-age=31536000; samesite=strict'; window.location.reload();"
|
|
>
|
|
<option value="en"{{LANG_SEL_EN}}>English</option>
|
|
<option value="de"{{LANG_SEL_DE}}>Deutsch</option>
|
|
<option value="es"{{LANG_SEL_ES}}>Español</option>
|
|
<option value="fr"{{LANG_SEL_FR}}>Français</option>
|
|
<option value="ro"{{LANG_SEL_RO}}>Română</option>
|
|
</select>
|
|
</div>
|
|
<p class="text-[10px] text-slate-600 text-center pt-2">v{{APP_VERSION}}</p>
|
|
</div>
|
|
</aside>
|
|
|
|
<main id="main" class="flex-1 p-6 overflow-x-hidden">
|
|
<div class="text-slate-500 text-sm">{{T_LOADING}}</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Toast container used by all admin handlers via hx-trigger="load delay:1s" -->
|
|
<div id="toast"
|
|
class="fixed bottom-4 right-4 max-w-sm space-y-2 pointer-events-none"></div>
|
|
|
|
<!-- Load fragment + highlight active link based on the URL hash.
|
|
Sub-routes like #users/new map to dedicated fragment URLs but
|
|
keep the parent section's nav-link highlighted. -->
|
|
<script>
|
|
// Hash → fragment URL for routes that aren't owned by a sidebar
|
|
// nav-link (e.g. forms on their own page). The first path segment
|
|
// also tells us which nav-link to highlight.
|
|
const SUB_ROUTES = {
|
|
'#users/new': '/admin/pages/users/new',
|
|
};
|
|
function topLevelHash(hash) {
|
|
const slash = hash.indexOf('/');
|
|
return slash === -1 ? hash : hash.slice(0, slash);
|
|
}
|
|
function linkForHash() {
|
|
const hash = location.hash || '#users';
|
|
const top = topLevelHash(hash);
|
|
return document.querySelector('.nav-link[hx-push-url="' + top + '"]')
|
|
|| document.querySelector('.nav-link[hx-push-url="#users"]');
|
|
}
|
|
function refreshActive() {
|
|
const active = linkForHash();
|
|
document.querySelectorAll('.nav-link').forEach(a => {
|
|
a.classList.toggle('active', a === active);
|
|
});
|
|
}
|
|
// Dynamic deep-links: `#devices/<id>` and `#devices/<id>/exec`.
|
|
// The detail / exec fragments are designed to swap into the
|
|
// devices index page's `#devices-region` section, so when we
|
|
// land here from a page refresh we have to chain two ajax calls:
|
|
// first render the parent page (which provides `#devices-region`),
|
|
// then swap the fragment into it. htmx.ajax has returned a Promise
|
|
// since 1.9.4, so the `.then` chain is safe at our pinned 1.9.10.
|
|
const DEEP_LINK_PATTERNS = [
|
|
{
|
|
re: /^#devices\/([^/]+)\/exec$/,
|
|
parent: '/admin/pages/devices',
|
|
fragment: id => `/admin/pages/devices/${encodeURIComponent(id)}/exec`,
|
|
},
|
|
{
|
|
re: /^#devices\/([^/]+)$/,
|
|
parent: '/admin/pages/devices',
|
|
fragment: id => `/admin/pages/devices/${encodeURIComponent(id)}/detail`,
|
|
},
|
|
];
|
|
function loadDeepLink(hash) {
|
|
for (const p of DEEP_LINK_PATTERNS) {
|
|
const m = hash.match(p.re);
|
|
if (!m) continue;
|
|
const id = decodeURIComponent(m[1]);
|
|
Promise.resolve(
|
|
htmx.ajax('GET', p.parent, { target: '#main', swap: 'innerHTML' })
|
|
).then(() =>
|
|
htmx.ajax('GET', p.fragment(id), {
|
|
target: '#devices-region',
|
|
swap: 'innerHTML',
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
function loadFromHash() {
|
|
const hash = location.hash || '#users';
|
|
if (loadDeepLink(hash)) {
|
|
refreshActive();
|
|
return;
|
|
}
|
|
const subUrl = SUB_ROUTES[hash];
|
|
if (subUrl) {
|
|
htmx.ajax('GET', subUrl, { target: '#main', swap: 'innerHTML' });
|
|
} else {
|
|
const link = linkForHash();
|
|
if (link) {
|
|
htmx.ajax('GET', link.getAttribute('hx-get'),
|
|
{ target: '#main', swap: 'innerHTML' });
|
|
}
|
|
}
|
|
refreshActive();
|
|
}
|
|
window.addEventListener('hashchange', loadFromHash);
|
|
document.body.addEventListener('htmx:afterSwap', refreshActive);
|
|
loadFromHash();
|
|
|
|
// Bounce to login if any HTMX request comes back 401.
|
|
document.body.addEventListener('htmx:responseError', (evt) => {
|
|
if (evt.detail.xhr.status === 401) {
|
|
window.location.href = '/admin/login.html';
|
|
}
|
|
});
|
|
|
|
// Plain-link confirmation prompt. HTMX has hx-confirm for its own
|
|
// requests; this is the equivalent for raw `<a href>` anchors that
|
|
// can't go through HTMX (e.g. "Connect (web client)", which opens
|
|
// a new tab and triggers a popup on the controlled machine — easy
|
|
// to fire by accident from the row action menu). Use:
|
|
//
|
|
// <a href="..." data-confirm="..." onclick="return confirmFromDataAttr(this)">
|
|
//
|
|
// The message lives in the data-attribute (only HTML-escaped, no
|
|
// JS-string escaping) which keeps the server-side renderer simple.
|
|
function confirmFromDataAttr(el) {
|
|
const msg = el && el.dataset ? el.dataset.confirm : '';
|
|
return !msg || window.confirm(msg);
|
|
}
|
|
window.confirmFromDataAttr = confirmFromDataAttr;
|
|
|
|
// Close any open per-row action popover when a click happens outside it.
|
|
// The action dropdowns are <details class="... relative"> with an
|
|
// absolutely-positioned panel; the deploy page uses <details> too but
|
|
// without `relative`, so the selector is specific to the popover style.
|
|
document.addEventListener('click', (e) => {
|
|
document.querySelectorAll('details.relative[open]').forEach(d => {
|
|
if (!d.contains(e.target)) d.removeAttribute('open');
|
|
});
|
|
});
|
|
|
|
// Read the current value of the users-search input (if present).
|
|
// Used by usersColumnToggle/usersPageSize so a column or page-size
|
|
// change preserves the active filter.
|
|
function usersSearchValue() {
|
|
const el = document.getElementById('users-search');
|
|
return el ? el.value : '';
|
|
}
|
|
|
|
// Users table column-visibility toggle. The popover in the page header
|
|
// emits checkboxes with onchange="usersColumnToggle(this)" — we POST
|
|
// the new state to the server (which persists it in user_prefs) and
|
|
// swap in the re-rendered table so the popover stays open.
|
|
function usersColumnToggle(input) {
|
|
const col = input.dataset.col;
|
|
if (!col) return;
|
|
htmx.ajax('POST', '/admin/pages/users/columns', {
|
|
target: '#users-region',
|
|
swap: 'innerHTML',
|
|
values: {
|
|
col: col,
|
|
visible: input.checked ? '1' : '0',
|
|
q: usersSearchValue(),
|
|
},
|
|
});
|
|
}
|
|
window.usersColumnToggle = usersColumnToggle;
|
|
|
|
// Users table per-page selector. Driven by the <select> in the
|
|
// pagination footer — POSTs to persist the choice and re-renders the
|
|
// table at page 1 (size change shifts which rows are on which page).
|
|
function usersPageSize(size) {
|
|
htmx.ajax('POST', '/admin/pages/users/page-size', {
|
|
target: '#users-region',
|
|
swap: 'innerHTML',
|
|
values: { size: size, q: usersSearchValue() },
|
|
});
|
|
}
|
|
window.usersPageSize = usersPageSize;
|
|
|
|
// Devices table — mirrors the users helpers above. Same persistence
|
|
// model (per-user prefs in `user_prefs`) and the same fragment-swap
|
|
// approach so the columns popover and search input stay put while
|
|
// pagination/columns/page-size all preserve the active filter.
|
|
function devicesSearchValue() {
|
|
const el = document.getElementById('devices-search');
|
|
return el ? el.value : '';
|
|
}
|
|
function devicesColumnToggle(input) {
|
|
const col = input.dataset.col;
|
|
if (!col) return;
|
|
htmx.ajax('POST', '/admin/pages/devices/columns', {
|
|
target: '#devices-region',
|
|
swap: 'innerHTML',
|
|
values: {
|
|
col: col,
|
|
visible: input.checked ? '1' : '0',
|
|
q: devicesSearchValue(),
|
|
},
|
|
});
|
|
}
|
|
window.devicesColumnToggle = devicesColumnToggle;
|
|
function devicesPageSize(size) {
|
|
htmx.ajax('POST', '/admin/pages/devices/page-size', {
|
|
target: '#devices-region',
|
|
swap: 'innerHTML',
|
|
values: { size: size, q: devicesSearchValue() },
|
|
});
|
|
}
|
|
window.devicesPageSize = devicesPageSize;
|
|
|
|
// The devices table lives inside an `overflow-x-auto` wrapper so wide
|
|
// column sets get a horizontal scrollbar instead of pushing the page
|
|
// out. CSS forces overflow-y to auto on the same axis, which would
|
|
// clip the per-row action popover (a `<details>` > `<div>` inside a
|
|
// <td>). On toggle we flip the popover to `position: fixed` and pin
|
|
// it to the summary's viewport rect so it escapes the scroll context.
|
|
// Inline `ontoggle=` is preserved across htmx swaps without re-binding.
|
|
function actionMenuToggle(details) {
|
|
const popover = details.querySelector('[data-action-menu]');
|
|
if (!popover) return;
|
|
if (!details.open) {
|
|
popover.style.position = '';
|
|
popover.style.top = '';
|
|
popover.style.left = '';
|
|
popover.style.right = '';
|
|
return;
|
|
}
|
|
const summary = details.querySelector('summary');
|
|
if (!summary) return;
|
|
const rect = summary.getBoundingClientRect();
|
|
popover.style.position = 'fixed';
|
|
popover.style.top = rect.bottom + 4 + 'px';
|
|
popover.style.right = (window.innerWidth - rect.right - 8) + 'px';
|
|
popover.style.left = 'auto';
|
|
}
|
|
window.actionMenuToggle = actionMenuToggle;
|
|
</script>
|
|
</body>
|
|
</html>
|