diff --git a/lib/server/admin.go b/lib/server/admin.go index 1f95896..a027de8 100644 --- a/lib/server/admin.go +++ b/lib/server/admin.go @@ -4,17 +4,93 @@ import ( "net/http" "strconv" "strings" + "syscall" + "time" "github.com/gin-gonic/gin" + "warpbox/lib/activity" "warpbox/lib/alerts" "warpbox/lib/config" + "warpbox/lib/helpers" "warpbox/lib/security" + "warpbox/lib/userstore" ) const adminSessionCookie = "warpbox_admin_session" const adminSessionMarker = "1" +type adminDashboardView struct { + ActiveBoxes int `json:"active_boxes"` + BoxesCreatedToday int `json:"boxes_created_today"` + PasswordedBoxes int `json:"passworded_boxes"` + StorageUsedLabel string `json:"storage_used_label"` + StorageFreeLabel string `json:"storage_free_label"` + StorageCapLabel string `json:"storage_cap_label"` + StorageMeter string `json:"storage_meter"` + StorageBackend string `json:"storage_backend"` + OpenAlerts int `json:"open_alerts"` + HighAlerts int `json:"high_alerts"` + MediumAlerts int `json:"medium_alerts"` + LowAlerts int `json:"low_alerts"` + TotalUsers int `json:"total_users"` + ActiveUsers int `json:"active_users"` + DisabledUsers int `json:"disabled_users"` + APIKeyCount int `json:"api_key_count"` + GuestUploadsLabel string `json:"guest_uploads_label"` + APIUploadsLabel string `json:"api_uploads_label"` + ZipDownloadsLabel string `json:"zip_downloads_label"` + Alerts []adminDashboardAlert `json:"alerts"` + Events []adminDashboardActivity `json:"events"` + Boxes []adminDashboardBox `json:"boxes"` +} + +type adminDashboardAlert struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Status string `json:"status"` + Group string `json:"group"` + Code string `json:"code"` + Trace string `json:"trace"` + Message string `json:"message"` + CreatedAt string `json:"created_at"` + CreatedAtLabel string `json:"created_at_label"` + Meta map[string]string `json:"meta,omitempty"` +} + +type adminDashboardActivity struct { + ID string `json:"id"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Message string `json:"message"` + IP string `json:"ip"` + Path string `json:"path"` + Method string `json:"method"` + CreatedAt string `json:"created_at"` + CreatedAtLabel string `json:"created_at_label"` + Meta map[string]string `json:"meta,omitempty"` + TagClass string `json:"tag_class"` + TagLabel string `json:"tag_label"` +} + +type adminDashboardBox struct { + ID string `json:"id"` + Status string `json:"status"` + StatusLabel string `json:"status_label"` + StatusClass string `json:"status_class"` + FileCount int `json:"file_count"` + CompleteFiles int `json:"complete_files"` + TotalSizeLabel string `json:"total_size_label"` + CreatedAtLabel string `json:"created_at_label"` + ExpiresAtLabel string `json:"expires_at_label"` + PasswordProtected bool `json:"password_protected"` + OneTimeDownload bool `json:"one_time_download"` + OpenURL string `json:"open_url"` + ZipURL string `json:"zip_url"` + Flags []string `json:"flags"` +} + func (app *App) adminLoginEnabled() bool { return app.config.AdminLoginEnabled(app.config.AdminPassword != "") } @@ -119,14 +195,256 @@ func (app *App) handleAdminDashboard(ctx *gin.Context) { dashboardEnabled = cfgVal } + dashboard := app.buildAdminDashboardView() ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{ "AdminUsername": app.config.AdminUsername, "AdminEmail": app.config.AdminEmail, "ActivePage": "dashboard", "DashboardEnabled": string(dashboardEnabled), + "Dashboard": dashboard, + "AlertChipClass": adminAlertChipClass(dashboard.OpenAlerts, dashboard.HighAlerts, dashboard.MediumAlerts), + "AlertChipLabel": adminAlertChipLabel(dashboard.OpenAlerts), }) } +func (app *App) buildAdminDashboardView() adminDashboardView { + boxes, _ := app.listAdminBoxes() + alertsList := []alerts.Alert{} + if app.alertStore != nil { + alertsList, _ = app.alertStore.List(500) + } + events := []activity.Event{} + if app.activityStore != nil { + events, _ = app.activityStore.List(80, app.config.ActivityRetentionSeconds) + } + users := []userstore.User{} + if app.userStore != nil { + users = app.userStore.List() + } + + view := adminDashboardView{ + StorageBackend: "local", + GuestUploadsLabel: adminBoolLabel(app.config.GuestUploadsEnabled && app.config.APIEnabled), + APIUploadsLabel: adminBoolLabel(app.config.APIEnabled), + ZipDownloadsLabel: adminBoolLabel(app.config.ZipDownloadsEnabled), + Alerts: make([]adminDashboardAlert, 0, 12), + Events: make([]adminDashboardActivity, 0, 15), + Boxes: make([]adminDashboardBox, 0, 12), + } + + now := time.Now().UTC() + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + usedBytes := int64(0) + for _, box := range boxes { + usedBytes += box.TotalSizeBytes + createdAt, _ := time.Parse(time.RFC3339, box.CreatedAtISO) + if !createdAt.IsZero() && createdAt.After(dayStart) { + view.BoxesCreatedToday++ + } + if box.PasswordProtected { + view.PasswordedBoxes++ + } + if box.Status != "expired" && box.Status != "consumed" { + view.ActiveBoxes++ + } + if len(view.Boxes) < 12 { + view.Boxes = append(view.Boxes, adminDashboardBox{ + ID: box.ID, + Status: box.Status, + StatusLabel: box.StatusLabel, + StatusClass: adminDashboardStatusClass(box.Status), + FileCount: box.FileCount, + CompleteFiles: box.CompleteFiles, + TotalSizeLabel: box.TotalSizeLabel, + CreatedAtLabel: box.CreatedAtLabel, + ExpiresAtLabel: box.ExpiresAtLabel, + PasswordProtected: box.PasswordProtected, + OneTimeDownload: box.OneTimeDownload, + OpenURL: box.OpenURL, + ZipURL: box.ZipURL, + Flags: box.Flags, + }) + } + } + + view.StorageUsedLabel = helpers.FormatBytes(usedBytes) + view.StorageCapLabel = "unknown" + view.StorageFreeLabel = "unknown" + view.StorageMeter = "0%" + if diskTotal, diskFree, ok := adminDiskCapacity(app.config.UploadsDir); ok && diskTotal > 0 { + diskUsed := diskTotal - diskFree + if diskUsed < 0 { + diskUsed = 0 + } + view.StorageUsedLabel = helpers.FormatBytes(diskUsed) + view.StorageFreeLabel = helpers.FormatBytes(diskFree) + view.StorageCapLabel = helpers.FormatBytes(diskTotal) + percent := float64(diskUsed) / float64(diskTotal) * 100 + if percent > 100 { + percent = 100 + } + view.StorageMeter = strconv.FormatFloat(percent, 'f', 1, 64) + "%" + } + + for _, alert := range alertsList { + if alert.Status != alerts.StatusClosed { + view.OpenAlerts++ + switch alert.Severity { + case "high": + view.HighAlerts++ + case "medium": + view.MediumAlerts++ + default: + view.LowAlerts++ + } + if len(view.Alerts) < 12 { + view.Alerts = append(view.Alerts, adminDashboardAlert{ + ID: alert.ID, + Title: alert.Title, + Severity: adminFallback(alert.Severity, "low"), + Status: adminFallback(string(alert.Status), "open"), + Group: alert.Group, + Code: alert.Code, + Trace: alert.Trace, + Message: alert.Message, + CreatedAt: formatBrowserTime(alert.CreatedAt), + CreatedAtLabel: adminShortTimeLabel(alert.CreatedAt), + Meta: alert.Meta, + }) + } + } + } + + for _, event := range events { + if len(view.Events) >= 15 { + break + } + view.Events = append(view.Events, adminDashboardActivity{ + ID: event.ID, + Kind: event.Kind, + Severity: adminFallback(event.Severity, "low"), + Message: event.Message, + IP: event.IP, + Path: event.Path, + Method: event.Method, + CreatedAt: formatBrowserTime(event.CreatedAt), + CreatedAtLabel: adminShortTimeLabel(event.CreatedAt), + Meta: event.Meta, + TagClass: adminSeverityTagClass(event.Severity), + TagLabel: adminActivityTagLabel(event.Kind), + }) + } + + for _, user := range users { + view.TotalUsers++ + if user.Status == userstore.StatusDisabled { + view.DisabledUsers++ + } else { + view.ActiveUsers++ + } + for _, key := range user.APIKeys { + if key.RevokedAt == nil { + view.APIKeyCount++ + } + } + } + + return view +} + +func adminBoolLabel(enabled bool) string { + if enabled { + return "enabled" + } + return "disabled" +} + +func adminFallback(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func adminShortTimeLabel(value time.Time) string { + if value.IsZero() { + return "-" + } + return value.UTC().Format("15:04") +} + +func adminDashboardStatusClass(status string) string { + switch status { + case "ready": + return "ok" + case "uploading", "legacy": + return "warn" + case "attention", "expired", "consumed": + return "danger" + default: + return "info" + } +} + +func adminSeverityTagClass(severity string) string { + switch severity { + case "high": + return "danger" + case "medium": + return "warn" + case "low": + return "ok" + default: + return "info" + } +} + +func adminActivityTagLabel(kind string) string { + parts := strings.Split(kind, ".") + if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { + return "event" + } + return parts[0] +} + +func adminDiskCapacity(path string) (int64, int64, bool) { + if strings.TrimSpace(path) == "" { + return 0, 0, false + } + var stats syscall.Statfs_t + if err := syscall.Statfs(path, &stats); err != nil { + return 0, 0, false + } + blockSize := int64(stats.Bsize) + if blockSize <= 0 { + return 0, 0, false + } + total := int64(stats.Blocks) * blockSize + free := int64(stats.Bavail) * blockSize + return total, free, true +} + +func adminAlertChipClass(total int, high int, medium int) string { + score := high*5 + medium*2 + (total - high - medium) + switch { + case high > 0 || score >= 12: + return "is-danger" + case medium >= 2 || score >= 5: + return "is-warning" + case total > 0: + return "is-info" + default: + return "is-ok" + } +} + +func adminAlertChipLabel(total int) string { + if total == 0 { + return "OK no alerts" + } + return "! " + strconv.Itoa(total) + " alerts" +} + func (app *App) handleAdminAlerts(ctx *gin.Context) { if !app.adminLoginEnabled() { ctx.Redirect(http.StatusSeeOther, "/") @@ -144,6 +462,7 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) { } openCount := 0 highCount := 0 + mediumCount := 0 ackedCount := 0 closedCount := 0 for _, alert := range alertsList { @@ -158,16 +477,21 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) { if alert.Severity == "high" && string(alert.Status) != "closed" { highCount++ } + if alert.Severity == "medium" && string(alert.Status) != "closed" { + mediumCount++ + } } ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{ - "AdminUsername": app.config.AdminUsername, - "AdminEmail": app.config.AdminEmail, - "ActivePage": "alerts", - "Alerts": alertsList, - "OpenCount": strconv.Itoa(openCount), - "HighCount": strconv.Itoa(highCount), - "AckCount": strconv.Itoa(ackedCount), - "ClosedCount": strconv.Itoa(closedCount), + "AdminUsername": app.config.AdminUsername, + "AdminEmail": app.config.AdminEmail, + "ActivePage": "alerts", + "Alerts": alertsList, + "OpenCount": strconv.Itoa(openCount), + "HighCount": strconv.Itoa(highCount), + "AckCount": strconv.Itoa(ackedCount), + "ClosedCount": strconv.Itoa(closedCount), + "AlertChipClass": adminAlertChipClass(openCount, highCount, mediumCount), + "AlertChipLabel": adminAlertChipLabel(openCount), }) } diff --git a/lib/server/admin_boxes.go b/lib/server/admin_boxes.go index a490c9b..b698154 100644 --- a/lib/server/admin_boxes.go +++ b/lib/server/admin_boxes.go @@ -37,6 +37,7 @@ type adminBoxView struct { CompleteFiles int `json:"complete_files"` PendingFiles int `json:"pending_files"` FailedFiles int `json:"failed_files"` + TotalSizeBytes int64 `json:"total_size_bytes"` TotalSizeLabel string `json:"total_size_label"` CreatedAtLabel string `json:"created_at_label"` CreatedAtISO string `json:"created_at_iso"` @@ -203,6 +204,7 @@ func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) { boxView := adminBoxView{ ID: summary.ID, FileCount: summary.FileCount, + TotalSizeBytes: summary.TotalSize, TotalSizeLabel: summary.TotalSizeLabel, CreatedAtLabel: adminTimeLabel(summary.CreatedAt), CreatedAtISO: formatBrowserTime(summary.CreatedAt), diff --git a/static/css/dashboard.css b/static/css/dashboard.css index b7981eb..bd516d8 100644 --- a/static/css/dashboard.css +++ b/static/css/dashboard.css @@ -111,6 +111,18 @@ .alerts-scroll { height: 326px; } .boxes-scroll { height: 352px; } .activity-scroll { height: 326px; } +.dashboard-empty-state { + margin: 8px; + padding: 10px; + color: #333333; + 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 */ .alert-list { display: grid; min-width: 0; } diff --git a/static/js/admin/dashboard.js b/static/js/admin/dashboard.js index f694a11..b3b2da9 100644 --- a/static/js/admin/dashboard.js +++ b/static/js/admin/dashboard.js @@ -7,6 +7,7 @@ }); } }; + const dataNode = document.getElementById("dashboard-data"); const toast = document.getElementById("toast"); const statusText = document.getElementById("statusText"); const modal = document.querySelector("[data-alert-modal]"); @@ -19,18 +20,28 @@ const topAlertChip = document.getElementById("topAlertChip"); const topTaskbar = document.querySelector(".admin-taskbar"); + const dashboardData = parseDashboardData(); + if (!statusText || !alertsCard || !topAlertChip) return; - function showToast(message, type = "info") { + function parseDashboardData() { + try { + return JSON.parse(dataNode?.textContent || "{}"); + } catch (_) { + return {}; + } + } + + function showToast(message, type = "info", duration = 2200) { if (window.WarpBoxUI) { - window.WarpBoxUI.toast(message, type, { target: toast }); + window.WarpBoxUI.toast(message, type, { target: toast, duration }); return; } if (!toast) return; toast.textContent = message; toast.classList.add("is-visible"); window.clearTimeout(showToast.timer); - showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), 2600); + showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), duration); } function setStatus(message) { @@ -91,45 +102,159 @@ setStatus(`Focused ${id.replace("-", " ")}`); } - const commandMessages = { - refresh: "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard refresh would re-fetch dashboard data.", - "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 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.", - "dismiss-low-alerts": "Closed visible low-severity alerts in this mock.", - "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 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." - }; + function downloadFile(filename, content, type) { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + } - function runCommand(command) { - if (command === "compact-mode") document.body.classList.toggle("is-compact"); - if (command === "dismiss-low-alerts") { - document.querySelectorAll('.alert-row[data-severity="low"]').forEach((row) => row.classList.add("is-dismissed")); - updateAlertSummary(); + function csvEscape(value) { + const text = String(value ?? ""); + if (!/[",\n]/.test(text)) return text; + return `"${text.replaceAll('"', '""')}"`; + } + + function exportBoxesCSV() { + const rows = dashboardData.boxes || []; + const header = ["id", "status", "files", "size", "created", "expires", "flags"]; + const lines = rows.map((box) => [ + box.id, + box.status_label, + `${box.complete_files}/${box.file_count}`, + box.total_size_label, + box.created_at_label, + box.expires_at_label, + (box.flags || []).join("|") + ].map(csvEscape).join(",")); + downloadFile(`warpbox-dashboard-boxes-${new Date().toISOString().replaceAll(":", "-")}.csv`, [header.join(","), ...lines].join("\n"), "text/csv;charset=utf-8"); + showToast("Dashboard boxes exported", "success"); + } + + function exportAlertsJSON() { + downloadFile(`warpbox-dashboard-alerts-${new Date().toISOString().replaceAll(":", "-")}.json`, JSON.stringify(dashboardData.alerts || [], null, 2), "application/json;charset=utf-8"); + showToast("Dashboard alerts exported", "success"); + } + + function exportSnapshot() { + downloadFile(`warpbox-dashboard-${new Date().toISOString().replaceAll(":", "-")}.json`, JSON.stringify(dashboardData, null, 2), "application/json;charset=utf-8"); + showToast("Dashboard snapshot exported", "success"); + } + + async function postAlertAction(action, ids) { + const response = await fetch("/admin/alerts/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, ids }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "Alert action failed"); + return payload; + } + + async function postBoxAction(action, extra = {}) { + const response = await fetch("/admin/boxes/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, ...extra }) + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "Box action failed"); + return payload; + } + + async function closeAlert(row) { + const id = row?.dataset.alertId; + if (!id) return; + await postAlertAction("close", [id]); + row.classList.add("is-dismissed"); + updateAlertSummary(); + showToast(`Closed alert ${row.dataset.alertCode || id}`, "success"); + setStatus(`Closed alert ${row.dataset.alertCode || id}`); + } + + async function closeLowAlerts() { + const rows = Array.from(document.querySelectorAll('.alert-row[data-severity="low"]')); + const ids = rows.map((row) => row.dataset.alertId).filter(Boolean); + if (!ids.length) { + showToast("No low alerts to close"); + return; } - if (command === "show-all-boxes") window.location.hash = "recent-boxes"; - if (command === "show-all-alerts") window.location.hash = "alerts"; + await postAlertAction("close", ids); + rows.forEach((row) => row.classList.add("is-dismissed")); + updateAlertSummary(); + showToast(`Closed ${ids.length} low alert(s)`, "success"); + setStatus(`Closed ${ids.length} low alert(s)`); + } - const message = commandMessages[command] || `Command: ${command}`; - showToast(message); - setStatus(message); + async function cleanupExpiredBoxes() { + if (!window.confirm("Clean up expired boxes now? This can delete expired box data.")) return; + const payload = await postBoxAction("cleanup_expired"); + showToast(payload.message || "Expired cleanup complete", payload.ok ? "success" : "warning", 3200); + setStatus(payload.message || "Expired cleanup complete"); + window.setTimeout(() => window.location.reload(), 900); + } + + async function runCommand(command) { + if (command === "refresh") { + window.location.reload(); + return; + } + if (command === "dashboard-snapshot") return exportSnapshot(); + if (command === "logout") { + window.location.href = "/admin/logout"; + return; + } + if (command === "compact-mode") { + document.body.classList.toggle("is-compact"); + showToast("Toggled compact density"); + return; + } + if (command === "show-all-boxes") { + window.location.href = "/admin/boxes"; + return; + } + if (command === "show-all-alerts") { + window.location.href = "/admin/alerts"; + return; + } + if (command === "open-users") { + window.location.href = "/admin/users"; + return; + } + if (command === "open-activity") { + window.location.href = "/admin/activity"; + return; + } + if (command === "open-settings") { + window.location.href = "/admin/settings"; + return; + } + if (command === "export-boxes") return exportBoxesCSV(); + if (command === "export-alerts") return exportAlertsJSON(); + if (command === "close-low-alerts") return closeLowAlerts(); + if (command === "cleanup-expired") return cleanupExpiredBoxes(); + if (command === "shortcuts") { + showToast("Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.", "info", 3600); + return; + } + if (command === "about") { + showToast("Live WarpBox admin dashboard backed by alerts, activity, boxes, users, and settings.", "info", 3600); + } } document.querySelectorAll("[data-command]").forEach((button) => { - button.addEventListener("click", () => { + button.addEventListener("click", async () => { menuController.close(); - runCommand(button.dataset.command); + try { + await runCommand(button.dataset.command); + } catch (error) { + showToast(error.message || "Command failed", "error", 3600); + setStatus(error.message || "Command failed"); + } }); }); @@ -150,37 +275,39 @@ } catch (_) { meta = row?.dataset.alertMeta || "{}"; } - openModal(`${title} (${row?.dataset.alertCode || "mock"})`, meta); + openModal(`${title} (${row?.dataset.alertCode || "alert"})`, meta); }); }); - document.querySelectorAll("[data-dismiss-alert]").forEach((button) => { - button.addEventListener("click", () => { - const row = button.closest(".alert-row"); - row?.classList.add("is-dismissed"); - updateAlertSummary(); - showToast(`Closed alert ${row?.dataset.alertCode || "mock"}.`); - setStatus(`Closed alert ${row?.dataset.alertCode || "mock"}`); + document.querySelectorAll("[data-close-alert]").forEach((button) => { + button.addEventListener("click", async () => { + try { + await closeAlert(button.closest(".alert-row")); + } catch (error) { + showToast(error.message || "Could not close alert", "error", 3600); + } }); }); document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal); backdrop?.addEventListener("click", closeModal); topAlertChip.addEventListener("click", (event) => { - event.preventDefault(); - scrollToSection("alerts"); + if (document.getElementById("alerts")) { + event.preventDefault(); + scrollToSection("alerts"); + } }); window.addEventListener("scroll", updateStickyHeader, { passive: true }); - document.addEventListener("keydown", (event) => { + document.addEventListener("keydown", async (event) => { if (event.key === "Escape") { menuController.close(); closeModal(); } if (event.key === "F5") { event.preventDefault(); - runCommand("refresh"); + await runCommand("refresh"); } if (event.altKey && event.key.toLowerCase() === "a") { event.preventDefault(); diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index ba2e6a5..2af692c 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -14,13 +14,12 @@
+ {{ $d := .Dashboard }}