openvibers/openvibe.network Commits
Admin clarity: plain words for every tab outside AI and Setup (#613)
Changes
nnew file mode 100644@@@ -0,0 +1,96 @@++# Admin clarity for the tabs outside AI and Setup++++Design review notes for the owner-facing admin panel, 2026-11-07.++Plan: `plan-state-2026-10-22/OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN-2026-11-07.md`, section 2++("Clarity rules for every admin page and every settings schema").++++## What this change covers++++Every admin tab except **AI** and the new **Setup** tab, which a separate PR rewrites:++++| File | Tab |++|---|---|++| `public/js/admin/dashboard.js` | Dashboard |++| `public/js/admin/users.js` | Users |++| `public/js/admin/moderation.js` | Streams, Lineage, Bans, Chat Logs, Moderators, Moderation log, Service grants, Theme review, Operator checklist, Event deliveries |++| `public/js/admin/settings.js` | Settings (Tools, Live, Media, URLs, Discord, GitHub) |++| `public/js/admin/payments.js` | Payments, account reservation keys |++| `public/js/admin/content.js` | Pastes, Storage, Notifications, Email |++| `public/js/admin/deploy.js` | Domains, Deploy, SSH |++| `public/js/admin/analytics.js` | Analytics |++++The static sections of `public/admin.html` (Broadcast, Audit, Grants, Event deliveries, Analytics cards,++Deploy panels) carry the same wording.++++## The rules applied++++1. **Every section starts with two sentences** — what this is, and when you would change it.++ Cards, fieldsets and panels each got an `.admin-intro` paragraph.++2. **Every field has a plain title and a help line** — what it does, what the default is, and an example.++ The machine key (`notification_max_age_days`) is still there, small and grey, after the title++ (`.admin-machine-key`), never instead of it. `ADMIN_SETTING_COPY` in `settings.js` holds the copy for the++ published settings; `adminSettingLabel()` renders it, and falls back to the service's own description plus++ the published default when a key is not listed.++3. **Every option of a choice has words** — role, delivery state, grant state, account role, domain role,++ certificate mode, web server mode and the payment switches are written as sentences, with the code kept++ only where it is the value that gets sent.++4. **Every number has its unit and what more or less means** — the help lines name the unit (minutes, days,++ kilobits per second, percent, US dollars, characters) and say which way the number moves the feature.++5. **Status is a sentence** — "The bot is not connected. Check its token and reconnect.", "Access was removed.",++ "The provider accepted the email." Raw codes stay only in grey text and tooltips.++6. **Raw JSON is advanced** — the long-text and JSON fields of the Live settings sit under an++ "Advanced settings" fold with a counted label; the fold opens itself when a value fails to save.++7. **Danger and money controls say what happens and how to undo it** — every confirmation now states the++ effect and the way back ("generate a new key", "save the previous value", "restore the backup on the server").++8. **Empty states say what to do next** — "No accounts match. Clear the search or try a different username++ or email."++9. **The glossary** is added by the Setup/AI PR. None of the six glossary words (Credits, BYOK, resale, cache,++ quota, workflow) appear in these tabs, so there is nothing to link here yet.++++## The Discord block++++Bot token, server ID, both channel IDs and the OAuth2 client id and secret each carry a **Where to get it**++line: the Discord Developer Portal page, the path inside it (Bot → Reset Token, OAuth2 → General), and what to++copy. The server and channel ID lines also explain that Developer Mode has to be on first, and link Discord's++own ID guide. The token and the client secret stay write-only in the server environment, as before.++++## Phone++++* Every control is at least 44 px tall and 44 px wide: inputs, selects, textareas, buttons, checkboxes and++ `summary` folds.++* Nothing is wider than 390 px: the tables that cannot fit become one card per row, grids collapse to one++ column, and long values wrap.++* The new rules are scoped to `.admin-clarity`, which is on the sections in scope, so the AI tab keeps its own++ styles. The shared tab bar is 44 px for every tab, which is a nav improvement rather than a change to the++ AI tab's own wording.++++## Proof++++`scripts/admin-clarity-proof.cjs` drives a local Network with synthetic API responses and captures each tab at++390 px and 1280 px in dark and light, then audits the page for overflow and for controls under 44 px.++++```++node --import ./test/helpers/pg-preload.mjs server/index.js # local Network on :4897++node scripts/admin-clarity-proof.cjs before # the same pages with the previous wording and styles++node scripts/admin-clarity-proof.cjs after # the pages as they are now++ADMIN_PROOF_CHECKS=1 node scripts/admin-clarity-proof.cjs after # the write contract++```++++Results in `work/shots/admin-clarity`:++++| | before | after |++|---|---|---|++| Views captured | 92 | 92 |++| 390 px views wider than the screen | 8 | 0 |++| Controls under 44 px | 696 | 0 |++| Page errors | 0 | 0 |++++The write contract check submits the same six requests with the same bodies before and after++(`after-requests.json` equals `before-requests.json`), and an invalid JSON value is refused and its field++revealed rather than left inside a closed fold.++++## What was left alone++++Every behaviour, permission and API call. The panels still call the same endpoints, the same payloads are sent,++the same ids and values are read from the same places, and the fields the owner cannot see (environment-held++secrets) are still disabled and empty.@@@ -95,18 +95,20 @@</div><!-- Dashboard -->-- <div class="section active" id="sec-dashboard">++ <div class="section active admin-clarity" id="sec-dashboard">++ <p class="admin-intro">This is a snapshot of accounts, streams, and Network health. Check it when reviewing activity or investigating a service problem; larger counts mean more activity, while memory and uptime describe this server.</p><div class="stats-grid" id="dashboard-stats"><div class="loading">Loading stats...</div></div><div class="card">-- <h3><i class="fa-solid fa-server"></i> System Health</h3>++ <h3><i class="fa-solid fa-server"></i> System Health</h3><p class="admin-intro">This describes the Network process running on this server. Check it when investigating downtime or high memory use; more memory means less room for other work.</p><div id="health-info" class="muted">Loading...</div></div></div><!-- Users -->-- <div class="section" id="sec-users">++ <div class="section admin-clarity" id="sec-users">++ <p class="admin-intro">These are Network accounts and the access each person has. Use search to find someone, then change access or help recover their account; role changes apply immediately and can be reversed by selecting the previous role.</p><div class="input-row">-- <input type="text" id="user-search" placeholder="Search users by name or email..." onkeydown="if(event.key==='Enter')loadUsers()">++ <span class="admin-field"><input type="text" id="user-search" aria-label="Find an account" aria-describedby="help-user-search" placeholder="Search users by name or email..." onkeydown="if(event.key==='Enter')loadUsers()"><span class="admin-help" id="help-user-search"><strong>Find an account.</strong> Search by name or email, for example alex. Blank shows all accounts; press Enter or Search to apply.</span></span><button class="btn btn-primary" onclick="loadUsers()"><i class="fa-solid fa-search"></i> Search</button></div><div id="users-content"><div class="loading">Loading...</div></div>@@@ -114,27 +116,31 @@</div><!-- Streams -->-- <div class="section" id="sec-streams">++ <div class="section admin-clarity" id="sec-streams">++ <p class="admin-intro">These are streams currently live on OpenVibe.Live. Review them during moderation; ending a stream disconnects the broadcast, and the streamer must start again to resume.</p><div id="streams-content"><div class="loading">Loading...</div></div></div><!-- Lineage: references the canonical resolver could not resolve (Live, roadmap D20) -->-- <div class="section" id="sec-lineage">-- <p class="muted">References Live's canonical channel/owner resolver answered <em>unresolved</em>, from other services and Live's own checks, counted by inputs and reason over the last 30 days. A display name never resolves anything.</p>++ <div class="section admin-clarity" id="sec-lineage">++ <p class="admin-intro">These are channel or owner references Live could not match during the last 30 days. Check them when a linked channel is missing, then correct the reference in the service shown as the last caller.</p>++ <p class="muted">Use a channel link or stable account ID when correcting a reference. A display name alone cannot identify the channel.</p><div class="sub-tabs" id="lineage-reasons"></div><div id="lineage-content"><div class="loading">Loading...</div></div></div><!-- Bans -->-- <div class="section" id="sec-bans">++ <div class="section admin-clarity" id="sec-bans">++ <p class="admin-intro">These people are currently banned on Live. Review the reason before restoring access; allowing access again removes the restriction, and a new ban can be applied from Users if needed.</p><div id="bans-content"><div class="loading">Loading...</div></div></div><!-- Chat Logs -->-- <div class="section" id="sec-chat-logs">++ <div class="section admin-clarity" id="sec-chat-logs">++ <p class="admin-intro">Search stored chat messages to investigate a report. Enter message text or a person below, then review the results before taking action.</p><div class="input-row">-- <input type="text" id="chat-search-q" placeholder="Search messages..." onkeydown="if(event.key==='Enter')searchChatLogs()">-- <input type="text" id="chat-search-uid" placeholder="Username, user ID, or anon123" style="max-width:200px" onkeydown="if(event.key==='Enter')searchChatLogs()">++ <span class="admin-field"><input type="text" id="chat-search-q" aria-label="Message text" aria-describedby="help-chat-search-q" placeholder="Search messages..." onkeydown="if(event.key==='Enter')searchChatLogs()"><span class="admin-help" id="help-chat-search-q"><strong>Message text.</strong> Search for words in a message, for example welcome. Blank leaves message text unrestricted when a person is supplied.</span></span>++ <span class="admin-field"><input type="text" id="chat-search-uid" aria-label="Person in chat" aria-describedby="help-chat-search-uid" placeholder="Username, user ID, or anon123" style="max-width:200px" onkeydown="if(event.key==='Enter')searchChatLogs()"><span class="admin-help" id="help-chat-search-uid"><strong>Person in chat.</strong> Enter a username or account ID, for example alex or anon123. Blank searches any person when message text is supplied.</span></span><button class="btn btn-primary" onclick="searchChatLogs()"><i class="fa-solid fa-search"></i> Search</button></div><div id="chat-logs-content"><p class="muted">Enter a search query or user ID</p></div>@@@ -142,16 +148,18 @@</div><!-- Moderators -->-- <div class="section" id="sec-moderators">++ <div class="section admin-clarity" id="sec-moderators">++ <p class="admin-intro">These people can moderate across Live channels. Add a trusted person when more coverage is needed; Remove moderator access reverses the promotion.</p><div class="input-row">-- <input type="text" id="mod-username" placeholder="Username to promote..." onkeydown="if(event.key==='Enter')promoteMod()">-- <button class="btn btn-primary" onclick="promoteMod()"><i class="fa-solid fa-shield-halved"></i> Promote to Global Mod</button>++ <span class="admin-field"><input type="text" id="mod-username" aria-label="Person to make a moderator" aria-describedby="help-mod-username" placeholder="Username to promote..." onkeydown="if(event.key==='Enter')promoteMod()"><span class="admin-help" id="help-mod-username"><strong>Person to make a moderator.</strong> Enter an existing username, for example alex; there is no default. Promotion grants moderation access across Live, and removing that access reverses it.</span></span>++ <button class="btn btn-primary" onclick="promoteMod()"><i class="fa-solid fa-shield-halved"></i> Make a network moderator</button></div><div id="mods-content"><div class="loading">Loading...</div></div></div><!-- Settings -->-- <div class="section" id="sec-settings">++ <div class="section admin-clarity" id="sec-settings">++ <p class="admin-intro">These settings control Network and its connected Live service. Choose a section before editing, and keep previous values so you can restore them after saving.</p><div class="sub-tabs" id="settings-sub-tabs"><button class="active" onclick="showSettingsSub('tools',this)">OpenVibe.Tools</button><button onclick="showSettingsSub('streamer',this)">OpenVibe.Live</button>@@@ -169,12 +177,13 @@</div><!-- TTS -->-- <div class="section" id="sec-tts">++ <div class="section admin-clarity" id="sec-tts">++ <p class="admin-intro">These are the services that read text aloud. Check this panel when reviewing available voices or diagnosing speech playback.</p><div id="tts-content"><div class="loading">Loading...</div></div></div><!-- Cashouts: decided in OpenVibe.Billing's staff console (Billing is the only money ledger since 2026-10-17) -->-- <div class="section" id="sec-cashouts">++ <div class="section admin-clarity" id="sec-cashouts"><div class="card" style="max-width:760px"><h3><i class="fa-solid fa-money-bill-transfer" aria-hidden="true"></i> Cashouts are decided in OpenVibe.Billing</h3><p class="muted" style="margin-bottom:8px">Billing holds every creator's balance. Its staff console lists cashouts still in escrow, ready to pay, paid and denied. Approving one records the payout reference; denying one puts the amount back in the creator's balance. The money freeze is there too.</p>@@@ -187,7 +196,8 @@</div><!-- Payments: the switches and prices Live reads; provider webhooks and credentials are OpenVibe.Billing's -->-- <div class="section" id="sec-payments">++ <div class="section admin-clarity" id="sec-payments">++ <p class="admin-intro">These settings control the payment choices and prices Live shows. Review them before offering paid features; Billing handles the payment itself and any refunds.</p><div id="payments-content"><div class="loading">Loading...</div></div></div>@@@ -200,53 +210,57 @@</div><!-- Pastes -->-- <div class="section" id="sec-pastes">++ <div class="section admin-clarity" id="sec-pastes">++ <p class="admin-intro">These are the limits and maintenance tools for shared text and image pastes. Check limits when reviewing capacity, and delete copies only when you intend permanent removal.</p><div id="pastes-content"><div class="loading">Loading...</div></div></div><!-- Storage -->-- <div class="section" id="sec-storage">++ <div class="section admin-clarity" id="sec-storage">++ <p class="admin-intro">This shows how much space recordings and other files use. Review it when storage runs low; deleting recordings is permanent and requires a separate backup to recover.</p><div id="storage-content"><div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div></div></div><!-- Notifications (Broadcast) -->-- <div class="section" id="sec-notifications">++ <div class="section admin-clarity" id="sec-notifications">++ <p class="admin-intro">Broadcasts send a notification to every user. Use them for network-wide announcements; sending cannot be recalled here, so send a correction if needed.</p><div class="card"><h3><i class="fa-solid fa-bullhorn"></i> Broadcast Notification</h3>-- <p class="muted" style="margin-bottom:16px">Send a notification to all users across the OpenVibe.</p>++ <p class="muted" style="margin-bottom:16px">This sends the title and message to every user across OpenVibe. Review the wording and destination before sending; there is no recall control here.</p><div class="form-field"><label>Title</label>-- <input type="text" id="broadcast-title" placeholder="Notification title...">++ <span class="admin-field"><input type="text" id="broadcast-title" aria-label="Notification title" aria-describedby="help-broadcast-title" placeholder="Notification title..."><span class="admin-help" id="help-broadcast-title"><strong>Notification title.</strong> The title everyone will see; required with no default. For example: Scheduled maintenance.</span></span></div><div class="form-field"><label>Message</label>-- <textarea id="broadcast-msg" rows="3" placeholder="Message body..." style="resize:vertical"></textarea>++ <span class="admin-field"><textarea id="broadcast-msg" aria-label="Notification message" aria-describedby="help-broadcast-msg" rows="3" placeholder="Message body..." style="resize:vertical"></textarea><span class="admin-help" id="help-broadcast-msg"><strong>Notification message.</strong> The announcement body; required with no default. For example: We will be back at 18:00 UTC.</span></span></div><div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px"><div class="form-field"><label>Icon (emoji)</label>-- <input type="text" id="broadcast-icon" value="📢" style="text-align:center">++ <span class="admin-field"><input type="text" id="broadcast-icon" aria-label="Notification icon" aria-describedby="help-broadcast-icon" value="📢" style="text-align:center"><span class="admin-help" id="help-broadcast-icon"><strong>Notification icon.</strong> The default is the announcement emoji. Use one recognizable emoji, for example 📢.</span></span></div><div class="form-field"><label>Priority</label>-- <select id="broadcast-priority">-- <option value="normal">Normal</option>-- <option value="high">High</option>-- <option value="critical">Critical</option>-- </select>++ <span class="admin-field"><select id="broadcast-priority" aria-label="How urgent is this?" aria-describedby="help-broadcast-priority">++ <option value="normal">Normal — routine announcement</option>++ <option value="high">High — needs attention</option>++ <option value="critical">Critical — urgent issue</option>++ </select><span class="admin-help" id="help-broadcast-priority"><strong>How urgent is this?</strong> Normal is the default for routine news; High calls for attention, and Critical marks an urgent issue. Choose Critical only when everyone needs to know promptly.</span></span></div><div class="form-field"><label>URL (optional)</label>-- <input type="text" id="broadcast-url" placeholder="https://...">++ <span class="admin-field"><input type="text" id="broadcast-url" aria-label="Link to open" aria-describedby="help-broadcast-url" placeholder="https://..."><span class="admin-help" id="help-broadcast-url"><strong>Link to open.</strong> Optional destination when someone opens the notification; blank means no destination link. For example: https://openvibe.network.</span></span></div></div>-- <button class="btn btn-primary" onclick="sendBroadcast()"><i class="fa-solid fa-paper-plane"></i> Send Broadcast</button>++ <button class="btn btn-primary" onclick="sendBroadcast()"><i class="fa-solid fa-paper-plane"></i> Send to everyone</button><p class="admin-help">This delivers the notification to all users. It cannot be recalled here; send a correction if needed.</p><p class="muted" style="margin-top:10px;font-size:0.85rem"><i class="fa-solid fa-clock"></i> Admins can send one global notification every 6 hours. Owners are exempt.</p></div></div><!-- Email -->-- <div class="section" id="sec-email">++ <div class="section admin-clarity" id="sec-email">++ <p class="admin-intro">Network sends recovery and notification emails through Resend. Change the sender or delivery switch when configuring email, then send a test to check it.</p><div class="card"><h3><i class="fa-solid fa-envelope"></i> Email Configuration</h3><div id="email-content"><div class="loading">Loading...</div></div>@@@ -254,32 +268,35 @@</div><!-- Verification Keys -->-- <div class="section" id="sec-vkeys">++ <div class="section admin-clarity" id="sec-vkeys">++ <p class="admin-intro">Account reservation keys let a person claim a reserved username. Generate one when arranging a reservation; revoking it is permanent, so create a new key if the person still needs access.</p><div class="input-row">-- <input type="text" id="vkey-username" placeholder="Username to reserve..." onkeydown="if(event.key==='Enter')generateVKey()">-- <input type="text" id="vkey-note" placeholder="Note (optional)">-- <button class="btn btn-primary" onclick="generateVKey()"><i class="fa-solid fa-key"></i> Generate Key</button>++ <span class="admin-field"><input type="text" id="vkey-username" aria-label="Username to reserve" aria-describedby="help-vkey-username" placeholder="Username to reserve..." onkeydown="if(event.key==='Enter')generateVKey()"><span class="admin-help" id="help-vkey-username"><strong>Username to reserve.</strong> Enter the username the recipient will claim, for example alex. No name is selected by default.</span></span>++ <span class="admin-field"><input type="text" id="vkey-note" aria-label="Reservation note" aria-describedby="help-vkey-note" placeholder="Note (optional)"><span class="admin-help" id="help-vkey-note"><strong>Reservation note.</strong> Optional note for staff, for example reserved for the event host. Blank adds no note.</span></span>++ <button class="btn btn-primary" onclick="generateVKey()"><i class="fa-solid fa-key"></i> Generate a reservation key</button></div><div id="vkeys-content"><div class="loading">Loading...</div></div></div><!-- Audit Log -->-- <div class="section" id="sec-audit">++ <div class="section admin-clarity" id="sec-audit">++ <p class="admin-intro">This is the history of administrative changes. Review it after an unexpected change, then use the relevant settings page to correct it.</p><div id="audit-content"><div class="loading">Loading...</div></div><div id="audit-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div></div><!-- Moderation log (ADR-022): every staff action any service reports, from /api/v1/staff/moderation-audit -->-- <div class="section" id="sec-modlog">++ <div class="section admin-clarity" id="sec-modlog">++ <p class="admin-intro">This records staff moderation actions across services. Filter it when investigating who acted and why; viewing or exporting the log changes no account permissions.</p><p class="muted" style="margin:0 0 10px">Staff actions reported by Chat, Live, Community, Tips, Billing and the other services (Tools, Games, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, Codes). Each service enforces its own moderation; this is the record.</p><form id="modlog-filters" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px" onsubmit="event.preventDefault(); loadModLog(true)"><label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service-- <select id="modlog-service" onchange="loadModLog(true)"><option value="">All</option><option value="chat">Chat</option><option value="live">Live</option><option value="community">Community</option><option value="tips">Tips</option><option value="billing">Billing</option><option value="blog">Blog</option><option value="codes">Codes</option><option value="coupons">Coupons</option><option value="deals">Deals</option><option value="games">Games</option><option value="media">Media</option><option value="news">News</option><option value="reviews">Reviews</option><option value="tools">Tools</option><option value="trade">Trade</option><option value="wiki">Wiki</option></select></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Action<input id="modlog-action" placeholder="e.g. site_ban" maxlength="64" style="width:130px"></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">By (subject)<input id="modlog-actor" placeholder="usr_…" maxlength="64" style="width:150px"></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Target<input id="modlog-target" placeholder="subject or id" maxlength="200" style="width:150px"></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">From<input id="modlog-since" type="date"></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Until<input id="modlog-until" type="date"></label>++ <span class="admin-field"><select id="modlog-service" aria-label="Service to review" aria-describedby="help-modlog-service" onchange="loadModLog(true)"><option value="">All</option><option value="chat">Chat</option><option value="live">Live</option><option value="community">Community</option><option value="tips">Tips</option><option value="billing">Billing</option><option value="blog">Blog</option><option value="codes">Codes</option><option value="coupons">Coupons</option><option value="deals">Deals</option><option value="games">Games</option><option value="media">Media</option><option value="news">News</option><option value="reviews">Reviews</option><option value="tools">Tools</option><option value="trade">Trade</option><option value="wiki">Wiki</option></select><span class="admin-help" id="help-modlog-service"><strong>Service to review.</strong> All services are shown by default. Select Live, for example, to review only actions from that service.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Action<span class="admin-field"><input id="modlog-action" aria-label="Action to find" aria-describedby="help-modlog-action" placeholder="e.g. site_ban" maxlength="64" style="width:130px"><span class="admin-help" id="help-modlog-action"><strong>Action to find.</strong> Blank includes all actions. Enter an exact action name, for example site_ban for a site ban.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">By (subject)<span class="admin-field"><input id="modlog-actor" aria-label="Staff account ID" aria-describedby="help-modlog-actor" placeholder="usr_…" maxlength="64" style="width:150px"><span class="admin-help" id="help-modlog-actor"><strong>Staff account ID.</strong> Blank includes all staff. Enter the stable account ID, for example a usr_ identifier from the record you are investigating.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Target<span class="admin-field"><input id="modlog-target" aria-label="Affected account or item" aria-describedby="help-modlog-target" placeholder="subject or id" maxlength="200" style="width:150px"><span class="admin-help" id="help-modlog-target"><strong>Affected account or item.</strong> Blank includes all targets. Enter the account or item ID from the report.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">From<span class="admin-field"><input id="modlog-since" aria-label="Start date" aria-describedby="help-modlog-since" type="date"><span class="admin-help" id="help-modlog-since"><strong>Start date.</strong> Blank sets no starting date. Choose an earlier date to include more history.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Until<span class="admin-field"><input id="modlog-until" aria-label="End date" aria-describedby="help-modlog-until" type="date"><span class="admin-help" id="help-modlog-until"><strong>End date.</strong> Blank sets no ending date. Choose a later date to include more history.</span></span></label><button class="btn" type="submit">Filter</button><button class="btn" type="button" onclick="exportModLog()">Export CSV</button></form>@@@ -288,31 +305,33 @@</div><!-- Operator parity checklist (WS-D task 5): where each operator job is done, and what has no console yet -->-- <div class="section" id="sec-checklist">++ <div class="section admin-clarity" id="sec-checklist">++ <p class="admin-intro">This checklist shows whether key operational areas are ready. Open the linked page when an item needs attention; a missing console means the listed command or API is the available route.</p><p class="muted" style="margin:0 0 10px">Every operator job the old admin did, and where it is done now: a tab here, another service's console, the host CLI, or an API that has no console yet (shown, not hidden). The dot is that service's readiness as the status page last saw it.</p><div id="checklist-content"><div class="loading">Loading...</div></div></div><!-- Community theme review (WS-E task 2): submissions stay private until approved here -->-- <div class="section" id="sec-themes">++ <div class="section admin-clarity" id="sec-themes"><p class="admin-intro">These themes are waiting for staff review. Approve a suitable theme to publish it, or reject it with a reason so its author can fix and resubmit it.</p><p class="muted" style="margin:0 0 10px">Community themes waiting for review. Approving one puts it in the public catalog; rejecting needs a reason, which the author sees. Values are already limited to the shared colour tokens.</p><div id="themes-review-content"><div class="loading">Loading...</div></div></div><!-- Service grants (WS-D task 3): what each service principal may do, owner-only and audited -->-- <div class="section" id="sec-grants">++ <div class="section admin-clarity" id="sec-grants">++ <p class="admin-intro">These permissions let one OpenVibe service call another. Add one only for a required integration; revoke it to stop that access, or grant it again to restore access.</p><p class="muted" style="margin:0 0 10px">What each service may ask other services for. Tokens carry a change within 5 minutes. Every change needs a reason, is audited below and is announced as <code>network.principal_grant.changed</code>. Defaults come from the code.</p><form id="grant-form" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:12px" onsubmit="event.preventDefault(); submitGrant()">-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service<input id="grant-client" placeholder="live" maxlength="64" style="width:110px" required></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Capability<input id="grant-cap" placeholder="network.staff.read" maxlength="120" style="width:210px" required></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Namespaces<input id="grant-ns" placeholder="comma separated (optional)" maxlength="400" style="width:180px"></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Expires<input id="grant-exp" type="date"></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Reason<input id="grant-reason" maxlength="500" style="width:220px" required></label>-- <button class="btn" type="submit">Grant</button>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service<span class="admin-field"><input id="grant-client" aria-label="Service receiving access" aria-describedby="help-grant-client" placeholder="live" maxlength="64" style="width:110px" required><span class="admin-help" id="help-grant-client"><strong>Service receiving access.</strong> Enter the service ID, for example live; required with no default. This is the service that will be allowed to call the API.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Capability<span class="admin-field"><input id="grant-cap" aria-label="Permission to grant" aria-describedby="help-grant-cap" placeholder="network.staff.read" maxlength="120" style="width:210px" required><span class="admin-help" id="help-grant-cap"><strong>Permission to grant.</strong> Enter the exact permission name, for example network.staff.read; required with no default. Granting it enables that operation until expiry or revocation.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Namespaces<span class="admin-field"><input id="grant-ns" aria-label="Allowed areas" aria-describedby="help-grant-ns" placeholder="comma separated (optional)" maxlength="400" style="width:180px"><span class="admin-help" id="help-grant-ns"><strong>Allowed areas.</strong> Optional comma-separated areas to restrict this permission, for example a namespace required by the integration. Blank adds no namespace restriction.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Expires<span class="admin-field"><input id="grant-exp" aria-label="Access expiry date" aria-describedby="help-grant-exp" type="date"><span class="admin-help" id="help-grant-exp"><strong>Access expiry date.</strong> Blank means no expiry date. Access ends at midnight UTC on the selected date; an earlier date grants access for less time.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Reason<span class="admin-field"><input id="grant-reason" aria-label="Why access is needed" aria-describedby="help-grant-reason" maxlength="500" style="width:220px" required><span class="admin-help" id="help-grant-reason"><strong>Why access is needed.</strong> Required explanation for the audit history, for example enable staff lookup for Live. No reason is filled in by default.</span></span></label>++ <button class="btn" type="submit">Grant access</button></form><div style="display:flex;gap:8px;align-items:center;margin-bottom:8px"><label for="grant-filter" class="muted" style="font-size:12px">Show</label>-- <input id="grant-filter" placeholder="service" maxlength="64" style="width:120px" oninput="renderGrants()">++ <span class="admin-field"><input id="grant-filter" aria-label="Find a service" aria-describedby="help-grant-filter" placeholder="service" maxlength="64" style="width:120px" oninput="renderGrants()"><span class="admin-help" id="help-grant-filter"><strong>Find a service.</strong> Blank shows every service permission. Enter live, for example, to narrow the list.</span></span></div><div id="grants-content"><div class="loading">Loading...</div></div><h3 style="margin:18px 0 8px;font-size:14px">Changes</h3>@@@ -320,20 +339,22 @@</div><!-- Event deliveries (WS-F task 3): OpenVibe.Events' dead-letter queue and replays -->-- <div class="section" id="sec-eventsops">++ <div class="section admin-clarity" id="sec-eventsops">++ <p class="admin-intro">These are event messages that have not reached a subscribed service. Fix the delivery problem before retrying; replay sends another delivery and cannot be recalled once sent.</p><p class="muted" style="margin:0 0 10px">Deliveries OpenVibe.Events could not make. A dead delivery stopped retrying; replay it once its consumer is fixed. Replays are audited.</p><div style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px"><label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Status-- <select id="dlq-status" onchange="loadDeliveries()"><option value="dead">Dead</option><option value="failed">Failing (retrying)</option><option value="pending">Pending</option></select></label>-- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Subscription<input id="dlq-sub" placeholder="sub_…" maxlength="40" style="width:240px"></label>-- <button class="btn" type="button" onclick="loadDeliveries()">Show</button>++ <span class="admin-field"><select id="dlq-status" aria-label="Delivery state" aria-describedby="help-dlq-status" onchange="loadDeliveries()"><option value="dead">Stopped retrying — needs attention</option><option value="failed">Retrying — delivery keeps failing</option><option value="pending">Waiting — delivery is queued</option></select><span class="admin-help" id="help-dlq-status"><strong>Delivery state.</strong> The default shows deliveries that stopped retrying. Choose Retrying to investigate ongoing failures, or Waiting for messages still queued.</span></span></label>++ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Subscription<span class="admin-field"><input id="dlq-sub" aria-label="Subscription ID" aria-describedby="help-dlq-sub" placeholder="sub_…" maxlength="40" style="width:240px"><span class="admin-help" id="help-dlq-sub"><strong>Subscription ID.</strong> Blank includes all subscriptions. Enter the sub_ identifier of a receiving service to narrow the list.</span></span></label>++ <button class="btn" type="button" onclick="loadDeliveries()">Show deliveries</button></div><div id="dlq-counts" class="muted" style="font-size:12px;margin-bottom:8px"></div><div id="dlq-content"><div class="loading">Loading...</div></div></div><!-- Analytics -->-- <div class="section" id="sec-analytics">++ <div class="section admin-clarity" id="sec-analytics">++ <p class="admin-intro">These charts count visits, requests, errors, and response times. Choose a time range or service when investigating a trend; a longer range shows more history and a shorter range highlights recent changes.</p><div class="sub-tabs" id="analytics-sub-tabs"><button class="active" onclick="showAnalyticsSubTab('overview',this)">Overview</button><button onclick="showAnalyticsSubTab('live',this)">OpenVibe.Live</button>@@@ -344,7 +365,7 @@<button onclick="showAnalyticsSubTab('bots',this)">Bot Analysis</button></div><div class="input-row" style="margin-bottom:16px">-- <select id="analytics-period" onchange="reloadAnalytics()" style="max-width:180px">++ <span class="admin-field"><select id="analytics-period" aria-label="Time range" aria-describedby="help-analytics-period" onchange="reloadAnalytics()" style="max-width:180px"><option value="1h">Last hour</option><option value="12h">Last 12 hours</option><option value="24h">Last 24 hours</option>@@@ -354,7 +375,7 @@<option value="30" selected>Last 30 days</option><option value="90">Last 90 days</option><option value="365">Last year</option>-- </select>++ </select><span class="admin-help" id="help-analytics-period"><strong>Time range.</strong> The default is the last 30 days. Longer ranges show more history and shorter ranges highlight recent changes; for example choose Last hour for a current incident.</span></span><button class="btn btn-outline" onclick="reloadAnalytics()"><i class="fa-solid fa-rotate"></i> Refresh</button></div>@@@ -363,60 +384,60 @@<div id="analytics-insights"></div><div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div><div class="card" id="analytics-realtime-card" style="display:none">-- <h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3>++ <h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3><p class="admin-intro">These counts cover requests in the last five minutes. Check them during an incident; higher request counts mean more activity and higher error counts mean more failures.</p><div id="analytics-realtime"></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"><div class="card">-- <h3><i class="fa-solid fa-chart-area"></i> Traffic Trend</h3>++ <h3><i class="fa-solid fa-chart-area"></i> Traffic Trend</h3><p class="admin-intro">This plots requests over the selected time range. Use a shorter range to inspect a spike or a longer range to compare activity across days.</p><canvas id="chart-traffic-trend" height="260"></canvas></div><div class="card">-- <h3><i class="fa-solid fa-ranking-star"></i> Service Comparison</h3>++ <h3><i class="fa-solid fa-ranking-star"></i> Service Comparison</h3><p class="admin-intro">This compares traffic across services. Review it when deciding which service needs more capacity or investigation.</p><canvas id="chart-service-compare" height="260"></canvas></div></div><div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-users"></i> Unique Visitors Trend</h3>++ <h3><i class="fa-solid fa-users"></i> Unique Visitors Trend</h3><p class="admin-intro">This plots estimated distinct visitors over time. Compare periods to see audience changes; larger counts mean more visitors, not necessarily more registered accounts.</p><canvas id="chart-visitors-trend" height="240"></canvas></div></div><div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-user-check"></i> Logged-in vs Anonymous</h3>++ <h3><i class="fa-solid fa-user-check"></i> Logged-in vs Anonymous</h3><p class="admin-intro">This compares visits with and without a signed-in account. Review it when checking how much activity comes from members versus visitors.</p><canvas id="chart-auth-trend" height="240"></canvas></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-chart-column"></i> Per-Service Traffic Share</h3>++ <h3><i class="fa-solid fa-chart-column"></i> Per-Service Traffic Share</h3><p class="admin-intro">This compares each service’s portion of recorded traffic. Use it to identify where most requests go before changing capacity.</p><canvas id="chart-service-share" height="260"></canvas></div><div class="card">-- <h3><i class="fa-solid fa-chart-column"></i> Per-Service Visitors</h3>++ <h3><i class="fa-solid fa-chart-column"></i> Per-Service Visitors</h3><p class="admin-intro">This compares estimated distinct visitors by service. Review it when checking audience size; a person visiting multiple services may be counted in each.</p><canvas id="chart-service-visitors" height="260"></canvas></div></div><div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-layer-group"></i> Per-Service Traffic Over Time</h3>++ <h3><i class="fa-solid fa-layer-group"></i> Per-Service Traffic Over Time</h3><p class="admin-intro">This plots each service’s traffic across the selected range. Review it when a network-wide spike needs tracing to one service.</p><canvas id="chart-per-service-trend" height="280"></canvas></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-robot"></i> Bots vs Humans</h3>++ <h3><i class="fa-solid fa-robot"></i> Bots vs Humans</h3><p class="admin-intro">This separates requests classified as automated from those classified as human. Check it when traffic rises unexpectedly; the classification is an estimate.</p><canvas id="chart-bot-ratio" height="220"></canvas></div><div class="card">-- <h3><i class="fa-solid fa-network-wired"></i> Bandwidth & Usage</h3>++ <h3><i class="fa-solid fa-network-wired"></i> Bandwidth & Usage</h3><p class="admin-intro">This estimates data transferred and request volume. Check it when planning capacity; more bytes mean more network use, not a confirmed bill.</p><div id="analytics-bandwidth"></div></div></div><div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-server"></i> Per-Service Traffic</h3>++ <h3><i class="fa-solid fa-server"></i> Per-Service Traffic</h3><p class="admin-intro">This lists request totals and errors for each service. Review services with more errors before changing their configuration.</p><div id="analytics-service-table"></div></div></div>@@@ -427,75 +448,75 @@<div class="stats-grid" id="svc-analytics-stats"><div class="loading">Loading...</div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"><div class="card">-- <h3><i class="fa-solid fa-chart-line"></i> Daily Traffic</h3>++ <h3><i class="fa-solid fa-chart-line"></i> Daily Traffic</h3><p class="admin-intro">This groups recorded traffic by day. Use it to compare daily activity over the selected period.</p><canvas id="chart-svc-daily" height="260"></canvas></div><div class="card">-- <h3><i class="fa-solid fa-clock"></i> <span id="svc-hourly-title">Hourly (24h)</span></h3>++ <h3><i class="fa-solid fa-clock"></i> <span id="svc-hourly-title">Hourly (24h)</span></h3><p class="admin-intro">This groups the last day of traffic by hour. Review it when locating a recent spike; taller bars mean more recorded requests.</p><canvas id="chart-svc-hourly" height="260"></canvas></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-gauge-high"></i> Response Time Percentiles</h3>++ <h3><i class="fa-solid fa-gauge-high"></i> Response Time Percentiles</h3><p class="admin-intro">This shows response times in milliseconds, including the time within which 95 percent of requests finish (p95). Check it when a service feels slow; higher times mean longer waits.</p><div id="svc-response-percentiles"></div></div><div class="card">-- <h3><i class="fa-solid fa-clock-rotate-left"></i> Peak Hours</h3>++ <h3><i class="fa-solid fa-clock-rotate-left"></i> Peak Hours</h3><p class="admin-intro">This shows which hours receive the most traffic. Use it when choosing maintenance windows or planning capacity.</p><canvas id="chart-svc-peak-hours" height="200"></canvas></div></div><div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-laptop"></i> Devices</h3>++ <h3><i class="fa-solid fa-laptop"></i> Devices</h3><p class="admin-intro">This groups traffic by device type. Review it when deciding which screen sizes need attention.</p><canvas id="chart-svc-devices" height="220"></canvas></div><div class="card">-- <h3><i class="fa-solid fa-globe"></i> Browsers</h3>++ <h3><i class="fa-solid fa-globe"></i> Browsers</h3><p class="admin-intro">This groups traffic by browser. Review it when investigating a browser-specific problem.</p><canvas id="chart-svc-browsers" height="220"></canvas></div><div class="card">-- <h3><i class="fa-solid fa-desktop"></i> OS</h3>++ <h3><i class="fa-solid fa-desktop"></i> OS</h3><p class="admin-intro">This groups traffic by operating system. Review it when investigating a platform-specific problem.</p><canvas id="chart-svc-os" height="220"></canvas></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-network-wired"></i> Bandwidth & Sessions</h3>++ <h3><i class="fa-solid fa-network-wired"></i> Bandwidth & Sessions</h3><p class="admin-intro">This shows estimated transferred data and browsing sessions. Review it when planning capacity; more bytes or sessions mean more use.</p><div id="svc-bandwidth-sessions"></div></div><div class="card">-- <h3><i class="fa-solid fa-user-check"></i> Auth vs Anonymous</h3>++ <h3><i class="fa-solid fa-user-check"></i> Auth vs Anonymous</h3><p class="admin-intro">This compares signed-in requests with requests from visitors without a session. Review it when investigating sign-in or audience patterns.</p><canvas id="chart-svc-auth" height="200"></canvas></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-link"></i> Top Pages</h3>++ <h3><i class="fa-solid fa-link"></i> Top Pages</h3><p class="admin-intro">These pages received the most visits during the selected period. Review them when prioritizing content or investigating unusual traffic.</p><div id="svc-top-pages"></div></div><div class="card">-- <h3><i class="fa-solid fa-bolt"></i> Top API Endpoints</h3>++ <h3><i class="fa-solid fa-bolt"></i> Top API Endpoints</h3><p class="admin-intro">These service operations received the most requests. Review them when investigating load; more requests mean more calls, not necessarily more people.</p><div id="svc-top-api"></div></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-arrow-up-right-from-square"></i> Top Referers</h3>++ <h3><i class="fa-solid fa-arrow-up-right-from-square"></i> Top Referers</h3><p class="admin-intro">These are the websites that referred visitors here. Review them when checking traffic sources; some browsers do not send a referring address.</p><div id="svc-top-referers"></div></div><div class="card">-- <h3><i class="fa-solid fa-gauge"></i> Slowest Endpoints</h3>++ <h3><i class="fa-solid fa-gauge"></i> Slowest Endpoints</h3><p class="admin-intro">These service operations had the longest response times in milliseconds. Review them during performance investigations; higher times mean slower responses.</p><div id="svc-slowest"></div></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px"><div class="card">-- <h3><i class="fa-solid fa-circle-exclamation"></i> Status Codes</h3>++ <h3><i class="fa-solid fa-circle-exclamation"></i> Status Codes</h3><p class="admin-intro">These counts group server responses by HTTP result: 2xx succeeded, 3xx redirected, 4xx could not accept the request, and 5xx failed at the server. Review rising error counts when investigating reliability.</p><canvas id="chart-svc-status" height="200"></canvas></div><div class="card">-- <h3><i class="fa-solid fa-earth-americas"></i> Countries</h3>++ <h3><i class="fa-solid fa-earth-americas"></i> Countries</h3><p class="admin-intro">These estimates group visits by country supplied by the edge service. Review them when understanding reach; missing country information does not mean no traffic.</p><div id="svc-countries"></div></div></div>@@@ -508,7 +529,8 @@</div><!-- Deploy -->-- <div class="section" id="sec-deploy">++ <div class="section admin-clarity" id="sec-deploy">++ <p class="admin-intro">These tools manage website addresses, HTTPS certificates, and the web server. Change them when bringing a domain online or fixing routing, and review previews before applying changes.</p><div class="sub-tabs" id="deploy-sub-tabs"><button class="active" onclick="showDeploySubTab('overview',this)">Overview</button><button onclick="showDeploySubTab('certs',this)">Certificates</button>@@@ -521,7 +543,7 @@<div id="deploy-overview"><div class="stats-grid" id="deploy-stats"><div class="loading">Loading deploy status...</div></div><div class="card" style="margin-top:16px">-- <h3><i class="fa-solid fa-circle-check"></i> Prerequisites</h3>++ <h3><i class="fa-solid fa-circle-check"></i> Prerequisites</h3><p class="admin-intro">These checks show whether certificate and web server tools are installed and configured. Fix a missing prerequisite before issuing certificates or applying web routing.</p><div id="deploy-prereqs"><div class="muted">Checking...</div></div></div></div>@@@ -530,24 +552,24 @@<div id="deploy-certs-panel" style="display:none"><div class="card"><h3><i class="fa-solid fa-lock"></i> SSL/TLS Certificates</h3>-- <p class="muted" style="margin:8px 0 16px">Manage Let's Encrypt wildcard certificates for all domains.</p>++ <p class="muted" style="margin:8px 0 16px">Certificates enable encrypted HTTPS connections for your domains. Check expiry dates when a certificate is near renewal or visitors see a security error; fewer remaining days means renewal is more urgent.</p><div id="deploy-certs-list"><div class="loading">Loading certificates...</div></div></div><div class="card" style="margin-top:16px">-- <h3><i class="fa-solid fa-plus-circle"></i> Issue New Certificate</h3>++ <h3><i class="fa-solid fa-plus-circle"></i> Issue New Certificate</h3><p class="admin-intro">This requests HTTPS certificates for a domain you control. Use it when adding a domain; renewal replaces expiring certificates, and issuance cannot be recalled from this page.</p><div class="form-group" style="margin:12px 0"><label>Domain</label>-- <input type="text" id="cert-issue-domain" placeholder="example.com" style="width:300px">++ <span class="admin-field"><input type="text" id="cert-issue-domain" aria-label="Domain for the certificate" aria-describedby="help-cert-issue-domain" placeholder="example.com" style="width:300px"><span class="admin-help" id="help-cert-issue-domain"><strong>Domain for the certificate.</strong> No domain is selected by default. Enter a domain you control, for example example.com, after its DNS is ready.</span></span></div><div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn btn-primary" onclick="issueCertCloudflare()"><i class="fa-brands fa-cloudflare"></i> Issue via Cloudflare DNS</button><button class="btn btn-secondary" onclick="showManualDnsInfo()">-- <i class="fa-solid fa-hand"></i> Manual DNS Mode++ <i class="fa-solid fa-hand"></i> Manual DNS</button><button class="btn btn-secondary" onclick="renewAllCerts()">-- <i class="fa-solid fa-rotate"></i> Renew All++ <i class="fa-solid fa-rotate"></i> Renew all certificates</button></div><div id="cert-issue-output" style="margin-top:12px"></div>@@@ -558,7 +580,7 @@<div id="deploy-nginx-panel" style="display:none"><div class="card"><h3><i class="fa-solid fa-sitemap"></i> Nginx Configuration</h3>-- <p class="muted" style="margin:8px 0 16px">Preview and apply generated Nginx configs derived from your domain and service settings.</p>++ <p class="muted" style="margin:8px 0 16px">Nginx directs web requests to the right service. Preview when changing domains, then validate before applying; Apply writes configuration and reloads the web server, and undo requires restoring the backup on the server.</p><div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px"><button class="btn btn-primary" onclick="previewNginxConfigs()"><i class="fa-solid fa-eye"></i> Preview All Configs@@@ -567,7 +589,7 @@<i class="fa-solid fa-check-circle"></i> Validate Current Config</button><button class="btn btn-warning" onclick="applyNginxConfigs()">-- <i class="fa-solid fa-upload"></i> Apply & Reload++ <i class="fa-solid fa-upload"></i> Apply configuration and reload</button></div><div id="nginx-preview-output"></div>@@@ -578,18 +600,18 @@<div id="deploy-domains-panel" style="display:none"><div class="card"><h3><i class="fa-solid fa-globe"></i> Managed Domains</h3>-- <p class="muted" style="margin:8px 0 16px">Configure which domains this instance manages for TLS and reverse proxy.</p>++ <p class="muted" style="margin:8px 0 16px">These domains are included in certificate and web routing configuration. Add a domain when bringing it online, or remove it to stop managing it here; add it again to undo.</p><div id="deploy-domains-list"><div class="loading">Loading...</div></div><div style="margin-top:16px;display:flex;gap:8px;align-items:end;flex-wrap:wrap"><div class="form-group" style="margin:0"><label>Add Domain</label>-- <input type="text" id="domain-add-input" placeholder="example.com" style="width:250px">++ <span class="admin-field"><input type="text" id="domain-add-input" aria-label="Domain to manage" aria-describedby="help-domain-add-input" placeholder="example.com" style="width:250px"><span class="admin-help" id="help-domain-add-input"><strong>Domain to manage.</strong> No domain is selected by default. Enter the base domain, for example example.com; removing it later reverses this list change.</span></span></div><label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary)">-- <input type="checkbox" id="domain-add-wildcard" checked> Wildcard (*.domain)++ <span class="admin-field"><input type="checkbox" id="domain-add-wildcard" aria-label="Include subdomains" aria-describedby="help-domain-add-wildcard" checked><span class="admin-help" id="help-domain-add-wildcard"><strong>Include subdomains.</strong> On by default, so the certificate also covers addresses such as app.example.com. Turn it off to request only the base domain.</span></span> Wildcard (*.domain)</label><button class="btn btn-primary" onclick="addManagedDomain()">-- <i class="fa-solid fa-plus"></i> Add++ <i class="fa-solid fa-plus"></i> Add domain</button></div></div>@@@ -598,38 +620,38 @@<!-- Deploy: Config --><div id="deploy-config-panel" style="display:none"><div class="card">-- <h3><i class="fa-solid fa-gear"></i> Deploy Configuration</h3>++ <h3><i class="fa-solid fa-gear"></i> Deploy Configuration</h3><p class="admin-intro">These settings choose how certificates and web routing are managed. Change them when setting up the server, using Preview only until the generated configuration has been checked.</p><div class="form-group" style="margin:12px 0">-- <label>ACME / Let's Encrypt Email</label>-- <input type="email" id="deploy-acme-email" placeholder="[email protected]" style="width:350px">++ <label for="deploy-acme-email">Certificate contact email</label>++ <span class="admin-field"><input type="email" id="deploy-acme-email" aria-label="Certificate contact email" aria-describedby="help-deploy-acme-email" placeholder="[email protected]" style="width:350px"><span class="admin-help" id="help-deploy-acme-email">No contact address is configured by default. Enter an address you monitor, for example [email protected], for certificate account notices.</span></span></div><div class="form-group" style="margin:12px 0">-- <label>Certificate Mode</label>-- <select id="deploy-cert-mode" style="width:250px">-- <option value="cloudflare">Cloudflare DNS (Automated)</option>-- <option value="manual">Manual DNS Challenge</option>-- <option value="none">None / External</option>-- </select>++ <label for="deploy-cert-mode">How certificates are obtained</label>++ <span class="admin-field"><select id="deploy-cert-mode" aria-label="How to obtain certificates" aria-describedby="help-deploy-cert-mode" style="width:250px">++ <option value="cloudflare">Automatic — a Cloudflare token does the DNS step</option>++ <option value="manual">Manual — you add the DNS record yourself</option>++ <option value="none">External — managed outside Network</option>++ </select><span class="admin-help" id="help-deploy-cert-mode">Manual DNS is the default and requires adding a verification record yourself. Cloudflare automates that step with a token; External means certificates are managed elsewhere.</span></span></div><div class="form-group" style="margin:12px 0" id="deploy-cf-token-group">-- <label>Cloudflare API Token <span class="muted">(Zone → DNS → Edit)</span></label>-- <input type="password" id="deploy-cf-token" placeholder="••••••••" style="width:400px">++ <label for="deploy-cf-token">Cloudflare DNS token <span class="muted">(needs Zone → DNS → Edit)</span></label>++ <span class="admin-field"><input type="password" id="deploy-cf-token" aria-label="Cloudflare DNS editing token" aria-describedby="help-deploy-cf-token" placeholder="••••••••" style="width:400px"><span class="admin-help" id="help-deploy-cf-token">No token is configured by default. Use a token with Zone → DNS → Edit for the managed domains; for example limit it to your website zone.</span></span><div class="muted" style="font-size:11px;margin-top:4px">Token with Zone:DNS:Edit permissions for all managed domains</div></div><div class="form-group" style="margin:12px 0">-- <label>Nginx Management Mode</label>-- <select id="deploy-nginx-mode" style="width:250px">-- <option value="preview">Preview Only</option>-- <option value="apply">Apply (Write + Validate + Reload)</option>-- <option value="disabled">Disabled</option>-- </select>++ <label for="deploy-nginx-mode">How the web server is managed</label>++ <span class="admin-field"><select id="deploy-nginx-mode" aria-label="How to manage the web server" aria-describedby="help-deploy-nginx-mode" style="width:250px">++ <option value="preview">Preview only — nothing is written</option>++ <option value="apply">Apply — write, check and reload the web server</option>++ <option value="disabled">Disabled — do not manage Nginx</option>++ </select><span class="admin-help" id="help-deploy-nginx-mode">Preview only is the default and writes nothing. Apply permits writing, checking, and reloading Nginx; Disabled turns management off, and restoring a backup on the server undoes applied changes.</span></span></div><div class="form-group" style="margin:12px 0">-- <label>Nginx sites-enabled Path</label>-- <input type="text" id="deploy-nginx-path" placeholder="/etc/nginx/sites-enabled" style="width:400px">++ <label for="deploy-nginx-path">Web server configuration folder</label>++ <span class="admin-field"><input type="text" id="deploy-nginx-path" aria-label="Web server configuration folder" aria-describedby="help-deploy-nginx-path" placeholder="/etc/nginx/sites-enabled" style="width:400px"><span class="admin-help" id="help-deploy-nginx-path">The default is /etc/nginx/sites-enabled. Change it only if this server stores enabled website configuration in another folder.</span></span></div><button class="btn btn-primary" onclick="saveDeployConfig()" style="margin-top:8px">-- <i class="fa-solid fa-save"></i> Save Deploy Config++ <i class="fa-solid fa-save"></i> Save deploy settings</button><span id="deploy-config-status" style="margin-left:12px"></span></div>@@@ -637,10 +659,12 @@</div><!-- SSH access provisioning -->-- <div class="section" id="sec-domains">++ <div class="section admin-clarity" id="sec-domains">++ <p class="admin-intro">These addresses route people to tools across OpenVibe. Add or change an address when introducing a domain; remove the saved override to return to the built-in routing.</p><div id="domains-content"><div class="loading">Loading...</div></div></div>-- <div class="section" id="sec-ssh">++ <div class="section admin-clarity" id="sec-ssh">++ <p class="admin-intro">SSH gives staff command-line access to the development server. Use these instructions when setting up access, and choose only the permissions the person needs.</p><div id="ssh-content"><div class="loading">Loading...</div></div></div></div>@@@ -280,6 +280,56 @@ a.btn{text-decoration:none}@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}++/* Owner clarity: scoped to the legacy admin sections; AI and Setup own their forms. */++.admin-clarity { min-width:0; overflow-wrap:anywhere; }++.admin-clarity .admin-intro { max-width:76ch; margin:0 0 18px; color:var(--text-secondary); font-size:14px; line-height:1.6; }++.admin-clarity h3 + .admin-intro { margin-top:4px; }++.admin-clarity .admin-help { display:block; color:var(--text-secondary); font-size:13px; font-weight:400; text-transform:none; letter-spacing:normal; line-height:1.55; margin:6px 0 10px; }++.admin-clarity .admin-machine-key { display:inline-block; color:var(--text-muted); font-size:12px; font-weight:400; overflow-wrap:anywhere; }++.admin-clarity .admin-field { display:flex; flex:1 1 220px; flex-direction:column; min-width:0; max-width:100%; }++.admin-clarity :is(input,select,textarea) { max-width:100%; min-height:44px; font-size:14px; }++.admin-clarity :is(button,.btn,summary) { min-height:44px; min-width:44px; white-space:normal; justify-content:center; }++.admin-clarity input[type=checkbox] { width:44px!important; height:44px!important; flex:0 0 44px; accent-color:var(--accent); }++.admin-clarity .setting-row { gap:12px; align-items:flex-start; }++.admin-clarity .setting-row label { flex:1; min-width:0; }++.admin-clarity :is(fieldset,.card,.form-group,.form-field) { min-width:0; }++.admin-clarity .admin-advanced { width:100%; margin:8px 0; padding:8px 12px; border:1px solid var(--border); border-radius:8px; }++.admin-clarity .admin-advanced summary { cursor:pointer; padding:10px 0; }++.admin-clarity .admin-advanced textarea { width:100%; }++.admin-clarity :is(code,pre) { overflow-wrap:anywhere; }++.admin-clarity pre { max-width:100%; overflow:auto; }++.admin-clarity .badge { text-transform:none; line-height:1.5; white-space:normal; }++.admin-clarity a:not(.btn) { color:var(--accent-light); text-underline-offset:3px; }++.section-tabs button { min-height:44px; font-size:12px; }++.tabs-scroll-btn { width:44px; height:44px; flex-basis:44px; }++@media(max-width:600px) {++ body:has(.admin-clarity.active) .page { padding:16px; }++ .admin-clarity .card { padding:16px; }++ .admin-clarity :is(.muted,small,.badge,th,td,.label) { font-size:12px!important; }++ .admin-clarity .setting-row { flex-wrap:wrap; }++ .admin-clarity .setting-row:not(:has(input[type=checkbox])) { flex-direction:column!important; align-items:stretch!important; }++ .admin-clarity .setting-row label { flex-basis:100%; }++ .admin-clarity :is(input:not([type=checkbox]),textarea,select:not([aria-hidden=true])) { width:100%!important; min-width:0!important; }++ .admin-clarity [style*="grid-template-columns"] { grid-template-columns:minmax(0,1fr)!important; }++ .admin-clarity [style*="display:flex"] { flex-wrap:wrap; min-width:0; }++ .admin-clarity :is(.admin-table,.data-table,.table) { display:block; max-width:100%; overflow-x:auto; }++ .admin-clarity :is(.admin-table,.data-table,.table) :is(th,td) { min-width:100px; white-space:normal!important; }++ .admin-clarity .stats-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }++ .admin-clarity .input-row { align-items:flex-start; }++ .admin-clarity .input-row > button { width:100%; }++ .admin-clarity .sub-tabs { flex-wrap:wrap; }++}++@media(max-width:600px) {++ #sec-users .admin-table thead { display:none; }++ #sec-users .admin-table :is(tbody,tr,td) { display:block; min-width:0; width:100%; }++ #sec-users .admin-table tr { padding:12px; margin:12px 0; border:1px solid var(--border); border-radius:10px; background:var(--bg-card); }++ #sec-users .admin-table td { border:0; padding:6px 0; }++ #sec-users .admin-table td::before { content:attr(data-label); display:block; color:var(--text-secondary); font-size:12px; font-weight:600; margin-bottom:4px; }++ #sec-users .admin-table td:last-child { display:flex!important; gap:8px!important; }++ #sec-users .admin-table td:last-child::before { flex-basis:100%; }++}++body:has(.admin-clarity.active) .ov-sel-search input { min-height:44px; }++body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }/* ── The clear admin panel (2026-11-07): machine keys small and grey, Advanced settings folds, forms for objects ── */.pai-mkey{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;font-weight:500;color:var(--text-muted);background:var(--bg-tertiary);padding:1px 6px;border-radius:5px;vertical-align:middle;overflow-wrap:anywhere}.pai-words{font-size:12px;margin-top:4px}@@@ -117,13 +117,13 @@ async function loadInsights() {el.innerHTML = `<div class="card" style="margin-bottom:16px"><h3 style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between"><span><i class="fa-solid fa-lightbulb"></i> Insights <small class="muted">last 24 hours · ${esc(new Date(ins.generated_at).toLocaleTimeString())}</small></span><button class="btn btn-primary" id="insights-ask" type="button"><i class="fa-solid fa-wand-magic-sparkles"></i> Ask Vibe what to fix</button></h3>-- <div class="stats-grid">${tile('New sign-ups today', fmtNum(u.new_1d))}${tile('New this week', fmtNum(u.new_7d), `${fmtNum(u.new_30d)} in 30 days · ${fmtNum(u.total)} accounts`)}${tile('Requests', fmtNum(tot.requests), `${(ins.services || []).length} services`)}${tile('Server errors', fmtNum(tot.server_errors), `${rate.toFixed(3)}% of requests`)}</div>++ <p class="admin-intro">These counts summarize sign-ups and service health over the last 24 hours. Review rising errors or slow responses before asking Vibe to investigate; more errors mean more failed requests.</p><div class="stats-grid">${tile('New sign-ups today', fmtNum(u.new_1d))}${tile('New this week', fmtNum(u.new_7d), `${fmtNum(u.new_30d)} in 30 days · ${fmtNum(u.total)} accounts`)}${tile('Requests', fmtNum(tot.requests), `${(ins.services || []).length} services`)}${tile('Server errors', fmtNum(tot.server_errors), `${rate.toFixed(3)}% of requests`)}</div>${(u.daily || []).length ? `<div style="margin:10px 0 4px" class="muted">Sign-ups per day (14 days)</div><div style="display:flex;align-items:flex-end;gap:4px;height:70px" role="img" aria-label="Sign-ups per day">${u.daily.map((d) => `<div title="${esc(d.day)}: ${d.n}" style="flex:1;background:var(--accent);opacity:.85;border-radius:3px 3px 0 0;height:${Math.max(4, Math.round(d.n / max * 100))}%"></div>`).join('')}</div>` : ''}${ins.metrics_error ? `<p class="muted">${esc(ins.metrics_error)}</p>` : `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px;margin-top:12px">-- <div><h4 style="margin:4px 0">Services</h4><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Service</th><th style="text-align:right">Requests</th><th style="text-align:right">4xx</th><th style="text-align:right">5xx</th><th style="text-align:right">p95</th></tr>${svcRows}</table></div>-- <div><h4 style="margin:4px 0">Most used</h4><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">Requests</th></tr>${(ins.top_routes || []).map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.requests)}</td></tr>`).join('')}</table>-- <h4 style="margin:12px 0 4px">Where errors happen</h4>${(ins.error_routes || []).length ? `<table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">5xx</th></tr>${ins.error_routes.map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.errors)}</td></tr>`).join('')}</table>` : '<p class="muted">No server errors in the last 24 hours.</p>'}</div>++ <div><h4 style="margin:4px 0">Services</h4><p class="admin-intro">These are the requests each service handled in the last 24 hours. Review the services with the most failures first.</p><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Service</th><th style="text-align:right">Requests</th><th style="text-align:right">Rejected requests (4xx)</th><th style="text-align:right">Server failures (5xx)</th><th style="text-align:right">95% finish within (ms)</th></tr>${svcRows}</table></div>++ <div><h4 style="margin:4px 0">Most used</h4><p class="admin-intro">These are the pages and operations people used most often. Use them to see where people spend their time.</p><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">Requests</th></tr>${(ins.top_routes || []).map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.requests)}</td></tr>`).join('')}</table>++ <h4 style="margin:12px 0 4px">Where errors happen</h4><p class="admin-intro">These are the operations that failed at the server. Fix the cause before the next period, and check whether the failures concentrate in one service.</p>${(ins.error_routes || []).length ? `<table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">Server failures (5xx)</th></tr>${ins.error_routes.map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.errors)}</td></tr>`).join('')}</table>` : '<p class="muted">No server errors in the last 24 hours.</p>'}</div></div>`}</div>`;const ask = document.getElementById('insights-ask');@@@ -341,7 +341,7 @@ async function loadAnalyticsOverview() {<td>${s.avg_response_ms || 0}ms</td></tr>`;}).join('')}</tbody>-- </table>` : '<p class="muted">No data yet</p>';++ </table>` : '<p class="muted">No traffic recorded for this period. Choose a longer time range or return after visits are recorded.</p>';// Bot ratio donutconst totalHuman = (t.total_pageviews || 0) + (t.total_api_calls || 0);@@@ -468,7 +468,7 @@ async function loadServiceAnalytics(serviceName) {};rpEl.innerHTML = pctBar('Median (p50)', rp.p50, rp.max) + pctBar('p90', rp.p90, rp.max) + pctBar('p95', rp.p95, rp.max) + pctBar('p99', rp.p99, rp.max) + pctBar('Max', rp.max, rp.max);} else {-- rpEl.innerHTML = '<p class="muted">No response time data</p>';++ rpEl.innerHTML = '<p class="muted">No response times recorded. Choose a longer time range or check that the service is reporting requests.</p>';}// Peak hours chart@@@ -562,7 +562,7 @@ async function loadServiceAnalytics(serviceName) {<tbody>${pages.slice(0, 15).map(p => `<tr><td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.path)}">${esc(p.path)}</td><td>${fmtNum(p.hits)}</td><td>${fmtNum(p.visitors)}</td></tr>`).join('')}</tbody>-- </table>` : '<p class="muted">No data</p>';++ </table>` : '<p class="muted">No requests recorded. Choose a longer time range or another service.</p>';// Top API endpointsconst apiEndpoints = a.topApiEndpoints || [];@@@ -578,7 +578,7 @@ async function loadServiceAnalytics(serviceName) {<td style="color:${e.errors > 0 ? 'var(--danger)' : 'inherit'}">${fmtNum(e.errors)}</td></tr>`).join('')}</tbody>-- </table>` : '<p class="muted">No API data</p>';++ </table>` : '<p class="muted">No API requests recorded. Choose a longer time range or another service.</p>';// Referersconst refs = a.topReferers || [];@@@ -588,7 +588,7 @@ async function loadServiceAnalytics(serviceName) {<tbody>${refs.slice(0, 10).map(r => `<tr><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.referer)}">${esc(r.referer)}</td><td>${fmtNum(r.hits)}</td></tr>`).join('')}</tbody>-- </table>` : '<p class="muted">No referers tracked</p>';++ </table>` : '<p class="muted">No referring websites recorded. Try a longer range; direct visits may have no referring address.</p>';// Slowest endpointsconst slow = a.slowestEndpoints || [];@@@ -604,7 +604,7 @@ async function loadServiceAnalytics(serviceName) {<td>${fmtNum(e.hits)}</td></tr>`).join('')}</tbody>-- </table>` : '<p class="muted">No data</p>';++ </table>` : '<p class="muted">No requests recorded. Choose a longer time range or another service.</p>';// Status codesconst statuses = a.statusCodes || [];@@@ -628,7 +628,7 @@ async function loadServiceAnalytics(serviceName) {<tbody>${countries.slice(0, 15).map(c => `<tr><td>${esc(c.country || 'Unknown')}</td><td>${fmtNum(c.cnt)}</td></tr>`).join('')}</tbody>-- </table>` : '<p class="muted">No country data (requires Cloudflare CF-IPCountry header)</p>';++ </table>` : '<p class="muted">No country information recorded. Check whether the edge service supplies country information, then return after new visits.</p>';} catch (e) {statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;}@@@ -647,12 +647,12 @@ async function loadBotAnalysis() {if (!svcBots) continue;const label = svcName === 'live' ? 'OpenVibe.Live' : svcName === 'openvibe-network' ? 'OpenVibe.Network' : svcName === 'tools' ? 'OpenVibe.Tools' : svcName === 'games' ? 'OpenVibe.Games' : svcName === 'media' ? 'OpenVibe.Media' : svcName;-- html += `<div class="card"><h3><i class="fa-solid fa-robot"></i> ${esc(label)}</h3>`;++ html += `<div class="card"><h3><i class="fa-solid fa-robot"></i> ${esc(label)}</h3><p class="admin-intro">These requests were classified as automated traffic for this service. Review the categories when investigating unusual load; higher counts mean more automated requests.</p>`;// Bot typesconst types = svcBots.botTypes || [];if (types.length) {-- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Bot Types</h4>++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Bot types</h4><p class="admin-intro">These categories group recorded automated requests. Review the largest categories first when checking a traffic spike.</p><table class="admin-table"><thead><tr><th>Type</th><th>Hits</th><th>Sessions</th></tr></thead><tbody>${types.map(t => `<tr><td><span class="badge badge-danger">${esc(t.bot_type || 'unknown')}</span></td><td>${fmtNum(t.hits)}</td><td>${t.unique_sessions != null ? fmtNum(t.unique_sessions) : '-'}</td></tr>`).join('')}</tbody></table>`;}@@@ -660,7 +660,7 @@ async function loadBotAnalysis() {// Top bots by user-agent class (ADR-021: analytics stores no IP addresses)const topBots = svcBots.topBotIPs || [];if (topBots.length) {-- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Top Bots (user-agent class)</h4>++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Most active bots</h4><p class="admin-intro">These groups come from the identity a browser or bot reports. Use them to investigate heavy automated traffic; the reported identity can be inaccurate.</p><table class="admin-table"><thead><tr><th>User-agent class</th><th>Hits</th><th>Sessions</th><th>Type</th><th>First Seen</th><th>Last Seen</th></tr></thead><tbody>${topBots.slice(0, 15).map(b => `<tr><td style="font-family:monospace;font-size:11px">${esc(b.ua_class || '-')}</td><td>${fmtNum(b.hits)}</td>@@@ -674,7 +674,7 @@ async function loadBotAnalysis() {// High-volume sessions classified as human (rotating session ids, never an IP or user id)const suspicious = svcBots.suspiciousIPs || [];if (suspicious.length) {-- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Suspicious Sessions (high volume, classified as human)</h4>++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Unusually busy browsing sessions</h4><p class="admin-intro">These high-volume sessions were classified as human traffic. Investigate them before taking action; a busy session alone does not prove abuse.</p><table class="admin-table"><thead><tr><th>Session</th><th>Total Hits</th><th>Errors</th><th>Unique Paths</th><th>Period</th></tr></thead><tbody>${suspicious.map(s => `<tr><td style="font-family:monospace;font-size:11px">${esc(s.session_id || '-')}</td><td>${fmtNum(s.total_hits)}</td>@@@ -36,14 +36,14 @@ async function loadPastesAdmin() {</div><div class="card"><h3><i class="fa-solid fa-sliders" aria-hidden="true"></i> Paste limits</h3>-- <p class="muted" style="margin-bottom:8px">OpenVibe.Community owns pastes and sets these limits itself; they cannot be changed from here.</p>++ <p class="muted" style="margin-bottom:8px">OpenVibe.Community sets these paste limits; this view is read-only. Change them in Community when adjusting capacity; higher limits allow more content and lower limits allow less.</p>${limit('Largest text paste', `${n(cfg.maxSizeKb)} KB`)}${limit('Largest image', `${n(cfg.screenshotMaxSizeMb)} MB`)}${limit('Wait between pastes', `${n(cfg.cooldownSeconds)} seconds`)}${limit('Pastes per person per day', Number(cfg.maxPerUserPerDay) === 0 ? 'No limit' : n(cfg.maxPerUserPerDay))}</div><div class="card">-- <h3><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Forks</h3>++ <h3><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Forks</h3><p class="admin-intro">These are copies people made of existing pastes. Delete them only during deliberate cleanup; deleted copies cannot be recovered here.</p><p class="muted" style="margin-bottom:12px">Copies people made of other pastes. Deleting them cannot be undone.</p><button class="btn btn-danger" onclick="deleteAllPasteForks()"${Number(s.forks || 0) ? '' : ' disabled'}><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete all forks (${n(s.forks || 0)})</button></div>`;@@@ -80,7 +80,7 @@ async function loadStorage() {c.innerHTML = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px"><div class="card">-- <h3><i class="fa-solid fa-hard-drive"></i> Disk Usage</h3>++ <h3><i class="fa-solid fa-hard-drive"></i> Disk Usage</h3><p class="admin-intro">This shows occupied and available disk space in bytes. Review it when the disk is filling up; a higher used percentage leaves less room for new files.</p><div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:14px"><span>${fmtBytes(d.used)} used of ${fmtBytes(d.total)}</span><span style="font-weight:700;color:${pctColor}">${usePct}%</span>@@@ -94,7 +94,7 @@ async function loadStorage() {</div></div><div class="card">-- <h3><i class="fa-solid fa-database"></i> Data Summary</h3>++ <h3><i class="fa-solid fa-database"></i> Data Summary</h3><p class="admin-intro">This counts stored files and their combined size in bytes. Check it when comparing growth; higher totals mean more stored data.</p><div class="stats-grid" style="margin:0"><div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.dataTotal?.bytes || 0)}</div><div class="label">Total Data</div></div><div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${(data.dataTotal?.files || 0).toLocaleString()}</div><div class="label">Files</div></div>@@@ -104,7 +104,7 @@ async function loadStorage() {</div></div>-- <h3 style="margin-bottom:12px"><i class="fa-solid fa-folder-tree"></i> Storage Breakdown</h3>++ <h3 style="margin-bottom:12px"><i class="fa-solid fa-folder-tree"></i> Storage Breakdown</h3><p class="admin-intro">This groups storage use by file category. Review the largest categories before deciding what to archive or remove.</p><div style="display:grid;gap:8px;margin-bottom:24px">${breakdown.map(b => `<div style="display:grid;grid-template-columns:140px 1fr 100px 80px;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px">@@@ -117,14 +117,14 @@ async function loadStorage() {</div><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">-- <h3><i class="fa-solid fa-video"></i> VOD Management</h3>++ <h3><i class="fa-solid fa-video"></i> VOD Management</h3><p class="admin-intro">These are saved stream recordings, also called VODs. Select recordings only when you intend to permanently delete them; recovery requires a separate backup.</p><div style="display:flex;gap:8px">-- <select id="vod-sort" onchange="loadVodTable()" style="padding:6px 10px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px">-- <option value="size">Sort by Size</option>-- <option value="date">Sort by Date</option>-- <option value="duration">Sort by Duration</option>++ <label for="vod-sort">Recording order</label><span class="admin-help">Largest file first is the default. Choose newest or longest to find recordings by age or duration.</span><select id="vod-sort" onchange="loadVodTable()" style="padding:6px 10px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px">++ <option value="size">Largest file first</option>++ <option value="date">Newest recording first</option>++ <option value="duration">Longest recording first</option></select>-- <button class="btn btn-sm btn-danger" id="vod-bulk-btn" disabled onclick="bulkDeleteVods()"><i class="fa-solid fa-trash-can"></i> Delete Selected</button>++ <button class="btn btn-sm btn-danger" id="vod-bulk-btn" disabled onclick="bulkDeleteVods()"><i class="fa-solid fa-trash-can"></i> Permanently delete selected</button></div></div><div id="vod-table"><div class="loading">Loading VODs...</div></div>@@@ -174,7 +174,7 @@ function buildStreamerTierHtml(tierData) {+ '<td style="padding:8px 10px;text-align:right">' + (r.clips || 0).toLocaleString() + '</td>'+ '<td style="padding:8px 10px">' + r.status + '</td></tr>').join('');return '<div class="card" style="margin-bottom:24px">'-- + '<h3 style="margin:0 0 6px"><i class="fa-solid fa-layer-group"></i> Storage Tiers <span style="color:var(--text-muted);font-size:13px;font-weight:400">— ' + offloadPct + '% of VOD data offloaded to cloud</span></h3>'++ + '<h3 style="margin:0 0 6px"><i class="fa-solid fa-layer-group"></i> Storage Tiers <span style="color:var(--text-muted);font-size:13px;font-weight:400">— ' + offloadPct + '% of recordings stored in cloud</span></h3><p class="admin-intro">These tiers show where recordings are stored. Review them when local space is low; a higher cloud percentage means more recording data is stored away from this server.</p>'+ bar+ '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">'+ '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'@@@ -200,8 +200,8 @@ async function loadStreamerCloudStorage(force) {const el = document.getElementById('streamer-cloud-storage');if (!el) return;const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'-- + '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud Storage — B2 & R2 Usage & Cost</h3>'-- + '<button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan</button></div>';++ + '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud storage usage and cost</h3>'++ + '<p class="admin-intro">This estimates storage in Backblaze B2 and Cloudflare R2, in bytes and US dollars per month. Rescan when checking capacity; higher usage means more stored data and may increase charges.</p><button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan now</button></div>';el.innerHTML = header + '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Scanning cloud buckets…</div>';try {const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));@@@ -211,7 +211,7 @@ async function loadStreamerCloudStorage(force) {const card = (key, label, color) => {const u = usage[key];const cc = costs[key];-- if (!u || u.configured === false) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--text-muted);margin-top:8px">Not configured</div></div>';++ if (!u || u.configured === false) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--text-muted);margin-top:8px">Cloud storage is not connected. Configure this provider in Live, then rescan.</div></div>';if (u.error) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--danger-text, var(--danger,#ef4444));margin-top:8px">Scan error: ' + esc(u.error) + '</div></div>';const tops = Object.entries(u.prefixes || {}).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 8);const prefixRows = tops.map(([p, v]) => '<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);padding:2px 0"><span>' + esc(p) + '</span><span>' + fmtBytes(v.bytes) + ' · ' + v.objects.toLocaleString() + '</span></div>').join('');@@@ -238,17 +238,17 @@ async function loadVodTable() {try {const data = await api(`/api/admin/streamer/storage/vods?sort=${sort}&order=desc&limit=100`);const vods = data.vods || [];-- if (!vods.length) { c.innerHTML = '<p class="muted">No VODs found</p>'; return; }++ if (!vods.length) { c.innerHTML = '<p class="muted">No saved recordings. Return after a stream has been recorded.</p>'; return; }c.innerHTML = `<table class="admin-table"><thead><tr>-- <th style="width:32px"><input type="checkbox" onchange="toggleAllVods(this.checked)" style="cursor:pointer"></th>++ <th style="width:32px"><input type="checkbox" aria-label="Select all recordings for permanent deletion" onchange="toggleAllVods(this.checked)" style="cursor:pointer"></th><th>Title</th><th>User</th><th style="text-align:right">Size</th><th style="text-align:right">Duration</th><th>Date</th></tr></thead><tbody>${vods.map(v => `<tr>-- <td><input type="checkbox" class="vod-cb" value="${v.id}" onchange="updateVodSelection()" style="cursor:pointer"></td>++ <td><input type="checkbox" aria-label="Select this recording for permanent deletion" class="vod-cb" value="${v.id}" onchange="updateVodSelection()" style="cursor:pointer"></td><td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(v.title||'')}">${esc(v.title || '(untitled)')}</td><td>${esc(v.username || '-')}</td><td style="text-align:right;font-weight:600;font-variant-numeric:tabular-nums">${fmtBytes(v.diskSize || v.file_size || 0)}</td>@@@ -265,13 +265,13 @@ function toggleAllVods(checked) { document.querySelectorAll('.vod-cb').forEach(cfunction updateVodSelection() {const n = document.querySelectorAll('.vod-cb:checked').length;const btn = document.getElementById('vod-bulk-btn');-- if (btn) { btn.disabled = n === 0; btn.innerHTML = `<i class="fa-solid fa-trash-can"></i> Delete Selected${n ? ` (${n})` : ''}`; }++ if (btn) { btn.disabled = n === 0; btn.innerHTML = `<i class="fa-solid fa-trash-can"></i> Permanently delete selected${n ? ` (${n})` : ''}`; }}async function bulkDeleteVods() {const ids = [...document.querySelectorAll('.vod-cb:checked')].map(cb => parseInt(cb.value));if (!ids.length) return;-- if (!confirm(`Permanently delete ${ids.length} VOD(s)?`)) return;++ if (!confirm(`Permanently delete ${ids.length} saved recording(s)? Viewers will lose access. This cannot be undone here; recovery requires a separate backup.`)) return;try {const data = await api('/api/admin/streamer/storage/vods/bulk', { method: 'DELETE', body: { ids } });toast(`Deleted ${data.deleted || 0} VOD(s), freed ${fmtBytes(data.freed || 0)}`, 'success');@@@ -289,7 +289,7 @@ async function sendBroadcast() {const priority = document.getElementById('broadcast-priority')?.value || 'normal';const url = document.getElementById('broadcast-url')?.value.trim();if (!title) return toast('Title required', 'error');-- if (!confirm(`Send broadcast "${title}" to all users?`)) return;++ if (!confirm(`Send broadcast "${title}" to all users? It cannot be recalled here. If the announcement is wrong, send a correction.`)) return;try {const data = await api('/api/admin/broadcast', { method: 'POST', body: { title, message, icon, priority, url } });toast(`Broadcast sent to ${data.sent || 0} users`, 'success');@@@ -313,45 +313,45 @@ async function loadEmail() {const recent = Array.isArray(metrics.recent) ? metrics.recent : [];c.innerHTML = `<div class="stats-grid" style="margin-bottom:16px">-- <div class="stat-card"><div class="value">${Number(summary.sent || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paper-plane"></i> Emails Sent</div></div>++ <div class="stat-card"><div class="value">${Number(summary.sent || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paper-plane"></i> Emails sent</div></div><div class="stat-card"><div class="value">${Number(summary.failed || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-triangle-exclamation"></i> Failed</div></div>-- <div class="stat-card"><div class="value">${Number(summary.sent_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-clock"></i> Sent · 24h</div></div>-- <div class="stat-card"><div class="value">${Number(summary.password_resets_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-key"></i> Password Resets · 24h</div></div>-- <div class="stat-card"><div class="value">${Number(summary.notifications_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-bell"></i> Notification Emails · 24h</div></div>-- <div class="stat-card"><div class="value">${Number(summary.tests_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-flask-vial"></i> Test Emails · 24h</div></div>++ <div class="stat-card"><div class="value">${Number(summary.sent_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-clock"></i> Sent · last 24 hours</div></div>++ <div class="stat-card"><div class="value">${Number(summary.password_resets_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-key"></i> Password resets · last 24 hours</div></div>++ <div class="stat-card"><div class="value">${Number(summary.notifications_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-bell"></i> Notification emails · last 24 hours</div></div>++ <div class="stat-card"><div class="value">${Number(summary.tests_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-flask-vial"></i> Test emails · last 24 hours</div></div></div><div class="card" style="padding:16px;margin-bottom:16px">-- <h3 style="margin-bottom:10px"><i class="fa-solid fa-chart-line"></i> Email Delivery Metrics</h3>++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-chart-line"></i> Email Delivery Metrics</h3><p class="admin-intro">These are recorded delivery counts and recent failures. Check them after a test email or a reported delivery problem; more failed messages means more deliveries need investigation.</p><div class="muted" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px">-- <div><strong>Total Logged:</strong> ${Number(summary.total || 0).toLocaleString()}</div>-- <div><strong>Failed · 24h:</strong> ${Number(summary.failed_24h || 0).toLocaleString()}</div>-- <div><strong>Last Sent:</strong> ${summary.last_sent_at ? esc(timeAgo(summary.last_sent_at)) : 'Never'}</div>-- <div><strong>Last Failure:</strong> ${summary.last_failed_at ? esc(timeAgo(summary.last_failed_at)) : 'Never'}</div>++ <div><strong>Total recorded:</strong> ${Number(summary.total || 0).toLocaleString()}</div>++ <div><strong>Failed in the last 24 hours:</strong> ${Number(summary.failed_24h || 0).toLocaleString()}</div>++ <div><strong>Last email sent:</strong> ${summary.last_sent_at ? esc(timeAgo(summary.last_sent_at)) : 'Never'}</div>++ <div><strong>Last failure:</strong> ${summary.last_failed_at ? esc(timeAgo(summary.last_failed_at)) : 'Never'}</div></div></div><form id="email-form" style="max-width:600px;margin-bottom:16px"><div class="setting-row">-- <label><strong>Email Enabled</strong></label>-- <input type="checkbox" id="email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer">++ <label for="email-enabled"><strong>Send email</strong></label>++ <input type="checkbox" id="email-enabled" aria-label="Send outgoing email" aria-describedby="help-email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer"><span class="admin-help" id="help-email-enabled">Off by default, so no email is sent. Turn it on after configuring Resend; turning it off stops delivery, and turning it on resumes it.</span></div>${email.issue ? `<div style="background:#e74c3c22;border:1px solid #e74c3c44;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#e74c3c"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(email.issue)}</div>` : email.ready ? `<div style="background:#27ae6022;border:1px solid #27ae6044;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#27ae60"><i class="fa-solid fa-circle-check"></i> Resend is ready — emails will be delivered</div>` : ''}-- <h4 style="margin:12px 0 10px;font-size:14px"><i class="fa-solid fa-key"></i> Resend API</h4>-- <div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="" placeholder="${email.api_key_source === 'env' ? `Set in the environment (${esc(email.api_key_env || 'RESEND_API_KEY')})` : 'Not set'}" disabled autocomplete="off"></div>++ <h4 style="margin:12px 0 10px;font-size:14px"><i class="fa-solid fa-key"></i> Resend email key</h4><p class="admin-intro">This is the key that lets Network send email through the Resend service. Set it in the server environment before turning email on; changing it here has no effect.</p>++ <div class="form-field"><label for="email-api-key">Resend email key</label><input type="password" id="email-api-key" aria-label="Resend email key" aria-describedby="help-email-api-key" value="" placeholder="${email.api_key_source === 'env' ? `Set in the environment (${esc(email.api_key_env || 'RESEND_API_KEY')})` : 'Not set'}" disabled autocomplete="off"><span class="admin-help" id="help-email-api-key">No key is configured by default. This field is read-only; use the Resend API keys page below and set the key in the server environment.</span></div><p class="muted" style="margin:-4px 0 8px;font-size:11px">${email.api_key_source === 'env' ? `The key comes from <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file; change it there.` : `Provider secrets live in the server's environment only: get a key from <a href="https://resend.com/api-keys" target="_blank" rel="noopener" style="color:var(--accent)">resend.com/api-keys</a>, set <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in <code>/etc/openvibe/network.env</code>, and restart Network.`}</p>-- <h4 style="margin:20px 0 10px;font-size:14px"><i class="fa-solid fa-envelope"></i> From Addresses</h4>-- <div class="form-field"><label>Default From Email</label><input type="email" id="email-from-email" value="${esc(email.from_email || '')}" placeholder="[email protected]"></div>-- <div class="form-field"><label>Default From Name</label><input type="text" id="email-from-name" value="${esc(email.from_name || '')}" placeholder="OpenVibe"></div>-- <div class="form-field"><label>OpenVibe.Live From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibelive" value="${esc(email.from_email_openvibelive || '')}" placeholder="[email protected]"></div>-- <div class="form-field"><label>OpenVibe Quest From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibegames" value="${esc(email.from_email_openvibegames || '')}" placeholder="[email protected]"></div>-- <div class="form-field"><label>OpenVibe Tools From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibenetwork" value="${esc(email.from_email_openvibenetwork || '')}" placeholder="[email protected]"></div>++ <h4 style="margin:20px 0 10px;font-size:14px"><i class="fa-solid fa-envelope"></i> From addresses</h4><p class="admin-intro">These are the addresses recipients see when an email arrives. Change them when you want a different sender name or a per-service address; the overrides below only affect the service they name.</p>++ <div class="form-field"><label for="email-from-email">Default sender address</label><input type="email" id="email-from-email" aria-label="Default sender address" aria-describedby="help-email-from-email" value="${esc(email.from_email || '')}" placeholder="[email protected]"><span class="admin-help" id="help-email-from-email">Uses the saved sender address, normally [email protected]. Enter an address on your verified domain, for example [email protected].</span></div>++ <div class="form-field"><label for="email-from-name">Sender name</label><input type="text" id="email-from-name" aria-label="Sender display name" aria-describedby="help-email-from-name" value="${esc(email.from_name || '')}" placeholder="OpenVibe"><span class="admin-help" id="help-email-from-name">The default is OpenVibe. Use a recognizable name, for example OpenVibe Support.</span></div>++ <div class="form-field"><label for="email-from-openvibelive">Live sender address <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibelive" aria-label="Live sender address" aria-describedby="help-email-from-openvibelive" value="${esc(email.from_email_openvibelive || '')}" placeholder="[email protected]"><span class="admin-help" id="help-email-from-openvibelive">Blank uses the default sender. Enter a verified address such as [email protected] to override it for Live.</span></div>++ <div class="form-field"><label for="email-from-openvibegames">Games sender address <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibegames" aria-label="Games sender address" aria-describedby="help-email-from-openvibegames" value="${esc(email.from_email_openvibegames || '')}" placeholder="[email protected]"><span class="admin-help" id="help-email-from-openvibegames">Blank uses the default sender. Enter a verified address such as [email protected] to override it for Games.</span></div>++ <div class="form-field"><label for="email-from-openvibenetwork">Tools sender address <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibenetwork" aria-label="Tools sender address" aria-describedby="help-email-from-openvibenetwork" value="${esc(email.from_email_openvibenetwork || '')}" placeholder="[email protected]"><span class="admin-help" id="help-email-from-openvibenetwork">Blank uses the default sender. Enter a verified address such as [email protected] to override it for Tools.</span></div><div style="display:flex;gap:8px;margin-top:16px">-- <button type="button" class="btn btn-primary" onclick="saveEmail()"><i class="fa-solid fa-floppy-disk"></i> Save Email Config</button>-- <button type="button" class="btn btn-outline" onclick="testEmail()"><i class="fa-solid fa-paper-plane"></i> Send Test Email</button>++ <button type="button" class="btn btn-primary" onclick="saveEmail()"><i class="fa-solid fa-floppy-disk"></i> Save email settings</button>++ <button type="button" class="btn btn-outline" onclick="testEmail()"><i class="fa-solid fa-paper-plane"></i> Send a test email</button></div><div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)"><strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>@@@ -360,7 +360,7 @@ async function loadEmail() {</form><div class="card" style="padding:16px;margin-bottom:16px">-- <h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery Breakdown · 30 days</h3>++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery breakdown · last 30 days</h3><p class="admin-intro">This counts sent and failed messages by type over the last 30 days. Review it when one kind of email is failing more often than others.</p>${byType.length ? `<table class="admin-table"><thead><tr><th>Email Type</th><th>Status</th><th>Count</th></tr></thead>@@@ -368,17 +368,17 @@ async function loadEmail() {${byType.map(row => `<tr><td>${esc(row.email_type)}</td>-- <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>++ <td>${row.status === 'sent' ? '<span class="badge badge-success">The provider accepted the email.</span>' : '<span class="badge badge-danger">The email could not be sent.</span>'}</td><td>${Number(row.count || 0).toLocaleString()}</td></tr>`).join('')}</tbody></table>`-- : '<p class="muted">No email activity logged yet.</p>'}++ : '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}</div><div class="card" style="padding:16px">-- <h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3>++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3><p class="admin-intro">These are the most recent email delivery attempts and provider errors. Check a failed row after a delivery report, then correct the configuration and send a new test.</p>${recent.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Subject</th><th>Error</th></tr></thead>@@@ -388,14 +388,14 @@ async function loadEmail() {<td style="white-space:nowrap">${esc(timeAgo(row.created_at))}</td><td>${esc(row.email_type)}</td><td>${esc(row.recipient)}</td>-- <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>++ <td>${row.status === 'sent' ? '<span class="badge badge-success">The provider accepted the email.</span>' : '<span class="badge badge-danger">The email could not be sent.</span>'}</td><td>${esc(row.subject || '-')}</td><td style="font-size:11px;color:var(--text-muted)">${esc(row.error_message || '-')}</td></tr>`).join('')}</tbody></table>`-- : '<p class="muted">No email activity logged yet.</p>'}++ : '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}</div>`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }}@@@ -27,8 +27,8 @@ async function loadDashboard() {{ label: 'Total Streams', value: ss?.streams?.total || ss?.totalStreams || 0, icon: 'fa-video' },{ label: 'Active Bans', value: ss?.users?.banned || ss?.activeBans || 0, icon: 'fa-ban' },{ label: 'Notifications', value: th?.total_notifications || 0, icon: 'fa-bell' },-- { label: 'Unread Notifs', value: th?.unread_notifications || 0, icon: 'fa-bell' },-- { label: 'Anon Users', value: th?.anon_users || 0, icon: 'fa-ghost' },++ { label: 'Unread notifications', value: th?.unread_notifications || 0, icon: 'fa-bell' },++ { label: 'Anonymous accounts', value: th?.anon_users || 0, icon: 'fa-ghost' },];statsEl.innerHTML = stats.map(s => `@@@ -45,14 +45,14 @@ async function loadDashboard() {const upM = Math.floor((th.uptime % 3600) / 60);healthEl.innerHTML = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;font-size:13px">-- <div><strong>Uptime:</strong> ${upH}h ${upM}m</div>-- <div><strong>Heap Used:</strong> ${fmtBytes(mem?.heapUsed)}</div>-- <div><strong>Heap Total:</strong> ${fmtBytes(mem?.heapTotal)}</div>-- <div><strong>RSS:</strong> ${fmtBytes(mem?.rss)}</div>-- <div><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success-text, var(--success))">Enabled</span>' : '<span style="color:var(--text-muted)">Disabled</span>'}</div>++ <div><strong>Time since restart:</strong> ${upH}h ${upM}m</div>++ <div><strong>Application memory in use:</strong> ${fmtBytes(mem?.heapUsed)}</div>++ <div><strong>Application memory allocated:</strong> ${fmtBytes(mem?.heapTotal)}</div>++ <div><strong>Total process memory:</strong> ${fmtBytes(mem?.rss)}</div>++ <div><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success-text, var(--success))">Email sending is enabled.</span>' : '<span style="color:var(--text-muted)">Email sending is turned off.</span>'}</div></div>`;} else {-- healthEl.innerHTML = '<p class="muted">Could not load health data</p>';++ healthEl.innerHTML = '<p class="muted">Health data could not be loaded. Reload the tab; if it still fails, check the Network service.</p>';}}@@@ -13,30 +13,30 @@ async function loadDomains() {_domCatalog = cat;const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];-- const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;++ const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc({canonical:"Main address for search engines",short:"Short public address",alias:"Redirect to the short address",mirror:"Additional address serving the tool"}[r] || adminWords(r))}</span>`;el.innerHTML = `<div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3><p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p><div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">-- <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div>-- <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>-- <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects to the short host)</option><option value="mirror">mirror (serves the tool, canonical tag points at the SEO host)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>-- <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div>++ <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool" aria-label="Tool receiving this address" aria-describedby="help-dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select><span class="admin-help" id="help-dom-tool">Choose the tool or family that should open at the domain. The first catalog item is selected initially; choose the intended tool before adding the host.</span></div>++ <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" aria-label="Website hostname" aria-describedby="help-dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"><span class="admin-help" id="help-dom-host">No hostname is entered by default. Enter a hostname without https://, for example tools.example.com, after pointing its DNS at the server.</span></div>++ <div class="form-field"><label for="dom-role">Role</label><select id="dom-role" aria-label="How this address works" aria-describedby="help-dom-role"><option value="alias">Redirect — sends visitors to the short address</option><option value="mirror">Mirror — serves the tool here, and points search engines at the main address</option><option value="short">Short — the address people type</option><option value="canonical">Main — the address search engines are pointed at</option></select><span class="admin-help" id="help-dom-role">Redirect is the default and sends visitors to the short address. Mirror serves the tool at this address, Short sets its public address, and Main sets its preferred search-engine address.</span></div>++ <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" aria-label="Staff note" aria-describedby="help-dom-note" maxlength="500"><span class="admin-help" id="help-dom-note">Optional note, blank by default. For example: campaign domain for November.</span></div><div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div></div><div id="dom-check" class="muted" style="margin-top:8px"></div></div>-- <div class="card"><h3>Overrides</h3>++ <div class="card"><h3>Overrides</h3><p class="admin-intro">These saved addresses replace or extend the built-in tool addresses. Change them when routing a custom domain; delete the override to return to built-in routing.</p>${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td><td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td><td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td><td>${esc(d.note || '')}</td>-- <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>-- <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}-- </tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}++ <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option value="${r}" ${r === d.role ? 'selected' : ''}>${{canonical:'Main address for search engines',short:'Short public address',alias:'Redirect to the short address',mirror:'Additional address serving the tool'}[r]}</option>`).join('')}</select>++ <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove override</button></td></tr>`).join('')}++ </tbody></table></div>` : '<p class="muted">No overrides yet; tools use their built-in addresses. Add a domain above when you need a custom address.</p>'}</div>-- <div class="card"><h3>Built-in hosts</h3><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>++ <div class="card"><h3>Built-in hosts</h3><p class="admin-intro">These are the default addresses supplied by the tool catalog. Check them before adding an override so you know which address it replaces.</p><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}</tbody></table></div></div>`;} catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }@@@ -46,7 +46,7 @@ async function addDomain() {try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }}async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }--async function deleteDomain(id, host) { if (!confirm(`Remove ${host}?`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }++async function deleteDomain(id, host) { if (!confirm(`Remove ${host} from the tool address overrides? This changes routing after refresh. Add the same host and role again to undo.`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }async function checkDomainDns() {const out = document.getElementById('dom-check'); out.textContent = 'Checking…';try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};@@@ -102,19 +102,19 @@ function renderSSHOwner(el) {<div class="card"><div class="form-group" style="margin-bottom:14px"><label>New Linux username</label>-- <input type="text" id="ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px">++ <input type="text" id="ssh-o-user" aria-label="New server username" aria-describedby="help-ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px"><span class="admin-help" id="help-ssh-o-user">No name is entered by default. Use a Linux username such as alex for the account you are provisioning.</span></div><div class="form-group" style="margin-bottom:14px"><label>Their SSH <b>public</b> key <span class="muted">— ask them to run the steps in their own SSH tab and paste their <code>.pub</code> here (recommended)</span></label>-- <textarea id="ssh-o-pubkey" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea>++ <textarea id="ssh-o-pubkey" aria-label="SSH public key" aria-describedby="ssh-public-help" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea><p class="admin-help" id="ssh-public-help">No key is entered by default. Paste the person’s public .pub key, for example an ssh-ed25519 public key; never enter their private key.</p></div><div class="form-group"><label>Access level</label>-- <select id="ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px">++ <select id="ssh-o-access" aria-label="Server access level" aria-describedby="help-ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px"><option value="code">Project code only (safest)</option><option value="restart">Project code + restart app services</option><option value="web">Full web + nginx — build & run their own sites (high trust)</option>-- </select>++ </select><span class="admin-help" id="help-ssh-o-access">Project code only is the default. Restart also allows service restarts, and Full web permits web server management; removing access requires the owner to update the server account.</span></div><div id="ssh-o-webwarn" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid var(--warning);border-radius:8px;background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning-text, var(--warning));font-size:0.84rem;line-height:1.5"><i class="fa-solid fa-triangle-exclamation"></i> <b>High trust:</b> web + nginx access lets this admin edit any nginx site config and reload the web server — they can serve anything and a bad config can take <em>all</em> the sites down. It still stops short of root (no package installs or arbitrary sudo), but only grant it to admins you fully trust to run the web layer with you.@@@ -123,13 +123,14 @@ function renderSSHOwner(el) {<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>-- <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>++ <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy the prompt</button></div><p class="muted" style="margin:8px 0">Paste into Claude Code running on the server with sudo (or locally with root SSH) to create and lock down the account.</p><pre id="ssh-o-prompt" class="ssh-prompt"></pre></div><div class="card"><h3><i class="fa-solid fa-shield-halved"></i> How it's locked down</h3>++ <p class="admin-intro">This lists the limits placed on a provisioned server account. Read it before you grant access, and after any change to the access level you chose above.</p><ul class="muted" style="line-height:1.7;margin:0;padding-left:20px"><li>Key-only login — password auth is disabled for the account.</li><li>Member of a shared <code>devs</code> group that owns the project dirs (<code>${esc(sshInfo.projectsRoot)}</code>) with setgid, so they can edit code and new files stay group-shared.</li>@@@ -209,16 +210,18 @@ function renderSSHUser(el) {el.innerHTML = `<div class="card"><h3><i class="fa-solid fa-terminal"></i> Connect to the dev server <span class="staff-pill user-pill">You</span></h3>++ <p class="admin-intro">This page explains how to sign in to the development server. Use it when you need server access; your private key never leaves your machine.</p><p class="muted">Generate your key, send the owner (<b>${esc(sshInfo.ownerUsername)}</b>) your <b>public</b> key to authorize, then SSH in and help build the sites with Claude. Your private key never leaves your machine.</p></div><div class="card"><div class="form-group"><label>Your SSH username <span class="muted">— confirm the exact name with the owner</span></label>-- <input type="text" id="ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px">++ <input type="text" id="ssh-u-user" aria-label="Your server username" aria-describedby="help-ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px"><span class="admin-help" id="help-ssh-u-user">Starts with your account username when available. Confirm the actual server login with the owner, for example alex.</span></div></div><div class="card"><h3><i class="fa-solid fa-list-ol"></i> Steps</h3>++ <p class="admin-intro">These are the commands that set up your key and the connection to the server. Run them once, then use the connection line to sign in.</p><ol class="muted" style="line-height:1.9;padding-left:20px;margin:0"><li>Generate a key pair (skip if you already have one you want to use):<br><code id="ssh-u-keygen"></code></li><li>Print your <b>public</b> key and send it to the owner to authorize:<br><code id="ssh-u-cat"></code></li>@@@ -229,7 +232,7 @@ function renderSSHUser(el) {<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap"><h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>-- <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>++ <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy the prompt</button></div><p class="muted" style="margin:8px 0">Paste into Claude Code on your own machine — it sets up the connection and gives it full context about the server so you can start vibe coding.</p><pre id="ssh-u-prompt" class="ssh-prompt"></pre>@@@ -313,12 +316,12 @@ async function loadDeployOverview() {const c = d.config;document.getElementById('deploy-stats').innerHTML = `<div class="stat-card"><div class="value" style="color:${p.certbotInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.certbotInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Certbot</div></div>-- <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">CF Plugin</div></div>++ <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">Cloudflare certificate plugin</div></div><div class="stat-card"><div class="value" style="color:${p.nginxInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.nginxInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Nginx</div></div>-- <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">CF Token</div></div>++ <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">Cloudflare access token</div></div><div class="stat-card"><div class="value">${c.domainsConfigured}</div><div class="label">Domains</div></div>-- <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.certMode)}</div><div class="label">Cert Mode</div></div>-- <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.nginxMode)}</div><div class="label">Nginx Mode</div></div>++ <div class="stat-card"><div class="value" style="font-size:14px">${esc({cloudflare:'Automatic DNS verification',manual:'Manual DNS verification',none:'Certificates managed elsewhere'}[c.certMode] || 'Certificate mode unavailable')}</div><div class="label">Certificate management</div></div>++ <div class="stat-card"><div class="value" style="font-size:14px">${esc({preview:'Preview only; no files written',apply:'Writing and reloading allowed',disabled:'Web server management disabled'}[c.nginxMode] || 'Web server mode unavailable')}</div><div class="label">Web server management</div></div>`;const prereqsEl = document.getElementById('deploy-prereqs');const items = [];@@@ -361,7 +364,7 @@ async function loadDeployCerts() {<div class="muted" style="font-size:11px;margin-top:2px">${(c.domains || []).map(d => esc(d)).join(', ')}</div></div><div style="text-align:right">-- <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? c.daysRemaining + ' days' : 'Unknown'}</span>++ <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? 'Expires in ' + c.daysRemaining + ' days.' : 'Expiry date is unknown.'}</span><div class="muted" style="font-size:11px">${c.expiry ? 'Expires ' + esc(c.expiry) : ''}</div></div></div>@@@ -409,7 +412,8 @@ async function showManualDnsInfo() {const d = await r.json();const instructions = (d.instructions || []).map(i => esc(i)).join('<br>');out.innerHTML = `<div class="card" style="padding:16px;margin-top:8px">-- <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS Challenge</h4>++ <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS challenge</h4>++ <p class="admin-intro">These are the DNS records to add at your domain registrar so the certificate can be issued by hand. Add them exactly as written, then issue the certificate; the request fails until the records are visible.</p><div style="font-size:13px;line-height:1.6">${instructions}</div><div style="margin-top:16px"><button class="btn btn-primary" onclick="issueManualCert('${esc(domain)}')">@@@ -505,7 +509,7 @@ async function validateNginx() {}async function applyNginxConfigs() {-- if (!confirm('Apply generated Nginx configs? This will backup existing configs, write new ones, validate, and reload Nginx.')) return;++ if (!confirm('Apply the generated web server configuration? This backs up the existing files, writes new ones, validates them, and reloads Nginx. Routing may change immediately. To undo, restore the backup on the server, validate it, and reload Nginx.')) return;const out = document.getElementById('nginx-preview-output');out.innerHTML = '<div class="loading">Applying Nginx configs...</div>';try {@@@ -571,7 +575,7 @@ async function addManagedDomain() {}async function removeManagedDomain(idx) {-- if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '?')) return;++ if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '? It will leave the managed domain list. Add it again to undo; existing certificates and web server files are managed separately.')) return;deployDomainsList.splice(idx, 1);await saveDomainsList();}@@@ -13,7 +13,7 @@ async function loadStreams() {try {const data = await api('/api/admin/streamer/streams');const streams = data.streams || [];-- if (!streams.length) { c.innerHTML = '<p class="muted">No active streams</p>'; return; }++ if (!streams.length) { c.innerHTML = '<p class="muted">No streams are live. Return when someone starts broadcasting.</p>'; return; }c.innerHTML = `<table class="admin-table"><thead><tr>@@@ -27,7 +27,7 @@ async function loadStreams() {<td>${s.viewer_count || 0}</td><td>${s.started_at ? timeAgo(s.started_at) : '-'}</td><td>-- <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End</button>++ <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End broadcast</button><span class="admin-help">Disconnects viewers; the streamer must restart to resume.</span></td></tr>`).join('')}</tbody>@@@ -46,14 +46,14 @@ async function loadLineage(reason = '') {tabs.innerHTML = [['', 'All']].concat(Object.keys(by).sort().map(r => [r, `${r.replace(/_/g, ' ')} (${by[r].refs})`])).map(([r, label]) => `<button class="${r === reason ? 'active' : ''}" onclick="loadLineage('${esc(r)}')">${esc(label)}</button>`).join('');const rows = data.unresolved || [];-- if (!rows.length) { c.innerHTML = '<p class="muted">Nothing unresolved in the last 30 days.</p>'; return; }++ if (!rows.length) { c.innerHTML = '<p class="muted">No unmatched references in the last 30 days. Return here if a channel link stops resolving.</p>'; return; }c.innerHTML = `<div style="overflow-x:auto"><table class="admin-table"><thead><tr><th>Reference</th><th>Reason</th><th>Detail</th><th>Last caller</th><th>Times</th><th>First seen</th><th>Last seen</th></tr></thead><tbody>${rows.map(u => `<tr><td><code>${esc(u.ref)}</code></td>-- <td><span class="badge badge-warning">${esc(u.reason)}</span></td>++ <td><span class="badge badge-warning">${esc(adminWords(u.reason))}</span></td><td>${esc(u.detail || '')}</td><td>${esc(u.last_caller || '-')}</td><td>${Number(u.count) || 0}</td>@@@ -65,7 +65,7 @@ async function loadLineage(reason = '') {}async function endStream(id) {-- if (!confirm('Force end this stream?')) return;++ if (!confirm('End this broadcast now? Viewers will be disconnected. The streamer must start a new broadcast to resume.')) return;try {await api(`/api/admin/streamer/streams/${id}`, { method: 'DELETE' });toast('Stream ended', 'success');@@@ -82,7 +82,7 @@ async function loadBans() {try {const data = await api('/api/admin/streamer/bans');const bans = data.bans || [];-- if (!bans.length) { c.innerHTML = '<p class="muted">No active bans</p>'; return; }++ if (!bans.length) { c.innerHTML = '<p class="muted">No active bans. Review Users if an account needs a restriction.</p>'; return; }c.innerHTML = `<table class="admin-table"><thead><tr>@@@ -96,7 +96,7 @@ async function loadBans() {<td>${b.created_at ? timeAgo(b.created_at) : '-'}</td><td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td><td>-- <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Unban</button>++ <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Allow access again</button></td></tr>`).join('')}</tbody>@@@ -137,7 +137,7 @@ async function fetchChatLogs() {const msgs = data.messages || [];const total = data.total || 0;-- if (!msgs.length) { c.innerHTML = '<p class="muted">No messages found</p>'; pager.innerHTML = ''; return; }++ if (!msgs.length) { c.innerHTML = '<p class="muted">No messages match. Try different words or another person.</p>'; pager.innerHTML = ''; return; }c.innerHTML = `<table class="admin-table">@@@ -171,7 +171,7 @@ async function loadModerators() {try {const data = await api('/api/admin/streamer/moderators');const mods = data.moderators || [];-- if (!mods.length) { c.innerHTML = '<p class="muted">No global moderators yet</p>'; return; }++ if (!mods.length) { c.innerHTML = '<p class="muted">No global moderators yet. Enter a trusted username above to add one.</p>'; return; }c.innerHTML = `<table class="admin-table"><thead><tr><th>Username</th><th>Display Name</th><th>Last Seen</th><th>Actions</th></tr></thead>@@@ -181,7 +181,7 @@ async function loadModerators() {<td>${esc(m.display_name || m.username)}</td><td>${m.last_seen ? timeAgo(m.last_seen) : 'Never'}</td><td>-- <button class="btn btn-sm btn-danger" onclick="demoteMod('${m.id}','${esc(m.username)}')"><i class="fa-solid fa-user-minus"></i> Demote</button>++ <button class="btn btn-sm btn-danger" onclick="demoteMod('${m.id}','${esc(m.username)}')"><i class="fa-solid fa-user-minus"></i> Remove moderator access</button></td></tr>`).join('')}</tbody>@@@ -201,7 +201,7 @@ async function promoteMod() {}async function demoteMod(id, username) {-- if (!confirm(`Demote ${username} from global moderator?`)) return;++ if (!confirm(`Remove global moderation access from ${username}? They will lose these permissions. Promote them again to restore access.`)) return;try {await api(`/api/admin/streamer/moderators/${id}`, { method: 'DELETE' });toast(`${username} demoted`, 'success');@@@ -248,21 +248,21 @@ async function loadDeliveries() {const d = await api(`/api/admin/events/deliveries?${q}`);_deliveries = d.deliveries || [];const counts = d.counts || {};-- document.getElementById('dlq-counts').textContent = Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(' · ');++ document.getElementById('dlq-counts').textContent = Object.entries(counts).map(([k, v]) => `${{dead:'Stopped retrying',failed:'Retrying after a failure',pending:'Waiting to be delivered',delivered:'Delivered successfully'}[k] || adminWords(k)}: ${v} deliveries`).join(' · ');const bySub = {};for (const x of _deliveries) (bySub[x.subscription_id] = bySub[x.subscription_id] || []).push(x.event_id);-- const replayAll = Object.entries(bySub).map(([s, ids]) => `<button class="btn btn-sm" data-sub="${esc(s)}" onclick="replayDeliveries(this.dataset.sub, null)">Replay ${ids.length} for ${esc(s.slice(0, 14))}…</button>`).join(' ');++ const replayAll = Object.entries(bySub).map(([s, ids]) => `<button class="btn btn-sm" data-sub="${esc(s)}" onclick="replayDeliveries(this.dataset.sub, null)">Replay ${ids.length} deliveries for ${esc(s.slice(0, 14))}…</button>`).join(' ');c.innerHTML = _deliveries.length ? `<div style="margin-bottom:8px">${replayAll}</div><table class="admin-table"><thead><tr><th>Event</th><th>Subscription</th><th>Seq</th><th>Attempts</th><th>Last answer</th><th></th></tr></thead><tbody>${_deliveries.map(x => `<tr><td style="font-size:11px"><code>${esc(x.event_id)}</code></td><td style="font-size:11px"><code>${esc(x.subscription_id)}</code></td><td>${esc(String(x.seq))}</td><td>${esc(String(x.attempt))}</td>-- <td style="font-size:11px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(x.last_error || '')}">${esc(x.last_status ? String(x.last_status) : '-')} ${esc(x.last_error || '')}</td>-- <td><button class="btn btn-sm" data-sub="${esc(x.subscription_id)}" data-evt="${esc(x.event_id)}" onclick="replayDeliveries(this.dataset.sub, [this.dataset.evt])">Replay</button></td></tr>`).join('')}</tbody></table>` : '<p class="muted">Nothing here.</p>';++ <td style="font-size:11px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(x.last_error || '')}">${x.last_status ? `The receiver answered HTTP ${esc(String(x.last_status))}.` : 'No response recorded.'} ${esc(x.last_error || '')}</td>++ <td><button class="btn btn-sm" data-sub="${esc(x.subscription_id)}" data-evt="${esc(x.event_id)}" onclick="replayDeliveries(this.dataset.sub, [this.dataset.evt])">Replay delivery</button></td></tr>`).join('')}</tbody></table>` : '<p class="muted">No deliveries match. Choose another delivery state or clear the subscription filter.</p>';} catch (err) {c.innerHTML = `<p class="muted">Deliveries could not be loaded: ${esc(err.message || 'error')}</p>`;}}async function replayDeliveries(sub, ids) {const eventIds = ids || _deliveries.filter(x => x.subscription_id === sub).map(x => x.event_id);-- if (!eventIds.length || !confirm(`Replay ${eventIds.length} deliver${eventIds.length === 1 ? 'y' : 'ies'} to ${sub}?`)) return;++ if (!eventIds.length || !confirm(`Replay ${eventIds.length} deliver${eventIds.length === 1 ? 'y' : 'ies'} to ${sub}? This sends the events again and cannot be recalled. Fix the destination before retrying.`)) return;try { const r = await api('/api/admin/events/replay', { method: 'POST', body: { subscription_id: sub, event_ids: eventIds } }); alert(`Queued ${r.queued}`); loadDeliveries(); }catch (err) { alert(err.message || 'Replay failed'); }}@@@ -279,7 +279,7 @@ async function loadOperatorChecklist() {row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;padding:8px 0;border-top:1px solid var(--border)';const dot = document.createElement('span'); dot.title = (it.service || '') + ': ' + it.status;dot.style.cssText = 'width:10px;height:10px;border-radius:50%;margin-top:6px;flex:none'; dot.style.background = colour[it.status] || 'var(--text-muted, #888)';-- const name = document.createElement('strong'); name.textContent = it.item; name.style.minWidth = '260px';++ const name = document.createElement('strong'); name.textContent = it.item + ': ' + ({ready:'Ready to use.',degraded:'Working with a problem; check the linked service.',down:'Unavailable; check the linked service.'}[it.status] || 'Status unavailable.'); name.style.minWidth = '260px';const places = document.createElement('div'); places.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center';for (const w of it.where) {let el;@@@ -289,7 +289,7 @@ async function loadOperatorChecklist() {places.appendChild(el);if (w.note) { const n = document.createElement('span'); n.className = 'muted'; n.textContent = '(' + w.note + ')'; places.appendChild(n); }}-- if (!it.console) { const gap = document.createElement('span'); gap.className = 'muted'; gap.textContent = 'no console yet'; gap.style.color = 'var(--warning, #f5a524)'; places.appendChild(gap); }++ if (!it.console) { const gap = document.createElement('span'); gap.className = 'muted'; gap.textContent = 'No settings page yet; use the listed command or API.'; gap.style.color = 'var(--warning, #f5a524)'; places.appendChild(gap); }row.append(dot, name, places);c.appendChild(row);}@@@ -301,7 +301,7 @@ async function loadThemeReview() {let list = [];try { list = (await api('/api/admin/themes?status=pending')).themes || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }c.replaceChildren();-- if (!list.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = 'Nothing waiting for review.'; c.appendChild(p); return; }++ if (!list.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = 'No themes are waiting for review. Return after a theme author submits one.'; c.appendChild(p); return; }for (const t of list) {const card = document.createElement('div');card.style.cssText = 'border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;display:grid;gap:8px';@@@ -311,9 +311,9 @@ async function loadThemeReview() {head.append(n, meta);const swatches = document.createElement('div'); swatches.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap';for (const [k, v] of Object.entries(t.variables || {}).filter(([, v]) => /^#|^rgb|^hsl/i.test(v)).slice(0, 14)) {-- const sw = document.createElement('span'); sw.title = `${k}: ${v}`; sw.style.cssText = 'width:22px;height:22px;border-radius:6px;border:1px solid var(--border)'; sw.style.background = v; swatches.appendChild(sw);++ const sw = document.createElement('span'); sw.title = `${adminWords(k)}: ${v}`; sw.style.cssText = 'width:22px;height:22px;border-radius:6px;border:1px solid var(--border)'; sw.style.background = v; swatches.appendChild(sw);}-- const note = document.createElement('input'); note.placeholder = 'Reason (needed to reject)'; note.maxLength = 300; note.style.minWidth = '240px';++ const note = document.createElement('input'); note.placeholder = 'Reason (needed to reject)'; note.setAttribute('aria-label', 'Review reason; required for rejection, for example unreadable text'); note.maxLength = 300; note.style.minWidth = '240px';const act = (decision) => async () => {try { await api(`/api/admin/themes/${encodeURIComponent(t.id)}/review`, { method: 'POST', body: JSON.stringify({ decision, note: note.value }) }); loadThemeReview(); }catch (err) { alert(err.message); }@@@ -321,7 +321,7 @@ async function loadThemeReview() {const ok = document.createElement('button'); ok.className = 'btn'; ok.textContent = 'Approve'; ok.onclick = act('approve');const no = document.createElement('button'); no.className = 'btn'; no.textContent = 'Reject'; no.onclick = act('reject');const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center'; row.append(note, ok, no);-- card.append(head, swatches, row);++ const help = document.createElement('p'); help.className = 'admin-help'; help.textContent = 'Explain a rejection so the author can fix and resubmit the theme; for example, text is unreadable. Approval publishes the theme; there is no undo control in this queue.'; card.append(head, swatches, help, row);c.appendChild(card);}}@@@ -345,9 +345,9 @@ function renderGrants() {const badge = { active: 'badge-success', revoked: 'badge-danger', expired: 'badge-warning' };document.getElementById('grants-content').innerHTML = list.length ? `<table class="admin-table"><thead><tr><th>Service</th><th>Capability</th><th>Audience</th><th>Namespaces</th><th>State</th><th>Expires</th><th>By</th><th></th></tr></thead><tbody>${list.map(g => `<tr><td><code>${esc(g.client_id)}</code></td><td><code>${esc(g.capability)}</code></td><td style="font-size:11px">${esc(g.audience)}</td>-- <td style="font-size:11px">${esc((g.namespaces || []).join(', ') || '-')}</td><td><span class="badge ${badge[g.state] || ''}">${esc(g.state)}</span></td>++ <td style="font-size:11px">${esc((g.namespaces || []).join(', ') || '-')}</td><td><span class="badge ${badge[g.state] || ''}">${esc({active:'Access is allowed.',revoked:'Access was removed.',expired:'Access has expired.'}[g.state] || 'Access state is unknown.')}</span></td><td style="font-size:11px">${g.expires_at ? esc(g.expires_at.slice(0, 10)) : '-'}</td><td style="font-size:11px">${esc(g.granted_by === 'default' ? 'default' : (g.granted_by || '-'))}</td>-- <td>${g.state === 'active' ? `<button class="btn btn-sm" data-client="${esc(g.client_id)}" data-cap="${esc(g.capability)}" onclick="revokeGrant(this.dataset.client, this.dataset.cap)">Revoke</button>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No grants match.</p>';++ <td>${g.state === 'active' ? `<button class="btn btn-sm" data-client="${esc(g.client_id)}" data-cap="${esc(g.capability)}" title="Remove access; grant again to restore it" onclick="revokeGrant(this.dataset.client, this.dataset.cap)">Revoke access</button>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No permissions match. Clear the service filter or add the required permission above.</p>';}async function submitGrant() {const v = (id) => document.getElementById(id).value.trim();@@@ -357,7 +357,7 @@ async function submitGrant() {catch (err) { alert(err.message || 'Grant failed'); }}async function revokeGrant(client, capability) {-- const reason = prompt(`Revoke ${capability} from ${client}? Reason:`);++ const reason = prompt(`Revoke ${capability} from ${client}? This stops that service from using the permission. Grant it again to restore access. Reason:`);if (!reason) return;try { await api('/api/admin/grants/revoke', { method: 'POST', body: { client_id: client, capability, reason } }); loadGrants(); }catch (err) { alert(err.message || 'Revoke failed'); }@@@ -382,7 +382,7 @@ async function loadModLog(reset) {<td style="font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.reason || '')}">${esc(e.reason || '-')}</td></tr>`).join('');if (reset) {-- c.innerHTML = items.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Service</th><th>Action</th><th>By</th><th>Target</th><th>Reason</th></tr></thead><tbody id="modlog-rows">${rowsHtml}</tbody></table>` : '<p class="muted">No staff actions recorded yet.</p>';++ c.innerHTML = items.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Service</th><th>Action</th><th>By</th><th>Target</th><th>Reason</th></tr></thead><tbody id="modlog-rows">${rowsHtml}</tbody></table>` : '<p class="muted">No staff actions match these filters. Clear the filters or return after staff activity.</p>';} else {document.getElementById('modlog-rows')?.insertAdjacentHTML('beforeend', rowsHtml);}@@@ -400,7 +400,7 @@ async function loadAudit() {try {const data = await api(`/api/admin/audit?limit=50&offset=${auditOffset}`);const entries = data.entries || [];-- if (!entries.length) { c.innerHTML = '<p class="muted">No audit entries</p>'; pager.innerHTML = ''; return; }++ if (!entries.length) { c.innerHTML = '<p class="muted">No administrative changes recorded here. Return after a settings or account change.</p>'; pager.innerHTML = ''; return; }c.innerHTML = `<table class="admin-table"><thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead>@@@ -417,7 +417,7 @@ async function loadAudit() {const hasMore = entries.length >= 50;pager.innerHTML = `<button class="btn btn-sm btn-outline" ${auditOffset<=0?'disabled':''} onclick="auditOffset=Math.max(0,auditOffset-50);loadAudit()"><i class="fa-solid fa-chevron-left"></i></button>-- <span class="muted">Offset ${auditOffset}</span>++ <span class="muted">Showing records from ${auditOffset + 1}</span><button class="btn btn-sm btn-outline" ${!hasMore?'disabled':''} onclick="auditOffset+=50;loadAudit()"><i class="fa-solid fa-chevron-right"></i></button>`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }@@@ -56,13 +56,13 @@ async function loadPayments() {const id = `pay-${s.key}`;if (s.type === 'boolean') return `<div class="setting-row">-- <label for="${esc(id)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>++ <label for="${esc(id)}">${adminSettingLabel(s.key, s)}</label><input type="checkbox" id="${esc(id)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');return `<div class="setting-row" style="flex-direction:column;align-items:stretch">-- <label for="${esc(id)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>++ <label for="${esc(id)}">${adminSettingLabel(s.key, s)}</label><input type="${type}" id="${esc(id)}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}" ${sensitive ? 'autocomplete="off"' : ''}style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;@@@ -70,13 +70,13 @@ async function loadPayments() {const groups = PAYMENT_GROUPS.map(g => `<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px"><legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>-- ${g.keys.map(k => field(byKey[k])).join('')}++ <p class="admin-intro">${g.title === 'General' ? 'These switches and prices control paid features on Live. Change them when adjusting what buyers pay or what creators receive; save previous values to restore future pricing.' : 'These switches choose which payment providers Live offers. Enable a provider after configuring it in Billing, or turn it off to remove that checkout option without refunding existing payments.'}</p>${g.keys.map(k => field(byKey[k])).join('')}</fieldset>`).join('');c.innerHTML = `<div style="max-width:860px">-- <p class="muted" style="margin-bottom:12px">OpenVibe.Billing runs every checkout, subscription and cashout. These are the switches and prices OpenVibe.Live reads to decide which ways to pay it offers. A way to pay is offered when <strong>payments_enabled</strong> and its own switch are on and Billing has that provider's keys in its environment (below); with no keys there, the switch has no effect.</p>++ <p class="muted" style="margin-bottom:12px">OpenVibe.Billing runs every checkout, subscription and cashout. These are the switches and prices OpenVibe.Live reads to decide which ways to pay it offers. A way to pay is offered when <strong>Allow purchases and subscriptions</strong> and its own switch are on and Billing has that provider's keys in its environment (below); with no keys there, the switch has no effect.</p><form id="payments-form">${groups}-- <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save payment settings</button>++ <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save prices and payment availability</button><p class="admin-help">Saving changes future payment offers. Restore the previous settings to undo; use Billing for refunds of completed purchases.</p></form><div style="margin-top:20px">${billingProvidersCard()}</div></div>`;@@@ -109,7 +109,7 @@ async function loadVKeys() {const data = await api('/api/admin/verification-keys');const keys = data.keys || [];keys.forEach(k => { if (k.key) secretStore[`vk-${k.id}`] = k.key; });-- if (!keys.length) { c.innerHTML = '<p class="muted">No verification keys generated yet</p>'; return; }++ if (!keys.length) { c.innerHTML = '<p class="muted">No account reservation keys yet. Enter a username and choose Generate to reserve it.</p>'; return; }c.innerHTML = `<table class="admin-table"><thead><tr><th>Key</th><th>Reserved Username</th><th>Status</th><th>Note</th><th>Created</th><th>Actions</th></tr></thead>@@@ -117,14 +117,14 @@ async function loadVKeys() {<tr><td><code id="vk-${k.id}" style="font-size:12px;color:var(--text-muted)">••••••••</code></td><td><strong>${esc(k.target_username)}</strong></td>-- <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc(k.status)}</span></td>++ <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc({ active: 'Ready to claim.', used: 'Already claimed.', revoked: 'No longer usable.' }[k.status] || 'Status unavailable.')}</span></td><td style="font-size:12px">${esc(k.note || '-')}</td><td style="font-size:11px">${k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}</td><td style="display:flex;gap:4px">${k.status === 'active' ? `-- <button class="btn btn-sm btn-outline" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>-- <button class="btn btn-sm btn-outline" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>-- <button class="btn btn-sm btn-danger" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>++ <button class="btn btn-sm btn-outline" aria-label="Show or hide reservation key" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>++ <button class="btn btn-sm btn-outline" aria-label="Copy reservation key" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>++ <button class="btn btn-sm btn-danger" title="Revoke permanently; generate a new key to replace it" aria-label="Revoke reservation key" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>` : k.status === 'used' ? `<span class="muted">Used by ${esc(k.used_by_name || '?')}</span>` : '<span class="muted">Revoked</span>'}</td></tr>@@@ -164,7 +164,7 @@ async function generateVKey() {}async function revokeVKey(id) {-- if (!confirm('Revoke this verification key?')) return;++ if (!confirm('Revoke this account reservation key? It will no longer work. This cannot be undone; generate a new key if access is needed again.')) return;try {await api(`/api/admin/verification-keys/${id}`, { method: 'DELETE' });toast('Key revoked', 'success');@@@ -1,3 +1,307 @@++// Copy only: setting values, types and save paths remain owned by the existing forms.++const ADMIN_SETTING_COPY = {++ "registration_open": [++ "Allow new accounts",++ "Allows people to register; the default is on. Turn it off to pause sign-ups, and on again to reopen them."++ ],++ "platform_name": [++ "Network name",++ "The name shown to people across Network; the default is OpenVibe. For example, keep OpenVibe for the shared network."++ ],++ "default_theme": [++ "Starting appearance",++ "The theme used before a person chooses their own; the default is vibe. Enter a theme name such as vibe."++ ],++ "notifications_enabled": [++ "Send notifications",++ "Allows Network notifications; the default is on. Turn it off to pause new notifications, then on to resume."++ ],++ "notification_max_age_days": [++ "Keep notifications (days)",++ "Notifications older than this are removed; the default is 90 days. Higher keeps more history, lower removes it sooner; removed history cannot be restored here."++ ],++ "notification_email_critical_only": [++ "Email only urgent notifications",++ "Limits notification emails to critical messages; the default is on. Turn it off to allow other notification priorities by email."++ ],++ "payments_enabled": [++ "Allow purchases and subscriptions",++ "Allows real-money checkout on Live when a payment provider is also ready; the default is off. Turn it off again to stop offering new purchases; this does not refund existing payments."++ ],++ "bucks_per_usd": [++ "Vibes per US dollar",++ "Sets the Vibes value of US $1; the default is 100 Vibes. Higher means each Vibe is worth less in dollars, lower means more; save the previous rate to undo future pricing changes."++ ],++ "bucks_min_purchase_bucks": [++ "Smallest purchase (Vibes)",++ "Sets the minimum Vibes purchase; the default is 100 Vibes. Higher requires a larger purchase, lower allows smaller purchases; restore the previous amount to undo."++ ],++ "sub_price_usd": [++ "Subscription price (US dollars)",++ "The fallback subscription price is US $4.99 when Billing supplies no price. Higher charges more and lower charges less; restore the old price for future checkouts, with refunds handled in Billing."++ ],++ "sub_streamer_share_pct": [++ "Creator share (percent)",++ "The creator share defaults to 70 percent. Higher gives the creator more and the site less; restore the old percentage for future calculations."++ ],++ "sub_site_route_fee_pct": [++ "Site routing fee (percent)",++ "The site routing fee defaults to 10 percent. Higher increases the site fee and lower reduces it; restore the old percentage for future calculations."++ ],++ "discord_bot_token": [++ "Discord bot token",++ "Lets the bot connect to Discord; blank by default means no bot connection. Set the token in the server environment, then reconnect the bot."++ ],++ "discord_guild_id": [++ "Discord server ID",++ "Selects the Discord server for the bot; blank by default. Copy the ID of your own server, not its name."++ ],++ "discord_alerts_channel_id": [++ "Live alert channel ID",++ "Sends go-live alerts to this channel; blank by default means no live alert destination. Copy the channel ID, for example the ID of your live-alerts channel."++ ],++ "discord_system_channel_id": [++ "System alert channel ID",++ "Sends operator alerts to this channel; blank by default means no system alert destination. Use a private staff channel, for example admin-alerts."++ ],++ "discord_dedupe_minutes": [++ "Wait between repeat alerts (minutes)",++ "Waits before repeating a stream alert; the default is 15 minutes. Higher sends fewer repeat alerts, lower sends them sooner (1 to 1,440 minutes)."++ ],++ "discord_alert_message": [++ "Live alert wording",++ "Customizes the live alert; blank uses the built-in message. For example: {display_name} went live: {title} {url}."++ ],++ "discord_oauth_client_id": [++ "Discord sign-in application ID",++ "Identifies the application used to link Discord accounts; blank by default leaves it unconfigured. Copy the Client ID of the same application as the client secret."++ ],++ "discord_oauth_client_secret": [++ "Discord sign-in application secret",++ "Authenticates account linking with Discord; blank by default leaves it unconfigured. Set the matching Client Secret in the server environment."++ ],++ "github_token": [++ "GitHub read-only access",++ "Allows more requests to GitHub public repositories; blank uses anonymous access. Use a fine-grained token with public repository read access only."++ ],++ "email_enabled": [++ "Send email",++ "Allows outgoing email; the default is off. Turning it off stops email delivery, including recovery messages; turn it on again to resume."++ ],++ "email_from_address": [++ "Sender email address",++ "The default sender is [email protected]. Use an address on a domain verified with the email provider, for example [email protected]."++ ],++ "email_from_name": [++ "Sender name",++ "The name recipients see; the default is OpenVibe. For example, use OpenVibe Support for support mail."++ ],++ "resend_api_key": [++ "Resend email key",++ "Lets Network send email through Resend; blank by default means email is not configured. Set the provider key in the server environment."++ ],++ "chat_slowmode_seconds": [++ "Wait between chat messages (seconds)",++ "The default is 0 seconds, which turns slow mode off. Higher makes people wait longer between messages; for example 5 allows one message every five seconds."++ ],++ "coins_per_minute": [++ "Watching reward (OpenCoins per minute)",++ "The default is 10 OpenCoins for each minute watched. Higher awards more coins and lower awards fewer; restore the old amount to undo future rewards."++ ],++ "ai_max_cost_usd_per_day": [++ "Daily AI spending limit (US dollars)",++ "The default is US $0, meaning no cap. A higher positive cap permits more spending; a lower positive cap stops spending sooner, for example US $5 per day."++ ],++ "ai_viewers_global_cap_usd_per_day": [++ "Daily AI viewer spending limit (US dollars)",++ "Caps site-paid AI viewers; the default is US $0, meaning no cap. A higher positive amount permits more spending and a lower positive amount less; for example US $5 per day."++ ],++ "ai_stream_capture_interval_sec": [++ "Time between stream captures (seconds)",++ "The default is 120 seconds between captures. Higher captures less often and lower more often; for example 60 captures once per minute."++ ],++ "ai_viewers_max_lines_per_min": [++ "AI chat messages per minute",++ "Limits bot messages per channel; the default is 12 per minute. Higher allows more chat and lower makes the bots quieter."++ ],++ "ai_viewers_max_roster": [++ "AI viewers per channel",++ "Limits the number of AI viewers in a channel; the default is 12 viewers. Higher permits more viewers and lower limits the roster."++ ],++ "stripe_enabled": [++ "Offer Stripe payments",++ "Offers Stripe at checkout when purchases are enabled and Billing has a working provider; the default is off. Turn it off to remove this payment option; existing payments are not refunded."++ ],++ "paypal_enabled": [++ "Offer PayPal payments",++ "Offers PayPal at checkout when purchases are enabled and Billing has a working provider; the default is off. Turn it off to remove this payment option; existing payments are not refunded."++ ],++ "ccbill_enabled": [++ "Offer CCBill payments",++ "Offers CCBill at checkout when purchases are enabled and Billing has a working provider; the default is off. Turn it off to remove this payment option; existing payments are not refunded."++ ],++ "crypto_enabled": [++ "Offer cryptocurrency payments",++ "Offers cryptocurrency at checkout when purchases are enabled and Billing has a working provider; the default is off. Turn it off to remove this payment option; existing payments are not refunded."++ ],++ "max_audio_bitrate": [++ "Audio quality limit (kilobits per second)",++ "The default is 320 kilobits per second. Higher raises this limit and lower reduces it; for example, keep 320 for the starting allowance."++ ],++ "max_video_bitrate": [++ "Video quality limit (kilobits per second)",++ "The default is 6000 kilobits per second. Higher raises this limit and lower reduces it; for example, keep 6000 for the starting allowance."++ ],++ "max_clip_duration": [++ "Clip length limit (seconds)",++ "The default is 60 seconds. Higher raises this limit and lower reduces it; for example, keep 60 for the starting allowance."++ ],++ "max_emotes_per_user": [++ "Custom emoji limit (emoji per person)",++ "The default is 25 emoji per person. Higher raises this limit and lower reduces it; for example, keep 25 for the starting allowance."++ ],++ "max_vod_size_mb": [++ "Recording size limit (MB)",++ "The default is 5120 MB. Higher raises this limit and lower reduces it; for example, keep 5120 for the starting allowance."++ ],++ "paste_comment_max_length": [++ "Comment length limit (characters)",++ "The default is 2000 characters. Higher raises this limit and lower reduces it; for example, keep 2000 for the starting allowance."++ ],++ "paste_max_size_kb": [++ "Paste size limit (KB)",++ "The default is 512 KB. Higher raises this limit and lower reduces it; for example, keep 512 for the starting allowance."++ ],++ "paste_screenshot_max_size_mb": [++ "Screenshot size limit (MB)",++ "The default is 8 MB. Higher raises this limit and lower reduces it; for example, keep 8 for the starting allowance."++ ],++ "paste_max_per_user_per_day": [++ "Daily paste limit (pastes per person per day)",++ "The default is 50 pastes per person per day. Higher raises this limit and lower reduces it; for example, keep 50 for the starting allowance. Zero means unlimited."++ ],++ "paste_comment_cooldown_seconds": [++ "Wait between comments (seconds)",++ "The default is 10 seconds. Higher raises this limit and lower reduces it; for example, keep 10 for the starting allowance."++ ],++ "paste_cooldown_seconds": [++ "Wait between pastes (seconds)",++ "The default is 30 seconds. Higher raises this limit and lower reduces it; for example, keep 30 for the starting allowance."++ ],++ "min_cashout_amount": [++ "Minimum cashout (Vibes)",++ "The default is 500 Vibes. Higher raises this limit and lower reduces it; for example, keep 500 for the starting allowance."++ ]++};++Object.assign(ADMIN_SETTING_COPY, {++ "tts_max_length": [++ "Spoken message limit (characters)",++ "The default is 200 characters per message. Higher allows longer speech and lower keeps messages shorter."++ ],++ "tts_max_queue_global": [++ "Speech waiting list (messages)",++ "The default is 20 waiting messages across the site. Higher allows a longer waiting list and lower rejects excess messages sooner."++ ],++ "tts_max_queue_per_user": [++ "Speech waiting list per person (messages)",++ "The default is 3 waiting messages per person. Higher permits more queued speech and lower leaves room for other people."++ ],++ "money_writes_frozen": [++ "Pause all Live money actions",++ "The default is off. When frozen, new money actions are refused while balances remain readable; the owner can lift the freeze in the money controls to resume."++ ],++ "ai_viewers_default_settings_json": [++ "Starting AI viewer settings",++ "Overrides the built-in defaults for channel AI viewers; the default empty object leaves those defaults in place. For example, restore {} to remove these overrides."++ ]++});++const ADMIN_LIVE_DEFAULTS = {++ "ai_enabled": "false",++ "ai_max_cost_usd_per_day": "0",++ "ai_paste_analysis_enabled": "true",++ "ai_stream_capture_interval_sec": "120",++ "ai_stream_memory_enabled": "false",++ "ai_timeline_enabled": "false",++ "ai_transcription_enabled": "true",++ "ai_viewers_default_settings_json": "{}",++ "ai_viewers_enabled": "true",++ "ai_viewers_global_cap_usd_per_day": "0",++ "ai_viewers_max_lines_per_min": "12",++ "ai_viewers_max_roster": "12",++ "bucks_min_purchase_bucks": "100",++ "bucks_per_usd": "100",++ "ccbill_client_account": "",++ "ccbill_enabled": "false",++ "ccbill_flexform_id": "",++ "ccbill_subaccount": "",++ "chat_slowmode_seconds": "0",++ "coins_per_minute": "10",++ "crypto_enabled": "false",++ "crypto_provider": "nowpayments",++ "google_client_id": "",++ "kick_client_id": "",++ "max_audio_bitrate": "320",++ "max_clip_duration": "60",++ "max_emotes_per_user": "25",++ "max_video_bitrate": "6000",++ "max_vod_size_mb": "5120",++ "min_cashout_amount": "500",++ "money_writes_frozen": "false",++ "motd": "",++ "nsfw_enabled": "true",++ "paste_anon_allowed": "true",++ "paste_comment_anon_allowed": "true",++ "paste_comment_cooldown_seconds": "10",++ "paste_comment_max_length": "2000",++ "paste_cooldown_seconds": "30",++ "paste_image_upload_enabled": "true",++ "paste_max_per_user_per_day": "50",++ "paste_max_size_kb": "512",++ "paste_screenshot_max_size_mb": "8",++ "payments_enabled": "false",++ "paypal_client_id": "",++ "paypal_enabled": "false",++ "paypal_mode": "sandbox",++ "paypal_webhook_id": "",++ "powerchat_allow_test_fulfillment": "false",++ "powerchat_base_url": "https://powerchatlive.dev",++ "powerchat_client_id": "",++ "powerchat_enabled": "false",++ "powerchat_sandbox_username": "alex",++ "powerchat_scopes": "profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write chat:read",++ "powerchat_site_tip_username": "",++ "registration_open": "true",++ "require_email": "false",++ "site_description": "Live streaming for camp culture",++ "site_name": "OpenVibe.Live",++ "stripe_enabled": "false",++ "stripe_publishable_key": "",++ "sub_price_usd": "4.99",++ "sub_site_route_fee_pct": "10",++ "sub_streamer_share_pct": "70",++ "tts_aws_region": "us-east-1",++ "tts_default_voice": "gary",++ "tts_enabled": "true",++ "tts_google_service_account": "",++ "tts_max_length": "200",++ "tts_max_queue_global": "20",++ "tts_max_queue_per_user": "3",++ "tts_provider": "espeak-ng",++ "twitch_client_id": ""++};++function adminWords(key) {++ const words = String(key || '').replace(/[_-]+/g, ' ');++ return words.charAt(0).toUpperCase() + words.slice(1);++}++function adminSettingLabel(key, setting = {}) {++ const copy = ADMIN_SETTING_COPY[key];++ const title = copy ? copy[0] : (setting.title || adminWords(key));++ const defaultValue = Object.prototype.hasOwnProperty.call(ADMIN_LIVE_DEFAULTS, key) ? ADMIN_LIVE_DEFAULTS[key] : undefined;++ const defaultHelp = defaultValue === undefined ? ' No default is published here; keep the saved value unless you know the change you need.' : defaultValue === '' ? ' Starts blank; add the value supplied by your provider when enabling this feature.' : setting.type === 'boolean' ? ` Starts ${defaultValue === 'true' ? 'on' : 'off'}; toggle it to ${defaultValue === 'true' ? 'disable' : 'enable'} the described feature, and toggle it back to undo.` : ` The starting value is ${defaultValue}; use it as an example when restoring the original setup.`;++ const help = copy ? copy[1] : (setting.description || 'This service supplies this setting without a description.') + defaultHelp;++ return `<strong>${esc(title)}</strong> <code class="admin-machine-key">${esc(key)}</code><span class="admin-help">${esc(help)}</span>`;++}++/* ═══════════════════════════════════════════════════════════════OpenVibe.Network — admin panel (/admin): settings sub-tabs, URL registry, streamer settings, Discord and GitHub.Split out of admin.html. Classic scripts, global scope: they rely on@@@ -37,34 +341,35 @@ async function loadToolsSettings() {const data = await api('/api/admin/settings');const settings = data.settings || {};const keys = Object.keys(settings);-- if (!keys.length) { c.innerHTML = '<p class="muted">No settings configured</p>'; return; }++ if (!keys.length) { c.innerHTML = '<p class="muted">No settings are available. Check Network setup, then reload this tab.</p>'; return; }_toolsSettingsOriginal = {};for (const key of keys) _toolsSettingsOriginal[key] = settings[key].type === 'boolean' ? (settings[key].value === 'true' ? 'true' : 'false') : String(settings[key].value ?? '');c.innerHTML = `++ <p class="admin-intro">These settings control accounts, notifications, and integrations in Network. Change them when you need to adjust how the network works; save the previous value to undo a change.</p><form id="tools-settings-form" style="max-width:700px">${keys.map(key => {const s = settings[key];const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer)/i.test(key);if (s.source === 'env') return `<div class="setting-row" style="flex-direction:column;align-items:stretch">-- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>++ <label for="ts-${esc(key)}">${adminSettingLabel(key, s)}</label><input type="password" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="" disabled placeholder="Set in the environment (${esc(s.env || '')})"style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;if (s.type === 'boolean') return `<div class="setting-row">-- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>++ <label for="ts-${esc(key)}">${adminSettingLabel(key, s)}</label><input type="checkbox" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;return `<div class="setting-row" style="flex-direction:column;align-items:stretch">-- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>++ <label for="ts-${esc(key)}">${adminSettingLabel(key, s)}</label><input type="${isSensitive?'password':'text'}" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">${s.source === 'database' && s.env && s.secret ? `<span class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(s.env)}</code> in the server's environment file.</span>` : ''}</div>`;}).join('')}-- <div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button></div>++ <div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Network settings</button></div></form>`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }}@@@ -99,15 +404,15 @@ async function loadUrlRegistry() {const data = await api('/api/admin/url-registry');const entries = data.entries || [];if (!entries.length) {-- c.innerHTML = '<p class="muted">No URL registry overrides configured.</p>';++ c.innerHTML = '<p class="muted">No service addresses are available to edit. Check the service registry setup, then reopen this tab.</p>';return;}c.innerHTML = `<div style="max-width:900px;display:grid;gap:16px"><div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap"><div>-- <strong>Network registry</strong>-- <div class="muted" style="font-size:13px">Edit first-party networking values and push refresh to services.</div>++ <strong>Service addresses</strong>++ <div class="muted" style="font-size:13px">These are the addresses services use to reach each other. Change them when moving a service, then refresh services to pick up the saved addresses.</div></div><div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn btn-sm btn-primary" onclick="refreshAllUrlRegistry()"><i class="fa-solid fa-arrows-rotate"></i> Refresh all services</button>@@@ -118,25 +423,25 @@ async function loadUrlRegistry() {<div class="card" style="padding:16px"><div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap"><div>-- <div style="font-weight:700">${esc(entry.key)}</div>-- <div class="muted" style="font-size:13px">${esc(entry.label)} — ${esc(entry.type)}</div>++ <div style="font-weight:700">${esc(entry.label)} <code class="admin-machine-key">${esc(entry.key)}</code></div>++ <div class="muted" id="url-registry-help-${esc(entry.key)}" style="font-size:13px">Use the address or switch for this service; the supplied example is shown in the field. Reset restores the configured default; save your previous value to undo.</div></div><div style="display:flex;gap:8px;flex-wrap:wrap">-- <button class="btn btn-sm btn-outline" onclick="resetUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-rotate-left"></i> Reset</button>-- <button class="btn btn-sm btn-primary" onclick="saveUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-floppy-disk"></i> Save</button>++ <button class="btn btn-sm btn-outline" onclick="resetUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-rotate-left"></i> Restore default</button>++ <button class="btn btn-sm btn-primary" onclick="saveUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-floppy-disk"></i> ${entry.type === 'boolean' ? 'Save the switch' : 'Save the address'}</button></div></div><div class="form-field" style="margin-top:12px">-- <label for="url-registry-${esc(entry.key)}">Value</label>++ <label for="url-registry-${esc(entry.key)}">${entry.type === 'boolean' ? 'Turn this feature on or off' : 'Saved address'}</label>${entry.type === 'boolean' ? `<div style="display:flex;align-items:center;gap:8px">-- <input type="checkbox" id="url-registry-${esc(entry.key)}" ${entry.value ? 'checked' : ''}>++ <input type="checkbox" id="url-registry-${esc(entry.key)}" aria-describedby="url-registry-help-${esc(entry.key)}" ${entry.value ? 'checked' : ''}><span>${esc(entry.label)}</span></div>` : `-- <input type="text" id="url-registry-${esc(entry.key)}" value="${esc(entry.value || '')}" placeholder="${esc(entry.placeholder || '')}">++ <input type="text" id="url-registry-${esc(entry.key)}" aria-describedby="url-registry-help-${esc(entry.key)}" value="${esc(entry.value || '')}" placeholder="${esc(entry.placeholder || '')}">`}-- <div class="muted" style="margin-top:4px">Source: ${esc(entry.source)}</div>++ <div class="muted" style="margin-top:4px">${entry.source === 'default' ? 'Using the configured default.' : entry.source === 'env' ? 'Using the server environment setting.' : 'Using a saved override.'}</div>${entry.key === 'WHIP_PUBLIC_URL' ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Note</div><div class="muted" style="margin-top:4px">A dedicated WHIP hostname also requires explicit enablement via WHIP_PUBLIC_URL_ENABLED, plus DNS/vhost/TLS support.</div>` : ''}${entry.warning ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Warning</div><div class="muted" style="margin-top:4px">${esc(entry.warning)}</div>` : ''}</div>@@@ -160,7 +465,7 @@ async function saveUrlRegistryEntry(key) {}async function resetUrlRegistryEntry(key) {-- if (!confirm('Reset this URL registry entry to its default?')) return;++ if (!confirm('Restore the configured default service address? This changes where services connect after refresh. Save the current value first; enter and save it again to undo.')) return;try {await api(`/api/admin/url-registry/${encodeURIComponent(key)}/reset`, { method: 'POST' });toast('URL registry entry reset', 'success');@@@ -179,14 +484,14 @@ async function refreshAllUrlRegistry() {const result = (service.results && service.results[0]) || {};const mode = result.mode || (result.ok ? 'success' : 'failed');const badge = result.mode === 'local'-- ? '<span class="badge badge-info">Local</span>'++ ? '<span class="badge badge-info">Refreshed this server.</span>': result.mode === 'not_configured'-- ? '<span class="badge badge-warning">Not configured</span>'++ ? '<span class="badge badge-warning">No refresh destination is configured.</span>': result.mode === 'unsupported'-- ? '<span class="badge badge-warning">Unsupported</span>'++ ? '<span class="badge badge-warning">This service cannot refresh remotely.</span>': result.ok-- ? '<span class="badge badge-success">Success</span>'-- : '<span class="badge badge-danger">Failed</span>';++ ? '<span class="badge badge-success">The service refreshed its settings.</span>'++ : '<span class="badge badge-danger">The service could not refresh.</span>';const message = result.error ? ` <span class="muted">${esc(result.error)}</span>` : (result.message ? ` <span class="muted">${esc(result.message)}</span>` : '');return `<div><strong>${esc(service.service)}</strong>: ${badge}${message}</div>`;}).join('');@@@ -198,6 +503,16 @@ async function refreshAllUrlRegistry() {}// Grouped, icon'd sections for the (otherwise huge) OpenVibe.Live settings list.++const ADMIN_SETTINGS_GROUP_INTROS = {++ "Site & General": "These settings control the public Live site and registration. Change them when updating the site identity or who may join.",++ "Payment Processors": "These settings control paid features on Live. Change them when reviewing prices or payment availability; restore earlier values to undo future pricing changes, and handle refunds in Billing.",++ "Kick / Twitch / YouTube": "These settings connect external streaming accounts to Live. Change them when configuring or replacing an application at the named provider.",++ "AI Config": "These settings control automated analysis and AI viewers on Live. Change limits when balancing useful analysis against cost and server load.",++ "Cloud TTS": "These settings connect services that read text aloud. Change them when setting up a voice provider or replacing its credentials.",++ "Media & Uploads": "These settings limit uploaded media and stream quality. Raise limits when more capacity is available, or lower them to reduce storage and bandwidth use.",++ "Chat & Rewards": "These settings control chat pacing and watching rewards. Change them when chat needs slowing down or rewards need adjusting.",++ "Other": "These are additional settings published by Live. Change a value only after reading its description and keeping the previous value for recovery."++};const STREAMER_SETTINGS_GROUPS = [{ title: 'Site & General', icon: 'fa-sliders', test: k => /^(site_|motd$|registration_open$|require_email$|nsfw_enabled$)/.test(k) },{ title: 'Payment Processors', icon: 'fa-credit-card', test: k => /^(payments_|bucks_|sub_|paypal_|stripe_|ccbill_|crypto_|min_cashout)/.test(k) },@@@ -216,7 +531,7 @@ function _isMultiline(s) {}function _streamerSettingField(s) {const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer|salt)/i.test(s.key);-- const label = `<label for="ss-${esc(s.key)}"><strong>${esc(s.key)}</strong>${s.redacted ? ' <span class="badge badge-warning" style="font-size:10px">owner-only</span>' : ''}<br><small>${esc(s.description||'')}</small></label>`;++ const label = `<label for="ss-${esc(s.key)}">${adminSettingLabel(s.key, s)}${s.redacted ? ' <span class="badge badge-warning" style="font-size:10px">owner-only</span>' : ''}</label>`;const base = 'margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px';if (s.type === 'boolean') return `<div class="setting-row">${label}<input type="checkbox" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;@@@ -226,8 +541,8 @@ function _streamerSettingField(s) {if (kind === 'json') { try { shown = JSON.stringify(JSON.parse(shown), null, 2); } catch { /* show as-is; save validation will flag it */ } }const rows = Math.min(18, Math.max(3, shown.split('\n').length + 1));return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}-- <textarea id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${kind}" rows="${rows}" spellcheck="false"-- style="${base};font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical;white-space:pre;overflow:auto">${esc(shown)}</textarea>++ <details class="admin-advanced" ${kind === 'json' ? '' : 'open'}><summary>Advanced settings: ${kind === 'json' ? 'structured data (JSON)' : 'long text'}</summary><p class="admin-help">Edit the complete value below. Keep a copy before saving so you can restore it.</p><textarea id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${kind}" rows="${rows}" spellcheck="false"++ style="${base};font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical;white-space:pre;overflow:auto">${esc(shown)}</textarea></details><div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger-text, var(--danger,#f87171));font-size:12px;margin-top:4px"></div></div>`;}const type = s.type === 'number' ? 'number' : (isSensitive ? 'password' : 'text');@@@ -267,7 +582,7 @@ async function loadStreamerSettings() {try {const data = await api('/api/admin/streamer/settings');const settings = data.settings || [];-- if (!settings.length) { c.innerHTML = '<p class="muted">No OpenVibe.Live settings found</p>'; return; }++ if (!settings.length) { c.innerHTML = '<p class="muted">No Live settings are available. Check the Live connection, then reopen this tab.</p>'; return; }_streamerSettingsOriginal = {};for (const s of settings) _streamerSettingsOriginal[s.key] = s.value == null ? '' : String(s.value);// Bucket each setting into the first matching group.@@@ -281,14 +596,14 @@ async function loadStreamerSettings() {if (!items.length) return '';return `<details class="settings-group" ${i < 2 ? 'open' : ''} style="border:1px solid var(--border);border-radius:8px;margin-bottom:10px"><summary style="cursor:pointer;padding:10px 14px;font-weight:700;list-style:none"><i class="fa-solid ${g.icon}"></i> ${esc(g.title)} <span style="opacity:.5;font-weight:400">(${items.length})</span></summary>-- <div style="padding:4px 14px 12px">${items.map(_streamerSettingField).join('')}</div>++ <div style="padding:4px 14px 12px"><p class="admin-intro">${esc(ADMIN_SETTINGS_GROUP_INTROS[g.title])}</p>${items.map(_streamerSettingField).join('')}</div></details>`;}).join('');c.innerHTML = `<form id="streamer-settings-form" style="max-width:760px">${sections}<div style="margin-top:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">-- <button type="button" id="streamer-settings-save" class="btn btn-primary" onclick="saveStreamerSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button>++ <button type="button" id="streamer-settings-save" class="btn btn-primary" onclick="saveStreamerSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Network settings</button><button type="button" class="btn btn-outline" onclick="loadStreamerSettings()"><i class="fa-solid fa-rotate-left"></i> Discard changes</button><span class="muted" style="font-size:12px">Only fields you changed are sent. JSON and numeric fields are validated first.</span></div>@@@ -333,7 +648,7 @@ async function saveStreamerSettings() {if (errors.length) {toast(`Not saved — fix ${errors.length} invalid field${errors.length === 1 ? '' : 's'}: ${errors.join(', ')}`, 'error');const first = document.getElementById(`ss-${errors[0]}`);-- if (first) { first.closest('details')?.setAttribute('open', ''); first.scrollIntoView({ behavior: 'smooth', block: 'center' }); }++ if (first) { for (let parent = first.parentElement; parent; parent = parent.parentElement) { if (parent.tagName === 'DETAILS') parent.open = true; } first.scrollIntoView({ behavior: 'smooth', block: 'center' }); }return;}const keys = Object.keys(settings);@@@ -367,7 +682,7 @@ function envSecretInput(sources, key, value, placeholder, style) {const src = (sources || {})[key] || {};const env = esc(src.env || '');const label = src.source === 'env' ? `Set in the environment (${env})` : `Not set: add ${env} to /etc/openvibe/network.env and restart Network`;-- return `<input type="password" data-key="${esc(key)}" value="" placeholder="${label}" disabled style="${style}">`;++ return `<input type="password" id="ds-${esc(key)}" data-key="${esc(key)}" value="" placeholder="${label}" disabled style="${style}">`;}async function loadDiscordSettings() {@@@ -379,54 +694,54 @@ async function loadDiscordSettings() {const st = data.status || {};const statusBadge = st.connected? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Connected as ${esc(st.botTag || '?')}</span>`-- : `<span class="badge" style="background:var(--danger-bg,#7f1d1d);color:var(--danger-text,#f87171)">● Disconnected</span>`;++ : `<span class="badge" style="background:var(--danger-bg,#7f1d1d);color:var(--danger-text,#f87171)">The bot is not connected. Check its token and reconnect.</span>`;c.innerHTML = `<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px"><h3 style="margin:0"><i class="fa-brands fa-discord"></i> Discord Bot</h3>${statusBadge}</div>-- <form id="discord-settings-form">++ <p class="admin-intro">The Discord bot posts live and system alerts to your server. Configure its application and channel IDs when enabling alerts, then send a test alert to check delivery.</p><form id="discord-settings-form"><div class="setting-row">-- <label>Bot Token</label>++ <label for="ds-discord_bot_token">${adminSettingLabel("discord_bot_token")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Open your application → Bot → Reset Token, then copy the new token into the server environment; resetting it invalidates the previous token.</span></label>${envSecretInput(data.sources, 'discord_bot_token', s.discord_bot_token, 'Bot token from Discord Developer Portal', 'width:100%;max-width:500px')}</div><div class="setting-row">-- <label>Guild / Server ID</label>-- <input type="text" data-key="discord_guild_id" value="${esc(s.discord_guild_id || '')}" placeholder="Right-click server → Copy Server ID" style="width:240px">++ <label for="ds-discord_guild_id">${adminSettingLabel("discord_guild_id")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Use the application you invited to your server; in Discord, enable User Settings → Advanced → Developer Mode, then right-click the server → Copy Server ID (<a href="https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID" target="_blank" rel="noopener">Discord’s ID guide</a>).</span></label>++ <input type="text" id="ds-discord_guild_id" data-key="discord_guild_id" value="${esc(s.discord_guild_id || '')}" placeholder="Right-click server → Copy Server ID" style="width:240px"></div><div class="setting-row">-- <label>Live Alerts Channel ID</label>-- <input type="text" data-key="discord_alerts_channel_id" value="${esc(s.discord_alerts_channel_id || '')}" placeholder="Channel for go-live alerts" style="width:240px">++ <label for="ds-discord_alerts_channel_id">${adminSettingLabel("discord_alerts_channel_id")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Use the server where you invited this application; in Discord with Developer Mode enabled, right-click the live-alerts channel → Copy Channel ID.</span></label>++ <input type="text" id="ds-discord_alerts_channel_id" data-key="discord_alerts_channel_id" value="${esc(s.discord_alerts_channel_id || '')}" placeholder="Channel for go-live alerts" style="width:240px"></div><div class="setting-row">-- <label>System Alerts Channel ID</label>-- <input type="text" data-key="discord_system_channel_id" value="${esc(s.discord_system_channel_id || '')}" placeholder="Channel for system/admin alerts (optional)" style="width:240px">++ <label for="ds-discord_system_channel_id">${adminSettingLabel("discord_system_channel_id")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Use the server where you invited this application; in Discord with Developer Mode enabled, right-click the staff-alerts channel → Copy Channel ID.</span></label>++ <input type="text" id="ds-discord_system_channel_id" data-key="discord_system_channel_id" value="${esc(s.discord_system_channel_id || '')}" placeholder="Channel for system/admin alerts (optional)" style="width:240px"></div><div class="setting-row">-- <label>Dedupe Cooldown (minutes)</label>-- <input type="number" data-key="discord_dedupe_minutes" value="${esc(s.discord_dedupe_minutes || '15')}" min="1" max="1440" style="width:100px">-- <span class="muted" style="margin-left:8px">Prevent spam if a streamer flaps live/offline</span>++ <label for="ds-discord_dedupe_minutes">${adminSettingLabel("discord_dedupe_minutes")}</label>++ <input type="number" id="ds-discord_dedupe_minutes" data-key="discord_dedupe_minutes" value="${esc(s.discord_dedupe_minutes || '15')}" min="1" max="1440" style="width:100px">++ <span class="muted" style="margin-left:8px">Fewer repeated alerts when a stream disconnects and reconnects.</span></div><div class="setting-row">-- <label>Custom Alert Message (optional)</label>-- <input type="text" data-key="discord_alert_message" value="${esc(s.discord_alert_message || '')}" placeholder="{display_name} went live: {title}" style="width:100%;max-width:500px">++ <label for="ds-discord_alert_message">${adminSettingLabel("discord_alert_message")}</label>++ <input type="text" id="ds-discord_alert_message" data-key="discord_alert_message" value="${esc(s.discord_alert_message || '')}" placeholder="{display_name} went live: {title}" style="width:100%;max-width:500px"><div class="muted" style="font-size:12px;margin-top:4px">Placeholders: {username}, {display_name}, {title}, {url}</div></div>-- <h4 style="margin-top:24px"><i class="fa-brands fa-discord"></i> Account Linking (OAuth2)</h4>++ <h4 style="margin-top:24px"><i class="fa-brands fa-discord"></i> Link Discord accounts</h4><p class="admin-intro">These application details let people connect their Discord identity. Configure them when enabling account linking, using the same Discord application for both fields.</p><div class="setting-row">-- <label>OAuth2 Client ID</label>-- <input type="text" data-key="discord_oauth_client_id" value="${esc(s.discord_oauth_client_id || '')}" placeholder="Discord application client ID" style="width:300px">++ <label for="ds-discord_oauth_client_id">${adminSettingLabel("discord_oauth_client_id")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Open your application → OAuth2 → General, then copy Client ID.</span></label>++ <input type="text" id="ds-discord_oauth_client_id" data-key="discord_oauth_client_id" value="${esc(s.discord_oauth_client_id || '')}" placeholder="Discord application client ID" style="width:300px"></div><div class="setting-row">-- <label>OAuth2 Client Secret</label>++ <label for="ds-discord_oauth_client_secret">${adminSettingLabel("discord_oauth_client_secret")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Open your application → OAuth2 → General, then copy Client Secret into the server environment; resetting it invalidates the previous secret.</span></label>${envSecretInput(data.sources, 'discord_oauth_client_secret', s.discord_oauth_client_secret, 'Discord application client secret', 'width:300px')}</div><div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">-- <button type="button" class="btn btn-primary" onclick="saveDiscordSettings()"><i class="fa-solid fa-save"></i> Save Settings</button>++ <button type="button" class="btn btn-primary" onclick="saveDiscordSettings()"><i class="fa-solid fa-save"></i> Save Discord settings</button><button type="button" class="btn btn-outline" onclick="testDiscordAlert()"><i class="fa-solid fa-bell"></i> Test System Alert</button><button type="button" class="btn btn-outline" onclick="testDiscordLiveAlert()"><i class="fa-solid fa-broadcast-tower"></i> Test Live Alert</button>-- <button type="button" class="btn btn-outline" onclick="reinitDiscordBot()"><i class="fa-solid fa-rotate"></i> Reconnect Bot</button>++ <button type="button" class="btn btn-outline" onclick="reinitDiscordBot()"><i class="fa-solid fa-rotate"></i> Reconnect the bot</button></div></form>`;} catch (e) {@@@ -451,7 +766,7 @@ async function loadGithubSettings() {<p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}.</p><p class="muted" style="max-width:720px">Provider secrets live in the server's environment only, never in the database. ${g.set ? `This one comes from <code>${env}</code>; change it there.` : `To add one, create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions, set <code>${env}</code> in <code>/etc/openvibe/network.env</code>, and restart Network.`}</p><div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">-- <button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test</button>++ <button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test the token</button></div><div id="github-test-result" class="muted" style="margin-top:10px"></div>`;} catch (e) { c.innerHTML = `<p class="muted">GitHub settings could not be loaded: ${esc(e.message)}</p>`; }@@@ -21,7 +21,7 @@ async function loadUsers() {const users = data.users || [];const total = data.total || 0;-- if (!users.length) { c.innerHTML = '<p class="muted">No users found</p>'; pager.innerHTML = ''; return; }++ if (!users.length) { c.innerHTML = '<p class="muted">No accounts match. Clear the search or try a different username or email.</p>'; pager.innerHTML = ''; return; }c.innerHTML = `<table class="admin-table">@@@ -30,30 +30,30 @@ async function loadUsers() {</tr></thead><tbody>${users.map(u => `<tr>-- <td><strong>${esc(u.username)}</strong></td>-- <td>${esc(u.display_name || '-')}</td>-- <td style="font-size:11px">${esc(u.email || '-')}</td>-- <td><span class="badge badge-${u.role}">${esc(u.role)}</span></td>-- <td style="font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>-- <td style="font-size:11px">${u.last_seen ? timeAgo(u.last_seen) : 'Never'}</td>-- <td style="display:flex;gap:4px;align-items:center">-- <select onchange="changeUserRole('${u.id}',this.value)" style="padding:4px 6px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:11px">++ <td data-label="Username"><strong>${esc(u.username)}</strong></td>++ <td data-label="Display name">${esc(u.display_name || '-')}</td>++ <td data-label="Email" style="font-size:11px">${esc(u.email || '-')}</td>++ <td data-label="Access role"><span class="badge badge-${u.role}">${esc({user:'Member access',streamer:'Streamer access',global_mod:'Network moderator access',admin:'Administrator access'}[u.role] || adminWords(u.role))}</span></td>++ <td data-label="Created" style="font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>++ <td data-label="Last seen" style="font-size:11px">${u.last_seen ? timeAgo(u.last_seen) : 'Never'}</td>++ <td data-label="Account actions" style="display:flex;gap:4px;align-items:center">++ <select aria-label="Account role; changes immediately, select the old role to undo" onchange="changeUserRole('${u.id}',this.value)" style="padding:4px 6px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:11px">${['user','streamer','global_mod','admin'].map(r =>-- `<option value="${r}" ${r===u.role?'selected':''}>${r}</option>`++ `<option value="${r}" ${r===u.role?'selected':''}>${{user:'Member — standard account',streamer:'Streamer — broadcast access',global_mod:'Global moderator — moderate across channels',admin:'Administrator — manage the network'}[r]}</option>`).join('')}</select>${u.is_banned ?-- `<button class="btn btn-sm btn-success" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check"></i></button>` :-- `<button class="btn btn-sm btn-danger" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban"></i></button>`++ `<button class="btn btn-sm btn-success" title="Restore access; ban again to restrict it" aria-label="Unban account" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check" aria-hidden="true"></i> Restore access</button>` :++ `<button class="btn btn-sm btn-danger" title="Block access; use Unban to restore it" aria-label="Ban account" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban" aria-hidden="true"></i> Ban account</button>`}-- <button class="btn btn-sm btn-outline" title="Rename (the old name redirects on Live and stays reserved)" onclick="renameUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-signature"></i></button>-- <button class="btn btn-sm btn-outline" title="Sign out on every device and site" onclick="signOutUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-right-from-bracket"></i></button>++ <button class="btn btn-sm btn-outline" title="Rename (the old name redirects on Live and stays reserved)" onclick="renameUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-signature" aria-hidden="true"></i> Rename</button>++ <button class="btn btn-sm btn-outline" title="Sign out on every device and site" onclick="signOutUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i> Sign out</button>${currentUser?.is_owner ? `<button class="btn btn-sm btn-outline" title="Edit email address"-- onclick="editUserEmail('${u.id}','${esc(u.username)}','${esc(u.email || '')}')"><i class="fa-solid fa-envelope"></i></button>++ onclick="editUserEmail('${u.id}','${esc(u.username)}','${esc(u.email || '')}')"><i class="fa-solid fa-envelope" aria-hidden="true"></i> Edit email</button><button class="btn btn-sm btn-outline" title="${u.email ? 'Send a password reset link' : 'No email address on file'}"${u.email ? '' : 'disabled'}-- onclick="sendUserReset('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-key"></i></button>++ onclick="sendUserReset('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-key" aria-hidden="true"></i> Send reset email</button>` : ''}</td></tr>@@@ -63,17 +63,17 @@ async function loadUsers() {const pages = Math.ceil(total / 50);const curPage = Math.floor(usersOffset / 50) + 1;pager.innerHTML = pages > 1 ? `-- <button class="btn btn-sm btn-outline" ${usersOffset <= 0 ? 'disabled' : ''} onclick="usersOffset=Math.max(0,usersOffset-50);loadUsers()"><i class="fa-solid fa-chevron-left"></i></button>++ <button class="btn btn-sm btn-outline" aria-label="Previous page" ${usersOffset <= 0 ? 'disabled' : ''} onclick="usersOffset=Math.max(0,usersOffset-50);loadUsers()"><i class="fa-solid fa-chevron-left"></i></button><span class="muted">Page ${curPage} / ${pages} (${total} total)</span>-- <button class="btn btn-sm btn-outline" ${curPage >= pages ? 'disabled' : ''} onclick="usersOffset+=50;loadUsers()"><i class="fa-solid fa-chevron-right"></i></button>-- ` : `<span class="muted">${total} users</span>`;++ <button class="btn btn-sm btn-outline" aria-label="Next page" ${curPage >= pages ? 'disabled' : ''} onclick="usersOffset+=50;loadUsers()"><i class="fa-solid fa-chevron-right"></i></button>++ ` : `<span class="muted">${total} account${total === 1 ? '' : 's'}</span>`;} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }}async function changeUserRole(userId, role) {try {await api(`/api/admin/users/${userId}/role`, { method: 'PUT', body: { role } });-- toast(`Role updated to ${role}`, 'success');++ toast(`Role updated to ${adminWords(role)}`, 'success');} catch (e) { toast(e.message, 'error'); loadUsers(); }}@@@ -81,7 +81,7 @@ async function changeUserRole(userId, role) {// The server enforces owner-only on both of these (requireOwner); hiding the buttons// for non-owners is purely so admins are not shown controls they cannot use.async function editUserEmail(userId, username, current) {-- const next = prompt(`Email address for @${username}\n\nLeave blank to remove it.`, current || '');++ const next = prompt(`Email address for @${username}\n\nLeave blank to remove it, which prevents email recovery. Enter the previous address again to undo.`, current || '');if (next === null) return; // cancelledconst email = next.trim();if (email === (current || '').trim()) return; // unchanged@@@ -113,7 +113,7 @@ async function renameUser(userId, username) {}async function signOutUser(userId, username) {-- if (!confirm(`Sign ${username} out on every device and every OpenVibe site?`)) return;++ if (!confirm(`Sign ${username} out on every device and every OpenVibe site? Existing sessions stop working; they must sign in again to restore access.`)) return;try {await api(`/api/admin/users/${userId}/sign-out`, { method: 'POST' });toast(`${username} signed out everywhere`, 'success');@@@ -122,7 +122,7 @@ async function signOutUser(userId, username) {async function toggleBan(userId, username, ban) {if (ban) {-- const reason = prompt(`Ban ${username}? Enter reason:`);++ const reason = prompt(`Ban ${username}? This blocks account access. Use Unban to restore access. Enter the reason:`);if (reason === null) return;try {await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: true, reason } });nnew file mode 100644@@@ -0,0 +1,88 @@++// Local visual and submission-contract proof with synthetic API responses; see docs/admin-clarity-review.md.++// Local visual and submission-contract proof with synthetic API responses; see docs/admin-clarity-review.md.++// Playwright and Chrome come from the local workstation; override ADMIN_PROOF_PLAYWRIGHT to point elsewhere.++const playwright=process.env.ADMIN_PROOF_PLAYWRIGHT || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright';++const {chromium}=require(playwright);++const fs=require('fs');const path=require('path');++const root=path.resolve(__dirname, '..');++const base=process.env.ADMIN_PROOF_URL || 'http://127.0.0.1:4897';++const baseline=process.env.ADMIN_PROOF_BASELINE || '/tmp/admin-clarity-before-public';++const out=process.env.ADMIN_PROOF_OUT || path.join(root, '..', 'shots', 'admin-clarity');fs.mkdirSync(out,{recursive:true});++const phase=process.argv[2]||'after';++const onlyChecks=process.env.ADMIN_PROOF_CHECKS === '1';++const settings=[['payments_enabled','false','boolean'],['bucks_per_usd','100','number'],['bucks_min_purchase_bucks','100','number'],['sub_price_usd','4.99','number'],['sub_streamer_share_pct','70','number'],['sub_site_route_fee_pct','10','number'],['stripe_enabled','false','boolean'],['paypal_enabled','false','boolean'],['ccbill_enabled','false','boolean'],['crypto_enabled','false','boolean'],['site_name','OpenVibe.Live','string'],['chat_slowmode_seconds','0','number'],['max_vod_size_mb','5120','number'],['ai_viewers_default_settings_json','{"enabled":false,"maxViewers":3}','json']].map(([key,value,type])=>({key,value,type,description:'Setting published by Live.'}));++const fixtures={++'/api/auth/me':{user:{id:'demo-owner',username:'design-review',display_name:'Design review',role:'admin',is_owner:true}},++'/api/admin/health':{health:{users:124,active_sessions:18,uptime:12600,memory:{heapUsed:123000000,heapTotal:240000000,rss:350000000},ses_enabled:false}},++'/api/admin/streamer/stats':{stats:{streams:{live:2,total:24}}},++'/api/admin/settings':{settings:Object.fromEntries([['registration_open','true','boolean'],['platform_name','OpenVibe','string'],['notification_max_age_days','90','number'],['email_enabled','false','boolean']].map(([key,value,type])=>[key,{value,type}]))},++'/api/admin/streamer/settings':{settings},++'/api/admin/discord':{settings:{discord_dedupe_minutes:'15'},status:{connected:false},sources:{discord_bot_token:{env:'DISCORD_BOT_TOKEN'},discord_oauth_client_secret:{env:'DISCORD_OAUTH_CLIENT_SECRET'}}},++'/api/admin/users':{users:[{id:'demo-1',username:'alex',display_name:'Alex Example',email:'[email protected]',role:'user',created_at:'2026-11-01',last_seen:'2026-11-07',is_banned:false}],total:1},++'/api/admin/streamer/streams':{streams:[{id:'demo-stream',username:'alex',title:'An example community broadcast',protocol:'webrtc',viewer_count:12,started_at:'2026-11-07'}]},++'/api/admin/streamer/moderators':{moderators:[]},++'/api/admin/streamer-pastes/admin/stats':{stats:{}},'/api/admin/streamer-pastes/config':{config:{maxSizeKb:512,maxPerUserPerDay:50}},++'/api/admin/streamer/storage':{disk:{total:10000000000,used:4000000000,available:6000000000},dataTotal:{bytes:3000000000,files:24},breakdown:[{name:'recordings',bytes:3000000000,files:24}]},++'/api/admin/streamer/storage/vods':{vods:[],total:0},++'/api/admin/email':{email:{enabled:false,from_email:'[email protected]',from_name:'OpenVibe'},metrics:{}},++'/api/admin/deploy/prerequisites':{ok:true,prerequisites:{certbotInstalled:true,nginxInstalled:true},config:{certMode:'manual',nginxMode:'preview',domainsConfigured:1}},++'/api/admin/deploy/config':{ok:true,config:{certMode:'manual',nginxMode:'preview',nginxSitesPath:'/etc/nginx/sites-enabled'}},++'/api/admin/deploy/certs':{ok:true,certs:[]},'/api/admin/deploy/domains':{ok:true,domains:[]},++'/api/admin/analytics/overview':{overview:{totals:{total_pageviews:1200,total_api_calls:400,total_unique_visitors:90,total_errors:4,avg_response_ms:120},services:[],dailyTrend:[]}},++'/api/admin/analytics/insights':{insights:{generated_at:'2026-11-07T04:00:00Z',users:{new_1d:4,new_7d:18,total:124},totals:{requests:1600,server_errors:4},services:[],top_routes:[]}},++'/api/admin/domains':{domains:[]},'/api/admin/domains/catalog':{source:'live',families:[{id:'tools',name:'Tools'}],tools:[]},++'/api/admin/events/deliveries':{deliveries:[],counts:{dead:0,failed:0,pending:0}},++'/api/admin/url-registry':{entries:[{key:'OV_LIVE_URL',label:'Live public address',type:'string',value:'https://openvibe.live',source:'default'}]},++};++const panels=[['dashboard'],['users'],['streams'],['moderators'],['settings'],['settings','discord'],['settings','streamer'],['settings','urls'],['payments'],['pastes'],['storage'],['notifications'],['email'],['deploy'],['deploy','config'],['deploy','certs'],['deploy','domains'],['analytics'],['grants'],['eventsops'],['domains'],['tts'],['cashouts']];++(async()=>{++ const b=await chromium.launch({executablePath:'/usr/bin/google-chrome',args:['--no-sandbox']});const results=[];++ for(const width of (onlyChecks ? [1280] : [390,1280]))for(const scheme of (onlyChecks ? ['dark'] : ['dark','light'])){++ const page=await b.newPage({viewport:{width,height:900},colorScheme:scheme,deviceScaleFactor:1,isMobile:width<700,hasTouch:width<700});const errors=[];const writes=[];++ page.on('pageerror',e=>errors.push(e.message));++ await page.addInitScript(({scheme})=>{localStorage.setItem('ov_token','local-design-fixture');localStorage.setItem('ov_theme',JSON.stringify({id:scheme,variables:scheme==='light'?{'--bg-primary':'#f7f8fc','--bg-secondary':'#eef1f6','--bg-card':'#ffffff','--bg-input':'#ffffff','--text-primary':'#202737','--text':'#202737','--text-secondary':'#455166','--text-muted':'#566176','--border':'#c9cfda','--accent-light':'#1759b0','--bg-elev':'#eef1f6'}:{'--bg-primary':'#0d0d14','--text-muted':'#a1a1b5'}}));},{scheme});++ await page.route('**/*',async route=>{++ const u=new URL(route.request().url());++ if(u.pathname.startsWith('/api/') && route.request().method() !== 'GET') writes.push({path:u.pathname,method:route.request().method(),body:route.request().postDataJSON()});++ if(u.pathname.startsWith('/api/'))return route.fulfill({json:fixtures[u.pathname]||{ok:true,entries:[],items:[],changes:[],grants:[],settings:[],voices:[]}});++ // Do not load production notifications or make remote requests during local review.++ if(u.hostname==='openvibe.live'||u.hostname==='openvibe.network')return route.fulfill({status:200,body:'',contentType:'application/javascript'});++ if(phase==='before'&&u.hostname==='127.0.0.1'){++ const rel=u.pathname.startsWith('/admin')?'admin.html':u.pathname.slice(1);const file=path.join(baseline,rel);++ if(fs.existsSync(file)&&fs.statSync(file).isFile())return route.fulfill({path:file});++ }++ return route.continue();});++ await page.goto(base+'/admin',{waitUntil:'networkidle'});++ for(const [tab,sub]of (onlyChecks ? [] : panels)){++ await page.evaluate(async({tab,sub})=>{showTab(tab);if(sub){if(tab==='settings')showSettingsSub(sub);if(tab==='deploy')showDeploySubTab(sub);}}, {tab,sub});++ await page.waitForTimeout(350);++ await page.evaluate(()=>window.scrollTo(0,0));++ const audit=await page.evaluate(()=>{++ const visible=e=>e.getAttribute('aria-hidden') !== 'true' && e.checkVisibility() && !!(e.offsetWidth||e.offsetHeight||e.getClientRects().length);++ const controls=[...document.querySelectorAll('.section.active button,.section.active input,.section.active select,.section.active textarea,.section.active summary')].filter(visible);++ return {docWidth:document.documentElement.scrollWidth,small:controls.filter(e=>{const r=e.getBoundingClientRect();return r.height<43.5||r.width<43.5}).map(e=>({id:e.id,label:e.getAttribute('aria-label')||e.textContent.slice(0,40),w:Math.round(e.getBoundingClientRect().width),h:Math.round(e.getBoundingClientRect().height)})),pageTextErrors:[...document.querySelectorAll('.section.active .muted')].filter(e=>/Error:|not defined|Cannot read/.test(e.textContent)).map(e=>e.textContent)};});++ const name=[phase,tab,sub,width,scheme].filter(Boolean).join('-');++ await page.screenshot({path:path.join(out,name+'.png'),fullPage:true});++ results.push({name,...audit});++ }++ if(onlyChecks){++ // All API traffic is fulfilled above. These interactions never write to the local or production database.++ await page.evaluate(async()=>{++ await loadToolsSettings(); document.getElementById('ts-notification_max_age_days').value='30'; await saveToolsSettings();++ await loadPayments(); document.getElementById('pay-stripe_enabled').checked=true; await savePayments();++ await loadDiscordSettings(); document.querySelector('[data-key=discord_dedupe_minutes]').value='20'; await saveDiscordSettings();++ await loadStreamerSettings(); document.getElementById('ss-chat_slowmode_seconds').value='5'; await saveStreamerSettings();++ await loadUsers(); const select=document.querySelector('#users-content select'); select.value='global_mod'; select.dispatchEvent(new Event('change'));++ });++ await page.waitForTimeout(300);++ const beforeInvalid=writes.length;++ await page.evaluate(async()=>{await loadStreamerSettings();document.getElementById('ss-ai_viewers_default_settings_json').value='{invalid';await saveStreamerSettings();});++ if(writes.length!==beforeInvalid)throw new Error('Invalid JSON sent a write');++ const disclosed=await page.evaluate(()=>{const field=document.getElementById('ss-ai_viewers_default_settings_json');return [...(function*(e){for(;e;e=e.parentElement)if(e.tagName==='DETAILS')yield e;})(field)].every(e=>e.open);});++ if(!disclosed)throw new Error('Invalid JSON field remained inside a closed section');++ results.push({writes,invalidJsonBlocked:true,invalidJsonDisclosed:true});++ }++ results.push({width,scheme,errors});await page.close();++ }++ await b.close();fs.writeFileSync(path.join(out,phase+(onlyChecks ? '-requests' : '-audit')+'.json'),JSON.stringify(results,null,2));console.log(JSON.stringify(results.filter(r=>r.name && r.docWidth>Number(r.name.match(/-(390|1280)-/)[1])||r.small?.length||r.errors?.length||r.pageTextErrors?.length),null,2));++})().catch(e=>{console.error(e);process.exit(1)});
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+# Admin clarity for the tabs outside AI and Setup
+
+Design review notes for the owner-facing admin panel, 2026-11-07.
+Plan: `plan-state-2026-10-22/OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN-2026-11-07.md`, section 2
+("Clarity rules for every admin page and every settings schema").
+
+## What this change covers
+
+Every admin tab except **AI** and the new **Setup** tab, which a separate PR rewrites:
+
+| File | Tab |
+|---|---|
+| `public/js/admin/dashboard.js` | Dashboard |
+| `public/js/admin/users.js` | Users |
+| `public/js/admin/moderation.js` | Streams, Lineage, Bans, Chat Logs, Moderators, Moderation log, Service grants, Theme review, Operator checklist, Event deliveries |
+| `public/js/admin/settings.js` | Settings (Tools, Live, Media, URLs, Discord, GitHub) |
+| `public/js/admin/payments.js` | Payments, account reservation keys |
+| `public/js/admin/content.js` | Pastes, Storage, Notifications, Email |
+| `public/js/admin/deploy.js` | Domains, Deploy, SSH |
+| `public/js/admin/analytics.js` | Analytics |
+
+The static sections of `public/admin.html` (Broadcast, Audit, Grants, Event deliveries, Analytics cards,
+Deploy panels) carry the same wording.
+
+## The rules applied
+
+1. **Every section starts with two sentences** — what this is, and when you would change it.
+ Cards, fieldsets and panels each got an `.admin-intro` paragraph.
+2. **Every field has a plain title and a help line** — what it does, what the default is, and an example.
+ The machine key (`notification_max_age_days`) is still there, small and grey, after the title
+ (`.admin-machine-key`), never instead of it. `ADMIN_SETTING_COPY` in `settings.js` holds the copy for the
+ published settings; `adminSettingLabel()` renders it, and falls back to the service's own description plus
+ the published default when a key is not listed.
+3. **Every option of a choice has words** — role, delivery state, grant state, account role, domain role,
+ certificate mode, web server mode and the payment switches are written as sentences, with the code kept
+ only where it is the value that gets sent.
+4. **Every number has its unit and what more or less means** — the help lines name the unit (minutes, days,
+ kilobits per second, percent, US dollars, characters) and say which way the number moves the feature.
+5. **Status is a sentence** — "The bot is not connected. Check its token and reconnect.", "Access was removed.",
+ "The provider accepted the email." Raw codes stay only in grey text and tooltips.
+6. **Raw JSON is advanced** — the long-text and JSON fields of the Live settings sit under an
+ "Advanced settings" fold with a counted label; the fold opens itself when a value fails to save.
+7. **Danger and money controls say what happens and how to undo it** — every confirmation now states the
+ effect and the way back ("generate a new key", "save the previous value", "restore the backup on the server").
+8. **Empty states say what to do next** — "No accounts match. Clear the search or try a different username
+ or email."
+9. **The glossary** is added by the Setup/AI PR. None of the six glossary words (Credits, BYOK, resale, cache,
+ quota, workflow) appear in these tabs, so there is nothing to link here yet.
+
+## The Discord block
+
+Bot token, server ID, both channel IDs and the OAuth2 client id and secret each carry a **Where to get it**
+line: the Discord Developer Portal page, the path inside it (Bot → Reset Token, OAuth2 → General), and what to
+copy. The server and channel ID lines also explain that Developer Mode has to be on first, and link Discord's
+own ID guide. The token and the client secret stay write-only in the server environment, as before.
+
+## Phone
+
+* Every control is at least 44 px tall and 44 px wide: inputs, selects, textareas, buttons, checkboxes and
+ `summary` folds.
+* Nothing is wider than 390 px: the tables that cannot fit become one card per row, grids collapse to one
+ column, and long values wrap.
+* The new rules are scoped to `.admin-clarity`, which is on the sections in scope, so the AI tab keeps its own
+ styles. The shared tab bar is 44 px for every tab, which is a nav improvement rather than a change to the
+ AI tab's own wording.
+
+## Proof
+
+`scripts/admin-clarity-proof.cjs` drives a local Network with synthetic API responses and captures each tab at
+390 px and 1280 px in dark and light, then audits the page for overflow and for controls under 44 px.
+
+```
+node --import ./test/helpers/pg-preload.mjs server/index.js # local Network on :4897
+node scripts/admin-clarity-proof.cjs before # the same pages with the previous wording and styles
+node scripts/admin-clarity-proof.cjs after # the pages as they are now
+ADMIN_PROOF_CHECKS=1 node scripts/admin-clarity-proof.cjs after # the write contract
+```
+
+Results in `work/shots/admin-clarity`:
+
+| | before | after |
+|---|---|---|
+| Views captured | 92 | 92 |
+| 390 px views wider than the screen | 8 | 0 |
+| Controls under 44 px | 696 | 0 |
+| Page errors | 0 | 0 |
+
+The write contract check submits the same six requests with the same bodies before and after
+(`after-requests.json` equals `before-requests.json`), and an invalid JSON value is refused and its field
+revealed rather than left inside a closed fold.
+
+## What was left alone
+
+Every behaviour, permission and API call. The panels still call the same endpoints, the same payloads are sent,
+the same ids and values are read from the same places, and the fields the owner cannot see (environment-held
+secrets) are still disabled and empty.
@@ -95,18 +95,20 @@
@@ -95,18 +95,20 @@
</div>
</div>
<!-- Dashboard -->
<!-- Dashboard -->
- <div class="section active" id="sec-dashboard">
+ <div class="section active admin-clarity" id="sec-dashboard">
+ <p class="admin-intro">This is a snapshot of accounts, streams, and Network health. Check it when reviewing activity or investigating a service problem; larger counts mean more activity, while memory and uptime describe this server.</p>
<div class="stats-grid" id="dashboard-stats"><div class="loading">Loading stats...</div></div>
<div class="stats-grid" id="dashboard-stats"><div class="loading">Loading stats...</div></div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-server"></i> System Health</h3>
+ <h3><i class="fa-solid fa-server"></i> System Health</h3><p class="admin-intro">This describes the Network process running on this server. Check it when investigating downtime or high memory use; more memory means less room for other work.</p>
<div id="health-info" class="muted">Loading...</div>
<div id="health-info" class="muted">Loading...</div>
</div>
</div>
</div>
</div>
<!-- Users -->
<!-- Users -->
- <div class="section" id="sec-users">
+ <div class="section admin-clarity" id="sec-users">
+ <p class="admin-intro">These are Network accounts and the access each person has. Use search to find someone, then change access or help recover their account; role changes apply immediately and can be reversed by selecting the previous role.</p>
<div class="input-row">
<div class="input-row">
- <input type="text" id="user-search" placeholder="Search users by name or email..." onkeydown="if(event.key==='Enter')loadUsers()">
+ <span class="admin-field"><input type="text" id="user-search" aria-label="Find an account" aria-describedby="help-user-search" placeholder="Search users by name or email..." onkeydown="if(event.key==='Enter')loadUsers()"><span class="admin-help" id="help-user-search"><strong>Find an account.</strong> Search by name or email, for example alex. Blank shows all accounts; press Enter or Search to apply.</span></span>
<button class="btn btn-primary" onclick="loadUsers()"><i class="fa-solid fa-search"></i> Search</button>
<button class="btn btn-primary" onclick="loadUsers()"><i class="fa-solid fa-search"></i> Search</button>
</div>
</div>
<div id="users-content"><div class="loading">Loading...</div></div>
<div id="users-content"><div class="loading">Loading...</div></div>
@@ -114,27 +116,31 @@
@@ -114,27 +116,31 @@
</div>
</div>
<!-- Streams -->
<!-- Streams -->
- <div class="section" id="sec-streams">
+ <div class="section admin-clarity" id="sec-streams">
+ <p class="admin-intro">These are streams currently live on OpenVibe.Live. Review them during moderation; ending a stream disconnects the broadcast, and the streamer must start again to resume.</p>
<div id="streams-content"><div class="loading">Loading...</div></div>
<div id="streams-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Lineage: references the canonical resolver could not resolve (Live, roadmap D20) -->
<!-- Lineage: references the canonical resolver could not resolve (Live, roadmap D20) -->
- <div class="section" id="sec-lineage">
- <p class="muted">References Live's canonical channel/owner resolver answered <em>unresolved</em>, from other services and Live's own checks, counted by inputs and reason over the last 30 days. A display name never resolves anything.</p>
+ <div class="section admin-clarity" id="sec-lineage">
+ <p class="admin-intro">These are channel or owner references Live could not match during the last 30 days. Check them when a linked channel is missing, then correct the reference in the service shown as the last caller.</p>
+ <p class="muted">Use a channel link or stable account ID when correcting a reference. A display name alone cannot identify the channel.</p>
<div class="sub-tabs" id="lineage-reasons"></div>
<div class="sub-tabs" id="lineage-reasons"></div>
<div id="lineage-content"><div class="loading">Loading...</div></div>
<div id="lineage-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Bans -->
<!-- Bans -->
- <div class="section" id="sec-bans">
+ <div class="section admin-clarity" id="sec-bans">
+ <p class="admin-intro">These people are currently banned on Live. Review the reason before restoring access; allowing access again removes the restriction, and a new ban can be applied from Users if needed.</p>
<div id="bans-content"><div class="loading">Loading...</div></div>
<div id="bans-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Chat Logs -->
<!-- Chat Logs -->
- <div class="section" id="sec-chat-logs">
+ <div class="section admin-clarity" id="sec-chat-logs">
+ <p class="admin-intro">Search stored chat messages to investigate a report. Enter message text or a person below, then review the results before taking action.</p>
<div class="input-row">
<div class="input-row">
- <input type="text" id="chat-search-q" placeholder="Search messages..." onkeydown="if(event.key==='Enter')searchChatLogs()">
- <input type="text" id="chat-search-uid" placeholder="Username, user ID, or anon123" style="max-width:200px" onkeydown="if(event.key==='Enter')searchChatLogs()">
+ <span class="admin-field"><input type="text" id="chat-search-q" aria-label="Message text" aria-describedby="help-chat-search-q" placeholder="Search messages..." onkeydown="if(event.key==='Enter')searchChatLogs()"><span class="admin-help" id="help-chat-search-q"><strong>Message text.</strong> Search for words in a message, for example welcome. Blank leaves message text unrestricted when a person is supplied.</span></span>
+ <span class="admin-field"><input type="text" id="chat-search-uid" aria-label="Person in chat" aria-describedby="help-chat-search-uid" placeholder="Username, user ID, or anon123" style="max-width:200px" onkeydown="if(event.key==='Enter')searchChatLogs()"><span class="admin-help" id="help-chat-search-uid"><strong>Person in chat.</strong> Enter a username or account ID, for example alex or anon123. Blank searches any person when message text is supplied.</span></span>
<button class="btn btn-primary" onclick="searchChatLogs()"><i class="fa-solid fa-search"></i> Search</button>
<button class="btn btn-primary" onclick="searchChatLogs()"><i class="fa-solid fa-search"></i> Search</button>
</div>
</div>
<div id="chat-logs-content"><p class="muted">Enter a search query or user ID</p></div>
<div id="chat-logs-content"><p class="muted">Enter a search query or user ID</p></div>
@@ -142,16 +148,18 @@
@@ -142,16 +148,18 @@
</div>
</div>
<!-- Moderators -->
<!-- Moderators -->
- <div class="section" id="sec-moderators">
+ <div class="section admin-clarity" id="sec-moderators">
+ <p class="admin-intro">These people can moderate across Live channels. Add a trusted person when more coverage is needed; Remove moderator access reverses the promotion.</p>
<div class="input-row">
<div class="input-row">
- <input type="text" id="mod-username" placeholder="Username to promote..." onkeydown="if(event.key==='Enter')promoteMod()">
- <button class="btn btn-primary" onclick="promoteMod()"><i class="fa-solid fa-shield-halved"></i> Promote to Global Mod</button>
+ <span class="admin-field"><input type="text" id="mod-username" aria-label="Person to make a moderator" aria-describedby="help-mod-username" placeholder="Username to promote..." onkeydown="if(event.key==='Enter')promoteMod()"><span class="admin-help" id="help-mod-username"><strong>Person to make a moderator.</strong> Enter an existing username, for example alex; there is no default. Promotion grants moderation access across Live, and removing that access reverses it.</span></span>
+ <button class="btn btn-primary" onclick="promoteMod()"><i class="fa-solid fa-shield-halved"></i> Make a network moderator</button>
</div>
</div>
<div id="mods-content"><div class="loading">Loading...</div></div>
<div id="mods-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Settings -->
<!-- Settings -->
- <div class="section" id="sec-settings">
+ <div class="section admin-clarity" id="sec-settings">
+ <p class="admin-intro">These settings control Network and its connected Live service. Choose a section before editing, and keep previous values so you can restore them after saving.</p>
<div class="sub-tabs" id="settings-sub-tabs">
<div class="sub-tabs" id="settings-sub-tabs">
<button class="active" onclick="showSettingsSub('tools',this)">OpenVibe.Tools</button>
<button class="active" onclick="showSettingsSub('tools',this)">OpenVibe.Tools</button>
<button onclick="showSettingsSub('streamer',this)">OpenVibe.Live</button>
<button onclick="showSettingsSub('streamer',this)">OpenVibe.Live</button>
@@ -169,12 +177,13 @@
@@ -169,12 +177,13 @@
</div>
</div>
<!-- TTS -->
<!-- TTS -->
- <div class="section" id="sec-tts">
+ <div class="section admin-clarity" id="sec-tts">
+ <p class="admin-intro">These are the services that read text aloud. Check this panel when reviewing available voices or diagnosing speech playback.</p>
<div id="tts-content"><div class="loading">Loading...</div></div>
<div id="tts-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Cashouts: decided in OpenVibe.Billing's staff console (Billing is the only money ledger since 2026-10-17) -->
<!-- Cashouts: decided in OpenVibe.Billing's staff console (Billing is the only money ledger since 2026-10-17) -->
- <div class="section" id="sec-cashouts">
+ <div class="section admin-clarity" id="sec-cashouts">
<div class="card" style="max-width:760px">
<div class="card" style="max-width:760px">
<h3><i class="fa-solid fa-money-bill-transfer" aria-hidden="true"></i> Cashouts are decided in OpenVibe.Billing</h3>
<h3><i class="fa-solid fa-money-bill-transfer" aria-hidden="true"></i> Cashouts are decided in OpenVibe.Billing</h3>
<p class="muted" style="margin-bottom:8px">Billing holds every creator's balance. Its staff console lists cashouts still in escrow, ready to pay, paid and denied. Approving one records the payout reference; denying one puts the amount back in the creator's balance. The money freeze is there too.</p>
<p class="muted" style="margin-bottom:8px">Billing holds every creator's balance. Its staff console lists cashouts still in escrow, ready to pay, paid and denied. Approving one records the payout reference; denying one puts the amount back in the creator's balance. The money freeze is there too.</p>
@@ -187,7 +196,8 @@
@@ -187,7 +196,8 @@
</div>
</div>
<!-- Payments: the switches and prices Live reads; provider webhooks and credentials are OpenVibe.Billing's -->
<!-- Payments: the switches and prices Live reads; provider webhooks and credentials are OpenVibe.Billing's -->
- <div class="section" id="sec-payments">
+ <div class="section admin-clarity" id="sec-payments">
+ <p class="admin-intro">These settings control the payment choices and prices Live shows. Review them before offering paid features; Billing handles the payment itself and any refunds.</p>
<div id="payments-content"><div class="loading">Loading...</div></div>
<div id="payments-content"><div class="loading">Loading...</div></div>
</div>
</div>
@@ -200,53 +210,57 @@
@@ -200,53 +210,57 @@
</div>
</div>
<!-- Pastes -->
<!-- Pastes -->
- <div class="section" id="sec-pastes">
+ <div class="section admin-clarity" id="sec-pastes">
+ <p class="admin-intro">These are the limits and maintenance tools for shared text and image pastes. Check limits when reviewing capacity, and delete copies only when you intend permanent removal.</p>
<div id="pastes-content"><div class="loading">Loading...</div></div>
<div id="pastes-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Storage -->
<!-- Storage -->
- <div class="section" id="sec-storage">
+ <div class="section admin-clarity" id="sec-storage">
+ <p class="admin-intro">This shows how much space recordings and other files use. Review it when storage runs low; deleting recordings is permanent and requires a separate backup to recover.</p>
<div id="storage-content"><div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div></div>
<div id="storage-content"><div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div></div>
</div>
</div>
<!-- Notifications (Broadcast) -->
<!-- Notifications (Broadcast) -->
- <div class="section" id="sec-notifications">
+ <div class="section admin-clarity" id="sec-notifications">
+ <p class="admin-intro">Broadcasts send a notification to every user. Use them for network-wide announcements; sending cannot be recalled here, so send a correction if needed.</p>
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-bullhorn"></i> Broadcast Notification</h3>
<h3><i class="fa-solid fa-bullhorn"></i> Broadcast Notification</h3>
- <p class="muted" style="margin-bottom:16px">Send a notification to all users across the OpenVibe.</p>
+ <p class="muted" style="margin-bottom:16px">This sends the title and message to every user across OpenVibe. Review the wording and destination before sending; there is no recall control here.</p>
<div class="form-field">
<div class="form-field">
<label>Title</label>
<label>Title</label>
- <input type="text" id="broadcast-title" placeholder="Notification title...">
+ <span class="admin-field"><input type="text" id="broadcast-title" aria-label="Notification title" aria-describedby="help-broadcast-title" placeholder="Notification title..."><span class="admin-help" id="help-broadcast-title"><strong>Notification title.</strong> The title everyone will see; required with no default. For example: Scheduled maintenance.</span></span>
</div>
</div>
<div class="form-field">
<div class="form-field">
<label>Message</label>
<label>Message</label>
- <textarea id="broadcast-msg" rows="3" placeholder="Message body..." style="resize:vertical"></textarea>
+ <span class="admin-field"><textarea id="broadcast-msg" aria-label="Notification message" aria-describedby="help-broadcast-msg" rows="3" placeholder="Message body..." style="resize:vertical"></textarea><span class="admin-help" id="help-broadcast-msg"><strong>Notification message.</strong> The announcement body; required with no default. For example: We will be back at 18:00 UTC.</span></span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
<div class="form-field">
<div class="form-field">
<label>Icon (emoji)</label>
<label>Icon (emoji)</label>
- <input type="text" id="broadcast-icon" value="📢" style="text-align:center">
+ <span class="admin-field"><input type="text" id="broadcast-icon" aria-label="Notification icon" aria-describedby="help-broadcast-icon" value="📢" style="text-align:center"><span class="admin-help" id="help-broadcast-icon"><strong>Notification icon.</strong> The default is the announcement emoji. Use one recognizable emoji, for example 📢.</span></span>
</div>
</div>
<div class="form-field">
<div class="form-field">
<label>Priority</label>
<label>Priority</label>
- <select id="broadcast-priority">
- <option value="normal">Normal</option>
- <option value="high">High</option>
- <option value="critical">Critical</option>
- </select>
+ <span class="admin-field"><select id="broadcast-priority" aria-label="How urgent is this?" aria-describedby="help-broadcast-priority">
+ <option value="normal">Normal — routine announcement</option>
+ <option value="high">High — needs attention</option>
+ <option value="critical">Critical — urgent issue</option>
+ </select><span class="admin-help" id="help-broadcast-priority"><strong>How urgent is this?</strong> Normal is the default for routine news; High calls for attention, and Critical marks an urgent issue. Choose Critical only when everyone needs to know promptly.</span></span>
</div>
</div>
<div class="form-field">
<div class="form-field">
<label>URL (optional)</label>
<label>URL (optional)</label>
- <input type="text" id="broadcast-url" placeholder="https://...">
+ <span class="admin-field"><input type="text" id="broadcast-url" aria-label="Link to open" aria-describedby="help-broadcast-url" placeholder="https://..."><span class="admin-help" id="help-broadcast-url"><strong>Link to open.</strong> Optional destination when someone opens the notification; blank means no destination link. For example: https://openvibe.network.</span></span>
</div>
</div>
</div>
</div>
- <button class="btn btn-primary" onclick="sendBroadcast()"><i class="fa-solid fa-paper-plane"></i> Send Broadcast</button>
+ <button class="btn btn-primary" onclick="sendBroadcast()"><i class="fa-solid fa-paper-plane"></i> Send to everyone</button><p class="admin-help">This delivers the notification to all users. It cannot be recalled here; send a correction if needed.</p>
<p class="muted" style="margin-top:10px;font-size:0.85rem"><i class="fa-solid fa-clock"></i> Admins can send one global notification every 6 hours. Owners are exempt.</p>
<p class="muted" style="margin-top:10px;font-size:0.85rem"><i class="fa-solid fa-clock"></i> Admins can send one global notification every 6 hours. Owners are exempt.</p>
</div>
</div>
</div>
</div>
<!-- Email -->
<!-- Email -->
- <div class="section" id="sec-email">
+ <div class="section admin-clarity" id="sec-email">
+ <p class="admin-intro">Network sends recovery and notification emails through Resend. Change the sender or delivery switch when configuring email, then send a test to check it.</p>
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-envelope"></i> Email Configuration</h3>
<h3><i class="fa-solid fa-envelope"></i> Email Configuration</h3>
<div id="email-content"><div class="loading">Loading...</div></div>
<div id="email-content"><div class="loading">Loading...</div></div>
@@ -254,32 +268,35 @@
@@ -254,32 +268,35 @@
</div>
</div>
<!-- Verification Keys -->
<!-- Verification Keys -->
- <div class="section" id="sec-vkeys">
+ <div class="section admin-clarity" id="sec-vkeys">
+ <p class="admin-intro">Account reservation keys let a person claim a reserved username. Generate one when arranging a reservation; revoking it is permanent, so create a new key if the person still needs access.</p>
<div class="input-row">
<div class="input-row">
- <input type="text" id="vkey-username" placeholder="Username to reserve..." onkeydown="if(event.key==='Enter')generateVKey()">
- <input type="text" id="vkey-note" placeholder="Note (optional)">
- <button class="btn btn-primary" onclick="generateVKey()"><i class="fa-solid fa-key"></i> Generate Key</button>
+ <span class="admin-field"><input type="text" id="vkey-username" aria-label="Username to reserve" aria-describedby="help-vkey-username" placeholder="Username to reserve..." onkeydown="if(event.key==='Enter')generateVKey()"><span class="admin-help" id="help-vkey-username"><strong>Username to reserve.</strong> Enter the username the recipient will claim, for example alex. No name is selected by default.</span></span>
+ <span class="admin-field"><input type="text" id="vkey-note" aria-label="Reservation note" aria-describedby="help-vkey-note" placeholder="Note (optional)"><span class="admin-help" id="help-vkey-note"><strong>Reservation note.</strong> Optional note for staff, for example reserved for the event host. Blank adds no note.</span></span>
+ <button class="btn btn-primary" onclick="generateVKey()"><i class="fa-solid fa-key"></i> Generate a reservation key</button>
</div>
</div>
<div id="vkeys-content"><div class="loading">Loading...</div></div>
<div id="vkeys-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Audit Log -->
<!-- Audit Log -->
- <div class="section" id="sec-audit">
+ <div class="section admin-clarity" id="sec-audit">
+ <p class="admin-intro">This is the history of administrative changes. Review it after an unexpected change, then use the relevant settings page to correct it.</p>
<div id="audit-content"><div class="loading">Loading...</div></div>
<div id="audit-content"><div class="loading">Loading...</div></div>
<div id="audit-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div>
<div id="audit-pager" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:12px"></div>
</div>
</div>
<!-- Moderation log (ADR-022): every staff action any service reports, from /api/v1/staff/moderation-audit -->
<!-- Moderation log (ADR-022): every staff action any service reports, from /api/v1/staff/moderation-audit -->
- <div class="section" id="sec-modlog">
+ <div class="section admin-clarity" id="sec-modlog">
+ <p class="admin-intro">This records staff moderation actions across services. Filter it when investigating who acted and why; viewing or exporting the log changes no account permissions.</p>
<p class="muted" style="margin:0 0 10px">Staff actions reported by Chat, Live, Community, Tips, Billing and the other services (Tools, Games, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, Codes). Each service enforces its own moderation; this is the record.</p>
<p class="muted" style="margin:0 0 10px">Staff actions reported by Chat, Live, Community, Tips, Billing and the other services (Tools, Games, Wiki, Blog, News, Reviews, Deals, Coupons, Trade, Codes). Each service enforces its own moderation; this is the record.</p>
<form id="modlog-filters" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px" onsubmit="event.preventDefault(); loadModLog(true)">
<form id="modlog-filters" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px" onsubmit="event.preventDefault(); loadModLog(true)">
<label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service
<label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service
- <select id="modlog-service" onchange="loadModLog(true)"><option value="">All</option><option value="chat">Chat</option><option value="live">Live</option><option value="community">Community</option><option value="tips">Tips</option><option value="billing">Billing</option><option value="blog">Blog</option><option value="codes">Codes</option><option value="coupons">Coupons</option><option value="deals">Deals</option><option value="games">Games</option><option value="media">Media</option><option value="news">News</option><option value="reviews">Reviews</option><option value="tools">Tools</option><option value="trade">Trade</option><option value="wiki">Wiki</option></select></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Action<input id="modlog-action" placeholder="e.g. site_ban" maxlength="64" style="width:130px"></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">By (subject)<input id="modlog-actor" placeholder="usr_…" maxlength="64" style="width:150px"></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Target<input id="modlog-target" placeholder="subject or id" maxlength="200" style="width:150px"></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">From<input id="modlog-since" type="date"></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Until<input id="modlog-until" type="date"></label>
+ <span class="admin-field"><select id="modlog-service" aria-label="Service to review" aria-describedby="help-modlog-service" onchange="loadModLog(true)"><option value="">All</option><option value="chat">Chat</option><option value="live">Live</option><option value="community">Community</option><option value="tips">Tips</option><option value="billing">Billing</option><option value="blog">Blog</option><option value="codes">Codes</option><option value="coupons">Coupons</option><option value="deals">Deals</option><option value="games">Games</option><option value="media">Media</option><option value="news">News</option><option value="reviews">Reviews</option><option value="tools">Tools</option><option value="trade">Trade</option><option value="wiki">Wiki</option></select><span class="admin-help" id="help-modlog-service"><strong>Service to review.</strong> All services are shown by default. Select Live, for example, to review only actions from that service.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Action<span class="admin-field"><input id="modlog-action" aria-label="Action to find" aria-describedby="help-modlog-action" placeholder="e.g. site_ban" maxlength="64" style="width:130px"><span class="admin-help" id="help-modlog-action"><strong>Action to find.</strong> Blank includes all actions. Enter an exact action name, for example site_ban for a site ban.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">By (subject)<span class="admin-field"><input id="modlog-actor" aria-label="Staff account ID" aria-describedby="help-modlog-actor" placeholder="usr_…" maxlength="64" style="width:150px"><span class="admin-help" id="help-modlog-actor"><strong>Staff account ID.</strong> Blank includes all staff. Enter the stable account ID, for example a usr_ identifier from the record you are investigating.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Target<span class="admin-field"><input id="modlog-target" aria-label="Affected account or item" aria-describedby="help-modlog-target" placeholder="subject or id" maxlength="200" style="width:150px"><span class="admin-help" id="help-modlog-target"><strong>Affected account or item.</strong> Blank includes all targets. Enter the account or item ID from the report.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">From<span class="admin-field"><input id="modlog-since" aria-label="Start date" aria-describedby="help-modlog-since" type="date"><span class="admin-help" id="help-modlog-since"><strong>Start date.</strong> Blank sets no starting date. Choose an earlier date to include more history.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Until<span class="admin-field"><input id="modlog-until" aria-label="End date" aria-describedby="help-modlog-until" type="date"><span class="admin-help" id="help-modlog-until"><strong>End date.</strong> Blank sets no ending date. Choose a later date to include more history.</span></span></label>
<button class="btn" type="submit">Filter</button>
<button class="btn" type="submit">Filter</button>
<button class="btn" type="button" onclick="exportModLog()">Export CSV</button>
<button class="btn" type="button" onclick="exportModLog()">Export CSV</button>
</form>
</form>
@@ -288,31 +305,33 @@
@@ -288,31 +305,33 @@
</div>
</div>
<!-- Operator parity checklist (WS-D task 5): where each operator job is done, and what has no console yet -->
<!-- Operator parity checklist (WS-D task 5): where each operator job is done, and what has no console yet -->
- <div class="section" id="sec-checklist">
+ <div class="section admin-clarity" id="sec-checklist">
+ <p class="admin-intro">This checklist shows whether key operational areas are ready. Open the linked page when an item needs attention; a missing console means the listed command or API is the available route.</p>
<p class="muted" style="margin:0 0 10px">Every operator job the old admin did, and where it is done now: a tab here, another service's console, the host CLI, or an API that has no console yet (shown, not hidden). The dot is that service's readiness as the status page last saw it.</p>
<p class="muted" style="margin:0 0 10px">Every operator job the old admin did, and where it is done now: a tab here, another service's console, the host CLI, or an API that has no console yet (shown, not hidden). The dot is that service's readiness as the status page last saw it.</p>
<div id="checklist-content"><div class="loading">Loading...</div></div>
<div id="checklist-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Community theme review (WS-E task 2): submissions stay private until approved here -->
<!-- Community theme review (WS-E task 2): submissions stay private until approved here -->
- <div class="section" id="sec-themes">
+ <div class="section admin-clarity" id="sec-themes"><p class="admin-intro">These themes are waiting for staff review. Approve a suitable theme to publish it, or reject it with a reason so its author can fix and resubmit it.</p>
<p class="muted" style="margin:0 0 10px">Community themes waiting for review. Approving one puts it in the public catalog; rejecting needs a reason, which the author sees. Values are already limited to the shared colour tokens.</p>
<p class="muted" style="margin:0 0 10px">Community themes waiting for review. Approving one puts it in the public catalog; rejecting needs a reason, which the author sees. Values are already limited to the shared colour tokens.</p>
<div id="themes-review-content"><div class="loading">Loading...</div></div>
<div id="themes-review-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Service grants (WS-D task 3): what each service principal may do, owner-only and audited -->
<!-- Service grants (WS-D task 3): what each service principal may do, owner-only and audited -->
- <div class="section" id="sec-grants">
+ <div class="section admin-clarity" id="sec-grants">
+ <p class="admin-intro">These permissions let one OpenVibe service call another. Add one only for a required integration; revoke it to stop that access, or grant it again to restore access.</p>
<p class="muted" style="margin:0 0 10px">What each service may ask other services for. Tokens carry a change within 5 minutes. Every change needs a reason, is audited below and is announced as <code>network.principal_grant.changed</code>. Defaults come from the code.</p>
<p class="muted" style="margin:0 0 10px">What each service may ask other services for. Tokens carry a change within 5 minutes. Every change needs a reason, is audited below and is announced as <code>network.principal_grant.changed</code>. Defaults come from the code.</p>
<form id="grant-form" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:12px" onsubmit="event.preventDefault(); submitGrant()">
<form id="grant-form" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:12px" onsubmit="event.preventDefault(); submitGrant()">
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service<input id="grant-client" placeholder="live" maxlength="64" style="width:110px" required></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Capability<input id="grant-cap" placeholder="network.staff.read" maxlength="120" style="width:210px" required></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Namespaces<input id="grant-ns" placeholder="comma separated (optional)" maxlength="400" style="width:180px"></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Expires<input id="grant-exp" type="date"></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Reason<input id="grant-reason" maxlength="500" style="width:220px" required></label>
- <button class="btn" type="submit">Grant</button>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Service<span class="admin-field"><input id="grant-client" aria-label="Service receiving access" aria-describedby="help-grant-client" placeholder="live" maxlength="64" style="width:110px" required><span class="admin-help" id="help-grant-client"><strong>Service receiving access.</strong> Enter the service ID, for example live; required with no default. This is the service that will be allowed to call the API.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Capability<span class="admin-field"><input id="grant-cap" aria-label="Permission to grant" aria-describedby="help-grant-cap" placeholder="network.staff.read" maxlength="120" style="width:210px" required><span class="admin-help" id="help-grant-cap"><strong>Permission to grant.</strong> Enter the exact permission name, for example network.staff.read; required with no default. Granting it enables that operation until expiry or revocation.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Namespaces<span class="admin-field"><input id="grant-ns" aria-label="Allowed areas" aria-describedby="help-grant-ns" placeholder="comma separated (optional)" maxlength="400" style="width:180px"><span class="admin-help" id="help-grant-ns"><strong>Allowed areas.</strong> Optional comma-separated areas to restrict this permission, for example a namespace required by the integration. Blank adds no namespace restriction.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Expires<span class="admin-field"><input id="grant-exp" aria-label="Access expiry date" aria-describedby="help-grant-exp" type="date"><span class="admin-help" id="help-grant-exp"><strong>Access expiry date.</strong> Blank means no expiry date. Access ends at midnight UTC on the selected date; an earlier date grants access for less time.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Reason<span class="admin-field"><input id="grant-reason" aria-label="Why access is needed" aria-describedby="help-grant-reason" maxlength="500" style="width:220px" required><span class="admin-help" id="help-grant-reason"><strong>Why access is needed.</strong> Required explanation for the audit history, for example enable staff lookup for Live. No reason is filled in by default.</span></span></label>
+ <button class="btn" type="submit">Grant access</button>
</form>
</form>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<label for="grant-filter" class="muted" style="font-size:12px">Show</label>
<label for="grant-filter" class="muted" style="font-size:12px">Show</label>
- <input id="grant-filter" placeholder="service" maxlength="64" style="width:120px" oninput="renderGrants()">
+ <span class="admin-field"><input id="grant-filter" aria-label="Find a service" aria-describedby="help-grant-filter" placeholder="service" maxlength="64" style="width:120px" oninput="renderGrants()"><span class="admin-help" id="help-grant-filter"><strong>Find a service.</strong> Blank shows every service permission. Enter live, for example, to narrow the list.</span></span>
</div>
</div>
<div id="grants-content"><div class="loading">Loading...</div></div>
<div id="grants-content"><div class="loading">Loading...</div></div>
<h3 style="margin:18px 0 8px;font-size:14px">Changes</h3>
<h3 style="margin:18px 0 8px;font-size:14px">Changes</h3>
@@ -320,20 +339,22 @@
@@ -320,20 +339,22 @@
</div>
</div>
<!-- Event deliveries (WS-F task 3): OpenVibe.Events' dead-letter queue and replays -->
<!-- Event deliveries (WS-F task 3): OpenVibe.Events' dead-letter queue and replays -->
- <div class="section" id="sec-eventsops">
+ <div class="section admin-clarity" id="sec-eventsops">
+ <p class="admin-intro">These are event messages that have not reached a subscribed service. Fix the delivery problem before retrying; replay sends another delivery and cannot be recalled once sent.</p>
<p class="muted" style="margin:0 0 10px">Deliveries OpenVibe.Events could not make. A dead delivery stopped retrying; replay it once its consumer is fixed. Replays are audited.</p>
<p class="muted" style="margin:0 0 10px">Deliveries OpenVibe.Events could not make. A dead delivery stopped retrying; replay it once its consumer is fixed. Replays are audited.</p>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px">
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin-bottom:10px">
<label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Status
<label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Status
- <select id="dlq-status" onchange="loadDeliveries()"><option value="dead">Dead</option><option value="failed">Failing (retrying)</option><option value="pending">Pending</option></select></label>
- <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Subscription<input id="dlq-sub" placeholder="sub_…" maxlength="40" style="width:240px"></label>
- <button class="btn" type="button" onclick="loadDeliveries()">Show</button>
+ <span class="admin-field"><select id="dlq-status" aria-label="Delivery state" aria-describedby="help-dlq-status" onchange="loadDeliveries()"><option value="dead">Stopped retrying — needs attention</option><option value="failed">Retrying — delivery keeps failing</option><option value="pending">Waiting — delivery is queued</option></select><span class="admin-help" id="help-dlq-status"><strong>Delivery state.</strong> The default shows deliveries that stopped retrying. Choose Retrying to investigate ongoing failures, or Waiting for messages still queued.</span></span></label>
+ <label class="muted" style="font-size:12px;display:flex;flex-direction:column;gap:2px">Subscription<span class="admin-field"><input id="dlq-sub" aria-label="Subscription ID" aria-describedby="help-dlq-sub" placeholder="sub_…" maxlength="40" style="width:240px"><span class="admin-help" id="help-dlq-sub"><strong>Subscription ID.</strong> Blank includes all subscriptions. Enter the sub_ identifier of a receiving service to narrow the list.</span></span></label>
+ <button class="btn" type="button" onclick="loadDeliveries()">Show deliveries</button>
</div>
</div>
<div id="dlq-counts" class="muted" style="font-size:12px;margin-bottom:8px"></div>
<div id="dlq-counts" class="muted" style="font-size:12px;margin-bottom:8px"></div>
<div id="dlq-content"><div class="loading">Loading...</div></div>
<div id="dlq-content"><div class="loading">Loading...</div></div>
</div>
</div>
<!-- Analytics -->
<!-- Analytics -->
- <div class="section" id="sec-analytics">
+ <div class="section admin-clarity" id="sec-analytics">
+ <p class="admin-intro">These charts count visits, requests, errors, and response times. Choose a time range or service when investigating a trend; a longer range shows more history and a shorter range highlights recent changes.</p>
<div class="sub-tabs" id="analytics-sub-tabs">
<div class="sub-tabs" id="analytics-sub-tabs">
<button class="active" onclick="showAnalyticsSubTab('overview',this)">Overview</button>
<button class="active" onclick="showAnalyticsSubTab('overview',this)">Overview</button>
<button onclick="showAnalyticsSubTab('live',this)">OpenVibe.Live</button>
<button onclick="showAnalyticsSubTab('live',this)">OpenVibe.Live</button>
@@ -344,7 +365,7 @@
@@ -344,7 +365,7 @@
<button onclick="showAnalyticsSubTab('bots',this)">Bot Analysis</button>
<button onclick="showAnalyticsSubTab('bots',this)">Bot Analysis</button>
</div>
</div>
<div class="input-row" style="margin-bottom:16px">
<div class="input-row" style="margin-bottom:16px">
- <select id="analytics-period" onchange="reloadAnalytics()" style="max-width:180px">
+ <span class="admin-field"><select id="analytics-period" aria-label="Time range" aria-describedby="help-analytics-period" onchange="reloadAnalytics()" style="max-width:180px">
<option value="1h">Last hour</option>
<option value="1h">Last hour</option>
<option value="12h">Last 12 hours</option>
<option value="12h">Last 12 hours</option>
<option value="24h">Last 24 hours</option>
<option value="24h">Last 24 hours</option>
@@ -354,7 +375,7 @@
@@ -354,7 +375,7 @@
<option value="30" selected>Last 30 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="90">Last 90 days</option>
<option value="365">Last year</option>
<option value="365">Last year</option>
- </select>
+ </select><span class="admin-help" id="help-analytics-period"><strong>Time range.</strong> The default is the last 30 days. Longer ranges show more history and shorter ranges highlight recent changes; for example choose Last hour for a current incident.</span></span>
<button class="btn btn-outline" onclick="reloadAnalytics()"><i class="fa-solid fa-rotate"></i> Refresh</button>
<button class="btn btn-outline" onclick="reloadAnalytics()"><i class="fa-solid fa-rotate"></i> Refresh</button>
</div>
</div>
@@ -363,60 +384,60 @@
@@ -363,60 +384,60 @@
<div id="analytics-insights"></div>
<div id="analytics-insights"></div>
<div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
<div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
<div class="card" id="analytics-realtime-card" style="display:none">
<div class="card" id="analytics-realtime-card" style="display:none">
- <h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3>
+ <h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3><p class="admin-intro">These counts cover requests in the last five minutes. Check them during an incident; higher request counts mean more activity and higher error counts mean more failures.</p>
<div id="analytics-realtime"></div>
<div id="analytics-realtime"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-chart-area"></i> Traffic Trend</h3>
+ <h3><i class="fa-solid fa-chart-area"></i> Traffic Trend</h3><p class="admin-intro">This plots requests over the selected time range. Use a shorter range to inspect a spike or a longer range to compare activity across days.</p>
<canvas id="chart-traffic-trend" height="260"></canvas>
<canvas id="chart-traffic-trend" height="260"></canvas>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-ranking-star"></i> Service Comparison</h3>
+ <h3><i class="fa-solid fa-ranking-star"></i> Service Comparison</h3><p class="admin-intro">This compares traffic across services. Review it when deciding which service needs more capacity or investigation.</p>
<canvas id="chart-service-compare" height="260"></canvas>
<canvas id="chart-service-compare" height="260"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-users"></i> Unique Visitors Trend</h3>
+ <h3><i class="fa-solid fa-users"></i> Unique Visitors Trend</h3><p class="admin-intro">This plots estimated distinct visitors over time. Compare periods to see audience changes; larger counts mean more visitors, not necessarily more registered accounts.</p>
<canvas id="chart-visitors-trend" height="240"></canvas>
<canvas id="chart-visitors-trend" height="240"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-user-check"></i> Logged-in vs Anonymous</h3>
+ <h3><i class="fa-solid fa-user-check"></i> Logged-in vs Anonymous</h3><p class="admin-intro">This compares visits with and without a signed-in account. Review it when checking how much activity comes from members versus visitors.</p>
<canvas id="chart-auth-trend" height="240"></canvas>
<canvas id="chart-auth-trend" height="240"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-chart-column"></i> Per-Service Traffic Share</h3>
+ <h3><i class="fa-solid fa-chart-column"></i> Per-Service Traffic Share</h3><p class="admin-intro">This compares each service’s portion of recorded traffic. Use it to identify where most requests go before changing capacity.</p>
<canvas id="chart-service-share" height="260"></canvas>
<canvas id="chart-service-share" height="260"></canvas>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-chart-column"></i> Per-Service Visitors</h3>
+ <h3><i class="fa-solid fa-chart-column"></i> Per-Service Visitors</h3><p class="admin-intro">This compares estimated distinct visitors by service. Review it when checking audience size; a person visiting multiple services may be counted in each.</p>
<canvas id="chart-service-visitors" height="260"></canvas>
<canvas id="chart-service-visitors" height="260"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-layer-group"></i> Per-Service Traffic Over Time</h3>
+ <h3><i class="fa-solid fa-layer-group"></i> Per-Service Traffic Over Time</h3><p class="admin-intro">This plots each service’s traffic across the selected range. Review it when a network-wide spike needs tracing to one service.</p>
<canvas id="chart-per-service-trend" height="280"></canvas>
<canvas id="chart-per-service-trend" height="280"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-robot"></i> Bots vs Humans</h3>
+ <h3><i class="fa-solid fa-robot"></i> Bots vs Humans</h3><p class="admin-intro">This separates requests classified as automated from those classified as human. Check it when traffic rises unexpectedly; the classification is an estimate.</p>
<canvas id="chart-bot-ratio" height="220"></canvas>
<canvas id="chart-bot-ratio" height="220"></canvas>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-network-wired"></i> Bandwidth & Usage</h3>
+ <h3><i class="fa-solid fa-network-wired"></i> Bandwidth & Usage</h3><p class="admin-intro">This estimates data transferred and request volume. Check it when planning capacity; more bytes mean more network use, not a confirmed bill.</p>
<div id="analytics-bandwidth"></div>
<div id="analytics-bandwidth"></div>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-server"></i> Per-Service Traffic</h3>
+ <h3><i class="fa-solid fa-server"></i> Per-Service Traffic</h3><p class="admin-intro">This lists request totals and errors for each service. Review services with more errors before changing their configuration.</p>
<div id="analytics-service-table"></div>
<div id="analytics-service-table"></div>
</div>
</div>
</div>
</div>
@@ -427,75 +448,75 @@
@@ -427,75 +448,75 @@
<div class="stats-grid" id="svc-analytics-stats"><div class="loading">Loading...</div></div>
<div class="stats-grid" id="svc-analytics-stats"><div class="loading">Loading...</div></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-chart-line"></i> Daily Traffic</h3>
+ <h3><i class="fa-solid fa-chart-line"></i> Daily Traffic</h3><p class="admin-intro">This groups recorded traffic by day. Use it to compare daily activity over the selected period.</p>
<canvas id="chart-svc-daily" height="260"></canvas>
<canvas id="chart-svc-daily" height="260"></canvas>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-clock"></i> <span id="svc-hourly-title">Hourly (24h)</span></h3>
+ <h3><i class="fa-solid fa-clock"></i> <span id="svc-hourly-title">Hourly (24h)</span></h3><p class="admin-intro">This groups the last day of traffic by hour. Review it when locating a recent spike; taller bars mean more recorded requests.</p>
<canvas id="chart-svc-hourly" height="260"></canvas>
<canvas id="chart-svc-hourly" height="260"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-gauge-high"></i> Response Time Percentiles</h3>
+ <h3><i class="fa-solid fa-gauge-high"></i> Response Time Percentiles</h3><p class="admin-intro">This shows response times in milliseconds, including the time within which 95 percent of requests finish (p95). Check it when a service feels slow; higher times mean longer waits.</p>
<div id="svc-response-percentiles"></div>
<div id="svc-response-percentiles"></div>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-clock-rotate-left"></i> Peak Hours</h3>
+ <h3><i class="fa-solid fa-clock-rotate-left"></i> Peak Hours</h3><p class="admin-intro">This shows which hours receive the most traffic. Use it when choosing maintenance windows or planning capacity.</p>
<canvas id="chart-svc-peak-hours" height="200"></canvas>
<canvas id="chart-svc-peak-hours" height="200"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-laptop"></i> Devices</h3>
+ <h3><i class="fa-solid fa-laptop"></i> Devices</h3><p class="admin-intro">This groups traffic by device type. Review it when deciding which screen sizes need attention.</p>
<canvas id="chart-svc-devices" height="220"></canvas>
<canvas id="chart-svc-devices" height="220"></canvas>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-globe"></i> Browsers</h3>
+ <h3><i class="fa-solid fa-globe"></i> Browsers</h3><p class="admin-intro">This groups traffic by browser. Review it when investigating a browser-specific problem.</p>
<canvas id="chart-svc-browsers" height="220"></canvas>
<canvas id="chart-svc-browsers" height="220"></canvas>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-desktop"></i> OS</h3>
+ <h3><i class="fa-solid fa-desktop"></i> OS</h3><p class="admin-intro">This groups traffic by operating system. Review it when investigating a platform-specific problem.</p>
<canvas id="chart-svc-os" height="220"></canvas>
<canvas id="chart-svc-os" height="220"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-network-wired"></i> Bandwidth & Sessions</h3>
+ <h3><i class="fa-solid fa-network-wired"></i> Bandwidth & Sessions</h3><p class="admin-intro">This shows estimated transferred data and browsing sessions. Review it when planning capacity; more bytes or sessions mean more use.</p>
<div id="svc-bandwidth-sessions"></div>
<div id="svc-bandwidth-sessions"></div>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-user-check"></i> Auth vs Anonymous</h3>
+ <h3><i class="fa-solid fa-user-check"></i> Auth vs Anonymous</h3><p class="admin-intro">This compares signed-in requests with requests from visitors without a session. Review it when investigating sign-in or audience patterns.</p>
<canvas id="chart-svc-auth" height="200"></canvas>
<canvas id="chart-svc-auth" height="200"></canvas>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-link"></i> Top Pages</h3>
+ <h3><i class="fa-solid fa-link"></i> Top Pages</h3><p class="admin-intro">These pages received the most visits during the selected period. Review them when prioritizing content or investigating unusual traffic.</p>
<div id="svc-top-pages"></div>
<div id="svc-top-pages"></div>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-bolt"></i> Top API Endpoints</h3>
+ <h3><i class="fa-solid fa-bolt"></i> Top API Endpoints</h3><p class="admin-intro">These service operations received the most requests. Review them when investigating load; more requests mean more calls, not necessarily more people.</p>
<div id="svc-top-api"></div>
<div id="svc-top-api"></div>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-arrow-up-right-from-square"></i> Top Referers</h3>
+ <h3><i class="fa-solid fa-arrow-up-right-from-square"></i> Top Referers</h3><p class="admin-intro">These are the websites that referred visitors here. Review them when checking traffic sources; some browsers do not send a referring address.</p>
<div id="svc-top-referers"></div>
<div id="svc-top-referers"></div>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-gauge"></i> Slowest Endpoints</h3>
+ <h3><i class="fa-solid fa-gauge"></i> Slowest Endpoints</h3><p class="admin-intro">These service operations had the longest response times in milliseconds. Review them during performance investigations; higher times mean slower responses.</p>
<div id="svc-slowest"></div>
<div id="svc-slowest"></div>
</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-circle-exclamation"></i> Status Codes</h3>
+ <h3><i class="fa-solid fa-circle-exclamation"></i> Status Codes</h3><p class="admin-intro">These counts group server responses by HTTP result: 2xx succeeded, 3xx redirected, 4xx could not accept the request, and 5xx failed at the server. Review rising error counts when investigating reliability.</p>
<canvas id="chart-svc-status" height="200"></canvas>
<canvas id="chart-svc-status" height="200"></canvas>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-earth-americas"></i> Countries</h3>
+ <h3><i class="fa-solid fa-earth-americas"></i> Countries</h3><p class="admin-intro">These estimates group visits by country supplied by the edge service. Review them when understanding reach; missing country information does not mean no traffic.</p>
<div id="svc-countries"></div>
<div id="svc-countries"></div>
</div>
</div>
</div>
</div>
@@ -508,7 +529,8 @@
@@ -508,7 +529,8 @@
</div>
</div>
<!-- Deploy -->
<!-- Deploy -->
- <div class="section" id="sec-deploy">
+ <div class="section admin-clarity" id="sec-deploy">
+ <p class="admin-intro">These tools manage website addresses, HTTPS certificates, and the web server. Change them when bringing a domain online or fixing routing, and review previews before applying changes.</p>
<div class="sub-tabs" id="deploy-sub-tabs">
<div class="sub-tabs" id="deploy-sub-tabs">
<button class="active" onclick="showDeploySubTab('overview',this)">Overview</button>
<button class="active" onclick="showDeploySubTab('overview',this)">Overview</button>
<button onclick="showDeploySubTab('certs',this)">Certificates</button>
<button onclick="showDeploySubTab('certs',this)">Certificates</button>
@@ -521,7 +543,7 @@
@@ -521,7 +543,7 @@
<div id="deploy-overview">
<div id="deploy-overview">
<div class="stats-grid" id="deploy-stats"><div class="loading">Loading deploy status...</div></div>
<div class="stats-grid" id="deploy-stats"><div class="loading">Loading deploy status...</div></div>
<div class="card" style="margin-top:16px">
<div class="card" style="margin-top:16px">
- <h3><i class="fa-solid fa-circle-check"></i> Prerequisites</h3>
+ <h3><i class="fa-solid fa-circle-check"></i> Prerequisites</h3><p class="admin-intro">These checks show whether certificate and web server tools are installed and configured. Fix a missing prerequisite before issuing certificates or applying web routing.</p>
<div id="deploy-prereqs"><div class="muted">Checking...</div></div>
<div id="deploy-prereqs"><div class="muted">Checking...</div></div>
</div>
</div>
</div>
</div>
@@ -530,24 +552,24 @@
@@ -530,24 +552,24 @@
<div id="deploy-certs-panel" style="display:none">
<div id="deploy-certs-panel" style="display:none">
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-lock"></i> SSL/TLS Certificates</h3>
<h3><i class="fa-solid fa-lock"></i> SSL/TLS Certificates</h3>
- <p class="muted" style="margin:8px 0 16px">Manage Let's Encrypt wildcard certificates for all domains.</p>
+ <p class="muted" style="margin:8px 0 16px">Certificates enable encrypted HTTPS connections for your domains. Check expiry dates when a certificate is near renewal or visitors see a security error; fewer remaining days means renewal is more urgent.</p>
<div id="deploy-certs-list"><div class="loading">Loading certificates...</div></div>
<div id="deploy-certs-list"><div class="loading">Loading certificates...</div></div>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card" style="margin-top:16px">
- <h3><i class="fa-solid fa-plus-circle"></i> Issue New Certificate</h3>
+ <h3><i class="fa-solid fa-plus-circle"></i> Issue New Certificate</h3><p class="admin-intro">This requests HTTPS certificates for a domain you control. Use it when adding a domain; renewal replaces expiring certificates, and issuance cannot be recalled from this page.</p>
<div class="form-group" style="margin:12px 0">
<div class="form-group" style="margin:12px 0">
<label>Domain</label>
<label>Domain</label>
- <input type="text" id="cert-issue-domain" placeholder="example.com" style="width:300px">
+ <span class="admin-field"><input type="text" id="cert-issue-domain" aria-label="Domain for the certificate" aria-describedby="help-cert-issue-domain" placeholder="example.com" style="width:300px"><span class="admin-help" id="help-cert-issue-domain"><strong>Domain for the certificate.</strong> No domain is selected by default. Enter a domain you control, for example example.com, after its DNS is ready.</span></span>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-primary" onclick="issueCertCloudflare()">
<button class="btn btn-primary" onclick="issueCertCloudflare()">
<i class="fa-brands fa-cloudflare"></i> Issue via Cloudflare DNS
<i class="fa-brands fa-cloudflare"></i> Issue via Cloudflare DNS
</button>
</button>
<button class="btn btn-secondary" onclick="showManualDnsInfo()">
<button class="btn btn-secondary" onclick="showManualDnsInfo()">
- <i class="fa-solid fa-hand"></i> Manual DNS Mode
+ <i class="fa-solid fa-hand"></i> Manual DNS
</button>
</button>
<button class="btn btn-secondary" onclick="renewAllCerts()">
<button class="btn btn-secondary" onclick="renewAllCerts()">
- <i class="fa-solid fa-rotate"></i> Renew All
+ <i class="fa-solid fa-rotate"></i> Renew all certificates
</button>
</button>
</div>
</div>
<div id="cert-issue-output" style="margin-top:12px"></div>
<div id="cert-issue-output" style="margin-top:12px"></div>
@@ -558,7 +580,7 @@
@@ -558,7 +580,7 @@
<div id="deploy-nginx-panel" style="display:none">
<div id="deploy-nginx-panel" style="display:none">
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-sitemap"></i> Nginx Configuration</h3>
<h3><i class="fa-solid fa-sitemap"></i> Nginx Configuration</h3>
- <p class="muted" style="margin:8px 0 16px">Preview and apply generated Nginx configs derived from your domain and service settings.</p>
+ <p class="muted" style="margin:8px 0 16px">Nginx directs web requests to the right service. Preview when changing domains, then validate before applying; Apply writes configuration and reloads the web server, and undo requires restoring the backup on the server.</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
<button class="btn btn-primary" onclick="previewNginxConfigs()">
<button class="btn btn-primary" onclick="previewNginxConfigs()">
<i class="fa-solid fa-eye"></i> Preview All Configs
<i class="fa-solid fa-eye"></i> Preview All Configs
@@ -567,7 +589,7 @@
@@ -567,7 +589,7 @@
<i class="fa-solid fa-check-circle"></i> Validate Current Config
<i class="fa-solid fa-check-circle"></i> Validate Current Config
</button>
</button>
<button class="btn btn-warning" onclick="applyNginxConfigs()">
<button class="btn btn-warning" onclick="applyNginxConfigs()">
- <i class="fa-solid fa-upload"></i> Apply & Reload
+ <i class="fa-solid fa-upload"></i> Apply configuration and reload
</button>
</button>
</div>
</div>
<div id="nginx-preview-output"></div>
<div id="nginx-preview-output"></div>
@@ -578,18 +600,18 @@
@@ -578,18 +600,18 @@
<div id="deploy-domains-panel" style="display:none">
<div id="deploy-domains-panel" style="display:none">
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-globe"></i> Managed Domains</h3>
<h3><i class="fa-solid fa-globe"></i> Managed Domains</h3>
- <p class="muted" style="margin:8px 0 16px">Configure which domains this instance manages for TLS and reverse proxy.</p>
+ <p class="muted" style="margin:8px 0 16px">These domains are included in certificate and web routing configuration. Add a domain when bringing it online, or remove it to stop managing it here; add it again to undo.</p>
<div id="deploy-domains-list"><div class="loading">Loading...</div></div>
<div id="deploy-domains-list"><div class="loading">Loading...</div></div>
<div style="margin-top:16px;display:flex;gap:8px;align-items:end;flex-wrap:wrap">
<div style="margin-top:16px;display:flex;gap:8px;align-items:end;flex-wrap:wrap">
<div class="form-group" style="margin:0">
<div class="form-group" style="margin:0">
<label>Add Domain</label>
<label>Add Domain</label>
- <input type="text" id="domain-add-input" placeholder="example.com" style="width:250px">
+ <span class="admin-field"><input type="text" id="domain-add-input" aria-label="Domain to manage" aria-describedby="help-domain-add-input" placeholder="example.com" style="width:250px"><span class="admin-help" id="help-domain-add-input"><strong>Domain to manage.</strong> No domain is selected by default. Enter the base domain, for example example.com; removing it later reverses this list change.</span></span>
</div>
</div>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary)">
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary)">
- <input type="checkbox" id="domain-add-wildcard" checked> Wildcard (*.domain)
+ <span class="admin-field"><input type="checkbox" id="domain-add-wildcard" aria-label="Include subdomains" aria-describedby="help-domain-add-wildcard" checked><span class="admin-help" id="help-domain-add-wildcard"><strong>Include subdomains.</strong> On by default, so the certificate also covers addresses such as app.example.com. Turn it off to request only the base domain.</span></span> Wildcard (*.domain)
</label>
</label>
<button class="btn btn-primary" onclick="addManagedDomain()">
<button class="btn btn-primary" onclick="addManagedDomain()">
- <i class="fa-solid fa-plus"></i> Add
+ <i class="fa-solid fa-plus"></i> Add domain
</button>
</button>
</div>
</div>
</div>
</div>
@@ -598,38 +620,38 @@
@@ -598,38 +620,38 @@
<!-- Deploy: Config -->
<!-- Deploy: Config -->
<div id="deploy-config-panel" style="display:none">
<div id="deploy-config-panel" style="display:none">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-gear"></i> Deploy Configuration</h3>
+ <h3><i class="fa-solid fa-gear"></i> Deploy Configuration</h3><p class="admin-intro">These settings choose how certificates and web routing are managed. Change them when setting up the server, using Preview only until the generated configuration has been checked.</p>
<div class="form-group" style="margin:12px 0">
<div class="form-group" style="margin:12px 0">
- <label>ACME / Let's Encrypt Email</label>
- <input type="email" id="deploy-acme-email" placeholder="[email protected]" style="width:350px">
+ <label for="deploy-acme-email">Certificate contact email</label>
+ <span class="admin-field"><input type="email" id="deploy-acme-email" aria-label="Certificate contact email" aria-describedby="help-deploy-acme-email" placeholder="[email protected]" style="width:350px"><span class="admin-help" id="help-deploy-acme-email">No contact address is configured by default. Enter an address you monitor, for example [email protected], for certificate account notices.</span></span>
</div>
</div>
<div class="form-group" style="margin:12px 0">
<div class="form-group" style="margin:12px 0">
- <label>Certificate Mode</label>
- <select id="deploy-cert-mode" style="width:250px">
- <option value="cloudflare">Cloudflare DNS (Automated)</option>
- <option value="manual">Manual DNS Challenge</option>
- <option value="none">None / External</option>
- </select>
+ <label for="deploy-cert-mode">How certificates are obtained</label>
+ <span class="admin-field"><select id="deploy-cert-mode" aria-label="How to obtain certificates" aria-describedby="help-deploy-cert-mode" style="width:250px">
+ <option value="cloudflare">Automatic — a Cloudflare token does the DNS step</option>
+ <option value="manual">Manual — you add the DNS record yourself</option>
+ <option value="none">External — managed outside Network</option>
+ </select><span class="admin-help" id="help-deploy-cert-mode">Manual DNS is the default and requires adding a verification record yourself. Cloudflare automates that step with a token; External means certificates are managed elsewhere.</span></span>
</div>
</div>
<div class="form-group" style="margin:12px 0" id="deploy-cf-token-group">
<div class="form-group" style="margin:12px 0" id="deploy-cf-token-group">
- <label>Cloudflare API Token <span class="muted">(Zone → DNS → Edit)</span></label>
- <input type="password" id="deploy-cf-token" placeholder="••••••••" style="width:400px">
+ <label for="deploy-cf-token">Cloudflare DNS token <span class="muted">(needs Zone → DNS → Edit)</span></label>
+ <span class="admin-field"><input type="password" id="deploy-cf-token" aria-label="Cloudflare DNS editing token" aria-describedby="help-deploy-cf-token" placeholder="••••••••" style="width:400px"><span class="admin-help" id="help-deploy-cf-token">No token is configured by default. Use a token with Zone → DNS → Edit for the managed domains; for example limit it to your website zone.</span></span>
<div class="muted" style="font-size:11px;margin-top:4px">Token with Zone:DNS:Edit permissions for all managed domains</div>
<div class="muted" style="font-size:11px;margin-top:4px">Token with Zone:DNS:Edit permissions for all managed domains</div>
</div>
</div>
<div class="form-group" style="margin:12px 0">
<div class="form-group" style="margin:12px 0">
- <label>Nginx Management Mode</label>
- <select id="deploy-nginx-mode" style="width:250px">
- <option value="preview">Preview Only</option>
- <option value="apply">Apply (Write + Validate + Reload)</option>
- <option value="disabled">Disabled</option>
- </select>
+ <label for="deploy-nginx-mode">How the web server is managed</label>
+ <span class="admin-field"><select id="deploy-nginx-mode" aria-label="How to manage the web server" aria-describedby="help-deploy-nginx-mode" style="width:250px">
+ <option value="preview">Preview only — nothing is written</option>
+ <option value="apply">Apply — write, check and reload the web server</option>
+ <option value="disabled">Disabled — do not manage Nginx</option>
+ </select><span class="admin-help" id="help-deploy-nginx-mode">Preview only is the default and writes nothing. Apply permits writing, checking, and reloading Nginx; Disabled turns management off, and restoring a backup on the server undoes applied changes.</span></span>
</div>
</div>
<div class="form-group" style="margin:12px 0">
<div class="form-group" style="margin:12px 0">
- <label>Nginx sites-enabled Path</label>
- <input type="text" id="deploy-nginx-path" placeholder="/etc/nginx/sites-enabled" style="width:400px">
+ <label for="deploy-nginx-path">Web server configuration folder</label>
+ <span class="admin-field"><input type="text" id="deploy-nginx-path" aria-label="Web server configuration folder" aria-describedby="help-deploy-nginx-path" placeholder="/etc/nginx/sites-enabled" style="width:400px"><span class="admin-help" id="help-deploy-nginx-path">The default is /etc/nginx/sites-enabled. Change it only if this server stores enabled website configuration in another folder.</span></span>
</div>
</div>
<button class="btn btn-primary" onclick="saveDeployConfig()" style="margin-top:8px">
<button class="btn btn-primary" onclick="saveDeployConfig()" style="margin-top:8px">
- <i class="fa-solid fa-save"></i> Save Deploy Config
+ <i class="fa-solid fa-save"></i> Save deploy settings
</button>
</button>
<span id="deploy-config-status" style="margin-left:12px"></span>
<span id="deploy-config-status" style="margin-left:12px"></span>
</div>
</div>
@@ -637,10 +659,12 @@
@@ -637,10 +659,12 @@
</div>
</div>
<!-- SSH access provisioning -->
<!-- SSH access provisioning -->
- <div class="section" id="sec-domains">
+ <div class="section admin-clarity" id="sec-domains">
+ <p class="admin-intro">These addresses route people to tools across OpenVibe. Add or change an address when introducing a domain; remove the saved override to return to the built-in routing.</p>
<div id="domains-content"><div class="loading">Loading...</div></div>
<div id="domains-content"><div class="loading">Loading...</div></div>
</div>
</div>
- <div class="section" id="sec-ssh">
+ <div class="section admin-clarity" id="sec-ssh">
+ <p class="admin-intro">SSH gives staff command-line access to the development server. Use these instructions when setting up access, and choose only the permissions the person needs.</p>
<div id="ssh-content"><div class="loading">Loading...</div></div>
<div id="ssh-content"><div class="loading">Loading...</div></div>
</div>
</div>
</div>
</div>
@@ -280,6 +280,56 @@ a.btn{text-decoration:none}
@@ -280,6 +280,56 @@ a.btn{text-decoration:none}
@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}
@media(max-width:800px){.pai-provider{grid-template-columns:minmax(0,1fr)}.pai-provider-actions{justify-content:flex-start;max-width:none}.pai-fields{grid-template-columns:minmax(0,1fr)}}
@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
@media(max-width:640px){.pai-form-row{grid-template-columns:minmax(0,1fr)}#platform-ai .btn,#platform-ai input,#platform-ai select,#platform-ai textarea,#platform-ai summary,#platform-ai .pai-index a,#platform-ai .pai-switch{min-height:44px}#platform-ai .pai-chip button{min-width:44px;min-height:44px}.pai-index{gap:2px;padding:5px}.pai-index a{padding:8px 9px;font-size:11.5px}.pai-section{scroll-margin-top:96px}.pai-dialog{width:calc(100vw - 16px)}.pai-form{padding:14px}.pai-card{padding:13px}}
+/* Owner clarity: scoped to the legacy admin sections; AI and Setup own their forms. */
+.admin-clarity { min-width:0; overflow-wrap:anywhere; }
+.admin-clarity .admin-intro { max-width:76ch; margin:0 0 18px; color:var(--text-secondary); font-size:14px; line-height:1.6; }
+.admin-clarity h3 + .admin-intro { margin-top:4px; }
+.admin-clarity .admin-help { display:block; color:var(--text-secondary); font-size:13px; font-weight:400; text-transform:none; letter-spacing:normal; line-height:1.55; margin:6px 0 10px; }
+.admin-clarity .admin-machine-key { display:inline-block; color:var(--text-muted); font-size:12px; font-weight:400; overflow-wrap:anywhere; }
+.admin-clarity .admin-field { display:flex; flex:1 1 220px; flex-direction:column; min-width:0; max-width:100%; }
+.admin-clarity :is(input,select,textarea) { max-width:100%; min-height:44px; font-size:14px; }
+.admin-clarity :is(button,.btn,summary) { min-height:44px; min-width:44px; white-space:normal; justify-content:center; }
+.admin-clarity input[type=checkbox] { width:44px!important; height:44px!important; flex:0 0 44px; accent-color:var(--accent); }
+.admin-clarity .setting-row { gap:12px; align-items:flex-start; }
+.admin-clarity .setting-row label { flex:1; min-width:0; }
+.admin-clarity :is(fieldset,.card,.form-group,.form-field) { min-width:0; }
+.admin-clarity .admin-advanced { width:100%; margin:8px 0; padding:8px 12px; border:1px solid var(--border); border-radius:8px; }
+.admin-clarity .admin-advanced summary { cursor:pointer; padding:10px 0; }
+.admin-clarity .admin-advanced textarea { width:100%; }
+.admin-clarity :is(code,pre) { overflow-wrap:anywhere; }
+.admin-clarity pre { max-width:100%; overflow:auto; }
+.admin-clarity .badge { text-transform:none; line-height:1.5; white-space:normal; }
+.admin-clarity a:not(.btn) { color:var(--accent-light); text-underline-offset:3px; }
+.section-tabs button { min-height:44px; font-size:12px; }
+.tabs-scroll-btn { width:44px; height:44px; flex-basis:44px; }
+@media(max-width:600px) {
+ body:has(.admin-clarity.active) .page { padding:16px; }
+ .admin-clarity .card { padding:16px; }
+ .admin-clarity :is(.muted,small,.badge,th,td,.label) { font-size:12px!important; }
+ .admin-clarity .setting-row { flex-wrap:wrap; }
+ .admin-clarity .setting-row:not(:has(input[type=checkbox])) { flex-direction:column!important; align-items:stretch!important; }
+ .admin-clarity .setting-row label { flex-basis:100%; }
+ .admin-clarity :is(input:not([type=checkbox]),textarea,select:not([aria-hidden=true])) { width:100%!important; min-width:0!important; }
+ .admin-clarity [style*="grid-template-columns"] { grid-template-columns:minmax(0,1fr)!important; }
+ .admin-clarity [style*="display:flex"] { flex-wrap:wrap; min-width:0; }
+ .admin-clarity :is(.admin-table,.data-table,.table) { display:block; max-width:100%; overflow-x:auto; }
+ .admin-clarity :is(.admin-table,.data-table,.table) :is(th,td) { min-width:100px; white-space:normal!important; }
+ .admin-clarity .stats-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
+ .admin-clarity .input-row { align-items:flex-start; }
+ .admin-clarity .input-row > button { width:100%; }
+ .admin-clarity .sub-tabs { flex-wrap:wrap; }
+}
+@media(max-width:600px) {
+ #sec-users .admin-table thead { display:none; }
+ #sec-users .admin-table :is(tbody,tr,td) { display:block; min-width:0; width:100%; }
+ #sec-users .admin-table tr { padding:12px; margin:12px 0; border:1px solid var(--border); border-radius:10px; background:var(--bg-card); }
+ #sec-users .admin-table td { border:0; padding:6px 0; }
+ #sec-users .admin-table td::before { content:attr(data-label); display:block; color:var(--text-secondary); font-size:12px; font-weight:600; margin-bottom:4px; }
+ #sec-users .admin-table td:last-child { display:flex!important; gap:8px!important; }
+ #sec-users .admin-table td:last-child::before { flex-basis:100%; }
+}
+body:has(.admin-clarity.active) .ov-sel-search input { min-height:44px; }
+body:has(.admin-clarity.active) .ov-sel-opt { min-height:44px; }
/* ── The clear admin panel (2026-11-07): machine keys small and grey, Advanced settings folds, forms for objects ── */
/* ── The clear admin panel (2026-11-07): machine keys small and grey, Advanced settings folds, forms for objects ── */
.pai-mkey{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;font-weight:500;color:var(--text-muted);background:var(--bg-tertiary);padding:1px 6px;border-radius:5px;vertical-align:middle;overflow-wrap:anywhere}
.pai-mkey{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;font-weight:500;color:var(--text-muted);background:var(--bg-tertiary);padding:1px 6px;border-radius:5px;vertical-align:middle;overflow-wrap:anywhere}
.pai-words{font-size:12px;margin-top:4px}
.pai-words{font-size:12px;margin-top:4px}
@@ -117,13 +117,13 @@ async function loadInsights() {
@@ -117,13 +117,13 @@ async function loadInsights() {
el.innerHTML = `<div class="card" style="margin-bottom:16px">
el.innerHTML = `<div class="card" style="margin-bottom:16px">
<h3 style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between"><span><i class="fa-solid fa-lightbulb"></i> Insights <small class="muted">last 24 hours · ${esc(new Date(ins.generated_at).toLocaleTimeString())}</small></span>
<h3 style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between"><span><i class="fa-solid fa-lightbulb"></i> Insights <small class="muted">last 24 hours · ${esc(new Date(ins.generated_at).toLocaleTimeString())}</small></span>
<button class="btn btn-primary" id="insights-ask" type="button"><i class="fa-solid fa-wand-magic-sparkles"></i> Ask Vibe what to fix</button></h3>
<button class="btn btn-primary" id="insights-ask" type="button"><i class="fa-solid fa-wand-magic-sparkles"></i> Ask Vibe what to fix</button></h3>
- <div class="stats-grid">${tile('New sign-ups today', fmtNum(u.new_1d))}${tile('New this week', fmtNum(u.new_7d), `${fmtNum(u.new_30d)} in 30 days · ${fmtNum(u.total)} accounts`)}${tile('Requests', fmtNum(tot.requests), `${(ins.services || []).length} services`)}${tile('Server errors', fmtNum(tot.server_errors), `${rate.toFixed(3)}% of requests`)}</div>
+ <p class="admin-intro">These counts summarize sign-ups and service health over the last 24 hours. Review rising errors or slow responses before asking Vibe to investigate; more errors mean more failed requests.</p><div class="stats-grid">${tile('New sign-ups today', fmtNum(u.new_1d))}${tile('New this week', fmtNum(u.new_7d), `${fmtNum(u.new_30d)} in 30 days · ${fmtNum(u.total)} accounts`)}${tile('Requests', fmtNum(tot.requests), `${(ins.services || []).length} services`)}${tile('Server errors', fmtNum(tot.server_errors), `${rate.toFixed(3)}% of requests`)}</div>
${(u.daily || []).length ? `<div style="margin:10px 0 4px" class="muted">Sign-ups per day (14 days)</div><div style="display:flex;align-items:flex-end;gap:4px;height:70px" role="img" aria-label="Sign-ups per day">${u.daily.map((d) => `<div title="${esc(d.day)}: ${d.n}" style="flex:1;background:var(--accent);opacity:.85;border-radius:3px 3px 0 0;height:${Math.max(4, Math.round(d.n / max * 100))}%"></div>`).join('')}</div>` : ''}
${(u.daily || []).length ? `<div style="margin:10px 0 4px" class="muted">Sign-ups per day (14 days)</div><div style="display:flex;align-items:flex-end;gap:4px;height:70px" role="img" aria-label="Sign-ups per day">${u.daily.map((d) => `<div title="${esc(d.day)}: ${d.n}" style="flex:1;background:var(--accent);opacity:.85;border-radius:3px 3px 0 0;height:${Math.max(4, Math.round(d.n / max * 100))}%"></div>`).join('')}</div>` : ''}
${ins.metrics_error ? `<p class="muted">${esc(ins.metrics_error)}</p>` : `
${ins.metrics_error ? `<p class="muted">${esc(ins.metrics_error)}</p>` : `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px;margin-top:12px">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px;margin-top:12px">
- <div><h4 style="margin:4px 0">Services</h4><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Service</th><th style="text-align:right">Requests</th><th style="text-align:right">4xx</th><th style="text-align:right">5xx</th><th style="text-align:right">p95</th></tr>${svcRows}</table></div>
- <div><h4 style="margin:4px 0">Most used</h4><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">Requests</th></tr>${(ins.top_routes || []).map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.requests)}</td></tr>`).join('')}</table>
- <h4 style="margin:12px 0 4px">Where errors happen</h4>${(ins.error_routes || []).length ? `<table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">5xx</th></tr>${ins.error_routes.map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.errors)}</td></tr>`).join('')}</table>` : '<p class="muted">No server errors in the last 24 hours.</p>'}</div>
+ <div><h4 style="margin:4px 0">Services</h4><p class="admin-intro">These are the requests each service handled in the last 24 hours. Review the services with the most failures first.</p><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Service</th><th style="text-align:right">Requests</th><th style="text-align:right">Rejected requests (4xx)</th><th style="text-align:right">Server failures (5xx)</th><th style="text-align:right">95% finish within (ms)</th></tr>${svcRows}</table></div>
+ <div><h4 style="margin:4px 0">Most used</h4><p class="admin-intro">These are the pages and operations people used most often. Use them to see where people spend their time.</p><table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">Requests</th></tr>${(ins.top_routes || []).map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.requests)}</td></tr>`).join('')}</table>
+ <h4 style="margin:12px 0 4px">Where errors happen</h4><p class="admin-intro">These are the operations that failed at the server. Fix the cause before the next period, and check whether the failures concentrate in one service.</p>${(ins.error_routes || []).length ? `<table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">Where</th><th style="text-align:right">Server failures (5xx)</th></tr>${ins.error_routes.map((r) => `<tr><td><code>${esc(r.service)}</code> ${esc(r.route)}</td><td style="text-align:right">${fmtNum(r.errors)}</td></tr>`).join('')}</table>` : '<p class="muted">No server errors in the last 24 hours.</p>'}</div>
</div>`}
</div>`}
</div>`;
</div>`;
const ask = document.getElementById('insights-ask');
const ask = document.getElementById('insights-ask');
@@ -341,7 +341,7 @@ async function loadAnalyticsOverview() {
@@ -341,7 +341,7 @@ async function loadAnalyticsOverview() {
<td>${s.avg_response_ms || 0}ms</td>
<td>${s.avg_response_ms || 0}ms</td>
</tr>`;
</tr>`;
}).join('')}</tbody>
}).join('')}</tbody>
- </table>` : '<p class="muted">No data yet</p>';
+ </table>` : '<p class="muted">No traffic recorded for this period. Choose a longer time range or return after visits are recorded.</p>';
// Bot ratio donut
// Bot ratio donut
const totalHuman = (t.total_pageviews || 0) + (t.total_api_calls || 0);
const totalHuman = (t.total_pageviews || 0) + (t.total_api_calls || 0);
@@ -468,7 +468,7 @@ async function loadServiceAnalytics(serviceName) {
@@ -468,7 +468,7 @@ async function loadServiceAnalytics(serviceName) {
};
};
rpEl.innerHTML = pctBar('Median (p50)', rp.p50, rp.max) + pctBar('p90', rp.p90, rp.max) + pctBar('p95', rp.p95, rp.max) + pctBar('p99', rp.p99, rp.max) + pctBar('Max', rp.max, rp.max);
rpEl.innerHTML = pctBar('Median (p50)', rp.p50, rp.max) + pctBar('p90', rp.p90, rp.max) + pctBar('p95', rp.p95, rp.max) + pctBar('p99', rp.p99, rp.max) + pctBar('Max', rp.max, rp.max);
} else {
} else {
- rpEl.innerHTML = '<p class="muted">No response time data</p>';
+ rpEl.innerHTML = '<p class="muted">No response times recorded. Choose a longer time range or check that the service is reporting requests.</p>';
}
}
// Peak hours chart
// Peak hours chart
@@ -562,7 +562,7 @@ async function loadServiceAnalytics(serviceName) {
@@ -562,7 +562,7 @@ async function loadServiceAnalytics(serviceName) {
<tbody>${pages.slice(0, 15).map(p => `
<tbody>${pages.slice(0, 15).map(p => `
<tr><td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.path)}">${esc(p.path)}</td><td>${fmtNum(p.hits)}</td><td>${fmtNum(p.visitors)}</td></tr>
<tr><td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.path)}">${esc(p.path)}</td><td>${fmtNum(p.hits)}</td><td>${fmtNum(p.visitors)}</td></tr>
`).join('')}</tbody>
`).join('')}</tbody>
- </table>` : '<p class="muted">No data</p>';
+ </table>` : '<p class="muted">No requests recorded. Choose a longer time range or another service.</p>';
// Top API endpoints
// Top API endpoints
const apiEndpoints = a.topApiEndpoints || [];
const apiEndpoints = a.topApiEndpoints || [];
@@ -578,7 +578,7 @@ async function loadServiceAnalytics(serviceName) {
@@ -578,7 +578,7 @@ async function loadServiceAnalytics(serviceName) {
<td style="color:${e.errors > 0 ? 'var(--danger)' : 'inherit'}">${fmtNum(e.errors)}</td>
<td style="color:${e.errors > 0 ? 'var(--danger)' : 'inherit'}">${fmtNum(e.errors)}</td>
</tr>
</tr>
`).join('')}</tbody>
`).join('')}</tbody>
- </table>` : '<p class="muted">No API data</p>';
+ </table>` : '<p class="muted">No API requests recorded. Choose a longer time range or another service.</p>';
// Referers
// Referers
const refs = a.topReferers || [];
const refs = a.topReferers || [];
@@ -588,7 +588,7 @@ async function loadServiceAnalytics(serviceName) {
@@ -588,7 +588,7 @@ async function loadServiceAnalytics(serviceName) {
<tbody>${refs.slice(0, 10).map(r => `
<tbody>${refs.slice(0, 10).map(r => `
<tr><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.referer)}">${esc(r.referer)}</td><td>${fmtNum(r.hits)}</td></tr>
<tr><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.referer)}">${esc(r.referer)}</td><td>${fmtNum(r.hits)}</td></tr>
`).join('')}</tbody>
`).join('')}</tbody>
- </table>` : '<p class="muted">No referers tracked</p>';
+ </table>` : '<p class="muted">No referring websites recorded. Try a longer range; direct visits may have no referring address.</p>';
// Slowest endpoints
// Slowest endpoints
const slow = a.slowestEndpoints || [];
const slow = a.slowestEndpoints || [];
@@ -604,7 +604,7 @@ async function loadServiceAnalytics(serviceName) {
@@ -604,7 +604,7 @@ async function loadServiceAnalytics(serviceName) {
<td>${fmtNum(e.hits)}</td>
<td>${fmtNum(e.hits)}</td>
</tr>
</tr>
`).join('')}</tbody>
`).join('')}</tbody>
- </table>` : '<p class="muted">No data</p>';
+ </table>` : '<p class="muted">No requests recorded. Choose a longer time range or another service.</p>';
// Status codes
// Status codes
const statuses = a.statusCodes || [];
const statuses = a.statusCodes || [];
@@ -628,7 +628,7 @@ async function loadServiceAnalytics(serviceName) {
@@ -628,7 +628,7 @@ async function loadServiceAnalytics(serviceName) {
<tbody>${countries.slice(0, 15).map(c => `
<tbody>${countries.slice(0, 15).map(c => `
<tr><td>${esc(c.country || 'Unknown')}</td><td>${fmtNum(c.cnt)}</td></tr>
<tr><td>${esc(c.country || 'Unknown')}</td><td>${fmtNum(c.cnt)}</td></tr>
`).join('')}</tbody>
`).join('')}</tbody>
- </table>` : '<p class="muted">No country data (requires Cloudflare CF-IPCountry header)</p>';
+ </table>` : '<p class="muted">No country information recorded. Check whether the edge service supplies country information, then return after new visits.</p>';
} catch (e) {
} catch (e) {
statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
}
}
@@ -647,12 +647,12 @@ async function loadBotAnalysis() {
@@ -647,12 +647,12 @@ async function loadBotAnalysis() {
if (!svcBots) continue;
if (!svcBots) continue;
const label = svcName === 'live' ? 'OpenVibe.Live' : svcName === 'openvibe-network' ? 'OpenVibe.Network' : svcName === 'tools' ? 'OpenVibe.Tools' : svcName === 'games' ? 'OpenVibe.Games' : svcName === 'media' ? 'OpenVibe.Media' : svcName;
const label = svcName === 'live' ? 'OpenVibe.Live' : svcName === 'openvibe-network' ? 'OpenVibe.Network' : svcName === 'tools' ? 'OpenVibe.Tools' : svcName === 'games' ? 'OpenVibe.Games' : svcName === 'media' ? 'OpenVibe.Media' : svcName;
- html += `<div class="card"><h3><i class="fa-solid fa-robot"></i> ${esc(label)}</h3>`;
+ html += `<div class="card"><h3><i class="fa-solid fa-robot"></i> ${esc(label)}</h3><p class="admin-intro">These requests were classified as automated traffic for this service. Review the categories when investigating unusual load; higher counts mean more automated requests.</p>`;
// Bot types
// Bot types
const types = svcBots.botTypes || [];
const types = svcBots.botTypes || [];
if (types.length) {
if (types.length) {
- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Bot Types</h4>
+ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Bot types</h4><p class="admin-intro">These categories group recorded automated requests. Review the largest categories first when checking a traffic spike.</p>
<table class="admin-table"><thead><tr><th>Type</th><th>Hits</th><th>Sessions</th></tr></thead>
<table class="admin-table"><thead><tr><th>Type</th><th>Hits</th><th>Sessions</th></tr></thead>
<tbody>${types.map(t => `<tr><td><span class="badge badge-danger">${esc(t.bot_type || 'unknown')}</span></td><td>${fmtNum(t.hits)}</td><td>${t.unique_sessions != null ? fmtNum(t.unique_sessions) : '-'}</td></tr>`).join('')}</tbody></table>`;
<tbody>${types.map(t => `<tr><td><span class="badge badge-danger">${esc(t.bot_type || 'unknown')}</span></td><td>${fmtNum(t.hits)}</td><td>${t.unique_sessions != null ? fmtNum(t.unique_sessions) : '-'}</td></tr>`).join('')}</tbody></table>`;
}
}
@@ -660,7 +660,7 @@ async function loadBotAnalysis() {
@@ -660,7 +660,7 @@ async function loadBotAnalysis() {
// Top bots by user-agent class (ADR-021: analytics stores no IP addresses)
// Top bots by user-agent class (ADR-021: analytics stores no IP addresses)
const topBots = svcBots.topBotIPs || [];
const topBots = svcBots.topBotIPs || [];
if (topBots.length) {
if (topBots.length) {
- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Top Bots (user-agent class)</h4>
+ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Most active bots</h4><p class="admin-intro">These groups come from the identity a browser or bot reports. Use them to investigate heavy automated traffic; the reported identity can be inaccurate.</p>
<table class="admin-table"><thead><tr><th>User-agent class</th><th>Hits</th><th>Sessions</th><th>Type</th><th>First Seen</th><th>Last Seen</th></tr></thead>
<table class="admin-table"><thead><tr><th>User-agent class</th><th>Hits</th><th>Sessions</th><th>Type</th><th>First Seen</th><th>Last Seen</th></tr></thead>
<tbody>${topBots.slice(0, 15).map(b => `
<tbody>${topBots.slice(0, 15).map(b => `
<tr><td style="font-family:monospace;font-size:11px">${esc(b.ua_class || '-')}</td><td>${fmtNum(b.hits)}</td>
<tr><td style="font-family:monospace;font-size:11px">${esc(b.ua_class || '-')}</td><td>${fmtNum(b.hits)}</td>
@@ -674,7 +674,7 @@ async function loadBotAnalysis() {
@@ -674,7 +674,7 @@ async function loadBotAnalysis() {
// High-volume sessions classified as human (rotating session ids, never an IP or user id)
// High-volume sessions classified as human (rotating session ids, never an IP or user id)
const suspicious = svcBots.suspiciousIPs || [];
const suspicious = svcBots.suspiciousIPs || [];
if (suspicious.length) {
if (suspicious.length) {
- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Suspicious Sessions (high volume, classified as human)</h4>
+ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Unusually busy browsing sessions</h4><p class="admin-intro">These high-volume sessions were classified as human traffic. Investigate them before taking action; a busy session alone does not prove abuse.</p>
<table class="admin-table"><thead><tr><th>Session</th><th>Total Hits</th><th>Errors</th><th>Unique Paths</th><th>Period</th></tr></thead>
<table class="admin-table"><thead><tr><th>Session</th><th>Total Hits</th><th>Errors</th><th>Unique Paths</th><th>Period</th></tr></thead>
<tbody>${suspicious.map(s => `
<tbody>${suspicious.map(s => `
<tr><td style="font-family:monospace;font-size:11px">${esc(s.session_id || '-')}</td><td>${fmtNum(s.total_hits)}</td>
<tr><td style="font-family:monospace;font-size:11px">${esc(s.session_id || '-')}</td><td>${fmtNum(s.total_hits)}</td>
@@ -36,14 +36,14 @@ async function loadPastesAdmin() {
@@ -36,14 +36,14 @@ async function loadPastesAdmin() {
</div>
</div>
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-sliders" aria-hidden="true"></i> Paste limits</h3>
<h3><i class="fa-solid fa-sliders" aria-hidden="true"></i> Paste limits</h3>
- <p class="muted" style="margin-bottom:8px">OpenVibe.Community owns pastes and sets these limits itself; they cannot be changed from here.</p>
+ <p class="muted" style="margin-bottom:8px">OpenVibe.Community sets these paste limits; this view is read-only. Change them in Community when adjusting capacity; higher limits allow more content and lower limits allow less.</p>
${limit('Largest text paste', `${n(cfg.maxSizeKb)} KB`)}
${limit('Largest text paste', `${n(cfg.maxSizeKb)} KB`)}
${limit('Largest image', `${n(cfg.screenshotMaxSizeMb)} MB`)}
${limit('Largest image', `${n(cfg.screenshotMaxSizeMb)} MB`)}
${limit('Wait between pastes', `${n(cfg.cooldownSeconds)} seconds`)}
${limit('Wait between pastes', `${n(cfg.cooldownSeconds)} seconds`)}
${limit('Pastes per person per day', Number(cfg.maxPerUserPerDay) === 0 ? 'No limit' : n(cfg.maxPerUserPerDay))}
${limit('Pastes per person per day', Number(cfg.maxPerUserPerDay) === 0 ? 'No limit' : n(cfg.maxPerUserPerDay))}
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Forks</h3>
+ <h3><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Forks</h3><p class="admin-intro">These are copies people made of existing pastes. Delete them only during deliberate cleanup; deleted copies cannot be recovered here.</p>
<p class="muted" style="margin-bottom:12px">Copies people made of other pastes. Deleting them cannot be undone.</p>
<p class="muted" style="margin-bottom:12px">Copies people made of other pastes. Deleting them cannot be undone.</p>
<button class="btn btn-danger" onclick="deleteAllPasteForks()"${Number(s.forks || 0) ? '' : ' disabled'}><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete all forks (${n(s.forks || 0)})</button>
<button class="btn btn-danger" onclick="deleteAllPasteForks()"${Number(s.forks || 0) ? '' : ' disabled'}><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete all forks (${n(s.forks || 0)})</button>
</div>`;
</div>`;
@@ -80,7 +80,7 @@ async function loadStorage() {
@@ -80,7 +80,7 @@ async function loadStorage() {
c.innerHTML = `
c.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px">
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-hard-drive"></i> Disk Usage</h3>
+ <h3><i class="fa-solid fa-hard-drive"></i> Disk Usage</h3><p class="admin-intro">This shows occupied and available disk space in bytes. Review it when the disk is filling up; a higher used percentage leaves less room for new files.</p>
<div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:14px">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:14px">
<span>${fmtBytes(d.used)} used of ${fmtBytes(d.total)}</span>
<span>${fmtBytes(d.used)} used of ${fmtBytes(d.total)}</span>
<span style="font-weight:700;color:${pctColor}">${usePct}%</span>
<span style="font-weight:700;color:${pctColor}">${usePct}%</span>
@@ -94,7 +94,7 @@ async function loadStorage() {
@@ -94,7 +94,7 @@ async function loadStorage() {
</div>
</div>
</div>
</div>
<div class="card">
<div class="card">
- <h3><i class="fa-solid fa-database"></i> Data Summary</h3>
+ <h3><i class="fa-solid fa-database"></i> Data Summary</h3><p class="admin-intro">This counts stored files and their combined size in bytes. Check it when comparing growth; higher totals mean more stored data.</p>
<div class="stats-grid" style="margin:0">
<div class="stats-grid" style="margin:0">
<div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.dataTotal?.bytes || 0)}</div><div class="label">Total Data</div></div>
<div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.dataTotal?.bytes || 0)}</div><div class="label">Total Data</div></div>
<div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${(data.dataTotal?.files || 0).toLocaleString()}</div><div class="label">Files</div></div>
<div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${(data.dataTotal?.files || 0).toLocaleString()}</div><div class="label">Files</div></div>
@@ -104,7 +104,7 @@ async function loadStorage() {
@@ -104,7 +104,7 @@ async function loadStorage() {
</div>
</div>
</div>
</div>
- <h3 style="margin-bottom:12px"><i class="fa-solid fa-folder-tree"></i> Storage Breakdown</h3>
+ <h3 style="margin-bottom:12px"><i class="fa-solid fa-folder-tree"></i> Storage Breakdown</h3><p class="admin-intro">This groups storage use by file category. Review the largest categories before deciding what to archive or remove.</p>
<div style="display:grid;gap:8px;margin-bottom:24px">
<div style="display:grid;gap:8px;margin-bottom:24px">
${breakdown.map(b => `
${breakdown.map(b => `
<div style="display:grid;grid-template-columns:140px 1fr 100px 80px;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px">
<div style="display:grid;grid-template-columns:140px 1fr 100px 80px;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px">
@@ -117,14 +117,14 @@ async function loadStorage() {
@@ -117,14 +117,14 @@ async function loadStorage() {
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
- <h3><i class="fa-solid fa-video"></i> VOD Management</h3>
+ <h3><i class="fa-solid fa-video"></i> VOD Management</h3><p class="admin-intro">These are saved stream recordings, also called VODs. Select recordings only when you intend to permanently delete them; recovery requires a separate backup.</p>
<div style="display:flex;gap:8px">
<div style="display:flex;gap:8px">
- <select id="vod-sort" onchange="loadVodTable()" style="padding:6px 10px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px">
- <option value="size">Sort by Size</option>
- <option value="date">Sort by Date</option>
- <option value="duration">Sort by Duration</option>
+ <label for="vod-sort">Recording order</label><span class="admin-help">Largest file first is the default. Choose newest or longest to find recordings by age or duration.</span><select id="vod-sort" onchange="loadVodTable()" style="padding:6px 10px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px">
+ <option value="size">Largest file first</option>
+ <option value="date">Newest recording first</option>
+ <option value="duration">Longest recording first</option>
</select>
</select>
- <button class="btn btn-sm btn-danger" id="vod-bulk-btn" disabled onclick="bulkDeleteVods()"><i class="fa-solid fa-trash-can"></i> Delete Selected</button>
+ <button class="btn btn-sm btn-danger" id="vod-bulk-btn" disabled onclick="bulkDeleteVods()"><i class="fa-solid fa-trash-can"></i> Permanently delete selected</button>
</div>
</div>
</div>
</div>
<div id="vod-table"><div class="loading">Loading VODs...</div></div>
<div id="vod-table"><div class="loading">Loading VODs...</div></div>
@@ -174,7 +174,7 @@ function buildStreamerTierHtml(tierData) {
@@ -174,7 +174,7 @@ function buildStreamerTierHtml(tierData) {
+ '<td style="padding:8px 10px;text-align:right">' + (r.clips || 0).toLocaleString() + '</td>'
+ '<td style="padding:8px 10px;text-align:right">' + (r.clips || 0).toLocaleString() + '</td>'
+ '<td style="padding:8px 10px">' + r.status + '</td></tr>').join('');
+ '<td style="padding:8px 10px">' + r.status + '</td></tr>').join('');
return '<div class="card" style="margin-bottom:24px">'
return '<div class="card" style="margin-bottom:24px">'
- + '<h3 style="margin:0 0 6px"><i class="fa-solid fa-layer-group"></i> Storage Tiers <span style="color:var(--text-muted);font-size:13px;font-weight:400">— ' + offloadPct + '% of VOD data offloaded to cloud</span></h3>'
+ + '<h3 style="margin:0 0 6px"><i class="fa-solid fa-layer-group"></i> Storage Tiers <span style="color:var(--text-muted);font-size:13px;font-weight:400">— ' + offloadPct + '% of recordings stored in cloud</span></h3><p class="admin-intro">These tiers show where recordings are stored. Review them when local space is low; a higher cloud percentage means more recording data is stored away from this server.</p>'
+ bar
+ bar
+ '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">'
+ '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">'
+ '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'
+ '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'
@@ -200,8 +200,8 @@ async function loadStreamerCloudStorage(force) {
@@ -200,8 +200,8 @@ async function loadStreamerCloudStorage(force) {
const el = document.getElementById('streamer-cloud-storage');
const el = document.getElementById('streamer-cloud-storage');
if (!el) return;
if (!el) return;
const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'
const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'
- + '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud Storage — B2 & R2 Usage & Cost</h3>'
- + '<button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan</button></div>';
+ + '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud storage usage and cost</h3>'
+ + '<p class="admin-intro">This estimates storage in Backblaze B2 and Cloudflare R2, in bytes and US dollars per month. Rescan when checking capacity; higher usage means more stored data and may increase charges.</p><button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan now</button></div>';
el.innerHTML = header + '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Scanning cloud buckets…</div>';
el.innerHTML = header + '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Scanning cloud buckets…</div>';
try {
try {
const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));
const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));
@@ -211,7 +211,7 @@ async function loadStreamerCloudStorage(force) {
@@ -211,7 +211,7 @@ async function loadStreamerCloudStorage(force) {
const card = (key, label, color) => {
const card = (key, label, color) => {
const u = usage[key];
const u = usage[key];
const cc = costs[key];
const cc = costs[key];
- if (!u || u.configured === false) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--text-muted);margin-top:8px">Not configured</div></div>';
+ if (!u || u.configured === false) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--text-muted);margin-top:8px">Cloud storage is not connected. Configure this provider in Live, then rescan.</div></div>';
if (u.error) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--danger-text, var(--danger,#ef4444));margin-top:8px">Scan error: ' + esc(u.error) + '</div></div>';
if (u.error) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--danger-text, var(--danger,#ef4444));margin-top:8px">Scan error: ' + esc(u.error) + '</div></div>';
const tops = Object.entries(u.prefixes || {}).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 8);
const tops = Object.entries(u.prefixes || {}).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 8);
const prefixRows = tops.map(([p, v]) => '<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);padding:2px 0"><span>' + esc(p) + '</span><span>' + fmtBytes(v.bytes) + ' · ' + v.objects.toLocaleString() + '</span></div>').join('');
const prefixRows = tops.map(([p, v]) => '<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);padding:2px 0"><span>' + esc(p) + '</span><span>' + fmtBytes(v.bytes) + ' · ' + v.objects.toLocaleString() + '</span></div>').join('');
@@ -238,17 +238,17 @@ async function loadVodTable() {
@@ -238,17 +238,17 @@ async function loadVodTable() {
try {
try {
const data = await api(`/api/admin/streamer/storage/vods?sort=${sort}&order=desc&limit=100`);
const data = await api(`/api/admin/streamer/storage/vods?sort=${sort}&order=desc&limit=100`);
const vods = data.vods || [];
const vods = data.vods || [];
- if (!vods.length) { c.innerHTML = '<p class="muted">No VODs found</p>'; return; }
+ if (!vods.length) { c.innerHTML = '<p class="muted">No saved recordings. Return after a stream has been recorded.</p>'; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
<thead><tr>
<thead><tr>
- <th style="width:32px"><input type="checkbox" onchange="toggleAllVods(this.checked)" style="cursor:pointer"></th>
+ <th style="width:32px"><input type="checkbox" aria-label="Select all recordings for permanent deletion" onchange="toggleAllVods(this.checked)" style="cursor:pointer"></th>
<th>Title</th><th>User</th><th style="text-align:right">Size</th><th style="text-align:right">Duration</th><th>Date</th>
<th>Title</th><th>User</th><th style="text-align:right">Size</th><th style="text-align:right">Duration</th><th>Date</th>
</tr></thead>
</tr></thead>
<tbody>${vods.map(v => `
<tbody>${vods.map(v => `
<tr>
<tr>
- <td><input type="checkbox" class="vod-cb" value="${v.id}" onchange="updateVodSelection()" style="cursor:pointer"></td>
+ <td><input type="checkbox" aria-label="Select this recording for permanent deletion" class="vod-cb" value="${v.id}" onchange="updateVodSelection()" style="cursor:pointer"></td>
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(v.title||'')}">${esc(v.title || '(untitled)')}</td>
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(v.title||'')}">${esc(v.title || '(untitled)')}</td>
<td>${esc(v.username || '-')}</td>
<td>${esc(v.username || '-')}</td>
<td style="text-align:right;font-weight:600;font-variant-numeric:tabular-nums">${fmtBytes(v.diskSize || v.file_size || 0)}</td>
<td style="text-align:right;font-weight:600;font-variant-numeric:tabular-nums">${fmtBytes(v.diskSize || v.file_size || 0)}</td>
@@ -265,13 +265,13 @@ function toggleAllVods(checked) { document.querySelectorAll('.vod-cb').forEach(c
@@ -265,13 +265,13 @@ function toggleAllVods(checked) { document.querySelectorAll('.vod-cb').forEach(c
function updateVodSelection() {
function updateVodSelection() {
const n = document.querySelectorAll('.vod-cb:checked').length;
const n = document.querySelectorAll('.vod-cb:checked').length;
const btn = document.getElementById('vod-bulk-btn');
const btn = document.getElementById('vod-bulk-btn');
- if (btn) { btn.disabled = n === 0; btn.innerHTML = `<i class="fa-solid fa-trash-can"></i> Delete Selected${n ? ` (${n})` : ''}`; }
+ if (btn) { btn.disabled = n === 0; btn.innerHTML = `<i class="fa-solid fa-trash-can"></i> Permanently delete selected${n ? ` (${n})` : ''}`; }
}
}
async function bulkDeleteVods() {
async function bulkDeleteVods() {
const ids = [...document.querySelectorAll('.vod-cb:checked')].map(cb => parseInt(cb.value));
const ids = [...document.querySelectorAll('.vod-cb:checked')].map(cb => parseInt(cb.value));
if (!ids.length) return;
if (!ids.length) return;
- if (!confirm(`Permanently delete ${ids.length} VOD(s)?`)) return;
+ if (!confirm(`Permanently delete ${ids.length} saved recording(s)? Viewers will lose access. This cannot be undone here; recovery requires a separate backup.`)) return;
try {
try {
const data = await api('/api/admin/streamer/storage/vods/bulk', { method: 'DELETE', body: { ids } });
const data = await api('/api/admin/streamer/storage/vods/bulk', { method: 'DELETE', body: { ids } });
toast(`Deleted ${data.deleted || 0} VOD(s), freed ${fmtBytes(data.freed || 0)}`, 'success');
toast(`Deleted ${data.deleted || 0} VOD(s), freed ${fmtBytes(data.freed || 0)}`, 'success');
@@ -289,7 +289,7 @@ async function sendBroadcast() {
@@ -289,7 +289,7 @@ async function sendBroadcast() {
const priority = document.getElementById('broadcast-priority')?.value || 'normal';
const priority = document.getElementById('broadcast-priority')?.value || 'normal';
const url = document.getElementById('broadcast-url')?.value.trim();
const url = document.getElementById('broadcast-url')?.value.trim();
if (!title) return toast('Title required', 'error');
if (!title) return toast('Title required', 'error');
- if (!confirm(`Send broadcast "${title}" to all users?`)) return;
+ if (!confirm(`Send broadcast "${title}" to all users? It cannot be recalled here. If the announcement is wrong, send a correction.`)) return;
try {
try {
const data = await api('/api/admin/broadcast', { method: 'POST', body: { title, message, icon, priority, url } });
const data = await api('/api/admin/broadcast', { method: 'POST', body: { title, message, icon, priority, url } });
toast(`Broadcast sent to ${data.sent || 0} users`, 'success');
toast(`Broadcast sent to ${data.sent || 0} users`, 'success');
@@ -313,45 +313,45 @@ async function loadEmail() {
@@ -313,45 +313,45 @@ async function loadEmail() {
const recent = Array.isArray(metrics.recent) ? metrics.recent : [];
const recent = Array.isArray(metrics.recent) ? metrics.recent : [];
c.innerHTML = `
c.innerHTML = `
<div class="stats-grid" style="margin-bottom:16px">
<div class="stats-grid" style="margin-bottom:16px">
- <div class="stat-card"><div class="value">${Number(summary.sent || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paper-plane"></i> Emails Sent</div></div>
+ <div class="stat-card"><div class="value">${Number(summary.sent || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paper-plane"></i> Emails sent</div></div>
<div class="stat-card"><div class="value">${Number(summary.failed || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-triangle-exclamation"></i> Failed</div></div>
<div class="stat-card"><div class="value">${Number(summary.failed || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-triangle-exclamation"></i> Failed</div></div>
- <div class="stat-card"><div class="value">${Number(summary.sent_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-clock"></i> Sent · 24h</div></div>
- <div class="stat-card"><div class="value">${Number(summary.password_resets_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-key"></i> Password Resets · 24h</div></div>
- <div class="stat-card"><div class="value">${Number(summary.notifications_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-bell"></i> Notification Emails · 24h</div></div>
- <div class="stat-card"><div class="value">${Number(summary.tests_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-flask-vial"></i> Test Emails · 24h</div></div>
+ <div class="stat-card"><div class="value">${Number(summary.sent_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-clock"></i> Sent · last 24 hours</div></div>
+ <div class="stat-card"><div class="value">${Number(summary.password_resets_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-key"></i> Password resets · last 24 hours</div></div>
+ <div class="stat-card"><div class="value">${Number(summary.notifications_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-bell"></i> Notification emails · last 24 hours</div></div>
+ <div class="stat-card"><div class="value">${Number(summary.tests_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-flask-vial"></i> Test emails · last 24 hours</div></div>
</div>
</div>
<div class="card" style="padding:16px;margin-bottom:16px">
<div class="card" style="padding:16px;margin-bottom:16px">
- <h3 style="margin-bottom:10px"><i class="fa-solid fa-chart-line"></i> Email Delivery Metrics</h3>
+ <h3 style="margin-bottom:10px"><i class="fa-solid fa-chart-line"></i> Email Delivery Metrics</h3><p class="admin-intro">These are recorded delivery counts and recent failures. Check them after a test email or a reported delivery problem; more failed messages means more deliveries need investigation.</p>
<div class="muted" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px">
<div class="muted" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px">
- <div><strong>Total Logged:</strong> ${Number(summary.total || 0).toLocaleString()}</div>
- <div><strong>Failed · 24h:</strong> ${Number(summary.failed_24h || 0).toLocaleString()}</div>
- <div><strong>Last Sent:</strong> ${summary.last_sent_at ? esc(timeAgo(summary.last_sent_at)) : 'Never'}</div>
- <div><strong>Last Failure:</strong> ${summary.last_failed_at ? esc(timeAgo(summary.last_failed_at)) : 'Never'}</div>
+ <div><strong>Total recorded:</strong> ${Number(summary.total || 0).toLocaleString()}</div>
+ <div><strong>Failed in the last 24 hours:</strong> ${Number(summary.failed_24h || 0).toLocaleString()}</div>
+ <div><strong>Last email sent:</strong> ${summary.last_sent_at ? esc(timeAgo(summary.last_sent_at)) : 'Never'}</div>
+ <div><strong>Last failure:</strong> ${summary.last_failed_at ? esc(timeAgo(summary.last_failed_at)) : 'Never'}</div>
</div>
</div>
</div>
</div>
<form id="email-form" style="max-width:600px;margin-bottom:16px">
<form id="email-form" style="max-width:600px;margin-bottom:16px">
<div class="setting-row">
<div class="setting-row">
- <label><strong>Email Enabled</strong></label>
- <input type="checkbox" id="email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer">
+ <label for="email-enabled"><strong>Send email</strong></label>
+ <input type="checkbox" id="email-enabled" aria-label="Send outgoing email" aria-describedby="help-email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer"><span class="admin-help" id="help-email-enabled">Off by default, so no email is sent. Turn it on after configuring Resend; turning it off stops delivery, and turning it on resumes it.</span>
</div>
</div>
${email.issue ? `<div style="background:#e74c3c22;border:1px solid #e74c3c44;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#e74c3c"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(email.issue)}</div>` : email.ready ? `<div style="background:#27ae6022;border:1px solid #27ae6044;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#27ae60"><i class="fa-solid fa-circle-check"></i> Resend is ready — emails will be delivered</div>` : ''}
${email.issue ? `<div style="background:#e74c3c22;border:1px solid #e74c3c44;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#e74c3c"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(email.issue)}</div>` : email.ready ? `<div style="background:#27ae6022;border:1px solid #27ae6044;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#27ae60"><i class="fa-solid fa-circle-check"></i> Resend is ready — emails will be delivered</div>` : ''}
- <h4 style="margin:12px 0 10px;font-size:14px"><i class="fa-solid fa-key"></i> Resend API</h4>
- <div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="" placeholder="${email.api_key_source === 'env' ? `Set in the environment (${esc(email.api_key_env || 'RESEND_API_KEY')})` : 'Not set'}" disabled autocomplete="off"></div>
+ <h4 style="margin:12px 0 10px;font-size:14px"><i class="fa-solid fa-key"></i> Resend email key</h4><p class="admin-intro">This is the key that lets Network send email through the Resend service. Set it in the server environment before turning email on; changing it here has no effect.</p>
+ <div class="form-field"><label for="email-api-key">Resend email key</label><input type="password" id="email-api-key" aria-label="Resend email key" aria-describedby="help-email-api-key" value="" placeholder="${email.api_key_source === 'env' ? `Set in the environment (${esc(email.api_key_env || 'RESEND_API_KEY')})` : 'Not set'}" disabled autocomplete="off"><span class="admin-help" id="help-email-api-key">No key is configured by default. This field is read-only; use the Resend API keys page below and set the key in the server environment.</span></div>
<p class="muted" style="margin:-4px 0 8px;font-size:11px">${email.api_key_source === 'env' ? `The key comes from <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file; change it there.` : `Provider secrets live in the server's environment only: get a key from <a href="https://resend.com/api-keys" target="_blank" rel="noopener" style="color:var(--accent)">resend.com/api-keys</a>, set <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in <code>/etc/openvibe/network.env</code>, and restart Network.`}</p>
<p class="muted" style="margin:-4px 0 8px;font-size:11px">${email.api_key_source === 'env' ? `The key comes from <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file; change it there.` : `Provider secrets live in the server's environment only: get a key from <a href="https://resend.com/api-keys" target="_blank" rel="noopener" style="color:var(--accent)">resend.com/api-keys</a>, set <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in <code>/etc/openvibe/network.env</code>, and restart Network.`}</p>
- <h4 style="margin:20px 0 10px;font-size:14px"><i class="fa-solid fa-envelope"></i> From Addresses</h4>
- <div class="form-field"><label>Default From Email</label><input type="email" id="email-from-email" value="${esc(email.from_email || '')}" placeholder="[email protected]"></div>
- <div class="form-field"><label>Default From Name</label><input type="text" id="email-from-name" value="${esc(email.from_name || '')}" placeholder="OpenVibe"></div>
- <div class="form-field"><label>OpenVibe.Live From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibelive" value="${esc(email.from_email_openvibelive || '')}" placeholder="[email protected]"></div>
- <div class="form-field"><label>OpenVibe Quest From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibegames" value="${esc(email.from_email_openvibegames || '')}" placeholder="[email protected]"></div>
- <div class="form-field"><label>OpenVibe Tools From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibenetwork" value="${esc(email.from_email_openvibenetwork || '')}" placeholder="[email protected]"></div>
+ <h4 style="margin:20px 0 10px;font-size:14px"><i class="fa-solid fa-envelope"></i> From addresses</h4><p class="admin-intro">These are the addresses recipients see when an email arrives. Change them when you want a different sender name or a per-service address; the overrides below only affect the service they name.</p>
+ <div class="form-field"><label for="email-from-email">Default sender address</label><input type="email" id="email-from-email" aria-label="Default sender address" aria-describedby="help-email-from-email" value="${esc(email.from_email || '')}" placeholder="[email protected]"><span class="admin-help" id="help-email-from-email">Uses the saved sender address, normally [email protected]. Enter an address on your verified domain, for example [email protected].</span></div>
+ <div class="form-field"><label for="email-from-name">Sender name</label><input type="text" id="email-from-name" aria-label="Sender display name" aria-describedby="help-email-from-name" value="${esc(email.from_name || '')}" placeholder="OpenVibe"><span class="admin-help" id="help-email-from-name">The default is OpenVibe. Use a recognizable name, for example OpenVibe Support.</span></div>
+ <div class="form-field"><label for="email-from-openvibelive">Live sender address <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibelive" aria-label="Live sender address" aria-describedby="help-email-from-openvibelive" value="${esc(email.from_email_openvibelive || '')}" placeholder="[email protected]"><span class="admin-help" id="help-email-from-openvibelive">Blank uses the default sender. Enter a verified address such as [email protected] to override it for Live.</span></div>
+ <div class="form-field"><label for="email-from-openvibegames">Games sender address <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibegames" aria-label="Games sender address" aria-describedby="help-email-from-openvibegames" value="${esc(email.from_email_openvibegames || '')}" placeholder="[email protected]"><span class="admin-help" id="help-email-from-openvibegames">Blank uses the default sender. Enter a verified address such as [email protected] to override it for Games.</span></div>
+ <div class="form-field"><label for="email-from-openvibenetwork">Tools sender address <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibenetwork" aria-label="Tools sender address" aria-describedby="help-email-from-openvibenetwork" value="${esc(email.from_email_openvibenetwork || '')}" placeholder="[email protected]"><span class="admin-help" id="help-email-from-openvibenetwork">Blank uses the default sender. Enter a verified address such as [email protected] to override it for Tools.</span></div>
<div style="display:flex;gap:8px;margin-top:16px">
<div style="display:flex;gap:8px;margin-top:16px">
- <button type="button" class="btn btn-primary" onclick="saveEmail()"><i class="fa-solid fa-floppy-disk"></i> Save Email Config</button>
- <button type="button" class="btn btn-outline" onclick="testEmail()"><i class="fa-solid fa-paper-plane"></i> Send Test Email</button>
+ <button type="button" class="btn btn-primary" onclick="saveEmail()"><i class="fa-solid fa-floppy-disk"></i> Save email settings</button>
+ <button type="button" class="btn btn-outline" onclick="testEmail()"><i class="fa-solid fa-paper-plane"></i> Send a test email</button>
</div>
</div>
<div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)">
<div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)">
<strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>
<strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>
@@ -360,7 +360,7 @@ async function loadEmail() {
@@ -360,7 +360,7 @@ async function loadEmail() {
</form>
</form>
<div class="card" style="padding:16px;margin-bottom:16px">
<div class="card" style="padding:16px;margin-bottom:16px">
- <h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery Breakdown · 30 days</h3>
+ <h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery breakdown · last 30 days</h3><p class="admin-intro">This counts sent and failed messages by type over the last 30 days. Review it when one kind of email is failing more often than others.</p>
${byType.length ? `
${byType.length ? `
<table class="admin-table">
<table class="admin-table">
<thead><tr><th>Email Type</th><th>Status</th><th>Count</th></tr></thead>
<thead><tr><th>Email Type</th><th>Status</th><th>Count</th></tr></thead>
@@ -368,17 +368,17 @@ async function loadEmail() {
@@ -368,17 +368,17 @@ async function loadEmail() {
${byType.map(row => `
${byType.map(row => `
<tr>
<tr>
<td>${esc(row.email_type)}</td>
<td>${esc(row.email_type)}</td>
- <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>
+ <td>${row.status === 'sent' ? '<span class="badge badge-success">The provider accepted the email.</span>' : '<span class="badge badge-danger">The email could not be sent.</span>'}</td>
<td>${Number(row.count || 0).toLocaleString()}</td>
<td>${Number(row.count || 0).toLocaleString()}</td>
</tr>
</tr>
`).join('')}
`).join('')}
</tbody>
</tbody>
</table>`
</table>`
- : '<p class="muted">No email activity logged yet.</p>'}
+ : '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}
</div>
</div>
<div class="card" style="padding:16px">
<div class="card" style="padding:16px">
- <h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3>
+ <h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3><p class="admin-intro">These are the most recent email delivery attempts and provider errors. Check a failed row after a delivery report, then correct the configuration and send a new test.</p>
${recent.length ? `
${recent.length ? `
<table class="admin-table">
<table class="admin-table">
<thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Subject</th><th>Error</th></tr></thead>
<thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Subject</th><th>Error</th></tr></thead>
@@ -388,14 +388,14 @@ async function loadEmail() {
@@ -388,14 +388,14 @@ async function loadEmail() {
<td style="white-space:nowrap">${esc(timeAgo(row.created_at))}</td>
<td style="white-space:nowrap">${esc(timeAgo(row.created_at))}</td>
<td>${esc(row.email_type)}</td>
<td>${esc(row.email_type)}</td>
<td>${esc(row.recipient)}</td>
<td>${esc(row.recipient)}</td>
- <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>
+ <td>${row.status === 'sent' ? '<span class="badge badge-success">The provider accepted the email.</span>' : '<span class="badge badge-danger">The email could not be sent.</span>'}</td>
<td>${esc(row.subject || '-')}</td>
<td>${esc(row.subject || '-')}</td>
<td style="font-size:11px;color:var(--text-muted)">${esc(row.error_message || '-')}</td>
<td style="font-size:11px;color:var(--text-muted)">${esc(row.error_message || '-')}</td>
</tr>
</tr>
`).join('')}
`).join('')}
</tbody>
</tbody>
</table>`
</table>`
- : '<p class="muted">No email activity logged yet.</p>'}
+ : '<p class="muted">No email deliveries recorded. Save the configuration and send a test email to check delivery.</p>'}
</div>`;
</div>`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
}
@@ -27,8 +27,8 @@ async function loadDashboard() {
@@ -27,8 +27,8 @@ async function loadDashboard() {
{ label: 'Total Streams', value: ss?.streams?.total || ss?.totalStreams || 0, icon: 'fa-video' },
{ label: 'Total Streams', value: ss?.streams?.total || ss?.totalStreams || 0, icon: 'fa-video' },
{ label: 'Active Bans', value: ss?.users?.banned || ss?.activeBans || 0, icon: 'fa-ban' },
{ label: 'Active Bans', value: ss?.users?.banned || ss?.activeBans || 0, icon: 'fa-ban' },
{ label: 'Notifications', value: th?.total_notifications || 0, icon: 'fa-bell' },
{ label: 'Notifications', value: th?.total_notifications || 0, icon: 'fa-bell' },
- { label: 'Unread Notifs', value: th?.unread_notifications || 0, icon: 'fa-bell' },
- { label: 'Anon Users', value: th?.anon_users || 0, icon: 'fa-ghost' },
+ { label: 'Unread notifications', value: th?.unread_notifications || 0, icon: 'fa-bell' },
+ { label: 'Anonymous accounts', value: th?.anon_users || 0, icon: 'fa-ghost' },
];
];
statsEl.innerHTML = stats.map(s => `
statsEl.innerHTML = stats.map(s => `
@@ -45,14 +45,14 @@ async function loadDashboard() {
@@ -45,14 +45,14 @@ async function loadDashboard() {
const upM = Math.floor((th.uptime % 3600) / 60);
const upM = Math.floor((th.uptime % 3600) / 60);
healthEl.innerHTML = `
healthEl.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;font-size:13px">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;font-size:13px">
- <div><strong>Uptime:</strong> ${upH}h ${upM}m</div>
- <div><strong>Heap Used:</strong> ${fmtBytes(mem?.heapUsed)}</div>
- <div><strong>Heap Total:</strong> ${fmtBytes(mem?.heapTotal)}</div>
- <div><strong>RSS:</strong> ${fmtBytes(mem?.rss)}</div>
- <div><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success-text, var(--success))">Enabled</span>' : '<span style="color:var(--text-muted)">Disabled</span>'}</div>
+ <div><strong>Time since restart:</strong> ${upH}h ${upM}m</div>
+ <div><strong>Application memory in use:</strong> ${fmtBytes(mem?.heapUsed)}</div>
+ <div><strong>Application memory allocated:</strong> ${fmtBytes(mem?.heapTotal)}</div>
+ <div><strong>Total process memory:</strong> ${fmtBytes(mem?.rss)}</div>
+ <div><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success-text, var(--success))">Email sending is enabled.</span>' : '<span style="color:var(--text-muted)">Email sending is turned off.</span>'}</div>
</div>
</div>
`;
`;
} else {
} else {
- healthEl.innerHTML = '<p class="muted">Could not load health data</p>';
+ healthEl.innerHTML = '<p class="muted">Health data could not be loaded. Reload the tab; if it still fails, check the Network service.</p>';
}
}
}
}
@@ -13,30 +13,30 @@ async function loadDomains() {
@@ -13,30 +13,30 @@ async function loadDomains() {
_domCatalog = cat;
_domCatalog = cat;
const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });
const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });
const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];
const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];
- const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;
+ const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc({canonical:"Main address for search engines",short:"Short public address",alias:"Redirect to the short address",mirror:"Additional address serving the tool"}[r] || adminWords(r))}</span>`;
el.innerHTML = `
el.innerHTML = `
<div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>
<div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>
<p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>
<p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>
<div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">
<div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">
- <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div>
- <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>
- <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects to the short host)</option><option value="mirror">mirror (serves the tool, canonical tag points at the SEO host)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>
- <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div>
+ <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool" aria-label="Tool receiving this address" aria-describedby="help-dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select><span class="admin-help" id="help-dom-tool">Choose the tool or family that should open at the domain. The first catalog item is selected initially; choose the intended tool before adding the host.</span></div>
+ <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" aria-label="Website hostname" aria-describedby="help-dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"><span class="admin-help" id="help-dom-host">No hostname is entered by default. Enter a hostname without https://, for example tools.example.com, after pointing its DNS at the server.</span></div>
+ <div class="form-field"><label for="dom-role">Role</label><select id="dom-role" aria-label="How this address works" aria-describedby="help-dom-role"><option value="alias">Redirect — sends visitors to the short address</option><option value="mirror">Mirror — serves the tool here, and points search engines at the main address</option><option value="short">Short — the address people type</option><option value="canonical">Main — the address search engines are pointed at</option></select><span class="admin-help" id="help-dom-role">Redirect is the default and sends visitors to the short address. Mirror serves the tool at this address, Short sets its public address, and Main sets its preferred search-engine address.</span></div>
+ <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" aria-label="Staff note" aria-describedby="help-dom-note" maxlength="500"><span class="admin-help" id="help-dom-note">Optional note, blank by default. For example: campaign domain for November.</span></div>
<div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>
<div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>
</div>
</div>
<div id="dom-check" class="muted" style="margin-top:8px"></div>
<div id="dom-check" class="muted" style="margin-top:8px"></div>
</div>
</div>
- <div class="card"><h3>Overrides</h3>
+ <div class="card"><h3>Overrides</h3><p class="admin-intro">These saved addresses replace or extend the built-in tool addresses. Change them when routing a custom domain; delete the override to return to built-in routing.</p>
${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>
${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>
${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td>
${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td>
<td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>
<td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>
<td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>
<td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>
<td>${esc(d.note || '')}</td>
<td>${esc(d.note || '')}</td>
- <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>
- <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}
- </tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}
+ <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option value="${r}" ${r === d.role ? 'selected' : ''}>${{canonical:'Main address for search engines',short:'Short public address',alias:'Redirect to the short address',mirror:'Additional address serving the tool'}[r]}</option>`).join('')}</select>
+ <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove override</button></td></tr>`).join('')}
+ </tbody></table></div>` : '<p class="muted">No overrides yet; tools use their built-in addresses. Add a domain above when you need a custom address.</p>'}
</div>
</div>
- <div class="card"><h3>Built-in hosts</h3><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>
+ <div class="card"><h3>Built-in hosts</h3><p class="admin-intro">These are the default addresses supplied by the tool catalog. Check them before adding an override so you know which address it replaces.</p><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>
${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}
${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}
</tbody></table></div></div>`;
</tbody></table></div></div>`;
} catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }
} catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }
@@ -46,7 +46,7 @@ async function addDomain() {
@@ -46,7 +46,7 @@ async function addDomain() {
try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }
try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }
}
}
async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
-async function deleteDomain(id, host) { if (!confirm(`Remove ${host}?`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
+async function deleteDomain(id, host) { if (!confirm(`Remove ${host} from the tool address overrides? This changes routing after refresh. Add the same host and role again to undo.`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
async function checkDomainDns() {
async function checkDomainDns() {
const out = document.getElementById('dom-check'); out.textContent = 'Checking…';
const out = document.getElementById('dom-check'); out.textContent = 'Checking…';
try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};
try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};
@@ -102,19 +102,19 @@ function renderSSHOwner(el) {
@@ -102,19 +102,19 @@ function renderSSHOwner(el) {
<div class="card">
<div class="card">
<div class="form-group" style="margin-bottom:14px">
<div class="form-group" style="margin-bottom:14px">
<label>New Linux username</label>
<label>New Linux username</label>
- <input type="text" id="ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px">
+ <input type="text" id="ssh-o-user" aria-label="New server username" aria-describedby="help-ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px"><span class="admin-help" id="help-ssh-o-user">No name is entered by default. Use a Linux username such as alex for the account you are provisioning.</span>
</div>
</div>
<div class="form-group" style="margin-bottom:14px">
<div class="form-group" style="margin-bottom:14px">
<label>Their SSH <b>public</b> key <span class="muted">— ask them to run the steps in their own SSH tab and paste their <code>.pub</code> here (recommended)</span></label>
<label>Their SSH <b>public</b> key <span class="muted">— ask them to run the steps in their own SSH tab and paste their <code>.pub</code> here (recommended)</span></label>
- <textarea id="ssh-o-pubkey" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea>
+ <textarea id="ssh-o-pubkey" aria-label="SSH public key" aria-describedby="ssh-public-help" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea><p class="admin-help" id="ssh-public-help">No key is entered by default. Paste the person’s public .pub key, for example an ssh-ed25519 public key; never enter their private key.</p>
</div>
</div>
<div class="form-group">
<div class="form-group">
<label>Access level</label>
<label>Access level</label>
- <select id="ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px">
+ <select id="ssh-o-access" aria-label="Server access level" aria-describedby="help-ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px">
<option value="code">Project code only (safest)</option>
<option value="code">Project code only (safest)</option>
<option value="restart">Project code + restart app services</option>
<option value="restart">Project code + restart app services</option>
<option value="web">Full web + nginx — build & run their own sites (high trust)</option>
<option value="web">Full web + nginx — build & run their own sites (high trust)</option>
- </select>
+ </select><span class="admin-help" id="help-ssh-o-access">Project code only is the default. Restart also allows service restarts, and Full web permits web server management; removing access requires the owner to update the server account.</span>
</div>
</div>
<div id="ssh-o-webwarn" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid var(--warning);border-radius:8px;background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning-text, var(--warning));font-size:0.84rem;line-height:1.5">
<div id="ssh-o-webwarn" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid var(--warning);border-radius:8px;background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning-text, var(--warning));font-size:0.84rem;line-height:1.5">
<i class="fa-solid fa-triangle-exclamation"></i> <b>High trust:</b> web + nginx access lets this admin edit any nginx site config and reload the web server — they can serve anything and a bad config can take <em>all</em> the sites down. It still stops short of root (no package installs or arbitrary sudo), but only grant it to admins you fully trust to run the web layer with you.
<i class="fa-solid fa-triangle-exclamation"></i> <b>High trust:</b> web + nginx access lets this admin edit any nginx site config and reload the web server — they can serve anything and a bad config can take <em>all</em> the sites down. It still stops short of root (no package installs or arbitrary sudo), but only grant it to admins you fully trust to run the web layer with you.
@@ -123,13 +123,14 @@ function renderSSHOwner(el) {
@@ -123,13 +123,14 @@ function renderSSHOwner(el) {
<div class="card">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
<h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
- <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>
+ <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy the prompt</button>
</div>
</div>
<p class="muted" style="margin:8px 0">Paste into Claude Code running on the server with sudo (or locally with root SSH) to create and lock down the account.</p>
<p class="muted" style="margin:8px 0">Paste into Claude Code running on the server with sudo (or locally with root SSH) to create and lock down the account.</p>
<pre id="ssh-o-prompt" class="ssh-prompt"></pre>
<pre id="ssh-o-prompt" class="ssh-prompt"></pre>
</div>
</div>
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-shield-halved"></i> How it's locked down</h3>
<h3><i class="fa-solid fa-shield-halved"></i> How it's locked down</h3>
+ <p class="admin-intro">This lists the limits placed on a provisioned server account. Read it before you grant access, and after any change to the access level you chose above.</p>
<ul class="muted" style="line-height:1.7;margin:0;padding-left:20px">
<ul class="muted" style="line-height:1.7;margin:0;padding-left:20px">
<li>Key-only login — password auth is disabled for the account.</li>
<li>Key-only login — password auth is disabled for the account.</li>
<li>Member of a shared <code>devs</code> group that owns the project dirs (<code>${esc(sshInfo.projectsRoot)}</code>) with setgid, so they can edit code and new files stay group-shared.</li>
<li>Member of a shared <code>devs</code> group that owns the project dirs (<code>${esc(sshInfo.projectsRoot)}</code>) with setgid, so they can edit code and new files stay group-shared.</li>
@@ -209,16 +210,18 @@ function renderSSHUser(el) {
@@ -209,16 +210,18 @@ function renderSSHUser(el) {
el.innerHTML = `
el.innerHTML = `
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-terminal"></i> Connect to the dev server <span class="staff-pill user-pill">You</span></h3>
<h3><i class="fa-solid fa-terminal"></i> Connect to the dev server <span class="staff-pill user-pill">You</span></h3>
+ <p class="admin-intro">This page explains how to sign in to the development server. Use it when you need server access; your private key never leaves your machine.</p>
<p class="muted">Generate your key, send the owner (<b>${esc(sshInfo.ownerUsername)}</b>) your <b>public</b> key to authorize, then SSH in and help build the sites with Claude. Your private key never leaves your machine.</p>
<p class="muted">Generate your key, send the owner (<b>${esc(sshInfo.ownerUsername)}</b>) your <b>public</b> key to authorize, then SSH in and help build the sites with Claude. Your private key never leaves your machine.</p>
</div>
</div>
<div class="card">
<div class="card">
<div class="form-group">
<div class="form-group">
<label>Your SSH username <span class="muted">— confirm the exact name with the owner</span></label>
<label>Your SSH username <span class="muted">— confirm the exact name with the owner</span></label>
- <input type="text" id="ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px">
+ <input type="text" id="ssh-u-user" aria-label="Your server username" aria-describedby="help-ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px"><span class="admin-help" id="help-ssh-u-user">Starts with your account username when available. Confirm the actual server login with the owner, for example alex.</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-list-ol"></i> Steps</h3>
<h3><i class="fa-solid fa-list-ol"></i> Steps</h3>
+ <p class="admin-intro">These are the commands that set up your key and the connection to the server. Run them once, then use the connection line to sign in.</p>
<ol class="muted" style="line-height:1.9;padding-left:20px;margin:0">
<ol class="muted" style="line-height:1.9;padding-left:20px;margin:0">
<li>Generate a key pair (skip if you already have one you want to use):<br><code id="ssh-u-keygen"></code></li>
<li>Generate a key pair (skip if you already have one you want to use):<br><code id="ssh-u-keygen"></code></li>
<li>Print your <b>public</b> key and send it to the owner to authorize:<br><code id="ssh-u-cat"></code></li>
<li>Print your <b>public</b> key and send it to the owner to authorize:<br><code id="ssh-u-cat"></code></li>
@@ -229,7 +232,7 @@ function renderSSHUser(el) {
@@ -229,7 +232,7 @@ function renderSSHUser(el) {
<div class="card">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
<h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
- <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>
+ <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy the prompt</button>
</div>
</div>
<p class="muted" style="margin:8px 0">Paste into Claude Code on your own machine — it sets up the connection and gives it full context about the server so you can start vibe coding.</p>
<p class="muted" style="margin:8px 0">Paste into Claude Code on your own machine — it sets up the connection and gives it full context about the server so you can start vibe coding.</p>
<pre id="ssh-u-prompt" class="ssh-prompt"></pre>
<pre id="ssh-u-prompt" class="ssh-prompt"></pre>
@@ -313,12 +316,12 @@ async function loadDeployOverview() {
@@ -313,12 +316,12 @@ async function loadDeployOverview() {
const c = d.config;
const c = d.config;
document.getElementById('deploy-stats').innerHTML = `
document.getElementById('deploy-stats').innerHTML = `
<div class="stat-card"><div class="value" style="color:${p.certbotInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.certbotInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Certbot</div></div>
<div class="stat-card"><div class="value" style="color:${p.certbotInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.certbotInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Certbot</div></div>
- <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">CF Plugin</div></div>
+ <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">Cloudflare certificate plugin</div></div>
<div class="stat-card"><div class="value" style="color:${p.nginxInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.nginxInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Nginx</div></div>
<div class="stat-card"><div class="value" style="color:${p.nginxInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.nginxInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Nginx</div></div>
- <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">CF Token</div></div>
+ <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">Cloudflare access token</div></div>
<div class="stat-card"><div class="value">${c.domainsConfigured}</div><div class="label">Domains</div></div>
<div class="stat-card"><div class="value">${c.domainsConfigured}</div><div class="label">Domains</div></div>
- <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.certMode)}</div><div class="label">Cert Mode</div></div>
- <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.nginxMode)}</div><div class="label">Nginx Mode</div></div>
+ <div class="stat-card"><div class="value" style="font-size:14px">${esc({cloudflare:'Automatic DNS verification',manual:'Manual DNS verification',none:'Certificates managed elsewhere'}[c.certMode] || 'Certificate mode unavailable')}</div><div class="label">Certificate management</div></div>
+ <div class="stat-card"><div class="value" style="font-size:14px">${esc({preview:'Preview only; no files written',apply:'Writing and reloading allowed',disabled:'Web server management disabled'}[c.nginxMode] || 'Web server mode unavailable')}</div><div class="label">Web server management</div></div>
`;
`;
const prereqsEl = document.getElementById('deploy-prereqs');
const prereqsEl = document.getElementById('deploy-prereqs');
const items = [];
const items = [];
@@ -361,7 +364,7 @@ async function loadDeployCerts() {
@@ -361,7 +364,7 @@ async function loadDeployCerts() {
<div class="muted" style="font-size:11px;margin-top:2px">${(c.domains || []).map(d => esc(d)).join(', ')}</div>
<div class="muted" style="font-size:11px;margin-top:2px">${(c.domains || []).map(d => esc(d)).join(', ')}</div>
</div>
</div>
<div style="text-align:right">
<div style="text-align:right">
- <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? c.daysRemaining + ' days' : 'Unknown'}</span>
+ <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? 'Expires in ' + c.daysRemaining + ' days.' : 'Expiry date is unknown.'}</span>
<div class="muted" style="font-size:11px">${c.expiry ? 'Expires ' + esc(c.expiry) : ''}</div>
<div class="muted" style="font-size:11px">${c.expiry ? 'Expires ' + esc(c.expiry) : ''}</div>
</div>
</div>
</div>
</div>
@@ -409,7 +412,8 @@ async function showManualDnsInfo() {
@@ -409,7 +412,8 @@ async function showManualDnsInfo() {
const d = await r.json();
const d = await r.json();
const instructions = (d.instructions || []).map(i => esc(i)).join('<br>');
const instructions = (d.instructions || []).map(i => esc(i)).join('<br>');
out.innerHTML = `<div class="card" style="padding:16px;margin-top:8px">
out.innerHTML = `<div class="card" style="padding:16px;margin-top:8px">
- <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS Challenge</h4>
+ <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS challenge</h4>
+ <p class="admin-intro">These are the DNS records to add at your domain registrar so the certificate can be issued by hand. Add them exactly as written, then issue the certificate; the request fails until the records are visible.</p>
<div style="font-size:13px;line-height:1.6">${instructions}</div>
<div style="font-size:13px;line-height:1.6">${instructions}</div>
<div style="margin-top:16px">
<div style="margin-top:16px">
<button class="btn btn-primary" onclick="issueManualCert('${esc(domain)}')">
<button class="btn btn-primary" onclick="issueManualCert('${esc(domain)}')">
@@ -505,7 +509,7 @@ async function validateNginx() {
@@ -505,7 +509,7 @@ async function validateNginx() {
}
}
async function applyNginxConfigs() {
async function applyNginxConfigs() {
- if (!confirm('Apply generated Nginx configs? This will backup existing configs, write new ones, validate, and reload Nginx.')) return;
+ if (!confirm('Apply the generated web server configuration? This backs up the existing files, writes new ones, validates them, and reloads Nginx. Routing may change immediately. To undo, restore the backup on the server, validate it, and reload Nginx.')) return;
const out = document.getElementById('nginx-preview-output');
const out = document.getElementById('nginx-preview-output');
out.innerHTML = '<div class="loading">Applying Nginx configs...</div>';
out.innerHTML = '<div class="loading">Applying Nginx configs...</div>';
try {
try {
@@ -571,7 +575,7 @@ async function addManagedDomain() {
@@ -571,7 +575,7 @@ async function addManagedDomain() {
}
}
async function removeManagedDomain(idx) {
async function removeManagedDomain(idx) {
- if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '?')) return;
+ if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '? It will leave the managed domain list. Add it again to undo; existing certificates and web server files are managed separately.')) return;
deployDomainsList.splice(idx, 1);
deployDomainsList.splice(idx, 1);
await saveDomainsList();
await saveDomainsList();
}
}
@@ -13,7 +13,7 @@ async function loadStreams() {
@@ -13,7 +13,7 @@ async function loadStreams() {
try {
try {
const data = await api('/api/admin/streamer/streams');
const data = await api('/api/admin/streamer/streams');
const streams = data.streams || [];
const streams = data.streams || [];
- if (!streams.length) { c.innerHTML = '<p class="muted">No active streams</p>'; return; }
+ if (!streams.length) { c.innerHTML = '<p class="muted">No streams are live. Return when someone starts broadcasting.</p>'; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
<thead><tr>
<thead><tr>
@@ -27,7 +27,7 @@ async function loadStreams() {
@@ -27,7 +27,7 @@ async function loadStreams() {
<td>${s.viewer_count || 0}</td>
<td>${s.viewer_count || 0}</td>
<td>${s.started_at ? timeAgo(s.started_at) : '-'}</td>
<td>${s.started_at ? timeAgo(s.started_at) : '-'}</td>
<td>
<td>
- <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End</button>
+ <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End broadcast</button><span class="admin-help">Disconnects viewers; the streamer must restart to resume.</span>
</td>
</td>
</tr>
</tr>
`).join('')}</tbody>
`).join('')}</tbody>
@@ -46,14 +46,14 @@ async function loadLineage(reason = '') {
@@ -46,14 +46,14 @@ async function loadLineage(reason = '') {
tabs.innerHTML = [['', 'All']].concat(Object.keys(by).sort().map(r => [r, `${r.replace(/_/g, ' ')} (${by[r].refs})`]))
tabs.innerHTML = [['', 'All']].concat(Object.keys(by).sort().map(r => [r, `${r.replace(/_/g, ' ')} (${by[r].refs})`]))
.map(([r, label]) => `<button class="${r === reason ? 'active' : ''}" onclick="loadLineage('${esc(r)}')">${esc(label)}</button>`).join('');
.map(([r, label]) => `<button class="${r === reason ? 'active' : ''}" onclick="loadLineage('${esc(r)}')">${esc(label)}</button>`).join('');
const rows = data.unresolved || [];
const rows = data.unresolved || [];
- if (!rows.length) { c.innerHTML = '<p class="muted">Nothing unresolved in the last 30 days.</p>'; return; }
+ if (!rows.length) { c.innerHTML = '<p class="muted">No unmatched references in the last 30 days. Return here if a channel link stops resolving.</p>'; return; }
c.innerHTML = `
c.innerHTML = `
<div style="overflow-x:auto"><table class="admin-table">
<div style="overflow-x:auto"><table class="admin-table">
<thead><tr><th>Reference</th><th>Reason</th><th>Detail</th><th>Last caller</th><th>Times</th><th>First seen</th><th>Last seen</th></tr></thead>
<thead><tr><th>Reference</th><th>Reason</th><th>Detail</th><th>Last caller</th><th>Times</th><th>First seen</th><th>Last seen</th></tr></thead>
<tbody>${rows.map(u => `
<tbody>${rows.map(u => `
<tr>
<tr>
<td><code>${esc(u.ref)}</code></td>
<td><code>${esc(u.ref)}</code></td>
- <td><span class="badge badge-warning">${esc(u.reason)}</span></td>
+ <td><span class="badge badge-warning">${esc(adminWords(u.reason))}</span></td>
<td>${esc(u.detail || '')}</td>
<td>${esc(u.detail || '')}</td>
<td>${esc(u.last_caller || '-')}</td>
<td>${esc(u.last_caller || '-')}</td>
<td>${Number(u.count) || 0}</td>
<td>${Number(u.count) || 0}</td>
@@ -65,7 +65,7 @@ async function loadLineage(reason = '') {
@@ -65,7 +65,7 @@ async function loadLineage(reason = '') {
}
}
async function endStream(id) {
async function endStream(id) {
- if (!confirm('Force end this stream?')) return;
+ if (!confirm('End this broadcast now? Viewers will be disconnected. The streamer must start a new broadcast to resume.')) return;
try {
try {
await api(`/api/admin/streamer/streams/${id}`, { method: 'DELETE' });
await api(`/api/admin/streamer/streams/${id}`, { method: 'DELETE' });
toast('Stream ended', 'success');
toast('Stream ended', 'success');
@@ -82,7 +82,7 @@ async function loadBans() {
@@ -82,7 +82,7 @@ async function loadBans() {
try {
try {
const data = await api('/api/admin/streamer/bans');
const data = await api('/api/admin/streamer/bans');
const bans = data.bans || [];
const bans = data.bans || [];
- if (!bans.length) { c.innerHTML = '<p class="muted">No active bans</p>'; return; }
+ if (!bans.length) { c.innerHTML = '<p class="muted">No active bans. Review Users if an account needs a restriction.</p>'; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
<thead><tr>
<thead><tr>
@@ -96,7 +96,7 @@ async function loadBans() {
@@ -96,7 +96,7 @@ async function loadBans() {
<td>${b.created_at ? timeAgo(b.created_at) : '-'}</td>
<td>${b.created_at ? timeAgo(b.created_at) : '-'}</td>
<td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>
<td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>
<td>
<td>
- <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Unban</button>
+ <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Allow access again</button>
</td>
</td>
</tr>
</tr>
`).join('')}</tbody>
`).join('')}</tbody>
@@ -137,7 +137,7 @@ async function fetchChatLogs() {
@@ -137,7 +137,7 @@ async function fetchChatLogs() {
const msgs = data.messages || [];
const msgs = data.messages || [];
const total = data.total || 0;
const total = data.total || 0;
- if (!msgs.length) { c.innerHTML = '<p class="muted">No messages found</p>'; pager.innerHTML = ''; return; }
+ if (!msgs.length) { c.innerHTML = '<p class="muted">No messages match. Try different words or another person.</p>'; pager.innerHTML = ''; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
@@ -171,7 +171,7 @@ async function loadModerators() {
@@ -171,7 +171,7 @@ async function loadModerators() {
try {
try {
const data = await api('/api/admin/streamer/moderators');
const data = await api('/api/admin/streamer/moderators');
const mods = data.moderators || [];
const mods = data.moderators || [];
- if (!mods.length) { c.innerHTML = '<p class="muted">No global moderators yet</p>'; return; }
+ if (!mods.length) { c.innerHTML = '<p class="muted">No global moderators yet. Enter a trusted username above to add one.</p>'; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
<thead><tr><th>Username</th><th>Display Name</th><th>Last Seen</th><th>Actions</th></tr></thead>
<thead><tr><th>Username</th><th>Display Name</th><th>Last Seen</th><th>Actions</th></tr></thead>
@@ -181,7 +181,7 @@ async function loadModerators() {
@@ -181,7 +181,7 @@ async function loadModerators() {
<td>${esc(m.display_name || m.username)}</td>
<td>${esc(m.display_name || m.username)}</td>
<td>${m.last_seen ? timeAgo(m.last_seen) : 'Never'}</td>
<td>${m.last_seen ? timeAgo(m.last_seen) : 'Never'}</td>
<td>
<td>
- <button class="btn btn-sm btn-danger" onclick="demoteMod('${m.id}','${esc(m.username)}')"><i class="fa-solid fa-user-minus"></i> Demote</button>
+ <button class="btn btn-sm btn-danger" onclick="demoteMod('${m.id}','${esc(m.username)}')"><i class="fa-solid fa-user-minus"></i> Remove moderator access</button>
</td>
</td>
</tr>
</tr>
`).join('')}</tbody>
`).join('')}</tbody>
@@ -201,7 +201,7 @@ async function promoteMod() {
@@ -201,7 +201,7 @@ async function promoteMod() {
}
}
async function demoteMod(id, username) {
async function demoteMod(id, username) {
- if (!confirm(`Demote ${username} from global moderator?`)) return;
+ if (!confirm(`Remove global moderation access from ${username}? They will lose these permissions. Promote them again to restore access.`)) return;
try {
try {
await api(`/api/admin/streamer/moderators/${id}`, { method: 'DELETE' });
await api(`/api/admin/streamer/moderators/${id}`, { method: 'DELETE' });
toast(`${username} demoted`, 'success');
toast(`${username} demoted`, 'success');
@@ -248,21 +248,21 @@ async function loadDeliveries() {
@@ -248,21 +248,21 @@ async function loadDeliveries() {
const d = await api(`/api/admin/events/deliveries?${q}`);
const d = await api(`/api/admin/events/deliveries?${q}`);
_deliveries = d.deliveries || [];
_deliveries = d.deliveries || [];
const counts = d.counts || {};
const counts = d.counts || {};
- document.getElementById('dlq-counts').textContent = Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(' · ');
+ document.getElementById('dlq-counts').textContent = Object.entries(counts).map(([k, v]) => `${{dead:'Stopped retrying',failed:'Retrying after a failure',pending:'Waiting to be delivered',delivered:'Delivered successfully'}[k] || adminWords(k)}: ${v} deliveries`).join(' · ');
const bySub = {};
const bySub = {};
for (const x of _deliveries) (bySub[x.subscription_id] = bySub[x.subscription_id] || []).push(x.event_id);
for (const x of _deliveries) (bySub[x.subscription_id] = bySub[x.subscription_id] || []).push(x.event_id);
- const replayAll = Object.entries(bySub).map(([s, ids]) => `<button class="btn btn-sm" data-sub="${esc(s)}" onclick="replayDeliveries(this.dataset.sub, null)">Replay ${ids.length} for ${esc(s.slice(0, 14))}…</button>`).join(' ');
+ const replayAll = Object.entries(bySub).map(([s, ids]) => `<button class="btn btn-sm" data-sub="${esc(s)}" onclick="replayDeliveries(this.dataset.sub, null)">Replay ${ids.length} deliveries for ${esc(s.slice(0, 14))}…</button>`).join(' ');
c.innerHTML = _deliveries.length ? `<div style="margin-bottom:8px">${replayAll}</div><table class="admin-table"><thead><tr><th>Event</th><th>Subscription</th><th>Seq</th><th>Attempts</th><th>Last answer</th><th></th></tr></thead><tbody>${_deliveries.map(x => `
c.innerHTML = _deliveries.length ? `<div style="margin-bottom:8px">${replayAll}</div><table class="admin-table"><thead><tr><th>Event</th><th>Subscription</th><th>Seq</th><th>Attempts</th><th>Last answer</th><th></th></tr></thead><tbody>${_deliveries.map(x => `
<tr><td style="font-size:11px"><code>${esc(x.event_id)}</code></td><td style="font-size:11px"><code>${esc(x.subscription_id)}</code></td><td>${esc(String(x.seq))}</td><td>${esc(String(x.attempt))}</td>
<tr><td style="font-size:11px"><code>${esc(x.event_id)}</code></td><td style="font-size:11px"><code>${esc(x.subscription_id)}</code></td><td>${esc(String(x.seq))}</td><td>${esc(String(x.attempt))}</td>
- <td style="font-size:11px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(x.last_error || '')}">${esc(x.last_status ? String(x.last_status) : '-')} ${esc(x.last_error || '')}</td>
- <td><button class="btn btn-sm" data-sub="${esc(x.subscription_id)}" data-evt="${esc(x.event_id)}" onclick="replayDeliveries(this.dataset.sub, [this.dataset.evt])">Replay</button></td></tr>`).join('')}</tbody></table>` : '<p class="muted">Nothing here.</p>';
+ <td style="font-size:11px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(x.last_error || '')}">${x.last_status ? `The receiver answered HTTP ${esc(String(x.last_status))}.` : 'No response recorded.'} ${esc(x.last_error || '')}</td>
+ <td><button class="btn btn-sm" data-sub="${esc(x.subscription_id)}" data-evt="${esc(x.event_id)}" onclick="replayDeliveries(this.dataset.sub, [this.dataset.evt])">Replay delivery</button></td></tr>`).join('')}</tbody></table>` : '<p class="muted">No deliveries match. Choose another delivery state or clear the subscription filter.</p>';
} catch (err) {
} catch (err) {
c.innerHTML = `<p class="muted">Deliveries could not be loaded: ${esc(err.message || 'error')}</p>`;
c.innerHTML = `<p class="muted">Deliveries could not be loaded: ${esc(err.message || 'error')}</p>`;
}
}
}
}
async function replayDeliveries(sub, ids) {
async function replayDeliveries(sub, ids) {
const eventIds = ids || _deliveries.filter(x => x.subscription_id === sub).map(x => x.event_id);
const eventIds = ids || _deliveries.filter(x => x.subscription_id === sub).map(x => x.event_id);
- if (!eventIds.length || !confirm(`Replay ${eventIds.length} deliver${eventIds.length === 1 ? 'y' : 'ies'} to ${sub}?`)) return;
+ if (!eventIds.length || !confirm(`Replay ${eventIds.length} deliver${eventIds.length === 1 ? 'y' : 'ies'} to ${sub}? This sends the events again and cannot be recalled. Fix the destination before retrying.`)) return;
try { const r = await api('/api/admin/events/replay', { method: 'POST', body: { subscription_id: sub, event_ids: eventIds } }); alert(`Queued ${r.queued}`); loadDeliveries(); }
try { const r = await api('/api/admin/events/replay', { method: 'POST', body: { subscription_id: sub, event_ids: eventIds } }); alert(`Queued ${r.queued}`); loadDeliveries(); }
catch (err) { alert(err.message || 'Replay failed'); }
catch (err) { alert(err.message || 'Replay failed'); }
}
}
@@ -279,7 +279,7 @@ async function loadOperatorChecklist() {
@@ -279,7 +279,7 @@ async function loadOperatorChecklist() {
row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;padding:8px 0;border-top:1px solid var(--border)';
row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;padding:8px 0;border-top:1px solid var(--border)';
const dot = document.createElement('span'); dot.title = (it.service || '') + ': ' + it.status;
const dot = document.createElement('span'); dot.title = (it.service || '') + ': ' + it.status;
dot.style.cssText = 'width:10px;height:10px;border-radius:50%;margin-top:6px;flex:none'; dot.style.background = colour[it.status] || 'var(--text-muted, #888)';
dot.style.cssText = 'width:10px;height:10px;border-radius:50%;margin-top:6px;flex:none'; dot.style.background = colour[it.status] || 'var(--text-muted, #888)';
- const name = document.createElement('strong'); name.textContent = it.item; name.style.minWidth = '260px';
+ const name = document.createElement('strong'); name.textContent = it.item + ': ' + ({ready:'Ready to use.',degraded:'Working with a problem; check the linked service.',down:'Unavailable; check the linked service.'}[it.status] || 'Status unavailable.'); name.style.minWidth = '260px';
const places = document.createElement('div'); places.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center';
const places = document.createElement('div'); places.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center';
for (const w of it.where) {
for (const w of it.where) {
let el;
let el;
@@ -289,7 +289,7 @@ async function loadOperatorChecklist() {
@@ -289,7 +289,7 @@ async function loadOperatorChecklist() {
places.appendChild(el);
places.appendChild(el);
if (w.note) { const n = document.createElement('span'); n.className = 'muted'; n.textContent = '(' + w.note + ')'; places.appendChild(n); }
if (w.note) { const n = document.createElement('span'); n.className = 'muted'; n.textContent = '(' + w.note + ')'; places.appendChild(n); }
}
}
- if (!it.console) { const gap = document.createElement('span'); gap.className = 'muted'; gap.textContent = 'no console yet'; gap.style.color = 'var(--warning, #f5a524)'; places.appendChild(gap); }
+ if (!it.console) { const gap = document.createElement('span'); gap.className = 'muted'; gap.textContent = 'No settings page yet; use the listed command or API.'; gap.style.color = 'var(--warning, #f5a524)'; places.appendChild(gap); }
row.append(dot, name, places);
row.append(dot, name, places);
c.appendChild(row);
c.appendChild(row);
}
}
@@ -301,7 +301,7 @@ async function loadThemeReview() {
@@ -301,7 +301,7 @@ async function loadThemeReview() {
let list = [];
let list = [];
try { list = (await api('/api/admin/themes?status=pending')).themes || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }
try { list = (await api('/api/admin/themes?status=pending')).themes || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }
c.replaceChildren();
c.replaceChildren();
- if (!list.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = 'Nothing waiting for review.'; c.appendChild(p); return; }
+ if (!list.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = 'No themes are waiting for review. Return after a theme author submits one.'; c.appendChild(p); return; }
for (const t of list) {
for (const t of list) {
const card = document.createElement('div');
const card = document.createElement('div');
card.style.cssText = 'border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;display:grid;gap:8px';
card.style.cssText = 'border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;display:grid;gap:8px';
@@ -311,9 +311,9 @@ async function loadThemeReview() {
@@ -311,9 +311,9 @@ async function loadThemeReview() {
head.append(n, meta);
head.append(n, meta);
const swatches = document.createElement('div'); swatches.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap';
const swatches = document.createElement('div'); swatches.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap';
for (const [k, v] of Object.entries(t.variables || {}).filter(([, v]) => /^#|^rgb|^hsl/i.test(v)).slice(0, 14)) {
for (const [k, v] of Object.entries(t.variables || {}).filter(([, v]) => /^#|^rgb|^hsl/i.test(v)).slice(0, 14)) {
- const sw = document.createElement('span'); sw.title = `${k}: ${v}`; sw.style.cssText = 'width:22px;height:22px;border-radius:6px;border:1px solid var(--border)'; sw.style.background = v; swatches.appendChild(sw);
+ const sw = document.createElement('span'); sw.title = `${adminWords(k)}: ${v}`; sw.style.cssText = 'width:22px;height:22px;border-radius:6px;border:1px solid var(--border)'; sw.style.background = v; swatches.appendChild(sw);
}
}
- const note = document.createElement('input'); note.placeholder = 'Reason (needed to reject)'; note.maxLength = 300; note.style.minWidth = '240px';
+ const note = document.createElement('input'); note.placeholder = 'Reason (needed to reject)'; note.setAttribute('aria-label', 'Review reason; required for rejection, for example unreadable text'); note.maxLength = 300; note.style.minWidth = '240px';
const act = (decision) => async () => {
const act = (decision) => async () => {
try { await api(`/api/admin/themes/${encodeURIComponent(t.id)}/review`, { method: 'POST', body: JSON.stringify({ decision, note: note.value }) }); loadThemeReview(); }
try { await api(`/api/admin/themes/${encodeURIComponent(t.id)}/review`, { method: 'POST', body: JSON.stringify({ decision, note: note.value }) }); loadThemeReview(); }
catch (err) { alert(err.message); }
catch (err) { alert(err.message); }
@@ -321,7 +321,7 @@ async function loadThemeReview() {
@@ -321,7 +321,7 @@ async function loadThemeReview() {
const ok = document.createElement('button'); ok.className = 'btn'; ok.textContent = 'Approve'; ok.onclick = act('approve');
const ok = document.createElement('button'); ok.className = 'btn'; ok.textContent = 'Approve'; ok.onclick = act('approve');
const no = document.createElement('button'); no.className = 'btn'; no.textContent = 'Reject'; no.onclick = act('reject');
const no = document.createElement('button'); no.className = 'btn'; no.textContent = 'Reject'; no.onclick = act('reject');
const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center'; row.append(note, ok, no);
const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center'; row.append(note, ok, no);
- card.append(head, swatches, row);
+ const help = document.createElement('p'); help.className = 'admin-help'; help.textContent = 'Explain a rejection so the author can fix and resubmit the theme; for example, text is unreadable. Approval publishes the theme; there is no undo control in this queue.'; card.append(head, swatches, help, row);
c.appendChild(card);
c.appendChild(card);
}
}
}
}
@@ -345,9 +345,9 @@ function renderGrants() {
@@ -345,9 +345,9 @@ function renderGrants() {
const badge = { active: 'badge-success', revoked: 'badge-danger', expired: 'badge-warning' };
const badge = { active: 'badge-success', revoked: 'badge-danger', expired: 'badge-warning' };
document.getElementById('grants-content').innerHTML = list.length ? `<table class="admin-table"><thead><tr><th>Service</th><th>Capability</th><th>Audience</th><th>Namespaces</th><th>State</th><th>Expires</th><th>By</th><th></th></tr></thead><tbody>${list.map(g => `
document.getElementById('grants-content').innerHTML = list.length ? `<table class="admin-table"><thead><tr><th>Service</th><th>Capability</th><th>Audience</th><th>Namespaces</th><th>State</th><th>Expires</th><th>By</th><th></th></tr></thead><tbody>${list.map(g => `
<tr><td><code>${esc(g.client_id)}</code></td><td><code>${esc(g.capability)}</code></td><td style="font-size:11px">${esc(g.audience)}</td>
<tr><td><code>${esc(g.client_id)}</code></td><td><code>${esc(g.capability)}</code></td><td style="font-size:11px">${esc(g.audience)}</td>
- <td style="font-size:11px">${esc((g.namespaces || []).join(', ') || '-')}</td><td><span class="badge ${badge[g.state] || ''}">${esc(g.state)}</span></td>
+ <td style="font-size:11px">${esc((g.namespaces || []).join(', ') || '-')}</td><td><span class="badge ${badge[g.state] || ''}">${esc({active:'Access is allowed.',revoked:'Access was removed.',expired:'Access has expired.'}[g.state] || 'Access state is unknown.')}</span></td>
<td style="font-size:11px">${g.expires_at ? esc(g.expires_at.slice(0, 10)) : '-'}</td><td style="font-size:11px">${esc(g.granted_by === 'default' ? 'default' : (g.granted_by || '-'))}</td>
<td style="font-size:11px">${g.expires_at ? esc(g.expires_at.slice(0, 10)) : '-'}</td><td style="font-size:11px">${esc(g.granted_by === 'default' ? 'default' : (g.granted_by || '-'))}</td>
- <td>${g.state === 'active' ? `<button class="btn btn-sm" data-client="${esc(g.client_id)}" data-cap="${esc(g.capability)}" onclick="revokeGrant(this.dataset.client, this.dataset.cap)">Revoke</button>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No grants match.</p>';
+ <td>${g.state === 'active' ? `<button class="btn btn-sm" data-client="${esc(g.client_id)}" data-cap="${esc(g.capability)}" title="Remove access; grant again to restore it" onclick="revokeGrant(this.dataset.client, this.dataset.cap)">Revoke access</button>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No permissions match. Clear the service filter or add the required permission above.</p>';
}
}
async function submitGrant() {
async function submitGrant() {
const v = (id) => document.getElementById(id).value.trim();
const v = (id) => document.getElementById(id).value.trim();
@@ -357,7 +357,7 @@ async function submitGrant() {
@@ -357,7 +357,7 @@ async function submitGrant() {
catch (err) { alert(err.message || 'Grant failed'); }
catch (err) { alert(err.message || 'Grant failed'); }
}
}
async function revokeGrant(client, capability) {
async function revokeGrant(client, capability) {
- const reason = prompt(`Revoke ${capability} from ${client}? Reason:`);
+ const reason = prompt(`Revoke ${capability} from ${client}? This stops that service from using the permission. Grant it again to restore access. Reason:`);
if (!reason) return;
if (!reason) return;
try { await api('/api/admin/grants/revoke', { method: 'POST', body: { client_id: client, capability, reason } }); loadGrants(); }
try { await api('/api/admin/grants/revoke', { method: 'POST', body: { client_id: client, capability, reason } }); loadGrants(); }
catch (err) { alert(err.message || 'Revoke failed'); }
catch (err) { alert(err.message || 'Revoke failed'); }
@@ -382,7 +382,7 @@ async function loadModLog(reset) {
@@ -382,7 +382,7 @@ async function loadModLog(reset) {
<td style="font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.reason || '')}">${esc(e.reason || '-')}</td>
<td style="font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.reason || '')}">${esc(e.reason || '-')}</td>
</tr>`).join('');
</tr>`).join('');
if (reset) {
if (reset) {
- c.innerHTML = items.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Service</th><th>Action</th><th>By</th><th>Target</th><th>Reason</th></tr></thead><tbody id="modlog-rows">${rowsHtml}</tbody></table>` : '<p class="muted">No staff actions recorded yet.</p>';
+ c.innerHTML = items.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Service</th><th>Action</th><th>By</th><th>Target</th><th>Reason</th></tr></thead><tbody id="modlog-rows">${rowsHtml}</tbody></table>` : '<p class="muted">No staff actions match these filters. Clear the filters or return after staff activity.</p>';
} else {
} else {
document.getElementById('modlog-rows')?.insertAdjacentHTML('beforeend', rowsHtml);
document.getElementById('modlog-rows')?.insertAdjacentHTML('beforeend', rowsHtml);
}
}
@@ -400,7 +400,7 @@ async function loadAudit() {
@@ -400,7 +400,7 @@ async function loadAudit() {
try {
try {
const data = await api(`/api/admin/audit?limit=50&offset=${auditOffset}`);
const data = await api(`/api/admin/audit?limit=50&offset=${auditOffset}`);
const entries = data.entries || [];
const entries = data.entries || [];
- if (!entries.length) { c.innerHTML = '<p class="muted">No audit entries</p>'; pager.innerHTML = ''; return; }
+ if (!entries.length) { c.innerHTML = '<p class="muted">No administrative changes recorded here. Return after a settings or account change.</p>'; pager.innerHTML = ''; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
@@ -417,7 +417,7 @@ async function loadAudit() {
@@ -417,7 +417,7 @@ async function loadAudit() {
const hasMore = entries.length >= 50;
const hasMore = entries.length >= 50;
pager.innerHTML = `
pager.innerHTML = `
<button class="btn btn-sm btn-outline" ${auditOffset<=0?'disabled':''} onclick="auditOffset=Math.max(0,auditOffset-50);loadAudit()"><i class="fa-solid fa-chevron-left"></i></button>
<button class="btn btn-sm btn-outline" ${auditOffset<=0?'disabled':''} onclick="auditOffset=Math.max(0,auditOffset-50);loadAudit()"><i class="fa-solid fa-chevron-left"></i></button>
- <span class="muted">Offset ${auditOffset}</span>
+ <span class="muted">Showing records from ${auditOffset + 1}</span>
<button class="btn btn-sm btn-outline" ${!hasMore?'disabled':''} onclick="auditOffset+=50;loadAudit()"><i class="fa-solid fa-chevron-right"></i></button>
<button class="btn btn-sm btn-outline" ${!hasMore?'disabled':''} onclick="auditOffset+=50;loadAudit()"><i class="fa-solid fa-chevron-right"></i></button>
`;
`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
@@ -56,13 +56,13 @@ async function loadPayments() {
@@ -56,13 +56,13 @@ async function loadPayments() {
const id = `pay-${s.key}`;
const id = `pay-${s.key}`;
if (s.type === 'boolean') return `
if (s.type === 'boolean') return `
<div class="setting-row">
<div class="setting-row">
- <label for="${esc(id)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
+ <label for="${esc(id)}">${adminSettingLabel(s.key, s)}</label>
<input type="checkbox" id="${esc(id)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
<input type="checkbox" id="${esc(id)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
</div>`;
</div>`;
const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
return `
return `
<div class="setting-row" style="flex-direction:column;align-items:stretch">
<div class="setting-row" style="flex-direction:column;align-items:stretch">
- <label for="${esc(id)}"><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
+ <label for="${esc(id)}">${adminSettingLabel(s.key, s)}</label>
<input type="${type}" id="${esc(id)}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}" ${sensitive ? 'autocomplete="off"' : ''}
<input type="${type}" id="${esc(id)}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}" ${sensitive ? 'autocomplete="off"' : ''}
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
</div>`;
</div>`;
@@ -70,13 +70,13 @@ async function loadPayments() {
@@ -70,13 +70,13 @@ async function loadPayments() {
const groups = PAYMENT_GROUPS.map(g => `
const groups = PAYMENT_GROUPS.map(g => `
<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
<fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
<legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>
<legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>
- ${g.keys.map(k => field(byKey[k])).join('')}
+ <p class="admin-intro">${g.title === 'General' ? 'These switches and prices control paid features on Live. Change them when adjusting what buyers pay or what creators receive; save previous values to restore future pricing.' : 'These switches choose which payment providers Live offers. Enable a provider after configuring it in Billing, or turn it off to remove that checkout option without refunding existing payments.'}</p>${g.keys.map(k => field(byKey[k])).join('')}
</fieldset>`).join('');
</fieldset>`).join('');
c.innerHTML = `
c.innerHTML = `
<div style="max-width:860px">
<div style="max-width:860px">
- <p class="muted" style="margin-bottom:12px">OpenVibe.Billing runs every checkout, subscription and cashout. These are the switches and prices OpenVibe.Live reads to decide which ways to pay it offers. A way to pay is offered when <strong>payments_enabled</strong> and its own switch are on and Billing has that provider's keys in its environment (below); with no keys there, the switch has no effect.</p>
+ <p class="muted" style="margin-bottom:12px">OpenVibe.Billing runs every checkout, subscription and cashout. These are the switches and prices OpenVibe.Live reads to decide which ways to pay it offers. A way to pay is offered when <strong>Allow purchases and subscriptions</strong> and its own switch are on and Billing has that provider's keys in its environment (below); with no keys there, the switch has no effect.</p>
<form id="payments-form">${groups}
<form id="payments-form">${groups}
- <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save payment settings</button>
+ <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save prices and payment availability</button><p class="admin-help">Saving changes future payment offers. Restore the previous settings to undo; use Billing for refunds of completed purchases.</p>
</form>
</form>
<div style="margin-top:20px">${billingProvidersCard()}</div>
<div style="margin-top:20px">${billingProvidersCard()}</div>
</div>`;
</div>`;
@@ -109,7 +109,7 @@ async function loadVKeys() {
@@ -109,7 +109,7 @@ async function loadVKeys() {
const data = await api('/api/admin/verification-keys');
const data = await api('/api/admin/verification-keys');
const keys = data.keys || [];
const keys = data.keys || [];
keys.forEach(k => { if (k.key) secretStore[`vk-${k.id}`] = k.key; });
keys.forEach(k => { if (k.key) secretStore[`vk-${k.id}`] = k.key; });
- if (!keys.length) { c.innerHTML = '<p class="muted">No verification keys generated yet</p>'; return; }
+ if (!keys.length) { c.innerHTML = '<p class="muted">No account reservation keys yet. Enter a username and choose Generate to reserve it.</p>'; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
<thead><tr><th>Key</th><th>Reserved Username</th><th>Status</th><th>Note</th><th>Created</th><th>Actions</th></tr></thead>
<thead><tr><th>Key</th><th>Reserved Username</th><th>Status</th><th>Note</th><th>Created</th><th>Actions</th></tr></thead>
@@ -117,14 +117,14 @@ async function loadVKeys() {
@@ -117,14 +117,14 @@ async function loadVKeys() {
<tr>
<tr>
<td><code id="vk-${k.id}" style="font-size:12px;color:var(--text-muted)">••••••••</code></td>
<td><code id="vk-${k.id}" style="font-size:12px;color:var(--text-muted)">••••••••</code></td>
<td><strong>${esc(k.target_username)}</strong></td>
<td><strong>${esc(k.target_username)}</strong></td>
- <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc(k.status)}</span></td>
+ <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc({ active: 'Ready to claim.', used: 'Already claimed.', revoked: 'No longer usable.' }[k.status] || 'Status unavailable.')}</span></td>
<td style="font-size:12px">${esc(k.note || '-')}</td>
<td style="font-size:12px">${esc(k.note || '-')}</td>
<td style="font-size:11px">${k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}</td>
<td style="font-size:11px">${k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}</td>
<td style="display:flex;gap:4px">
<td style="display:flex;gap:4px">
${k.status === 'active' ? `
${k.status === 'active' ? `
- <button class="btn btn-sm btn-outline" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>
- <button class="btn btn-sm btn-outline" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>
- <button class="btn btn-sm btn-danger" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>
+ <button class="btn btn-sm btn-outline" aria-label="Show or hide reservation key" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>
+ <button class="btn btn-sm btn-outline" aria-label="Copy reservation key" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>
+ <button class="btn btn-sm btn-danger" title="Revoke permanently; generate a new key to replace it" aria-label="Revoke reservation key" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>
` : k.status === 'used' ? `<span class="muted">Used by ${esc(k.used_by_name || '?')}</span>` : '<span class="muted">Revoked</span>'}
` : k.status === 'used' ? `<span class="muted">Used by ${esc(k.used_by_name || '?')}</span>` : '<span class="muted">Revoked</span>'}
</td>
</td>
</tr>
</tr>
@@ -164,7 +164,7 @@ async function generateVKey() {
@@ -164,7 +164,7 @@ async function generateVKey() {
}
}
async function revokeVKey(id) {
async function revokeVKey(id) {
- if (!confirm('Revoke this verification key?')) return;
+ if (!confirm('Revoke this account reservation key? It will no longer work. This cannot be undone; generate a new key if access is needed again.')) return;
try {
try {
await api(`/api/admin/verification-keys/${id}`, { method: 'DELETE' });
await api(`/api/admin/verification-keys/${id}`, { method: 'DELETE' });
toast('Key revoked', 'success');
toast('Key revoked', 'success');
@@ -1,3 +1,307 @@
@@ -1,3 +1,307 @@
+// Copy only: setting values, types and save paths remain owned by the existing forms.
+const ADMIN_SETTING_COPY = {
+ "registration_open": [
+ "Allow new accounts",
+ "Allows people to register; the default is on. Turn it off to pause sign-ups, and on again to reopen them."
+ ],
+ "platform_name": [
+ "Network name",
+ "The name shown to people across Network; the default is OpenVibe. For example, keep OpenVibe for the shared network."
+ ],
+ "default_theme": [
+ "Starting appearance",
+ "The theme used before a person chooses their own; the default is vibe. Enter a theme name such as vibe."
+ ],
+ "notifications_enabled": [
+ "Send notifications",
+ "Allows Network notifications; the default is on. Turn it off to pause new notifications, then on to resume."
+ ],
+ "notification_max_age_days": [
+ "Keep notifications (days)",
+ "Notifications older than this are removed; the default is 90 days. Higher keeps more history, lower removes it sooner; removed history cannot be restored here."
+ ],
+ "notification_email_critical_only": [
+ "Email only urgent notifications",
+ "Limits notification emails to critical messages; the default is on. Turn it off to allow other notification priorities by email."
+ ],
+ "payments_enabled": [
+ "Allow purchases and subscriptions",
+ "Allows real-money checkout on Live when a payment provider is also ready; the default is off. Turn it off again to stop offering new purchases; this does not refund existing payments."
+ ],
+ "bucks_per_usd": [
+ "Vibes per US dollar",
+ "Sets the Vibes value of US $1; the default is 100 Vibes. Higher means each Vibe is worth less in dollars, lower means more; save the previous rate to undo future pricing changes."
+ ],
+ "bucks_min_purchase_bucks": [
+ "Smallest purchase (Vibes)",
+ "Sets the minimum Vibes purchase; the default is 100 Vibes. Higher requires a larger purchase, lower allows smaller purchases; restore the previous amount to undo."
+ ],
+ "sub_price_usd": [
+ "Subscription price (US dollars)",
+ "The fallback subscription price is US $4.99 when Billing supplies no price. Higher charges more and lower charges less; restore the old price for future checkouts, with refunds handled in Billing."
+ ],
+ "sub_streamer_share_pct": [
+ "Creator share (percent)",
+ "The creator share defaults to 70 percent. Higher gives the creator more and the site less; restore the old percentage for future calculations."
+ ],
+ "sub_site_route_fee_pct": [
+ "Site routing fee (percent)",
+ "The site routing fee defaults to 10 percent. Higher increases the site fee and lower reduces it; restore the old percentage for future calculations."
+ ],
+ "discord_bot_token": [
+ "Discord bot token",
+ "Lets the bot connect to Discord; blank by default means no bot connection. Set the token in the server environment, then reconnect the bot."
+ ],
+ "discord_guild_id": [
+ "Discord server ID",
+ "Selects the Discord server for the bot; blank by default. Copy the ID of your own server, not its name."
+ ],
+ "discord_alerts_channel_id": [
+ "Live alert channel ID",
+ "Sends go-live alerts to this channel; blank by default means no live alert destination. Copy the channel ID, for example the ID of your live-alerts channel."
+ ],
+ "discord_system_channel_id": [
+ "System alert channel ID",
+ "Sends operator alerts to this channel; blank by default means no system alert destination. Use a private staff channel, for example admin-alerts."
+ ],
+ "discord_dedupe_minutes": [
+ "Wait between repeat alerts (minutes)",
+ "Waits before repeating a stream alert; the default is 15 minutes. Higher sends fewer repeat alerts, lower sends them sooner (1 to 1,440 minutes)."
+ ],
+ "discord_alert_message": [
+ "Live alert wording",
+ "Customizes the live alert; blank uses the built-in message. For example: {display_name} went live: {title} {url}."
+ ],
+ "discord_oauth_client_id": [
+ "Discord sign-in application ID",
+ "Identifies the application used to link Discord accounts; blank by default leaves it unconfigured. Copy the Client ID of the same application as the client secret."
+ ],
+ "discord_oauth_client_secret": [
+ "Discord sign-in application secret",
+ "Authenticates account linking with Discord; blank by default leaves it unconfigured. Set the matching Client Secret in the server environment."
+ ],
+ "github_token": [
+ "GitHub read-only access",
+ "Allows more requests to GitHub public repositories; blank uses anonymous access. Use a fine-grained token with public repository read access only."
+ ],
+ "email_enabled": [
+ "Send email",
+ "Allows outgoing email; the default is off. Turning it off stops email delivery, including recovery messages; turn it on again to resume."
+ ],
+ "email_from_address": [
+ "Sender email address",
+ "The default sender is [email protected]. Use an address on a domain verified with the email provider, for example [email protected]."
+ ],
+ "email_from_name": [
+ "Sender name",
+ "The name recipients see; the default is OpenVibe. For example, use OpenVibe Support for support mail."
+ ],
+ "resend_api_key": [
+ "Resend email key",
+ "Lets Network send email through Resend; blank by default means email is not configured. Set the provider key in the server environment."
+ ],
+ "chat_slowmode_seconds": [
+ "Wait between chat messages (seconds)",
+ "The default is 0 seconds, which turns slow mode off. Higher makes people wait longer between messages; for example 5 allows one message every five seconds."
+ ],
+ "coins_per_minute": [
+ "Watching reward (OpenCoins per minute)",
+ "The default is 10 OpenCoins for each minute watched. Higher awards more coins and lower awards fewer; restore the old amount to undo future rewards."
+ ],
+ "ai_max_cost_usd_per_day": [
+ "Daily AI spending limit (US dollars)",
+ "The default is US $0, meaning no cap. A higher positive cap permits more spending; a lower positive cap stops spending sooner, for example US $5 per day."
+ ],
+ "ai_viewers_global_cap_usd_per_day": [
+ "Daily AI viewer spending limit (US dollars)",
+ "Caps site-paid AI viewers; the default is US $0, meaning no cap. A higher positive amount permits more spending and a lower positive amount less; for example US $5 per day."
+ ],
+ "ai_stream_capture_interval_sec": [
+ "Time between stream captures (seconds)",
+ "The default is 120 seconds between captures. Higher captures less often and lower more often; for example 60 captures once per minute."
+ ],
+ "ai_viewers_max_lines_per_min": [
+ "AI chat messages per minute",
+ "Limits bot messages per channel; the default is 12 per minute. Higher allows more chat and lower makes the bots quieter."
+ ],
+ "ai_viewers_max_roster": [
+ "AI viewers per channel",
+ "Limits the number of AI viewers in a channel; the default is 12 viewers. Higher permits more viewers and lower limits the roster."
+ ],
+ "stripe_enabled": [
+ "Offer Stripe payments",
+ "Offers Stripe at checkout when purchases are enabled and Billing has a working provider; the default is off. Turn it off to remove this payment option; existing payments are not refunded."
+ ],
+ "paypal_enabled": [
+ "Offer PayPal payments",
+ "Offers PayPal at checkout when purchases are enabled and Billing has a working provider; the default is off. Turn it off to remove this payment option; existing payments are not refunded."
+ ],
+ "ccbill_enabled": [
+ "Offer CCBill payments",
+ "Offers CCBill at checkout when purchases are enabled and Billing has a working provider; the default is off. Turn it off to remove this payment option; existing payments are not refunded."
+ ],
+ "crypto_enabled": [
+ "Offer cryptocurrency payments",
+ "Offers cryptocurrency at checkout when purchases are enabled and Billing has a working provider; the default is off. Turn it off to remove this payment option; existing payments are not refunded."
+ ],
+ "max_audio_bitrate": [
+ "Audio quality limit (kilobits per second)",
+ "The default is 320 kilobits per second. Higher raises this limit and lower reduces it; for example, keep 320 for the starting allowance."
+ ],
+ "max_video_bitrate": [
+ "Video quality limit (kilobits per second)",
+ "The default is 6000 kilobits per second. Higher raises this limit and lower reduces it; for example, keep 6000 for the starting allowance."
+ ],
+ "max_clip_duration": [
+ "Clip length limit (seconds)",
+ "The default is 60 seconds. Higher raises this limit and lower reduces it; for example, keep 60 for the starting allowance."
+ ],
+ "max_emotes_per_user": [
+ "Custom emoji limit (emoji per person)",
+ "The default is 25 emoji per person. Higher raises this limit and lower reduces it; for example, keep 25 for the starting allowance."
+ ],
+ "max_vod_size_mb": [
+ "Recording size limit (MB)",
+ "The default is 5120 MB. Higher raises this limit and lower reduces it; for example, keep 5120 for the starting allowance."
+ ],
+ "paste_comment_max_length": [
+ "Comment length limit (characters)",
+ "The default is 2000 characters. Higher raises this limit and lower reduces it; for example, keep 2000 for the starting allowance."
+ ],
+ "paste_max_size_kb": [
+ "Paste size limit (KB)",
+ "The default is 512 KB. Higher raises this limit and lower reduces it; for example, keep 512 for the starting allowance."
+ ],
+ "paste_screenshot_max_size_mb": [
+ "Screenshot size limit (MB)",
+ "The default is 8 MB. Higher raises this limit and lower reduces it; for example, keep 8 for the starting allowance."
+ ],
+ "paste_max_per_user_per_day": [
+ "Daily paste limit (pastes per person per day)",
+ "The default is 50 pastes per person per day. Higher raises this limit and lower reduces it; for example, keep 50 for the starting allowance. Zero means unlimited."
+ ],
+ "paste_comment_cooldown_seconds": [
+ "Wait between comments (seconds)",
+ "The default is 10 seconds. Higher raises this limit and lower reduces it; for example, keep 10 for the starting allowance."
+ ],
+ "paste_cooldown_seconds": [
+ "Wait between pastes (seconds)",
+ "The default is 30 seconds. Higher raises this limit and lower reduces it; for example, keep 30 for the starting allowance."
+ ],
+ "min_cashout_amount": [
+ "Minimum cashout (Vibes)",
+ "The default is 500 Vibes. Higher raises this limit and lower reduces it; for example, keep 500 for the starting allowance."
+ ]
+};
+Object.assign(ADMIN_SETTING_COPY, {
+ "tts_max_length": [
+ "Spoken message limit (characters)",
+ "The default is 200 characters per message. Higher allows longer speech and lower keeps messages shorter."
+ ],
+ "tts_max_queue_global": [
+ "Speech waiting list (messages)",
+ "The default is 20 waiting messages across the site. Higher allows a longer waiting list and lower rejects excess messages sooner."
+ ],
+ "tts_max_queue_per_user": [
+ "Speech waiting list per person (messages)",
+ "The default is 3 waiting messages per person. Higher permits more queued speech and lower leaves room for other people."
+ ],
+ "money_writes_frozen": [
+ "Pause all Live money actions",
+ "The default is off. When frozen, new money actions are refused while balances remain readable; the owner can lift the freeze in the money controls to resume."
+ ],
+ "ai_viewers_default_settings_json": [
+ "Starting AI viewer settings",
+ "Overrides the built-in defaults for channel AI viewers; the default empty object leaves those defaults in place. For example, restore {} to remove these overrides."
+ ]
+});
+const ADMIN_LIVE_DEFAULTS = {
+ "ai_enabled": "false",
+ "ai_max_cost_usd_per_day": "0",
+ "ai_paste_analysis_enabled": "true",
+ "ai_stream_capture_interval_sec": "120",
+ "ai_stream_memory_enabled": "false",
+ "ai_timeline_enabled": "false",
+ "ai_transcription_enabled": "true",
+ "ai_viewers_default_settings_json": "{}",
+ "ai_viewers_enabled": "true",
+ "ai_viewers_global_cap_usd_per_day": "0",
+ "ai_viewers_max_lines_per_min": "12",
+ "ai_viewers_max_roster": "12",
+ "bucks_min_purchase_bucks": "100",
+ "bucks_per_usd": "100",
+ "ccbill_client_account": "",
+ "ccbill_enabled": "false",
+ "ccbill_flexform_id": "",
+ "ccbill_subaccount": "",
+ "chat_slowmode_seconds": "0",
+ "coins_per_minute": "10",
+ "crypto_enabled": "false",
+ "crypto_provider": "nowpayments",
+ "google_client_id": "",
+ "kick_client_id": "",
+ "max_audio_bitrate": "320",
+ "max_clip_duration": "60",
+ "max_emotes_per_user": "25",
+ "max_video_bitrate": "6000",
+ "max_vod_size_mb": "5120",
+ "min_cashout_amount": "500",
+ "money_writes_frozen": "false",
+ "motd": "",
+ "nsfw_enabled": "true",
+ "paste_anon_allowed": "true",
+ "paste_comment_anon_allowed": "true",
+ "paste_comment_cooldown_seconds": "10",
+ "paste_comment_max_length": "2000",
+ "paste_cooldown_seconds": "30",
+ "paste_image_upload_enabled": "true",
+ "paste_max_per_user_per_day": "50",
+ "paste_max_size_kb": "512",
+ "paste_screenshot_max_size_mb": "8",
+ "payments_enabled": "false",
+ "paypal_client_id": "",
+ "paypal_enabled": "false",
+ "paypal_mode": "sandbox",
+ "paypal_webhook_id": "",
+ "powerchat_allow_test_fulfillment": "false",
+ "powerchat_base_url": "https://powerchatlive.dev",
+ "powerchat_client_id": "",
+ "powerchat_enabled": "false",
+ "powerchat_sandbox_username": "alex",
+ "powerchat_scopes": "profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write chat:read",
+ "powerchat_site_tip_username": "",
+ "registration_open": "true",
+ "require_email": "false",
+ "site_description": "Live streaming for camp culture",
+ "site_name": "OpenVibe.Live",
+ "stripe_enabled": "false",
+ "stripe_publishable_key": "",
+ "sub_price_usd": "4.99",
+ "sub_site_route_fee_pct": "10",
+ "sub_streamer_share_pct": "70",
+ "tts_aws_region": "us-east-1",
+ "tts_default_voice": "gary",
+ "tts_enabled": "true",
+ "tts_google_service_account": "",
+ "tts_max_length": "200",
+ "tts_max_queue_global": "20",
+ "tts_max_queue_per_user": "3",
+ "tts_provider": "espeak-ng",
+ "twitch_client_id": ""
+};
+function adminWords(key) {
+ const words = String(key || '').replace(/[_-]+/g, ' ');
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+function adminSettingLabel(key, setting = {}) {
+ const copy = ADMIN_SETTING_COPY[key];
+ const title = copy ? copy[0] : (setting.title || adminWords(key));
+ const defaultValue = Object.prototype.hasOwnProperty.call(ADMIN_LIVE_DEFAULTS, key) ? ADMIN_LIVE_DEFAULTS[key] : undefined;
+ const defaultHelp = defaultValue === undefined ? ' No default is published here; keep the saved value unless you know the change you need.' : defaultValue === '' ? ' Starts blank; add the value supplied by your provider when enabling this feature.' : setting.type === 'boolean' ? ` Starts ${defaultValue === 'true' ? 'on' : 'off'}; toggle it to ${defaultValue === 'true' ? 'disable' : 'enable'} the described feature, and toggle it back to undo.` : ` The starting value is ${defaultValue}; use it as an example when restoring the original setup.`;
+ const help = copy ? copy[1] : (setting.description || 'This service supplies this setting without a description.') + defaultHelp;
+ return `<strong>${esc(title)}</strong> <code class="admin-machine-key">${esc(key)}</code><span class="admin-help">${esc(help)}</span>`;
+}
+
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
OpenVibe.Network — admin panel (/admin): settings sub-tabs, URL registry, streamer settings, Discord and GitHub.
OpenVibe.Network — admin panel (/admin): settings sub-tabs, URL registry, streamer settings, Discord and GitHub.
Split out of admin.html. Classic scripts, global scope: they rely on
Split out of admin.html. Classic scripts, global scope: they rely on
@@ -37,34 +341,35 @@ async function loadToolsSettings() {
@@ -37,34 +341,35 @@ async function loadToolsSettings() {
const data = await api('/api/admin/settings');
const data = await api('/api/admin/settings');
const settings = data.settings || {};
const settings = data.settings || {};
const keys = Object.keys(settings);
const keys = Object.keys(settings);
- if (!keys.length) { c.innerHTML = '<p class="muted">No settings configured</p>'; return; }
+ if (!keys.length) { c.innerHTML = '<p class="muted">No settings are available. Check Network setup, then reload this tab.</p>'; return; }
_toolsSettingsOriginal = {};
_toolsSettingsOriginal = {};
for (const key of keys) _toolsSettingsOriginal[key] = settings[key].type === 'boolean' ? (settings[key].value === 'true' ? 'true' : 'false') : String(settings[key].value ?? '');
for (const key of keys) _toolsSettingsOriginal[key] = settings[key].type === 'boolean' ? (settings[key].value === 'true' ? 'true' : 'false') : String(settings[key].value ?? '');
c.innerHTML = `
c.innerHTML = `
+ <p class="admin-intro">These settings control accounts, notifications, and integrations in Network. Change them when you need to adjust how the network works; save the previous value to undo a change.</p>
<form id="tools-settings-form" style="max-width:700px">
<form id="tools-settings-form" style="max-width:700px">
${keys.map(key => {
${keys.map(key => {
const s = settings[key];
const s = settings[key];
const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer)/i.test(key);
const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer)/i.test(key);
if (s.source === 'env') return `
if (s.source === 'env') return `
<div class="setting-row" style="flex-direction:column;align-items:stretch">
<div class="setting-row" style="flex-direction:column;align-items:stretch">
- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
+ <label for="ts-${esc(key)}">${adminSettingLabel(key, s)}</label>
<input type="password" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="" disabled placeholder="Set in the environment (${esc(s.env || '')})"
<input type="password" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="" disabled placeholder="Set in the environment (${esc(s.env || '')})"
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
</div>`;
</div>`;
if (s.type === 'boolean') return `
if (s.type === 'boolean') return `
<div class="setting-row">
<div class="setting-row">
- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
+ <label for="ts-${esc(key)}">${adminSettingLabel(key, s)}</label>
<input type="checkbox" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
<input type="checkbox" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
</div>`;
</div>`;
return `
return `
<div class="setting-row" style="flex-direction:column;align-items:stretch">
<div class="setting-row" style="flex-direction:column;align-items:stretch">
- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
+ <label for="ts-${esc(key)}">${adminSettingLabel(key, s)}</label>
<input type="${isSensitive?'password':'text'}" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
<input type="${isSensitive?'password':'text'}" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
${s.source === 'database' && s.env && s.secret ? `<span class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(s.env)}</code> in the server's environment file.</span>` : ''}
${s.source === 'database' && s.env && s.secret ? `<span class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(s.env)}</code> in the server's environment file.</span>` : ''}
</div>`;
</div>`;
}).join('')}
}).join('')}
- <div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button></div>
+ <div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Network settings</button></div>
</form>`;
</form>`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
}
}
@@ -99,15 +404,15 @@ async function loadUrlRegistry() {
@@ -99,15 +404,15 @@ async function loadUrlRegistry() {
const data = await api('/api/admin/url-registry');
const data = await api('/api/admin/url-registry');
const entries = data.entries || [];
const entries = data.entries || [];
if (!entries.length) {
if (!entries.length) {
- c.innerHTML = '<p class="muted">No URL registry overrides configured.</p>';
+ c.innerHTML = '<p class="muted">No service addresses are available to edit. Check the service registry setup, then reopen this tab.</p>';
return;
return;
}
}
c.innerHTML = `
c.innerHTML = `
<div style="max-width:900px;display:grid;gap:16px">
<div style="max-width:900px;display:grid;gap:16px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<div>
- <strong>Network registry</strong>
- <div class="muted" style="font-size:13px">Edit first-party networking values and push refresh to services.</div>
+ <strong>Service addresses</strong>
+ <div class="muted" style="font-size:13px">These are the addresses services use to reach each other. Change them when moving a service, then refresh services to pick up the saved addresses.</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-sm btn-primary" onclick="refreshAllUrlRegistry()"><i class="fa-solid fa-arrows-rotate"></i> Refresh all services</button>
<button class="btn btn-sm btn-primary" onclick="refreshAllUrlRegistry()"><i class="fa-solid fa-arrows-rotate"></i> Refresh all services</button>
@@ -118,25 +423,25 @@ async function loadUrlRegistry() {
@@ -118,25 +423,25 @@ async function loadUrlRegistry() {
<div class="card" style="padding:16px">
<div class="card" style="padding:16px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<div>
- <div style="font-weight:700">${esc(entry.key)}</div>
- <div class="muted" style="font-size:13px">${esc(entry.label)} — ${esc(entry.type)}</div>
+ <div style="font-weight:700">${esc(entry.label)} <code class="admin-machine-key">${esc(entry.key)}</code></div>
+ <div class="muted" id="url-registry-help-${esc(entry.key)}" style="font-size:13px">Use the address or switch for this service; the supplied example is shown in the field. Reset restores the configured default; save your previous value to undo.</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div style="display:flex;gap:8px;flex-wrap:wrap">
- <button class="btn btn-sm btn-outline" onclick="resetUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-rotate-left"></i> Reset</button>
- <button class="btn btn-sm btn-primary" onclick="saveUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-floppy-disk"></i> Save</button>
+ <button class="btn btn-sm btn-outline" onclick="resetUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-rotate-left"></i> Restore default</button>
+ <button class="btn btn-sm btn-primary" onclick="saveUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-floppy-disk"></i> ${entry.type === 'boolean' ? 'Save the switch' : 'Save the address'}</button>
</div>
</div>
</div>
</div>
<div class="form-field" style="margin-top:12px">
<div class="form-field" style="margin-top:12px">
- <label for="url-registry-${esc(entry.key)}">Value</label>
+ <label for="url-registry-${esc(entry.key)}">${entry.type === 'boolean' ? 'Turn this feature on or off' : 'Saved address'}</label>
${entry.type === 'boolean' ? `
${entry.type === 'boolean' ? `
<div style="display:flex;align-items:center;gap:8px">
<div style="display:flex;align-items:center;gap:8px">
- <input type="checkbox" id="url-registry-${esc(entry.key)}" ${entry.value ? 'checked' : ''}>
+ <input type="checkbox" id="url-registry-${esc(entry.key)}" aria-describedby="url-registry-help-${esc(entry.key)}" ${entry.value ? 'checked' : ''}>
<span>${esc(entry.label)}</span>
<span>${esc(entry.label)}</span>
</div>
</div>
` : `
` : `
- <input type="text" id="url-registry-${esc(entry.key)}" value="${esc(entry.value || '')}" placeholder="${esc(entry.placeholder || '')}">
+ <input type="text" id="url-registry-${esc(entry.key)}" aria-describedby="url-registry-help-${esc(entry.key)}" value="${esc(entry.value || '')}" placeholder="${esc(entry.placeholder || '')}">
`}
`}
- <div class="muted" style="margin-top:4px">Source: ${esc(entry.source)}</div>
+ <div class="muted" style="margin-top:4px">${entry.source === 'default' ? 'Using the configured default.' : entry.source === 'env' ? 'Using the server environment setting.' : 'Using a saved override.'}</div>
${entry.key === 'WHIP_PUBLIC_URL' ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Note</div><div class="muted" style="margin-top:4px">A dedicated WHIP hostname also requires explicit enablement via WHIP_PUBLIC_URL_ENABLED, plus DNS/vhost/TLS support.</div>` : ''}
${entry.key === 'WHIP_PUBLIC_URL' ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Note</div><div class="muted" style="margin-top:4px">A dedicated WHIP hostname also requires explicit enablement via WHIP_PUBLIC_URL_ENABLED, plus DNS/vhost/TLS support.</div>` : ''}
${entry.warning ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Warning</div><div class="muted" style="margin-top:4px">${esc(entry.warning)}</div>` : ''}
${entry.warning ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Warning</div><div class="muted" style="margin-top:4px">${esc(entry.warning)}</div>` : ''}
</div>
</div>
@@ -160,7 +465,7 @@ async function saveUrlRegistryEntry(key) {
@@ -160,7 +465,7 @@ async function saveUrlRegistryEntry(key) {
}
}
async function resetUrlRegistryEntry(key) {
async function resetUrlRegistryEntry(key) {
- if (!confirm('Reset this URL registry entry to its default?')) return;
+ if (!confirm('Restore the configured default service address? This changes where services connect after refresh. Save the current value first; enter and save it again to undo.')) return;
try {
try {
await api(`/api/admin/url-registry/${encodeURIComponent(key)}/reset`, { method: 'POST' });
await api(`/api/admin/url-registry/${encodeURIComponent(key)}/reset`, { method: 'POST' });
toast('URL registry entry reset', 'success');
toast('URL registry entry reset', 'success');
@@ -179,14 +484,14 @@ async function refreshAllUrlRegistry() {
@@ -179,14 +484,14 @@ async function refreshAllUrlRegistry() {
const result = (service.results && service.results[0]) || {};
const result = (service.results && service.results[0]) || {};
const mode = result.mode || (result.ok ? 'success' : 'failed');
const mode = result.mode || (result.ok ? 'success' : 'failed');
const badge = result.mode === 'local'
const badge = result.mode === 'local'
- ? '<span class="badge badge-info">Local</span>'
+ ? '<span class="badge badge-info">Refreshed this server.</span>'
: result.mode === 'not_configured'
: result.mode === 'not_configured'
- ? '<span class="badge badge-warning">Not configured</span>'
+ ? '<span class="badge badge-warning">No refresh destination is configured.</span>'
: result.mode === 'unsupported'
: result.mode === 'unsupported'
- ? '<span class="badge badge-warning">Unsupported</span>'
+ ? '<span class="badge badge-warning">This service cannot refresh remotely.</span>'
: result.ok
: result.ok
- ? '<span class="badge badge-success">Success</span>'
- : '<span class="badge badge-danger">Failed</span>';
+ ? '<span class="badge badge-success">The service refreshed its settings.</span>'
+ : '<span class="badge badge-danger">The service could not refresh.</span>';
const message = result.error ? ` <span class="muted">${esc(result.error)}</span>` : (result.message ? ` <span class="muted">${esc(result.message)}</span>` : '');
const message = result.error ? ` <span class="muted">${esc(result.error)}</span>` : (result.message ? ` <span class="muted">${esc(result.message)}</span>` : '');
return `<div><strong>${esc(service.service)}</strong>: ${badge}${message}</div>`;
return `<div><strong>${esc(service.service)}</strong>: ${badge}${message}</div>`;
}).join('');
}).join('');
@@ -198,6 +503,16 @@ async function refreshAllUrlRegistry() {
@@ -198,6 +503,16 @@ async function refreshAllUrlRegistry() {
}
}
// Grouped, icon'd sections for the (otherwise huge) OpenVibe.Live settings list.
// Grouped, icon'd sections for the (otherwise huge) OpenVibe.Live settings list.
+const ADMIN_SETTINGS_GROUP_INTROS = {
+ "Site & General": "These settings control the public Live site and registration. Change them when updating the site identity or who may join.",
+ "Payment Processors": "These settings control paid features on Live. Change them when reviewing prices or payment availability; restore earlier values to undo future pricing changes, and handle refunds in Billing.",
+ "Kick / Twitch / YouTube": "These settings connect external streaming accounts to Live. Change them when configuring or replacing an application at the named provider.",
+ "AI Config": "These settings control automated analysis and AI viewers on Live. Change limits when balancing useful analysis against cost and server load.",
+ "Cloud TTS": "These settings connect services that read text aloud. Change them when setting up a voice provider or replacing its credentials.",
+ "Media & Uploads": "These settings limit uploaded media and stream quality. Raise limits when more capacity is available, or lower them to reduce storage and bandwidth use.",
+ "Chat & Rewards": "These settings control chat pacing and watching rewards. Change them when chat needs slowing down or rewards need adjusting.",
+ "Other": "These are additional settings published by Live. Change a value only after reading its description and keeping the previous value for recovery."
+};
const STREAMER_SETTINGS_GROUPS = [
const STREAMER_SETTINGS_GROUPS = [
{ title: 'Site & General', icon: 'fa-sliders', test: k => /^(site_|motd$|registration_open$|require_email$|nsfw_enabled$)/.test(k) },
{ title: 'Site & General', icon: 'fa-sliders', test: k => /^(site_|motd$|registration_open$|require_email$|nsfw_enabled$)/.test(k) },
{ title: 'Payment Processors', icon: 'fa-credit-card', test: k => /^(payments_|bucks_|sub_|paypal_|stripe_|ccbill_|crypto_|min_cashout)/.test(k) },
{ title: 'Payment Processors', icon: 'fa-credit-card', test: k => /^(payments_|bucks_|sub_|paypal_|stripe_|ccbill_|crypto_|min_cashout)/.test(k) },
@@ -216,7 +531,7 @@ function _isMultiline(s) {
@@ -216,7 +531,7 @@ function _isMultiline(s) {
}
}
function _streamerSettingField(s) {
function _streamerSettingField(s) {
const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer|salt)/i.test(s.key);
const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer|salt)/i.test(s.key);
- const label = `<label for="ss-${esc(s.key)}"><strong>${esc(s.key)}</strong>${s.redacted ? ' <span class="badge badge-warning" style="font-size:10px">owner-only</span>' : ''}<br><small>${esc(s.description||'')}</small></label>`;
+ const label = `<label for="ss-${esc(s.key)}">${adminSettingLabel(s.key, s)}${s.redacted ? ' <span class="badge badge-warning" style="font-size:10px">owner-only</span>' : ''}</label>`;
const base = 'margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px';
const base = 'margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px';
if (s.type === 'boolean') return `<div class="setting-row">${label}
if (s.type === 'boolean') return `<div class="setting-row">${label}
<input type="checkbox" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
<input type="checkbox" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
@@ -226,8 +541,8 @@ function _streamerSettingField(s) {
@@ -226,8 +541,8 @@ function _streamerSettingField(s) {
if (kind === 'json') { try { shown = JSON.stringify(JSON.parse(shown), null, 2); } catch { /* show as-is; save validation will flag it */ } }
if (kind === 'json') { try { shown = JSON.stringify(JSON.parse(shown), null, 2); } catch { /* show as-is; save validation will flag it */ } }
const rows = Math.min(18, Math.max(3, shown.split('\n').length + 1));
const rows = Math.min(18, Math.max(3, shown.split('\n').length + 1));
return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
- <textarea id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${kind}" rows="${rows}" spellcheck="false"
- style="${base};font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical;white-space:pre;overflow:auto">${esc(shown)}</textarea>
+ <details class="admin-advanced" ${kind === 'json' ? '' : 'open'}><summary>Advanced settings: ${kind === 'json' ? 'structured data (JSON)' : 'long text'}</summary><p class="admin-help">Edit the complete value below. Keep a copy before saving so you can restore it.</p><textarea id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${kind}" rows="${rows}" spellcheck="false"
+ style="${base};font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical;white-space:pre;overflow:auto">${esc(shown)}</textarea></details>
<div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger-text, var(--danger,#f87171));font-size:12px;margin-top:4px"></div></div>`;
<div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger-text, var(--danger,#f87171));font-size:12px;margin-top:4px"></div></div>`;
}
}
const type = s.type === 'number' ? 'number' : (isSensitive ? 'password' : 'text');
const type = s.type === 'number' ? 'number' : (isSensitive ? 'password' : 'text');
@@ -267,7 +582,7 @@ async function loadStreamerSettings() {
@@ -267,7 +582,7 @@ async function loadStreamerSettings() {
try {
try {
const data = await api('/api/admin/streamer/settings');
const data = await api('/api/admin/streamer/settings');
const settings = data.settings || [];
const settings = data.settings || [];
- if (!settings.length) { c.innerHTML = '<p class="muted">No OpenVibe.Live settings found</p>'; return; }
+ if (!settings.length) { c.innerHTML = '<p class="muted">No Live settings are available. Check the Live connection, then reopen this tab.</p>'; return; }
_streamerSettingsOriginal = {};
_streamerSettingsOriginal = {};
for (const s of settings) _streamerSettingsOriginal[s.key] = s.value == null ? '' : String(s.value);
for (const s of settings) _streamerSettingsOriginal[s.key] = s.value == null ? '' : String(s.value);
// Bucket each setting into the first matching group.
// Bucket each setting into the first matching group.
@@ -281,14 +596,14 @@ async function loadStreamerSettings() {
@@ -281,14 +596,14 @@ async function loadStreamerSettings() {
if (!items.length) return '';
if (!items.length) return '';
return `<details class="settings-group" ${i < 2 ? 'open' : ''} style="border:1px solid var(--border);border-radius:8px;margin-bottom:10px">
return `<details class="settings-group" ${i < 2 ? 'open' : ''} style="border:1px solid var(--border);border-radius:8px;margin-bottom:10px">
<summary style="cursor:pointer;padding:10px 14px;font-weight:700;list-style:none"><i class="fa-solid ${g.icon}"></i> ${esc(g.title)} <span style="opacity:.5;font-weight:400">(${items.length})</span></summary>
<summary style="cursor:pointer;padding:10px 14px;font-weight:700;list-style:none"><i class="fa-solid ${g.icon}"></i> ${esc(g.title)} <span style="opacity:.5;font-weight:400">(${items.length})</span></summary>
- <div style="padding:4px 14px 12px">${items.map(_streamerSettingField).join('')}</div>
+ <div style="padding:4px 14px 12px"><p class="admin-intro">${esc(ADMIN_SETTINGS_GROUP_INTROS[g.title])}</p>${items.map(_streamerSettingField).join('')}</div>
</details>`;
</details>`;
}).join('');
}).join('');
c.innerHTML = `
c.innerHTML = `
<form id="streamer-settings-form" style="max-width:760px">
<form id="streamer-settings-form" style="max-width:760px">
${sections}
${sections}
<div style="margin-top:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<div style="margin-top:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
- <button type="button" id="streamer-settings-save" class="btn btn-primary" onclick="saveStreamerSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button>
+ <button type="button" id="streamer-settings-save" class="btn btn-primary" onclick="saveStreamerSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Network settings</button>
<button type="button" class="btn btn-outline" onclick="loadStreamerSettings()"><i class="fa-solid fa-rotate-left"></i> Discard changes</button>
<button type="button" class="btn btn-outline" onclick="loadStreamerSettings()"><i class="fa-solid fa-rotate-left"></i> Discard changes</button>
<span class="muted" style="font-size:12px">Only fields you changed are sent. JSON and numeric fields are validated first.</span>
<span class="muted" style="font-size:12px">Only fields you changed are sent. JSON and numeric fields are validated first.</span>
</div>
</div>
@@ -333,7 +648,7 @@ async function saveStreamerSettings() {
@@ -333,7 +648,7 @@ async function saveStreamerSettings() {
if (errors.length) {
if (errors.length) {
toast(`Not saved — fix ${errors.length} invalid field${errors.length === 1 ? '' : 's'}: ${errors.join(', ')}`, 'error');
toast(`Not saved — fix ${errors.length} invalid field${errors.length === 1 ? '' : 's'}: ${errors.join(', ')}`, 'error');
const first = document.getElementById(`ss-${errors[0]}`);
const first = document.getElementById(`ss-${errors[0]}`);
- if (first) { first.closest('details')?.setAttribute('open', ''); first.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
+ if (first) { for (let parent = first.parentElement; parent; parent = parent.parentElement) { if (parent.tagName === 'DETAILS') parent.open = true; } first.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
return;
return;
}
}
const keys = Object.keys(settings);
const keys = Object.keys(settings);
@@ -367,7 +682,7 @@ function envSecretInput(sources, key, value, placeholder, style) {
@@ -367,7 +682,7 @@ function envSecretInput(sources, key, value, placeholder, style) {
const src = (sources || {})[key] || {};
const src = (sources || {})[key] || {};
const env = esc(src.env || '');
const env = esc(src.env || '');
const label = src.source === 'env' ? `Set in the environment (${env})` : `Not set: add ${env} to /etc/openvibe/network.env and restart Network`;
const label = src.source === 'env' ? `Set in the environment (${env})` : `Not set: add ${env} to /etc/openvibe/network.env and restart Network`;
- return `<input type="password" data-key="${esc(key)}" value="" placeholder="${label}" disabled style="${style}">`;
+ return `<input type="password" id="ds-${esc(key)}" data-key="${esc(key)}" value="" placeholder="${label}" disabled style="${style}">`;
}
}
async function loadDiscordSettings() {
async function loadDiscordSettings() {
@@ -379,54 +694,54 @@ async function loadDiscordSettings() {
@@ -379,54 +694,54 @@ async function loadDiscordSettings() {
const st = data.status || {};
const st = data.status || {};
const statusBadge = st.connected
const statusBadge = st.connected
? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Connected as ${esc(st.botTag || '?')}</span>`
? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Connected as ${esc(st.botTag || '?')}</span>`
- : `<span class="badge" style="background:var(--danger-bg,#7f1d1d);color:var(--danger-text,#f87171)">● Disconnected</span>`;
+ : `<span class="badge" style="background:var(--danger-bg,#7f1d1d);color:var(--danger-text,#f87171)">The bot is not connected. Check its token and reconnect.</span>`;
c.innerHTML = `
c.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
<h3 style="margin:0"><i class="fa-brands fa-discord"></i> Discord Bot</h3>
<h3 style="margin:0"><i class="fa-brands fa-discord"></i> Discord Bot</h3>
${statusBadge}
${statusBadge}
</div>
</div>
- <form id="discord-settings-form">
+ <p class="admin-intro">The Discord bot posts live and system alerts to your server. Configure its application and channel IDs when enabling alerts, then send a test alert to check delivery.</p><form id="discord-settings-form">
<div class="setting-row">
<div class="setting-row">
- <label>Bot Token</label>
+ <label for="ds-discord_bot_token">${adminSettingLabel("discord_bot_token")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Open your application → Bot → Reset Token, then copy the new token into the server environment; resetting it invalidates the previous token.</span></label>
${envSecretInput(data.sources, 'discord_bot_token', s.discord_bot_token, 'Bot token from Discord Developer Portal', 'width:100%;max-width:500px')}
${envSecretInput(data.sources, 'discord_bot_token', s.discord_bot_token, 'Bot token from Discord Developer Portal', 'width:100%;max-width:500px')}
</div>
</div>
<div class="setting-row">
<div class="setting-row">
- <label>Guild / Server ID</label>
- <input type="text" data-key="discord_guild_id" value="${esc(s.discord_guild_id || '')}" placeholder="Right-click server → Copy Server ID" style="width:240px">
+ <label for="ds-discord_guild_id">${adminSettingLabel("discord_guild_id")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Use the application you invited to your server; in Discord, enable User Settings → Advanced → Developer Mode, then right-click the server → Copy Server ID (<a href="https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID" target="_blank" rel="noopener">Discord’s ID guide</a>).</span></label>
+ <input type="text" id="ds-discord_guild_id" data-key="discord_guild_id" value="${esc(s.discord_guild_id || '')}" placeholder="Right-click server → Copy Server ID" style="width:240px">
</div>
</div>
<div class="setting-row">
<div class="setting-row">
- <label>Live Alerts Channel ID</label>
- <input type="text" data-key="discord_alerts_channel_id" value="${esc(s.discord_alerts_channel_id || '')}" placeholder="Channel for go-live alerts" style="width:240px">
+ <label for="ds-discord_alerts_channel_id">${adminSettingLabel("discord_alerts_channel_id")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Use the server where you invited this application; in Discord with Developer Mode enabled, right-click the live-alerts channel → Copy Channel ID.</span></label>
+ <input type="text" id="ds-discord_alerts_channel_id" data-key="discord_alerts_channel_id" value="${esc(s.discord_alerts_channel_id || '')}" placeholder="Channel for go-live alerts" style="width:240px">
</div>
</div>
<div class="setting-row">
<div class="setting-row">
- <label>System Alerts Channel ID</label>
- <input type="text" data-key="discord_system_channel_id" value="${esc(s.discord_system_channel_id || '')}" placeholder="Channel for system/admin alerts (optional)" style="width:240px">
+ <label for="ds-discord_system_channel_id">${adminSettingLabel("discord_system_channel_id")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Use the server where you invited this application; in Discord with Developer Mode enabled, right-click the staff-alerts channel → Copy Channel ID.</span></label>
+ <input type="text" id="ds-discord_system_channel_id" data-key="discord_system_channel_id" value="${esc(s.discord_system_channel_id || '')}" placeholder="Channel for system/admin alerts (optional)" style="width:240px">
</div>
</div>
<div class="setting-row">
<div class="setting-row">
- <label>Dedupe Cooldown (minutes)</label>
- <input type="number" data-key="discord_dedupe_minutes" value="${esc(s.discord_dedupe_minutes || '15')}" min="1" max="1440" style="width:100px">
- <span class="muted" style="margin-left:8px">Prevent spam if a streamer flaps live/offline</span>
+ <label for="ds-discord_dedupe_minutes">${adminSettingLabel("discord_dedupe_minutes")}</label>
+ <input type="number" id="ds-discord_dedupe_minutes" data-key="discord_dedupe_minutes" value="${esc(s.discord_dedupe_minutes || '15')}" min="1" max="1440" style="width:100px">
+ <span class="muted" style="margin-left:8px">Fewer repeated alerts when a stream disconnects and reconnects.</span>
</div>
</div>
<div class="setting-row">
<div class="setting-row">
- <label>Custom Alert Message (optional)</label>
- <input type="text" data-key="discord_alert_message" value="${esc(s.discord_alert_message || '')}" placeholder="{display_name} went live: {title}" style="width:100%;max-width:500px">
+ <label for="ds-discord_alert_message">${adminSettingLabel("discord_alert_message")}</label>
+ <input type="text" id="ds-discord_alert_message" data-key="discord_alert_message" value="${esc(s.discord_alert_message || '')}" placeholder="{display_name} went live: {title}" style="width:100%;max-width:500px">
<div class="muted" style="font-size:12px;margin-top:4px">Placeholders: {username}, {display_name}, {title}, {url}</div>
<div class="muted" style="font-size:12px;margin-top:4px">Placeholders: {username}, {display_name}, {title}, {url}</div>
</div>
</div>
- <h4 style="margin-top:24px"><i class="fa-brands fa-discord"></i> Account Linking (OAuth2)</h4>
+ <h4 style="margin-top:24px"><i class="fa-brands fa-discord"></i> Link Discord accounts</h4><p class="admin-intro">These application details let people connect their Discord identity. Configure them when enabling account linking, using the same Discord application for both fields.</p>
<div class="setting-row">
<div class="setting-row">
- <label>OAuth2 Client ID</label>
- <input type="text" data-key="discord_oauth_client_id" value="${esc(s.discord_oauth_client_id || '')}" placeholder="Discord application client ID" style="width:300px">
+ <label for="ds-discord_oauth_client_id">${adminSettingLabel("discord_oauth_client_id")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Open your application → OAuth2 → General, then copy Client ID.</span></label>
+ <input type="text" id="ds-discord_oauth_client_id" data-key="discord_oauth_client_id" value="${esc(s.discord_oauth_client_id || '')}" placeholder="Discord application client ID" style="width:300px">
</div>
</div>
<div class="setting-row">
<div class="setting-row">
- <label>OAuth2 Client Secret</label>
+ <label for="ds-discord_oauth_client_secret">${adminSettingLabel("discord_oauth_client_secret")}<span class="admin-help">Where to get it: <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a>. Open your application → OAuth2 → General, then copy Client Secret into the server environment; resetting it invalidates the previous secret.</span></label>
${envSecretInput(data.sources, 'discord_oauth_client_secret', s.discord_oauth_client_secret, 'Discord application client secret', 'width:300px')}
${envSecretInput(data.sources, 'discord_oauth_client_secret', s.discord_oauth_client_secret, 'Discord application client secret', 'width:300px')}
</div>
</div>
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">
- <button type="button" class="btn btn-primary" onclick="saveDiscordSettings()"><i class="fa-solid fa-save"></i> Save Settings</button>
+ <button type="button" class="btn btn-primary" onclick="saveDiscordSettings()"><i class="fa-solid fa-save"></i> Save Discord settings</button>
<button type="button" class="btn btn-outline" onclick="testDiscordAlert()"><i class="fa-solid fa-bell"></i> Test System Alert</button>
<button type="button" class="btn btn-outline" onclick="testDiscordAlert()"><i class="fa-solid fa-bell"></i> Test System Alert</button>
<button type="button" class="btn btn-outline" onclick="testDiscordLiveAlert()"><i class="fa-solid fa-broadcast-tower"></i> Test Live Alert</button>
<button type="button" class="btn btn-outline" onclick="testDiscordLiveAlert()"><i class="fa-solid fa-broadcast-tower"></i> Test Live Alert</button>
- <button type="button" class="btn btn-outline" onclick="reinitDiscordBot()"><i class="fa-solid fa-rotate"></i> Reconnect Bot</button>
+ <button type="button" class="btn btn-outline" onclick="reinitDiscordBot()"><i class="fa-solid fa-rotate"></i> Reconnect the bot</button>
</div>
</div>
</form>`;
</form>`;
} catch (e) {
} catch (e) {
@@ -451,7 +766,7 @@ async function loadGithubSettings() {
@@ -451,7 +766,7 @@ async function loadGithubSettings() {
<p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}.</p>
<p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}.</p>
<p class="muted" style="max-width:720px">Provider secrets live in the server's environment only, never in the database. ${g.set ? `This one comes from <code>${env}</code>; change it there.` : `To add one, create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions, set <code>${env}</code> in <code>/etc/openvibe/network.env</code>, and restart Network.`}</p>
<p class="muted" style="max-width:720px">Provider secrets live in the server's environment only, never in the database. ${g.set ? `This one comes from <code>${env}</code>; change it there.` : `To add one, create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions, set <code>${env}</code> in <code>/etc/openvibe/network.env</code>, and restart Network.`}</p>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
- <button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test</button>
+ <button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test the token</button>
</div>
</div>
<div id="github-test-result" class="muted" style="margin-top:10px"></div>`;
<div id="github-test-result" class="muted" style="margin-top:10px"></div>`;
} catch (e) { c.innerHTML = `<p class="muted">GitHub settings could not be loaded: ${esc(e.message)}</p>`; }
} catch (e) { c.innerHTML = `<p class="muted">GitHub settings could not be loaded: ${esc(e.message)}</p>`; }
@@ -21,7 +21,7 @@ async function loadUsers() {
@@ -21,7 +21,7 @@ async function loadUsers() {
const users = data.users || [];
const users = data.users || [];
const total = data.total || 0;
const total = data.total || 0;
- if (!users.length) { c.innerHTML = '<p class="muted">No users found</p>'; pager.innerHTML = ''; return; }
+ if (!users.length) { c.innerHTML = '<p class="muted">No accounts match. Clear the search or try a different username or email.</p>'; pager.innerHTML = ''; return; }
c.innerHTML = `
c.innerHTML = `
<table class="admin-table">
<table class="admin-table">
@@ -30,30 +30,30 @@ async function loadUsers() {
@@ -30,30 +30,30 @@ async function loadUsers() {
</tr></thead>
</tr></thead>
<tbody>${users.map(u => `
<tbody>${users.map(u => `
<tr>
<tr>
- <td><strong>${esc(u.username)}</strong></td>
- <td>${esc(u.display_name || '-')}</td>
- <td style="font-size:11px">${esc(u.email || '-')}</td>
- <td><span class="badge badge-${u.role}">${esc(u.role)}</span></td>
- <td style="font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
- <td style="font-size:11px">${u.last_seen ? timeAgo(u.last_seen) : 'Never'}</td>
- <td style="display:flex;gap:4px;align-items:center">
- <select onchange="changeUserRole('${u.id}',this.value)" style="padding:4px 6px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:11px">
+ <td data-label="Username"><strong>${esc(u.username)}</strong></td>
+ <td data-label="Display name">${esc(u.display_name || '-')}</td>
+ <td data-label="Email" style="font-size:11px">${esc(u.email || '-')}</td>
+ <td data-label="Access role"><span class="badge badge-${u.role}">${esc({user:'Member access',streamer:'Streamer access',global_mod:'Network moderator access',admin:'Administrator access'}[u.role] || adminWords(u.role))}</span></td>
+ <td data-label="Created" style="font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
+ <td data-label="Last seen" style="font-size:11px">${u.last_seen ? timeAgo(u.last_seen) : 'Never'}</td>
+ <td data-label="Account actions" style="display:flex;gap:4px;align-items:center">
+ <select aria-label="Account role; changes immediately, select the old role to undo" onchange="changeUserRole('${u.id}',this.value)" style="padding:4px 6px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:11px">
${['user','streamer','global_mod','admin'].map(r =>
${['user','streamer','global_mod','admin'].map(r =>
- `<option value="${r}" ${r===u.role?'selected':''}>${r}</option>`
+ `<option value="${r}" ${r===u.role?'selected':''}>${{user:'Member — standard account',streamer:'Streamer — broadcast access',global_mod:'Global moderator — moderate across channels',admin:'Administrator — manage the network'}[r]}</option>`
).join('')}
).join('')}
</select>
</select>
${u.is_banned ?
${u.is_banned ?
- `<button class="btn btn-sm btn-success" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check"></i></button>` :
- `<button class="btn btn-sm btn-danger" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban"></i></button>`
+ `<button class="btn btn-sm btn-success" title="Restore access; ban again to restrict it" aria-label="Unban account" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check" aria-hidden="true"></i> Restore access</button>` :
+ `<button class="btn btn-sm btn-danger" title="Block access; use Unban to restore it" aria-label="Ban account" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban" aria-hidden="true"></i> Ban account</button>`
}
}
- <button class="btn btn-sm btn-outline" title="Rename (the old name redirects on Live and stays reserved)" onclick="renameUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-signature"></i></button>
- <button class="btn btn-sm btn-outline" title="Sign out on every device and site" onclick="signOutUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-right-from-bracket"></i></button>
+ <button class="btn btn-sm btn-outline" title="Rename (the old name redirects on Live and stays reserved)" onclick="renameUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-signature" aria-hidden="true"></i> Rename</button>
+ <button class="btn btn-sm btn-outline" title="Sign out on every device and site" onclick="signOutUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i> Sign out</button>
${currentUser?.is_owner ? `
${currentUser?.is_owner ? `
<button class="btn btn-sm btn-outline" title="Edit email address"
<button class="btn btn-sm btn-outline" title="Edit email address"
- onclick="editUserEmail('${u.id}','${esc(u.username)}','${esc(u.email || '')}')"><i class="fa-solid fa-envelope"></i></button>
+ onclick="editUserEmail('${u.id}','${esc(u.username)}','${esc(u.email || '')}')"><i class="fa-solid fa-envelope" aria-hidden="true"></i> Edit email</button>
<button class="btn btn-sm btn-outline" title="${u.email ? 'Send a password reset link' : 'No email address on file'}"
<button class="btn btn-sm btn-outline" title="${u.email ? 'Send a password reset link' : 'No email address on file'}"
${u.email ? '' : 'disabled'}
${u.email ? '' : 'disabled'}
- onclick="sendUserReset('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-key"></i></button>
+ onclick="sendUserReset('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-key" aria-hidden="true"></i> Send reset email</button>
` : ''}
` : ''}
</td>
</td>
</tr>
</tr>
@@ -63,17 +63,17 @@ async function loadUsers() {
@@ -63,17 +63,17 @@ async function loadUsers() {
const pages = Math.ceil(total / 50);
const pages = Math.ceil(total / 50);
const curPage = Math.floor(usersOffset / 50) + 1;
const curPage = Math.floor(usersOffset / 50) + 1;
pager.innerHTML = pages > 1 ? `
pager.innerHTML = pages > 1 ? `
- <button class="btn btn-sm btn-outline" ${usersOffset <= 0 ? 'disabled' : ''} onclick="usersOffset=Math.max(0,usersOffset-50);loadUsers()"><i class="fa-solid fa-chevron-left"></i></button>
+ <button class="btn btn-sm btn-outline" aria-label="Previous page" ${usersOffset <= 0 ? 'disabled' : ''} onclick="usersOffset=Math.max(0,usersOffset-50);loadUsers()"><i class="fa-solid fa-chevron-left"></i></button>
<span class="muted">Page ${curPage} / ${pages} (${total} total)</span>
<span class="muted">Page ${curPage} / ${pages} (${total} total)</span>
- <button class="btn btn-sm btn-outline" ${curPage >= pages ? 'disabled' : ''} onclick="usersOffset+=50;loadUsers()"><i class="fa-solid fa-chevron-right"></i></button>
- ` : `<span class="muted">${total} users</span>`;
+ <button class="btn btn-sm btn-outline" aria-label="Next page" ${curPage >= pages ? 'disabled' : ''} onclick="usersOffset+=50;loadUsers()"><i class="fa-solid fa-chevron-right"></i></button>
+ ` : `<span class="muted">${total} account${total === 1 ? '' : 's'}</span>`;
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
} catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
}
}
async function changeUserRole(userId, role) {
async function changeUserRole(userId, role) {
try {
try {
await api(`/api/admin/users/${userId}/role`, { method: 'PUT', body: { role } });
await api(`/api/admin/users/${userId}/role`, { method: 'PUT', body: { role } });
- toast(`Role updated to ${role}`, 'success');
+ toast(`Role updated to ${adminWords(role)}`, 'success');
} catch (e) { toast(e.message, 'error'); loadUsers(); }
} catch (e) { toast(e.message, 'error'); loadUsers(); }
}
}
@@ -81,7 +81,7 @@ async function changeUserRole(userId, role) {
@@ -81,7 +81,7 @@ async function changeUserRole(userId, role) {
// The server enforces owner-only on both of these (requireOwner); hiding the buttons
// The server enforces owner-only on both of these (requireOwner); hiding the buttons
// for non-owners is purely so admins are not shown controls they cannot use.
// for non-owners is purely so admins are not shown controls they cannot use.
async function editUserEmail(userId, username, current) {
async function editUserEmail(userId, username, current) {
- const next = prompt(`Email address for @${username}\n\nLeave blank to remove it.`, current || '');
+ const next = prompt(`Email address for @${username}\n\nLeave blank to remove it, which prevents email recovery. Enter the previous address again to undo.`, current || '');
if (next === null) return; // cancelled
if (next === null) return; // cancelled
const email = next.trim();
const email = next.trim();
if (email === (current || '').trim()) return; // unchanged
if (email === (current || '').trim()) return; // unchanged
@@ -113,7 +113,7 @@ async function renameUser(userId, username) {
@@ -113,7 +113,7 @@ async function renameUser(userId, username) {
}
}
async function signOutUser(userId, username) {
async function signOutUser(userId, username) {
- if (!confirm(`Sign ${username} out on every device and every OpenVibe site?`)) return;
+ if (!confirm(`Sign ${username} out on every device and every OpenVibe site? Existing sessions stop working; they must sign in again to restore access.`)) return;
try {
try {
await api(`/api/admin/users/${userId}/sign-out`, { method: 'POST' });
await api(`/api/admin/users/${userId}/sign-out`, { method: 'POST' });
toast(`${username} signed out everywhere`, 'success');
toast(`${username} signed out everywhere`, 'success');
@@ -122,7 +122,7 @@ async function signOutUser(userId, username) {
@@ -122,7 +122,7 @@ async function signOutUser(userId, username) {
async function toggleBan(userId, username, ban) {
async function toggleBan(userId, username, ban) {
if (ban) {
if (ban) {
- const reason = prompt(`Ban ${username}? Enter reason:`);
+ const reason = prompt(`Ban ${username}? This blocks account access. Use Unban to restore access. Enter the reason:`);
if (reason === null) return;
if (reason === null) return;
try {
try {
await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: true, reason } });
await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: true, reason } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+// Local visual and submission-contract proof with synthetic API responses; see docs/admin-clarity-review.md.
+// Local visual and submission-contract proof with synthetic API responses; see docs/admin-clarity-review.md.
+// Playwright and Chrome come from the local workstation; override ADMIN_PROOF_PLAYWRIGHT to point elsewhere.
+const playwright=process.env.ADMIN_PROOF_PLAYWRIGHT || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright';
+const {chromium}=require(playwright);
+const fs=require('fs');const path=require('path');
+const root=path.resolve(__dirname, '..');
+const base=process.env.ADMIN_PROOF_URL || 'http://127.0.0.1:4897';
+const baseline=process.env.ADMIN_PROOF_BASELINE || '/tmp/admin-clarity-before-public';
+const out=process.env.ADMIN_PROOF_OUT || path.join(root, '..', 'shots', 'admin-clarity');fs.mkdirSync(out,{recursive:true});
+const phase=process.argv[2]||'after';
+const onlyChecks=process.env.ADMIN_PROOF_CHECKS === '1';
+const settings=[['payments_enabled','false','boolean'],['bucks_per_usd','100','number'],['bucks_min_purchase_bucks','100','number'],['sub_price_usd','4.99','number'],['sub_streamer_share_pct','70','number'],['sub_site_route_fee_pct','10','number'],['stripe_enabled','false','boolean'],['paypal_enabled','false','boolean'],['ccbill_enabled','false','boolean'],['crypto_enabled','false','boolean'],['site_name','OpenVibe.Live','string'],['chat_slowmode_seconds','0','number'],['max_vod_size_mb','5120','number'],['ai_viewers_default_settings_json','{"enabled":false,"maxViewers":3}','json']].map(([key,value,type])=>({key,value,type,description:'Setting published by Live.'}));
+const fixtures={
+'/api/auth/me':{user:{id:'demo-owner',username:'design-review',display_name:'Design review',role:'admin',is_owner:true}},
+'/api/admin/health':{health:{users:124,active_sessions:18,uptime:12600,memory:{heapUsed:123000000,heapTotal:240000000,rss:350000000},ses_enabled:false}},
+'/api/admin/streamer/stats':{stats:{streams:{live:2,total:24}}},
+'/api/admin/settings':{settings:Object.fromEntries([['registration_open','true','boolean'],['platform_name','OpenVibe','string'],['notification_max_age_days','90','number'],['email_enabled','false','boolean']].map(([key,value,type])=>[key,{value,type}]))},
+'/api/admin/streamer/settings':{settings},
+'/api/admin/discord':{settings:{discord_dedupe_minutes:'15'},status:{connected:false},sources:{discord_bot_token:{env:'DISCORD_BOT_TOKEN'},discord_oauth_client_secret:{env:'DISCORD_OAUTH_CLIENT_SECRET'}}},
+'/api/admin/users':{users:[{id:'demo-1',username:'alex',display_name:'Alex Example',email:'[email protected]',role:'user',created_at:'2026-11-01',last_seen:'2026-11-07',is_banned:false}],total:1},
+'/api/admin/streamer/streams':{streams:[{id:'demo-stream',username:'alex',title:'An example community broadcast',protocol:'webrtc',viewer_count:12,started_at:'2026-11-07'}]},
+'/api/admin/streamer/moderators':{moderators:[]},
+'/api/admin/streamer-pastes/admin/stats':{stats:{}},'/api/admin/streamer-pastes/config':{config:{maxSizeKb:512,maxPerUserPerDay:50}},
+'/api/admin/streamer/storage':{disk:{total:10000000000,used:4000000000,available:6000000000},dataTotal:{bytes:3000000000,files:24},breakdown:[{name:'recordings',bytes:3000000000,files:24}]},
+'/api/admin/streamer/storage/vods':{vods:[],total:0},
+'/api/admin/email':{email:{enabled:false,from_email:'[email protected]',from_name:'OpenVibe'},metrics:{}},
+'/api/admin/deploy/prerequisites':{ok:true,prerequisites:{certbotInstalled:true,nginxInstalled:true},config:{certMode:'manual',nginxMode:'preview',domainsConfigured:1}},
+'/api/admin/deploy/config':{ok:true,config:{certMode:'manual',nginxMode:'preview',nginxSitesPath:'/etc/nginx/sites-enabled'}},
+'/api/admin/deploy/certs':{ok:true,certs:[]},'/api/admin/deploy/domains':{ok:true,domains:[]},
+'/api/admin/analytics/overview':{overview:{totals:{total_pageviews:1200,total_api_calls:400,total_unique_visitors:90,total_errors:4,avg_response_ms:120},services:[],dailyTrend:[]}},
+'/api/admin/analytics/insights':{insights:{generated_at:'2026-11-07T04:00:00Z',users:{new_1d:4,new_7d:18,total:124},totals:{requests:1600,server_errors:4},services:[],top_routes:[]}},
+'/api/admin/domains':{domains:[]},'/api/admin/domains/catalog':{source:'live',families:[{id:'tools',name:'Tools'}],tools:[]},
+'/api/admin/events/deliveries':{deliveries:[],counts:{dead:0,failed:0,pending:0}},
+'/api/admin/url-registry':{entries:[{key:'OV_LIVE_URL',label:'Live public address',type:'string',value:'https://openvibe.live',source:'default'}]},
+};
+const panels=[['dashboard'],['users'],['streams'],['moderators'],['settings'],['settings','discord'],['settings','streamer'],['settings','urls'],['payments'],['pastes'],['storage'],['notifications'],['email'],['deploy'],['deploy','config'],['deploy','certs'],['deploy','domains'],['analytics'],['grants'],['eventsops'],['domains'],['tts'],['cashouts']];
+(async()=>{
+ const b=await chromium.launch({executablePath:'/usr/bin/google-chrome',args:['--no-sandbox']});const results=[];
+ for(const width of (onlyChecks ? [1280] : [390,1280]))for(const scheme of (onlyChecks ? ['dark'] : ['dark','light'])){
+ const page=await b.newPage({viewport:{width,height:900},colorScheme:scheme,deviceScaleFactor:1,isMobile:width<700,hasTouch:width<700});const errors=[];const writes=[];
+ page.on('pageerror',e=>errors.push(e.message));
+ await page.addInitScript(({scheme})=>{localStorage.setItem('ov_token','local-design-fixture');localStorage.setItem('ov_theme',JSON.stringify({id:scheme,variables:scheme==='light'?{'--bg-primary':'#f7f8fc','--bg-secondary':'#eef1f6','--bg-card':'#ffffff','--bg-input':'#ffffff','--text-primary':'#202737','--text':'#202737','--text-secondary':'#455166','--text-muted':'#566176','--border':'#c9cfda','--accent-light':'#1759b0','--bg-elev':'#eef1f6'}:{'--bg-primary':'#0d0d14','--text-muted':'#a1a1b5'}}));},{scheme});
+ await page.route('**/*',async route=>{
+ const u=new URL(route.request().url());
+ if(u.pathname.startsWith('/api/') && route.request().method() !== 'GET') writes.push({path:u.pathname,method:route.request().method(),body:route.request().postDataJSON()});
+ if(u.pathname.startsWith('/api/'))return route.fulfill({json:fixtures[u.pathname]||{ok:true,entries:[],items:[],changes:[],grants:[],settings:[],voices:[]}});
+ // Do not load production notifications or make remote requests during local review.
+ if(u.hostname==='openvibe.live'||u.hostname==='openvibe.network')return route.fulfill({status:200,body:'',contentType:'application/javascript'});
+ if(phase==='before'&&u.hostname==='127.0.0.1'){
+ const rel=u.pathname.startsWith('/admin')?'admin.html':u.pathname.slice(1);const file=path.join(baseline,rel);
+ if(fs.existsSync(file)&&fs.statSync(file).isFile())return route.fulfill({path:file});
+ }
+ return route.continue();});
+ await page.goto(base+'/admin',{waitUntil:'networkidle'});
+ for(const [tab,sub]of (onlyChecks ? [] : panels)){
+ await page.evaluate(async({tab,sub})=>{showTab(tab);if(sub){if(tab==='settings')showSettingsSub(sub);if(tab==='deploy')showDeploySubTab(sub);}}, {tab,sub});
+ await page.waitForTimeout(350);
+ await page.evaluate(()=>window.scrollTo(0,0));
+ const audit=await page.evaluate(()=>{
+ const visible=e=>e.getAttribute('aria-hidden') !== 'true' && e.checkVisibility() && !!(e.offsetWidth||e.offsetHeight||e.getClientRects().length);
+ const controls=[...document.querySelectorAll('.section.active button,.section.active input,.section.active select,.section.active textarea,.section.active summary')].filter(visible);
+ return {docWidth:document.documentElement.scrollWidth,small:controls.filter(e=>{const r=e.getBoundingClientRect();return r.height<43.5||r.width<43.5}).map(e=>({id:e.id,label:e.getAttribute('aria-label')||e.textContent.slice(0,40),w:Math.round(e.getBoundingClientRect().width),h:Math.round(e.getBoundingClientRect().height)})),pageTextErrors:[...document.querySelectorAll('.section.active .muted')].filter(e=>/Error:|not defined|Cannot read/.test(e.textContent)).map(e=>e.textContent)};});
+ const name=[phase,tab,sub,width,scheme].filter(Boolean).join('-');
+ await page.screenshot({path:path.join(out,name+'.png'),fullPage:true});
+ results.push({name,...audit});
+ }
+ if(onlyChecks){
+ // All API traffic is fulfilled above. These interactions never write to the local or production database.
+ await page.evaluate(async()=>{
+ await loadToolsSettings(); document.getElementById('ts-notification_max_age_days').value='30'; await saveToolsSettings();
+ await loadPayments(); document.getElementById('pay-stripe_enabled').checked=true; await savePayments();
+ await loadDiscordSettings(); document.querySelector('[data-key=discord_dedupe_minutes]').value='20'; await saveDiscordSettings();
+ await loadStreamerSettings(); document.getElementById('ss-chat_slowmode_seconds').value='5'; await saveStreamerSettings();
+ await loadUsers(); const select=document.querySelector('#users-content select'); select.value='global_mod'; select.dispatchEvent(new Event('change'));
+ });
+ await page.waitForTimeout(300);
+ const beforeInvalid=writes.length;
+ await page.evaluate(async()=>{await loadStreamerSettings();document.getElementById('ss-ai_viewers_default_settings_json').value='{invalid';await saveStreamerSettings();});
+ if(writes.length!==beforeInvalid)throw new Error('Invalid JSON sent a write');
+ const disclosed=await page.evaluate(()=>{const field=document.getElementById('ss-ai_viewers_default_settings_json');return [...(function*(e){for(;e;e=e.parentElement)if(e.tagName==='DETAILS')yield e;})(field)].every(e=>e.open);});
+ if(!disclosed)throw new Error('Invalid JSON field remained inside a closed section');
+ results.push({writes,invalidJsonBlocked:true,invalidJsonDisclosed:true});
+ }
+ results.push({width,scheme,errors});await page.close();
+ }
+ await b.close();fs.writeFileSync(path.join(out,phase+(onlyChecks ? '-requests' : '-audit')+'.json'),JSON.stringify(results,null,2));console.log(JSON.stringify(results.filter(r=>r.name && r.docWidth>Number(r.name.match(/-(390|1280)-/)[1])||r.small?.length||r.errors?.length||r.pageTextErrors?.length),null,2));
+})().catch(e=>{console.error(e);process.exit(1)});