From c70f65e83bc1876fb368fd117d342573ff18a9e8 Mon Sep 17 00:00:00 2001
From: Yarden Shoham <git@yardenshoham.com>
Date: Fri, 16 Feb 2024 04:52:25 +0200
Subject: [PATCH] Auto-update the system status in admin dashboard (#29163)

- Refactor the system status list into its own template
- Change the backend to return only the system status if htmx initiated
the request
- `hx-get="{{$.Link}}/system_status`: reuse the backend handler
- `hx-swap="innerHTML"`: replace the `<div>`'s innerHTML (essentially
the new template)
- `hx-trigger="every 5s"`: call every 5 seconds
- `hx-indicator=".divider"`: the `is-loading` class shouldn't be added
to the div during the request, so set it on an element it has no effect
on
- Render "Since Last GC Time" with `<relative-time>`, so we send a
timestamp

# Auto-update in action GIF

![action](https://github.com/go-gitea/gitea/assets/20454870/c6e1f220-f0fb-4460-ac3b-59f315e30e29)

---------

Signed-off-by: Yarden Shoham <git@yardenshoham.com>
Co-authored-by: silverwind <me@silverwind.io>
---
 package-lock.json                  |  6 +++
 package.json                       |  1 +
 routers/web/admin/admin.go         | 26 +++++++-----
 routers/web/web.go                 |  1 +
 templates/admin/dashboard.tmpl     | 66 ++----------------------------
 templates/admin/system_status.tmpl | 62 ++++++++++++++++++++++++++++
 templates/base/head.tmpl           |  2 +-
 web_src/js/htmx.js                 |  3 ++
 webpack.config.js                  |  4 ++
 9 files changed, 97 insertions(+), 74 deletions(-)
 create mode 100644 templates/admin/system_status.tmpl

diff --git a/package-lock.json b/package-lock.json
index 48da8124e0..13f03b8d28 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
         "escape-goat": "4.0.0",
         "fast-glob": "3.3.2",
         "htmx.org": "1.9.10",
+        "idiomorph": "0.3.0",
         "jquery": "3.7.1",
         "katex": "0.16.9",
         "license-checker-webpack-plugin": "0.2.1",
@@ -6174,6 +6175,11 @@
         "postcss": "^8.1.0"
       }
     },
+    "node_modules/idiomorph": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/idiomorph/-/idiomorph-0.3.0.tgz",
+      "integrity": "sha512-UhV1Ey5xCxIwR9B+OgIjQa+1Jx99XQ1vQHUsKBU1RpQzCx1u+b+N6SOXgf5mEJDqemUI/ffccu6+71l2mJUsRA=="
+    },
     "node_modules/ieee754": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
diff --git a/package.json b/package.json
index ac79741711..3d753a567c 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
     "escape-goat": "4.0.0",
     "fast-glob": "3.3.2",
     "htmx.org": "1.9.10",
+    "idiomorph": "0.3.0",
     "jquery": "3.7.1",
     "katex": "0.16.9",
     "license-checker-webpack-plugin": "0.2.1",
diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go
index d31cb1cd25..9fbd429f74 100644
--- a/routers/web/admin/admin.go
+++ b/routers/web/admin/admin.go
@@ -28,13 +28,14 @@ import (
 )
 
 const (
-	tplDashboard   base.TplName = "admin/dashboard"
-	tplSelfCheck   base.TplName = "admin/self_check"
-	tplCron        base.TplName = "admin/cron"
-	tplQueue       base.TplName = "admin/queue"
-	tplStacktrace  base.TplName = "admin/stacktrace"
-	tplQueueManage base.TplName = "admin/queue_manage"
-	tplStats       base.TplName = "admin/stats"
+	tplDashboard    base.TplName = "admin/dashboard"
+	tplSystemStatus base.TplName = "admin/system_status"
+	tplSelfCheck    base.TplName = "admin/self_check"
+	tplCron         base.TplName = "admin/cron"
+	tplQueue        base.TplName = "admin/queue"
+	tplStacktrace   base.TplName = "admin/stacktrace"
+	tplQueueManage  base.TplName = "admin/queue_manage"
+	tplStats        base.TplName = "admin/stats"
 )
 
 var sysStatus struct {
@@ -72,7 +73,7 @@ var sysStatus struct {
 
 	// Garbage collector statistics.
 	NextGC       string // next run in HeapAlloc time (bytes)
-	LastGC       string // last run in absolute time (ns)
+	LastGCTime   string // last run time
 	PauseTotalNs string
 	PauseNs      string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256]
 	NumGC        uint32
@@ -110,7 +111,7 @@ func updateSystemStatus() {
 	sysStatus.OtherSys = base.FileSize(int64(m.OtherSys))
 
 	sysStatus.NextGC = base.FileSize(int64(m.NextGC))
-	sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
+	sysStatus.LastGCTime = time.Unix(0, int64(m.LastGC)).Format(time.RFC3339)
 	sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
 	sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
 	sysStatus.NumGC = m.NumGC
@@ -132,7 +133,6 @@ func Dashboard(ctx *context.Context) {
 	ctx.Data["PageIsAdminDashboard"] = true
 	ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate(ctx)
 	ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion(ctx)
-	// FIXME: update periodically
 	updateSystemStatus()
 	ctx.Data["SysStatus"] = sysStatus
 	ctx.Data["SSH"] = setting.SSH
@@ -140,6 +140,12 @@ func Dashboard(ctx *context.Context) {
 	ctx.HTML(http.StatusOK, tplDashboard)
 }
 
+func SystemStatus(ctx *context.Context) {
+	updateSystemStatus()
+	ctx.Data["SysStatus"] = sysStatus
+	ctx.HTML(http.StatusOK, tplSystemStatus)
+}
+
 // DashboardPost run an admin operation
 func DashboardPost(ctx *context.Context) {
 	form := web.GetForm(ctx).(*forms.AdminDashboardForm)
diff --git a/routers/web/web.go b/routers/web/web.go
index a6288caaf6..0528b20328 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -677,6 +677,7 @@ func registerRoutes(m *web.Route) {
 	// ***** START: Admin *****
 	m.Group("/admin", func() {
 		m.Get("", admin.Dashboard)
+		m.Get("/system_status", admin.SystemStatus)
 		m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost)
 
 		m.Get("/self_check", admin.SelfCheck)
diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl
index f43b4c5385..8088315f17 100644
--- a/templates/admin/dashboard.tmpl
+++ b/templates/admin/dashboard.tmpl
@@ -75,69 +75,9 @@
 		<h4 class="ui top attached header">
 			{{ctx.Locale.Tr "admin.dashboard.system_status"}}
 		</h4>
-		<div class="ui attached table segment">
-			<dl class="admin-dl-horizontal">
-				<dt>{{ctx.Locale.Tr "admin.dashboard.server_uptime"}}</dt>
-				<dd><relative-time format="duration" datetime="{{.SysStatus.StartTime}}">{{.SysStatus.StartTime}}</relative-time></dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.current_goroutine"}}</dt>
-				<dd>{{.SysStatus.NumGoroutine}}</dd>
-				<div class="divider"></div>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.current_memory_usage"}}</dt>
-				<dd>{{.SysStatus.MemAllocated}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.total_memory_allocated"}}</dt>
-				<dd>{{.SysStatus.MemTotal}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.memory_obtained"}}</dt>
-				<dd>{{.SysStatus.MemSys}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.pointer_lookup_times"}}</dt>
-				<dd>{{.SysStatus.Lookups}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.memory_allocate_times"}}</dt>
-				<dd>{{.SysStatus.MemMallocs}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.memory_free_times"}}</dt>
-				<dd>{{.SysStatus.MemFrees}}</dd>
-				<div class="divider"></div>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.current_heap_usage"}}</dt>
-				<dd>{{.SysStatus.HeapAlloc}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.heap_memory_obtained"}}</dt>
-				<dd>{{.SysStatus.HeapSys}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.heap_memory_idle"}}</dt>
-				<dd>{{.SysStatus.HeapIdle}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.heap_memory_in_use"}}</dt>
-				<dd>{{.SysStatus.HeapInuse}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.heap_memory_released"}}</dt>
-				<dd>{{.SysStatus.HeapReleased}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.heap_objects"}}</dt>
-				<dd>{{.SysStatus.HeapObjects}}</dd>
-				<div class="divider"></div>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.bootstrap_stack_usage"}}</dt>
-				<dd>{{.SysStatus.StackInuse}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.stack_memory_obtained"}}</dt>
-				<dd>{{.SysStatus.StackSys}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.mspan_structures_usage"}}</dt>
-				<dd>{{.SysStatus.MSpanInuse}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.mspan_structures_obtained"}}</dt>
-				<dd>{{.SysStatus.MSpanSys}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.mcache_structures_usage"}}</dt>
-				<dd>{{.SysStatus.MCacheInuse}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.mcache_structures_obtained"}}</dt>
-				<dd>{{.SysStatus.MCacheSys}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.profiling_bucket_hash_table_obtained"}}</dt>
-				<dd>{{.SysStatus.BuckHashSys}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.gc_metadata_obtained"}}</dt>
-				<dd>{{.SysStatus.GCSys}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.other_system_allocation_obtained"}}</dt>
-				<dd>{{.SysStatus.OtherSys}}</dd>
-				<div class="divider"></div>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.next_gc_recycle"}}</dt>
-				<dd>{{.SysStatus.NextGC}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.last_gc_time"}}</dt>
-				<dd>{{.SysStatus.LastGC}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.total_gc_pause"}}</dt>
-				<dd>{{.SysStatus.PauseTotalNs}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.last_gc_pause"}}</dt>
-				<dd>{{.SysStatus.PauseNs}}</dd>
-				<dt>{{ctx.Locale.Tr "admin.dashboard.gc_times"}}</dt>
-				<dd>{{.SysStatus.NumGC}}</dd>
-			</dl>
+		{{/* TODO: make these stats work in multi-server deployments, likely needs per-server stats in DB */}}
+		<div hx-get="{{$.Link}}/system_status" hx-swap="morph:innerHTML" hx-trigger="every 5s" hx-indicator=".divider" class="ui attached table segment">
+			{{template "admin/system_status" .}}
 		</div>
 	</div>
 {{template "admin/layout_footer" .}}
diff --git a/templates/admin/system_status.tmpl b/templates/admin/system_status.tmpl
new file mode 100644
index 0000000000..7b5c9be6cc
--- /dev/null
+++ b/templates/admin/system_status.tmpl
@@ -0,0 +1,62 @@
+<dl class="admin-dl-horizontal">
+	<dt>{{ctx.Locale.Tr "admin.dashboard.server_uptime"}}</dt>
+	<dd><relative-time format="duration" datetime="{{.SysStatus.StartTime}}">{{.SysStatus.StartTime}}</relative-time></dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.current_goroutine"}}</dt>
+	<dd>{{.SysStatus.NumGoroutine}}</dd>
+	<div class="divider"></div>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.current_memory_usage"}}</dt>
+	<dd>{{.SysStatus.MemAllocated}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.total_memory_allocated"}}</dt>
+	<dd>{{.SysStatus.MemTotal}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.memory_obtained"}}</dt>
+	<dd>{{.SysStatus.MemSys}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.pointer_lookup_times"}}</dt>
+	<dd>{{.SysStatus.Lookups}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.memory_allocate_times"}}</dt>
+	<dd>{{.SysStatus.MemMallocs}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.memory_free_times"}}</dt>
+	<dd>{{.SysStatus.MemFrees}}</dd>
+	<div class="divider"></div>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.current_heap_usage"}}</dt>
+	<dd>{{.SysStatus.HeapAlloc}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.heap_memory_obtained"}}</dt>
+	<dd>{{.SysStatus.HeapSys}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.heap_memory_idle"}}</dt>
+	<dd>{{.SysStatus.HeapIdle}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.heap_memory_in_use"}}</dt>
+	<dd>{{.SysStatus.HeapInuse}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.heap_memory_released"}}</dt>
+	<dd>{{.SysStatus.HeapReleased}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.heap_objects"}}</dt>
+	<dd>{{.SysStatus.HeapObjects}}</dd>
+	<div class="divider"></div>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.bootstrap_stack_usage"}}</dt>
+	<dd>{{.SysStatus.StackInuse}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.stack_memory_obtained"}}</dt>
+	<dd>{{.SysStatus.StackSys}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.mspan_structures_usage"}}</dt>
+	<dd>{{.SysStatus.MSpanInuse}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.mspan_structures_obtained"}}</dt>
+	<dd>{{.SysStatus.MSpanSys}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.mcache_structures_usage"}}</dt>
+	<dd>{{.SysStatus.MCacheInuse}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.mcache_structures_obtained"}}</dt>
+	<dd>{{.SysStatus.MCacheSys}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.profiling_bucket_hash_table_obtained"}}</dt>
+	<dd>{{.SysStatus.BuckHashSys}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.gc_metadata_obtained"}}</dt>
+	<dd>{{.SysStatus.GCSys}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.other_system_allocation_obtained"}}</dt>
+	<dd>{{.SysStatus.OtherSys}}</dd>
+	<div class="divider"></div>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.next_gc_recycle"}}</dt>
+	<dd>{{.SysStatus.NextGC}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.last_gc_time"}}</dt>
+	<dd><relative-time format="duration" datetime="{{.SysStatus.LastGCTime}}">{{.SysStatus.LastGCTime}}</relative-time></dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.total_gc_pause"}}</dt>
+	<dd>{{.SysStatus.PauseTotalNs}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.last_gc_pause"}}</dt>
+	<dd>{{.SysStatus.PauseNs}}</dd>
+	<dt>{{ctx.Locale.Tr "admin.dashboard.gc_times"}}</dt>
+	<dd>{{.SysStatus.NumGC}}</dd>
+</dl>
diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl
index b9c050fdd5..e910bb0cd9 100644
--- a/templates/base/head.tmpl
+++ b/templates/base/head.tmpl
@@ -29,7 +29,7 @@
 	{{template "base/head_style" .}}
 	{{template "custom/header" .}}
 </head>
-<body hx-headers='{"x-csrf-token": "{{.CsrfToken}}"}' hx-swap="outerHTML" hx-push-url="false">
+<body hx-headers='{"x-csrf-token": "{{.CsrfToken}}"}' hx-swap="outerHTML" hx-ext="morph" hx-push-url="false">
 	{{ctx.DataRaceCheck $.Context}}
 	{{template "custom/body_outer_pre" .}}
 
diff --git a/web_src/js/htmx.js b/web_src/js/htmx.js
index 92400d1cbe..5ca3018308 100644
--- a/web_src/js/htmx.js
+++ b/web_src/js/htmx.js
@@ -1,6 +1,9 @@
 import * as htmx from 'htmx.org';
 import {showErrorToast} from './modules/toast.js';
 
+// https://github.com/bigskysoftware/idiomorph#htmx
+import 'idiomorph/dist/idiomorph-ext.js';
+
 // https://htmx.org/reference/#config
 htmx.config.requestClass = 'is-loading';
 htmx.config.scrollIntoViewOnBoost = false;
diff --git a/webpack.config.js b/webpack.config.js
index 8b3b8477c1..82d76d9e8d 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -169,6 +169,9 @@ export default {
     ],
   },
   plugins: [
+    new webpack.ProvidePlugin({ // for htmx extensions
+      htmx: 'htmx.org',
+    }),
     new DefinePlugin({
       __VUE_OPTIONS_API__: true, // at the moment, many Vue components still use the Vue Options API
       __VUE_PROD_DEVTOOLS__: false, // do not enable devtools support in production
@@ -207,6 +210,7 @@ export default {
       override: {
         'khroma@*': {licenseName: 'MIT'}, // https://github.com/fabiospampinato/khroma/pull/33
         'htmx.org@1.9.10': {licenseName: 'BSD-2-Clause'}, // "BSD 2-Clause" -> "BSD-2-Clause"
+        'idiomorph@0.3.0': {licenseName: 'BSD-2-Clause'}, // "BSD 2-Clause" -> "BSD-2-Clause"
       },
       emitError: true,
       allow: '(Apache-2.0 OR BSD-2-Clause OR BSD-3-Clause OR MIT OR ISC OR CPAL-1.0 OR Unlicense OR EPL-1.0 OR EPL-2.0)',