Open alerts
+5
+Requires attention
+diff --git a/lib/routing/routes.go b/lib/routing/routes.go index 656b1f4..d36a481 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -21,6 +21,7 @@ type Handlers struct { AdminLoginPost gin.HandlerFunc AdminLogout gin.HandlerFunc AdminDashboard gin.HandlerFunc + AdminAlerts gin.HandlerFunc AdminAuth gin.HandlerFunc } @@ -50,4 +51,5 @@ func Register(router *gin.Engine, handlers Handlers) { protected := router.Group("/admin", handlers.AdminAuth) protected.GET("/dashboard", handlers.AdminDashboard) + protected.GET("/alerts", handlers.AdminAlerts) } diff --git a/lib/server/admin.go b/lib/server/admin.go index 1303c37..8ed3ecf 100644 --- a/lib/server/admin.go +++ b/lib/server/admin.go @@ -97,6 +97,20 @@ func (app *App) handleAdminDashboard(ctx *gin.Context) { ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{ "AdminUsername": app.config.AdminUsername, "AdminEmail": app.config.AdminEmail, + "ActivePage": "dashboard", "DashboardEnabled": string(dashboardEnabled), }) } + +func (app *App) handleAdminAlerts(ctx *gin.Context) { + if !app.adminLoginEnabled() { + ctx.Redirect(http.StatusSeeOther, "/") + return + } + + ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{ + "AdminUsername": app.config.AdminUsername, + "AdminEmail": app.config.AdminEmail, + "ActivePage": "alerts", + }) +} diff --git a/lib/server/server.go b/lib/server/server.go index d308aba..127bef2 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -55,6 +55,7 @@ func Run(addr string) error { AdminLoginPost: app.handleAdminLoginPost, AdminLogout: app.handleAdminLogout, AdminDashboard: app.handleAdminDashboard, + AdminAlerts: app.handleAdminAlerts, AdminAuth: app.adminAuthMiddleware, }) diff --git a/static/css/admin.css b/static/css/admin.css index 23715e7..fbbcef4 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -205,7 +205,8 @@ /* =========================== Dashboard Window =========================== */ -.admin-dashboard-window { +.admin-dashboard-window, +.admin-workspace-window { width: 100%; min-height: 0; padding: 0; @@ -215,11 +216,13 @@ background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06)); } -.admin-dashboard-window > .win98-titlebar { +.admin-dashboard-window > .win98-titlebar, +.admin-workspace-window > .win98-titlebar { margin: 2px 2px 0; } -.admin-dashboard-window > .menu-bar { +.admin-dashboard-window > .menu-bar, +.admin-workspace-window > .menu-bar { flex: 0 0 auto; height: auto; min-height: 24px; @@ -234,11 +237,13 @@ z-index: 30; } -.admin-dashboard-window > .menu-bar .menu-button { +.admin-dashboard-window > .menu-bar .menu-button, +.admin-workspace-window > .menu-bar .menu-button { color: #000000; } -.admin-dashboard-window > .dashboard-body { +.admin-dashboard-window > .dashboard-body, +.admin-workspace-window > .admin-workspace-body { flex: 1 1 auto; margin-top: 0; padding: 0 10px 10px; @@ -673,7 +678,8 @@ body.is-compact .admin-section-body { flex: 0 0 auto; } - .admin-dashboard-window { + .admin-dashboard-window, + .admin-workspace-window { min-height: 100dvh; border-left: 0; border-right: 0; diff --git a/static/css/alerts.css b/static/css/alerts.css new file mode 100644 index 0000000..e3f4bd5 --- /dev/null +++ b/static/css/alerts.css @@ -0,0 +1,394 @@ +.alerts-page-body { + display: grid; + gap: 10px; +} + +.alerts-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.alerts-stat-card { + min-width: 0; + padding: 8px; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0; +} + +.alerts-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); } +.alerts-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); } +.alerts-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); } + +.alerts-stat-label { + margin: 0 0 4px; + font-size: 12px; + line-height: 12px; + text-transform: uppercase; + color: #333333; +} + +.alerts-stat-value { + margin: 0; + font-size: 24px; + line-height: 24px; + font-weight: bold; +} + +.alerts-stat-note { + margin: 6px 0 0; + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + color: #222222; + background: rgba(255,255,255,.65); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #a0a0a0; + border-bottom: 1px solid #a0a0a0; + font-size: 12px; + line-height: 12px; +} + +.alerts-content-grid { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(320px, .7fr); + gap: 10px; + min-height: 0; +} + +.alerts-column { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; +} + +.alerts-list-panel { + flex: 1 1 auto; + min-height: 520px; +} + +.alerts-actions-panel { + flex: 1 1 auto; + min-height: 220px; +} + +.alerts-panel { + display: flex; + flex-direction: column; + min-height: 0; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08); +} + +.alerts-panel-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 34px; + padding: 6px 8px; + background: #dfdfdf; + border-bottom: 1px solid #b0b0b0; + box-shadow: inset 1px 1px 0 #f7f7f7; +} + +.alerts-panel-title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + min-height: 22px; + font-weight: bold; + font-size: 15px; + line-height: 15px; +} + +.alerts-panel-sub { + color: #444444; + font-size: 12px; + line-height: 12px; + font-weight: normal; +} + +.alerts-panel-tools { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.alerts-panel-body { + flex: 1 1 auto; + min-height: 0; + padding: 10px; + overflow: auto; + background-color: #ffffff; + background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)); +} + +.alerts-tool-button, +.alerts-row-button, +.alerts-footer-button { + min-width: 64px; + height: 24px; + padding: 0 8px; + font-size: 12px; + line-height: 12px; +} + +.alerts-action-button { + width: 100%; + min-width: 0; +} + +.alerts-toolbar-grid { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, .6fr)); + gap: 8px; + margin-bottom: 8px; +} + +.alerts-input, +.alerts-select, +.alerts-textarea { + width: 100%; + min-width: 0; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 4px 6px; + font-family: inherit; + font-size: 13px; +} + +.alerts-input, +.alerts-select { + height: 28px; +} + +.alerts-table-wrap { + height: 430px; + overflow: auto; + background: #ffffff; + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} + +.alerts-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; + line-height: 14px; + color: #000000; +} + +.alerts-table thead th { + position: sticky; + top: 0; + z-index: 2; + padding: 6px; + text-align: left; + background: #dfdfdf; + border-bottom: 1px solid #b0b0b0; + box-shadow: inset 0 1px 0 #ffffff; +} + +.alerts-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); } +.alerts-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); } +.alerts-table tbody tr:hover { background: #d8e5f8; } +.alerts-table tbody tr.is-selected { background: #c5dcff; } + +.alerts-table td { + padding: 6px; + border-bottom: 1px solid #e1e1e1; + vertical-align: middle; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.alerts-col-check { width: 34px; } +.alerts-col-severity { width: 76px; } +.alerts-col-status { width: 82px; } +.alerts-col-code { width: 70px; } +.alerts-col-time { width: 110px; } +.alerts-col-actions { width: 88px; } + +.alerts-pill { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + color: #222222; + background: #f1f1f1; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #b0b0b0; + border-bottom: 1px solid #b0b0b0; + font-size: 12px; + line-height: 12px; +} + +.alerts-pill.low { background: #deebff; } +.alerts-pill.medium { background: #fff2c8; } +.alerts-pill.high { background: #ffdcdc; } +.alerts-pill.open { background: #f2e1ff; } +.alerts-pill.acked { background: #e2f0e2; } +.alerts-pill.closed { background: #ececec; } + +.alerts-info-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.alerts-info-item { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 6px 8px; + background: #f5f5f5; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #c0c0c0; + border-bottom: 1px solid #c0c0c0; +} + +.alerts-info-item strong { + font-size: 13px; + line-height: 13px; +} + +.alerts-info-item span { + min-width: 0; + color: #222222; + word-break: break-word; +} + +.alerts-json-box { + max-height: 180px; + overflow: auto; + margin: 0; + padding: 8px; + color: #b7ffc8; + background: #050505; + border-top: 2px solid #808080; + border-left: 2px solid #808080; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; + font-family: "MonoCraft", "Courier New", monospace; + font-size: 12px; + line-height: 15px; + white-space: pre-wrap; + word-break: break-word; +} + +.alerts-mini-note { + margin-top: 8px; + padding: 8px; + color: #000000; + background: #ffffcc; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #a08000; + border-bottom: 1px solid #a08000; + font-size: 12px; + line-height: 15px; +} + +.alerts-action-stack { + display: grid; + gap: 8px; +} + +.alerts-footerbar { + flex: 0 0 auto; + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 8px; + border-top: 1px solid #ffffff; + background: #dfdfdf; +} + +.alerts-footer-left, +.alerts-footer-right { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.alerts-status-pill { + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 8px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-size: 12px; + line-height: 12px; +} + +@media (max-width: 1120px) { + .alerts-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .alerts-content-grid { + grid-template-columns: 1fr; + } + + .alerts-toolbar-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .alerts-summary-grid, + .alerts-toolbar-grid { + grid-template-columns: 1fr; + } + + .alerts-table-wrap { + height: 360px; + } + + .alerts-panel-header, + .alerts-footerbar { + align-items: flex-start; + flex-direction: column; + } + + .alerts-info-item { + grid-template-columns: 1fr; + } +} diff --git a/static/js/admin/alerts.js b/static/js/admin/alerts.js new file mode 100644 index 0000000..e50cf30 --- /dev/null +++ b/static/js/admin/alerts.js @@ -0,0 +1,216 @@ +(() => { + const menuController = window.WarpBoxUI?.bindMenuBar?.() || { + close() { + document.querySelectorAll(".menu-item.is-open").forEach((item) => { + item.classList.remove("is-open"); + item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); + }); + } + }; + const toast = document.getElementById("toast"); + const searchInput = document.getElementById("search-input"); + const severityFilter = document.getElementById("severity-filter"); + const statusFilter = document.getElementById("status-filter"); + const sourceFilter = document.getElementById("source-filter"); + const sortFilter = document.getElementById("sort-filter"); + const alertsBody = document.getElementById("alerts-body"); + const selectedCountEl = document.getElementById("selected-count"); + const openCountEl = document.querySelector("[data-open-count]"); + const highCountEl = document.querySelector("[data-high-count]"); + const ackCountEl = document.querySelector("[data-ack-count]"); + const closedCountEl = document.querySelector("[data-closed-count]"); + const selectAll = document.getElementById("select-all"); + + const detailEls = { + title: document.getElementById("detail-title"), + severity: document.getElementById("detail-severity"), + status: document.getElementById("detail-status"), + code: document.getElementById("detail-code"), + trace: document.getElementById("detail-trace"), + time: document.getElementById("detail-time"), + description: document.getElementById("detail-description"), + metadata: document.getElementById("detail-metadata") + }; + + if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return; + + function showToast(message, type = "info", duration = 1800) { + if (window.WarpBoxUI) { + window.WarpBoxUI.toast(message, type, { target: toast, duration }); + return; + } + if (!toast) return; + toast.textContent = message; + toast.classList.add("is-visible"); + window.setTimeout(() => toast.classList.remove("is-visible"), duration); + } + + function allRows() { + return Array.from(alertsBody.querySelectorAll("tr")); + } + + function visibleRows() { + return allRows().filter((row) => row.style.display !== "none"); + } + + function selectedRows() { + return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none"); + } + + function updateSelectedCount() { + selectedCountEl.textContent = `Selected: ${selectedRows().length}`; + } + + function updateSummaryCounts() { + const rows = visibleRows(); + openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length); + highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length); + ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length); + closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length); + } + + function updateDetails(row) { + if (!row) return; + allRows().forEach((item) => item.classList.remove("is-selected")); + row.classList.add("is-selected"); + detailEls.title.textContent = row.dataset.title || ""; + detailEls.severity.textContent = row.dataset.severity || ""; + detailEls.status.textContent = row.dataset.status || ""; + detailEls.code.textContent = row.dataset.code || ""; + detailEls.trace.textContent = row.dataset.trace || ""; + detailEls.time.textContent = row.dataset.time || ""; + detailEls.description.textContent = row.dataset.description || ""; + try { + detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2); + } catch (_) { + detailEls.metadata.textContent = row.dataset.metadata || "{}"; + } + } + + function applyFilters() { + const search = searchInput.value.trim().toLowerCase(); + const severity = severityFilter.value; + const status = statusFilter.value; + const group = sourceFilter.value; + + allRows().forEach((row) => { + const haystack = [ + row.dataset.title, + row.dataset.description, + row.dataset.code, + row.dataset.trace, + row.dataset.group + ].join(" ").toLowerCase(); + const matchesSearch = !search || haystack.includes(search); + const matchesSeverity = severity === "all" || row.dataset.severity === severity; + const matchesStatus = status === "all" || row.dataset.status === status; + const matchesGroup = group === "all" || row.dataset.group === group; + row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none"; + }); + + const order = { high: 3, medium: 2, low: 1 }; + visibleRows().sort((a, b) => { + if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity]; + if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id); + return Number(b.dataset.id) - Number(a.dataset.id); + }).forEach((row) => alertsBody.appendChild(row)); + + const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected")); + if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]); + updateSelectedCount(); + updateSummaryCounts(); + } + + function setRowStatus(row, nextStatus) { + row.dataset.status = nextStatus; + const statusCell = row.children[3]?.querySelector(".alerts-pill"); + if (!statusCell) return; + statusCell.className = `alerts-pill ${nextStatus}`; + statusCell.textContent = nextStatus; + } + + function changeSelectedStatus(nextStatus) { + const rows = selectedRows(); + if (!rows.length) { + showToast("Select one or more alerts first", "warning"); + return; + } + + rows.forEach((row) => { + setRowStatus(row, nextStatus); + row.querySelector(".row-check").checked = false; + }); + if (selectAll) selectAll.checked = false; + updateSelectedCount(); + updateSummaryCounts(); + + const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0]; + if (currentRow) updateDetails(currentRow); + showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed"); + } + + const commandMessages = { + refresh: "Alerts refreshed in mock view", + export: "Visible alerts exported in mock view", + "copy-meta": "Metadata copied in mock view", + "help-codes": "Each alert code maps to a unique trigger point and trace identifier.", + "help-meta": "Metadata explains why the alert happened and includes extra context." + }; + + function runCommand(command) { + switch (command) { + case "ack": + changeSelectedStatus("acked"); + return; + case "close": + changeSelectedStatus("closed"); + return; + case "open-only": + statusFilter.value = "open"; + applyFilters(); + showToast("Showing open alerts only"); + return; + default: + showToast(commandMessages[command] || `Mock action: ${command}`); + } + } + + [searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => { + control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters); + }); + + allRows().forEach((row) => { + row.addEventListener("click", (event) => { + if (event.target.closest("button") || event.target.closest("input")) return; + updateDetails(row); + }); + row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row)); + row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount); + }); + + selectAll?.addEventListener("change", () => { + visibleRows().forEach((row) => { + const checkbox = row.querySelector(".row-check"); + if (checkbox) checkbox.checked = selectAll.checked; + }); + updateSelectedCount(); + }); + + document.querySelectorAll("[data-command]").forEach((button) => { + button.addEventListener("click", () => { + menuController.close(); + runCommand(button.dataset.command); + }); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") menuController.close(); + if (event.key === "F5") { + event.preventDefault(); + runCommand("refresh"); + } + }); + + applyFilters(); + updateDetails(allRows()[0]); +})(); diff --git a/static/js/admin/dashboard.js b/static/js/admin/dashboard.js index 690dfc7..f694a11 100644 --- a/static/js/admin/dashboard.js +++ b/static/js/admin/dashboard.js @@ -96,8 +96,8 @@ "dashboard-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard snapshot export would start here.", logout: "CURRENTLY_MOCKED_LEAVE_AS_IS: logout would submit to the account logout route.", "compact-mode": "Toggled compact density.", - "show-all-boxes": "TO-DO: navigate to /account/boxes.", - "show-all-alerts": "TO-DO: navigate to /account/alerts.", + "show-all-boxes": "TO-DO: navigate to the admin boxes view when that page exists.", + "show-all-alerts": "TO-DO: navigate to /admin/alerts.", "export-boxes": "CURRENTLY_MOCKED_LEAVE_AS_IS: boxes CSV export would be requested.", "export-alerts": "CURRENTLY_MOCKED_LEAVE_AS_IS: alerts JSON export would be requested.", "cleanup-dry-run": "CURRENTLY_MOCKED_LEAVE_AS_IS: cleanup dry run would calculate affected boxes without deleting.", @@ -105,8 +105,8 @@ "config-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: config snapshot would summarize runtime settings and sources.", "support-summary": "CURRENTLY_MOCKED_LEAVE_AS_IS: support summary would collect safe diagnostic information.", "thumbnail-rebuild": "CURRENTLY_MOCKED_LEAVE_AS_IS: thumbnail rebuild would enqueue preview regeneration.", - "open-users": "TO-DO: navigate to /account/users for admins.", - "open-settings": "TO-DO: navigate to /account/settings for admins.", + "open-users": "TO-DO: navigate to the admin users view when that page exists.", + "open-settings": "TO-DO: navigate to the admin settings view when that page exists.", "alerts-help": "Alerts use title, description, severity, metadata JSON, trace identifier, and unique numeric code.", shortcuts: "Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.", about: "WarpBox dashboard mock v5, single-window Win98 account dashboard." diff --git a/templates/admin/alerts.html b/templates/admin/alerts.html new file mode 100644 index 0000000..579e87f --- /dev/null +++ b/templates/admin/alerts.html @@ -0,0 +1,321 @@ +{{ define "admin/alerts.html" }} + + +
+ + +Open alerts
+5
+Requires attention
+High severity
+2
+Escalate first
+Acknowledged
+3
+Seen but not closed
+Closed today
+2
+History stays lightweight
+| + | Title | +Severity | +Status | +Code | +Trace | +Created | +Actions | +
|---|---|---|---|---|---|---|---|
| + | Storage connector unavailable | +high | +open | +301 | +storage.connector.health_failed | +today 14:08 | ++ |
| + | Thumbnail generation failed | +medium | +open | +601 | +thumbnail.generate.failed | +today 13:40 | ++ |
| + | Large upload nearing account cap | +low | +acked | +124 | +upload.quota.nearing_cap | +today 12:58 | ++ |
| + | Repeated admin login failures | +high | +open | +211 | +auth.admin.failed_login_burst | +today 12:10 | ++ |
| + | Cleanup skipped locked files | +medium | +acked | +342 | +cleanup.skip.locked_files | +today 10:22 | ++ |
| + | Archive completed with warnings | +low | +closed | +145 | +archive.complete.with_warning | +today 09:02 | ++ |
| + | Upload session expired mid-transfer | +medium | +open | +156 | +upload.session.expired_mid_transfer | +yesterday | ++ |
| + | Thumbnail worker restarted | +low | +closed | +602 | +thumbnail.worker.restarted | +yesterday | ++ |
| + | User invited without email delivery confirmation | +medium | +acked | +224 | +auth.invite.delivery_unknown | +2 days ago | ++ |
| + | Secondary connector caught up | +low | +closed | +329 | +storage.secondary.sync_recovered | +2 days ago | ++ |
{
+ "connector": "local-main",
+ "mode": "read_only",
+ "retry_in": "30s"
+}
+