diff --git a/.changeset/admin-operations.md b/.changeset/admin-operations.md
new file mode 100644
index 000000000..32b35af74
--- /dev/null
+++ b/.changeset/admin-operations.md
@@ -0,0 +1,5 @@
+---
+"nostream": minor
+---
+
+Add admin Operations area with mirror and worker heartbeats, NIP-05 verification ops, storage counts, and dashboard view.
diff --git a/resources/admin/assets/admin-ops.js b/resources/admin/assets/admin-ops.js
new file mode 100644
index 000000000..10a429d82
--- /dev/null
+++ b/resources/admin/assets/admin-ops.js
@@ -0,0 +1,387 @@
+(() => {
+ const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' }
+ const adminBase = `${config.pathPrefix || ''}/admin`
+
+ const mirrorsBody = document.getElementById('ops-mirrors-body')
+ if (!mirrorsBody) {
+ return
+ }
+
+ const elements = {
+ mirrorsSync: document.getElementById('ops-mirrors-sync'),
+ mirrorsBody,
+ workersSync: document.getElementById('ops-workers-sync'),
+ workersBody: document.getElementById('ops-workers-body'),
+ nip05Error: document.getElementById('ops-nip05-error'),
+ nip05Domain: document.getElementById('ops-nip05-domain'),
+ nip05Verified: document.getElementById('ops-nip05-verified'),
+ nip05Stale: document.getElementById('ops-nip05-stale'),
+ nip05MinFailures: document.getElementById('ops-nip05-min-failures'),
+ nip05Refresh: document.getElementById('ops-nip05-refresh'),
+ nip05Body: document.getElementById('ops-nip05-body'),
+ nip05Pagination: document.getElementById('ops-nip05-pagination'),
+ storageKinds: document.getElementById('ops-storage-kinds'),
+ storageSince: document.getElementById('ops-storage-since'),
+ storageUntil: document.getElementById('ops-storage-until'),
+ storageRun: document.getElementById('ops-storage-run'),
+ storageError: document.getElementById('ops-storage-error'),
+ storageBody: document.getElementById('ops-storage-body'),
+ }
+
+ let pollTimer
+ let nip05Offset = 0
+ const nip05Limit = 50
+
+ const formatTime = (iso) => {
+ if (!iso) {
+ return '—'
+ }
+ const date = new Date(iso)
+ if (Number.isNaN(date.getTime())) {
+ return iso
+ }
+ return date.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC')
+ }
+
+ const shortPubkey = (pubkey) => (pubkey.length > 16 ? `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}` : pubkey)
+
+ const escapeHtml = (value) =>
+ String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+
+ const escapeAttr = (value) => escapeHtml(value).replaceAll('`', '`')
+
+ const signalBadge = (stale) => {
+ if (stale) {
+ return 'Stale'
+ }
+ return 'Live'
+ }
+
+ const workerSignalBadge = (worker) => {
+ if (worker.monitored === false) {
+ return 'Unmonitored'
+ }
+ return signalBadge(worker.stale)
+ }
+
+ const adminFetch = async (path, options = {}) => {
+ const response = await fetch(`${adminBase}${path}`, {
+ credentials: 'same-origin',
+ ...options,
+ })
+ if (response.status === 401) {
+ throw new Error('Unauthorized')
+ }
+ return response
+ }
+
+ const renderMirrors = (mirrors) => {
+ if (!Array.isArray(mirrors) || !mirrors.length) {
+ elements.mirrorsBody.innerHTML =
+ '
| No mirrors configured. |
'
+ return
+ }
+
+ elements.mirrorsBody.innerHTML = mirrors
+ .map((mirror) => {
+ const heartbeat = mirror.heartbeat
+ const state = heartbeat?.connectionState ?? 'unknown'
+ const reconnects = heartbeat?.reconnectCount ?? 0
+ const lastEvent = formatTime(heartbeat?.lastEventAt)
+ const errorNote = heartbeat?.lastError
+ ? `${escapeHtml(heartbeat.lastError)}`
+ : ''
+ return `
+ | ${escapeHtml(mirror.index)} |
+ ${escapeHtml(mirror.targetUrl)}${errorNote} |
+ ${escapeHtml(state)} |
+ ${escapeHtml(reconnects)} |
+ ${escapeHtml(lastEvent)} |
+ ${signalBadge(mirror.stale)} |
+
`
+ })
+ .join('')
+ }
+
+ const renderWorkers = (workers) => {
+ if (!Array.isArray(workers) || !workers.length) {
+ elements.workersBody.innerHTML =
+ '| No workers configured. |
'
+ return
+ }
+
+ elements.workersBody.innerHTML = workers
+ .map((worker) => {
+ const heartbeat = worker.heartbeat
+ const finished = formatTime(heartbeat?.lastRunFinishedAt ?? heartbeat?.updatedAt)
+ const lastError = heartbeat?.lastError ?? '—'
+ return `
+ | ${escapeHtml(worker.label)} |
+ ${escapeHtml(finished)} |
+ ${escapeHtml(lastError)} |
+ ${workerSignalBadge(worker)} |
+
`
+ })
+ .join('')
+ }
+
+ const renderNip05 = (rows, pagination) => {
+ if (!Array.isArray(rows) || !rows.length) {
+ elements.nip05Body.innerHTML = '| No matching verifications. |
'
+ } else {
+ elements.nip05Body.innerHTML = rows
+ .map(
+ (row) => `
+ ${escapeHtml(shortPubkey(row.pubkey))} |
+ ${escapeHtml(row.nip05)} |
+ ${row.isVerified ? 'yes' : 'no'} |
+ ${escapeHtml(row.failureCount)} |
+ ${escapeHtml(formatTime(row.lastVerifiedAt))} |
+ ${row.stale ? 'yes' : 'no'} |
+ |
+
`,
+ )
+ .join('')
+ }
+
+ if (elements.nip05Pagination && pagination) {
+ const { offset, limit, total } = pagination
+ elements.nip05Pagination.replaceChildren()
+
+ const summary = document.createElement('span')
+ summary.textContent = `Showing ${offset + 1}–${Math.min(offset + limit, total)} of ${total}`
+ elements.nip05Pagination.append(summary)
+
+ if (total > limit) {
+ const controls = document.createElement('span')
+ controls.className = 'ms-2'
+
+ const previous = document.createElement('button')
+ previous.type = 'button'
+ previous.className = 'btn btn-console btn-sm me-1'
+ previous.textContent = 'Previous'
+ previous.disabled = offset <= 0
+ previous.addEventListener('click', () => {
+ nip05Offset = Math.max(0, offset - limit)
+ void loadNip05()
+ })
+
+ const next = document.createElement('button')
+ next.type = 'button'
+ next.className = 'btn btn-console btn-sm'
+ next.textContent = 'Next'
+ next.disabled = offset + limit >= total
+ next.addEventListener('click', () => {
+ nip05Offset = offset + limit
+ void loadNip05()
+ })
+
+ controls.append(previous, next)
+ elements.nip05Pagination.append(controls)
+ }
+ }
+ }
+
+ const renderStorage = (counts) => {
+ if (!Array.isArray(counts) || !counts.length) {
+ elements.storageBody.innerHTML =
+ '| No counts returned. |
'
+ return
+ }
+
+ elements.storageBody.innerHTML = counts
+ .map(
+ (row) => `
+ ${escapeHtml((row.kinds ?? []).join(', '))} |
+ ${escapeHtml(row.count)} |
+
`,
+ )
+ .join('')
+ }
+
+ const hideNip05Error = () => elements.nip05Error?.classList.add('d-none')
+ const showNip05Error = (message) => {
+ if (!elements.nip05Error) {
+ return
+ }
+ elements.nip05Error.textContent = message
+ elements.nip05Error.classList.remove('d-none')
+ }
+
+ const hideStorageError = () => elements.storageError?.classList.add('d-none')
+ const showStorageError = (message) => {
+ if (!elements.storageError) {
+ return
+ }
+ elements.storageError.textContent = message
+ elements.storageError.classList.remove('d-none')
+ }
+
+ const loadMirrors = async () => {
+ const response = await adminFetch('/ops/mirrors')
+ if (!response.ok) {
+ throw new Error('Failed to load mirrors')
+ }
+ const payload = await response.json()
+ renderMirrors(payload.mirrors)
+ if (elements.mirrorsSync) {
+ elements.mirrorsSync.innerHTML = `> mirrors: updated ${formatTime(new Date().toISOString())}`
+ }
+ }
+
+ const loadWorkers = async () => {
+ const response = await adminFetch('/ops/workers')
+ if (!response.ok) {
+ throw new Error('Failed to load workers')
+ }
+ const payload = await response.json()
+ renderWorkers(payload.workers)
+ if (elements.workersSync) {
+ elements.workersSync.innerHTML = `> workers: updated ${formatTime(new Date().toISOString())}`
+ }
+ }
+
+ const buildNip05Query = () => {
+ const params = new URLSearchParams()
+ params.set('limit', String(nip05Limit))
+ params.set('offset', String(nip05Offset))
+
+ const domain = elements.nip05Domain?.value.trim()
+ if (domain) {
+ params.set('domain', domain)
+ }
+
+ const verified = elements.nip05Verified?.value
+ if (verified) {
+ params.set('verified', verified)
+ }
+
+ const stale = elements.nip05Stale?.value
+ if (stale) {
+ params.set('stale', stale)
+ }
+
+ const minFailures = elements.nip05MinFailures?.value
+ if (minFailures !== undefined && minFailures !== '') {
+ params.set('minFailures', minFailures)
+ }
+
+ return params.toString()
+ }
+
+ const loadNip05 = async () => {
+ hideNip05Error()
+ const response = await adminFetch(`/ops/nip05-verifications?${buildNip05Query()}`)
+ const payload = await response.json().catch(() => ({}))
+ if (!response.ok) {
+ showNip05Error(payload.error ?? 'Failed to load NIP-05 verifications')
+ return
+ }
+ renderNip05(payload.verifications, payload.pagination)
+ }
+
+ const loadStorage = async () => {
+ hideStorageError()
+ const kinds = elements.storageKinds?.value.trim()
+ if (!kinds) {
+ showStorageError('Enter at least one kind.')
+ return
+ }
+
+ const params = new URLSearchParams({ kinds })
+ const since = elements.storageSince?.value
+ const until = elements.storageUntil?.value
+ if (since) {
+ params.set('since', since)
+ }
+ if (until) {
+ params.set('until', until)
+ }
+
+ const response = await adminFetch(`/ops/storage/counts?${params.toString()}`)
+ const payload = await response.json().catch(() => ({}))
+ if (!response.ok) {
+ showStorageError(payload.error ?? 'Storage count query failed')
+ return
+ }
+ renderStorage(payload.counts)
+ }
+
+ const reverify = async (pubkey) => {
+ hideNip05Error()
+ const response = await adminFetch(`/ops/nip05-verifications/${encodeURIComponent(pubkey)}/reverify`, {
+ method: 'POST',
+ })
+ const payload = await response.json().catch(() => ({}))
+ if (!response.ok) {
+ showNip05Error(payload.error ?? 'Re-verification failed')
+ return
+ }
+ await loadNip05()
+ }
+
+ const refreshAll = async () => {
+ try {
+ await Promise.all([loadMirrors(), loadWorkers(), loadNip05()])
+ } catch {
+ showNip05Error('Unable to refresh operations data.')
+ }
+ }
+
+ const stopPolling = () => {
+ if (pollTimer) {
+ clearInterval(pollTimer)
+ pollTimer = undefined
+ }
+ }
+
+ const startPolling = () => {
+ stopPolling()
+ pollTimer = setInterval(() => {
+ void refreshAll()
+ }, 30_000)
+ }
+
+ const resetNip05Filters = () => {
+ nip05Offset = 0
+ void loadNip05()
+ }
+
+ elements.nip05Refresh?.addEventListener('click', resetNip05Filters)
+ elements.nip05Domain?.addEventListener('change', resetNip05Filters)
+ elements.nip05Verified?.addEventListener('change', resetNip05Filters)
+ elements.nip05Stale?.addEventListener('change', resetNip05Filters)
+ elements.nip05MinFailures?.addEventListener('change', resetNip05Filters)
+
+ elements.storageRun?.addEventListener('click', () => {
+ void loadStorage()
+ })
+
+ elements.nip05Body?.addEventListener('click', (event) => {
+ const target = event.target
+ if (!(target instanceof HTMLElement)) {
+ return
+ }
+ const pubkey = target.getAttribute('data-reverify-pubkey')
+ if (!pubkey) {
+ return
+ }
+ void reverify(pubkey)
+ })
+
+ window.__ADMIN_OPS__ = {
+ load: () => {
+ nip05Offset = 0
+ void refreshAll()
+ startPolling()
+ },
+ stop: () => {
+ stopPolling()
+ },
+ }
+})()
diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js
index 746ea5ad0..b008c7b36 100644
--- a/resources/admin/assets/dashboard.js
+++ b/resources/admin/assets/dashboard.js
@@ -345,6 +345,7 @@
unloadGrafanaFrames()
stopMetricsStream()
stopNetworkHealthPolling()
+ window.__ADMIN_OPS__?.stop?.()
setNavOpen(false)
loginPanel.classList.remove('d-none')
dashboardPanel.classList.add('d-none')
@@ -374,9 +375,8 @@
loadGrafanaFrames()
startMetricsStream()
- void refreshNetworkHealth()
- void refreshNetworkHealthHistory()
- startNetworkHealthPolling()
+ stopNetworkHealthPolling()
+ window.__ADMIN_OPS__?.stop?.()
}
const stopNetworkHealthPolling = () => {
@@ -1485,16 +1485,33 @@
})
if (viewId === 'settings-view') {
+ stopNetworkHealthPolling()
+ window.__ADMIN_OPS__?.stop?.()
+ stopMetricsStream()
void loadSettingsData()
return
}
if (viewId === 'notifications-view') {
+ stopNetworkHealthPolling()
+ window.__ADMIN_OPS__?.stop?.()
+ stopMetricsStream()
window.__ADMIN_NOTIFICATIONS__?.load?.()
return
}
+ if (viewId === 'operations-view') {
+ stopMetricsStream()
+ void refreshNetworkHealth()
+ void refreshNetworkHealthHistory()
+ startNetworkHealthPolling()
+ window.__ADMIN_OPS__?.load?.()
+ return
+ }
+
if (viewId === 'metrics-view') {
+ stopNetworkHealthPolling()
+ window.__ADMIN_OPS__?.stop?.()
startMetricsStream()
}
}
diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html
index c51206f42..2b514deac 100644
--- a/resources/admin/dashboard.html
+++ b/resources/admin/dashboard.html
@@ -65,6 +65,7 @@ Sign in
@@ -102,37 +103,6 @@ Health
-
- Network Health
- > probes: waiting for snapshot…
- No external probe results yet. Enable NIP-66 relay monitoring to populate this panel.
-
-
HIST Recent probe runs
-
-
-
-
-
-
RUN Probe status
-
Pending
-
-
-
-
-
-
TS Last probe run
-
—
-
-
-
-
-
-
Throughput
@@ -232,6 +202,170 @@
Charts
+
+
+ Mirror health
+ > mirrors: not loaded
+
+
+ Static mirror connection status
+
+
+ | Index |
+ Target |
+ State |
+ Reconnects |
+ Last event |
+ Signal |
+
+
+
+ | No mirrors configured. |
+
+
+
+
+
+
+ Worker ops
+ > workers: not loaded
+
+
+ Background worker heartbeats
+
+
+ | Worker |
+ Last run |
+ Last error |
+ Signal |
+
+
+
+ | No workers reported yet. |
+
+
+
+
+
+
+ Tor / I2P & network probes
+ > probes: waiting for snapshot…
+ No external probe results yet. Enable NIP-66 relay monitoring to populate this panel.
+
+
HIST Recent probe runs
+
+
+
+
+
+
RUN Probe status
+
Pending
+
+
+
+
+
+
TS Last probe run
+
—
+
+
+
+
+
+
+
+ NIP-05 verifications
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NIP-05 verification records
+
+
+ | Pubkey |
+ NIP-05 |
+ Verified |
+ Failures |
+ Last verified |
+ Stale |
+ |
+
+
+
+ | No rows loaded. |
+
+
+
+
+
+
+
+ Storage counts
+ Per-kind counts use NIP-45-style filters (comma-separated kinds, optional since/until unix seconds).
+
+
+
+
+ Event storage counts by kind
+
+
+ | Kinds |
+ Count |
+
+
+
+ | Run a query to load counts. |
+
+
+
+
+
+
Operator notifications
@@ -392,5 +526,6 @@ Preview diff
+