5 Commits

Author SHA1 Message Date
steve 06748f5582 Merge pull request 'ui(relTime): tick relative timestamps client-side' (#28) from fix-stale-reltime into main
Release / Build + push image (push) Successful in 3m52s
Reviewed-on: #28
2026-05-15 20:14:08 +00:00
steve a4d705db6b Merge branch 'main' into fix-stale-reltime
CI / Test (store) (pull_request) Successful in 1m15s
CI / Lint (pull_request) Successful in 19s
CI / Build (windows/amd64) (pull_request) Successful in 25s
CI / Test (server-http) (pull_request) Successful in 2m2s
CI / Test (rest) (pull_request) Successful in 2m12s
CI / Build (linux/amd64) (pull_request) Successful in 26s
CI / Build (linux/arm64) (pull_request) Successful in 26s
e2e / Playwright vs docker-compose (pull_request) Successful in 2m59s
2026-05-15 20:05:45 +00:00
steve c6f73f790d ci: pull ci-runner-go from zot registry 2026-05-15 19:51:02 +00:00
steve 068f08d96d ci: migrate release workflow to zot registry 2026-05-15 19:50:50 +00:00
steve 28ef9750d3 ui(relTime): tick relative timestamps client-side so long-open tabs don't freeze
CI / Test (rest) (pull_request) Successful in 9s
CI / Test (store) (pull_request) Successful in 6s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Build (linux/amd64) (pull_request) Successful in 7s
CI / Lint (pull_request) Successful in 19s
CI / Build (linux/arm64) (pull_request) Successful in 7s
e2e / Playwright vs docker-compose (pull_request) Successful in 1m26s
CI / Test (server-http) (pull_request) Successful in 2m34s
formatRelTime now wraps its label in <time data-rel-ts=...>, and
both layouts include a small ticker that re-renders every 30s.
Without this, a job-detail page rendered an hour ago kept showing
'2h ago' when the wall-clock truth was '3h ago'.
2026-05-10 07:37:03 +01:00
6 changed files with 156 additions and 21 deletions
+15 -3
View File
@@ -70,7 +70,11 @@ jobs:
# one runner. The third shard ("rest") covers everything else.
name: Test (${{ matrix.name }})
runs-on: ubuntu-latest
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08
container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
strategy:
fail-fast: false
matrix:
@@ -105,7 +109,11 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-latest
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08
container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: golangci/golangci-lint-action@v7
@@ -121,7 +129,11 @@ jobs:
build:
name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08
container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
strategy:
fail-fast: false
matrix:
+11 -13
View File
@@ -12,18 +12,12 @@
# plus install.sh / install.ps1 / the systemd unit baked in under
# /opt/restic-manager/dist (the read-only fallback path the server
# handlers use when <DataDir>/... is empty).
# * Pushes to this Gitea instance's container registry under
# <gitea-host>/<owner>/restic-manager.
# * Pushes to zot OCI registry (docker.dcglab.co.uk).
#
# Tag fan-out
# * tag push: :vX.Y.Z, :X.Y, :X
# * tag push and X >= 1: also :latest
# * workflow_dispatch: only :snapshot-<shortsha>; nothing else moves.
#
# Why no goreleaser
# The architecture already routes agent distribution through the
# server's /agent/binary endpoint. The image is the only deliverable;
# binary archives would just be a second source of truth.
name: Release
@@ -34,8 +28,8 @@ on:
workflow_dispatch:
env:
REGISTRY: gitea.dcglab.co.uk
IMAGE_NAME: ${{ gitea.repository }}
REGISTRY: docker.dcglab.co.uk
IMAGE_NAME: restic-manager
# Force bash as the default shell — see ci.yml header.
defaults:
@@ -46,19 +40,23 @@ jobs:
image:
name: Build + push image
runs-on: ubuntu-latest
container: gitea.dcglab.co.uk/steve/ci-runner-go:2026-05-08
container:
image: docker.dcglab.co.uk/ci-runner-go:2026-05-15
credentials:
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
- name: Log in to zot registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.DEV_TOKEN }}
username: ${{ secrets.ZOT_USERNAME }}
password: ${{ secrets.ZOT_PASSWORD }}
- name: Compute tags + version
id: meta
+22 -5
View File
@@ -221,23 +221,40 @@ func formatBytes(n int64) template.HTML {
// "in 5m"-style. Accepts *time.Time or time.Time so templates can
// pass either without fighting Go's lack of an address-of operator.
// Anything else returns "—".
func formatRelTime(v any) string {
//
// The output is wrapped in a <time data-rel-ts="..."> element so a
// small client-side ticker (see base.html) can refresh the label
// without a full page reload — otherwise a long-open tab shows
// timestamps frozen at render time.
func formatRelTime(v any) template.HTML {
var t time.Time
switch x := v.(type) {
case time.Time:
t = x
case *time.Time:
if x == nil {
return "—"
return template.HTML("—")
}
t = *x
default:
return "—"
return template.HTML("—")
}
if t.IsZero() {
return "—"
return template.HTML("—")
}
d := time.Since(t)
label := relTimeLabel(time.Since(t))
return template.HTML(fmt.Sprintf(
`<time data-rel-ts="%s" title="%s">%s</time>`,
t.UTC().Format(time.RFC3339Nano),
t.UTC().Format("2006-01-02 15:04:05 UTC"),
label,
))
}
// relTimeLabel turns a duration-since-now into the short human label
// used by formatRelTime (and mirrored verbatim by the JS ticker, so
// keep the two in sync if you change the buckets).
func relTimeLabel(d time.Duration) string {
suffix := "ago"
if d < 0 {
d = -d
+49
View File
@@ -0,0 +1,49 @@
package ui
import (
"strings"
"testing"
"time"
)
func TestFormatRelTimeWrapsInTickableTimeElement(t *testing.T) {
// A long-open tab needs a stable anchor so the JS ticker can
// refresh the label — see base.html.
when := time.Now().Add(-3 * time.Hour)
got := string(formatRelTime(when))
if !strings.Contains(got, `<time data-rel-ts="`) {
t.Errorf("missing data-rel-ts anchor in %q", got)
}
if !strings.Contains(got, "3h ago</time>") {
t.Errorf("expected '3h ago' label, got %q", got)
}
}
func TestFormatRelTimeNilReturnsDash(t *testing.T) {
var p *time.Time
if string(formatRelTime(p)) != "—" {
t.Errorf("nil should render as em-dash, got %q", formatRelTime(p))
}
if string(formatRelTime(time.Time{})) != "—" {
t.Errorf("zero should render as em-dash")
}
}
func TestRelTimeLabelBuckets(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{30 * time.Second, "30s ago"},
{5 * time.Minute, "5m ago"},
{2 * time.Hour, "2h ago"},
{3 * 24 * time.Hour, "3d ago"},
{2 * 7 * 24 * time.Hour, "2w ago"},
{-5 * time.Minute, "5m from now"},
}
for _, c := range cases {
if got := relTimeLabel(c.d); got != c.want {
t.Errorf("relTimeLabel(%v) = %q, want %q", c.d, got, c.want)
}
}
}
+31
View File
@@ -20,6 +20,37 @@
{{template "toast" .}}
<script>
// Tick <time data-rel-ts> labels so long-open tabs don't freeze
// (e.g. a job page rendered an hour ago kept showing "2h ago" when
// the truth was "3h ago"). Buckets must match relTimeLabel in
// internal/server/ui/funcs.go.
(function () {
function label(ms) {
var suffix = 'ago';
if (ms < 0) { ms = -ms; suffix = 'from now'; }
var s = Math.floor(ms / 1000);
if (s < 60) return s + 's ' + suffix;
var m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + suffix;
var h = Math.floor(m / 60);
if (h < 24) return h + 'h ' + suffix;
var d = Math.floor(h / 24);
if (d < 7) return d + 'd ' + suffix;
return Math.floor(d / 7) + 'w ' + suffix;
}
function tick() {
var now = Date.now();
document.querySelectorAll('time[data-rel-ts]').forEach(function (el) {
var t = Date.parse(el.getAttribute('data-rel-ts'));
if (!isNaN(t)) el.textContent = label(now - t);
});
}
tick();
setInterval(tick, 30000);
})();
</script>
</body>
</html>
{{end}}
+28
View File
@@ -11,6 +11,34 @@
</head>
<body class="min-h-screen flex flex-col">
{{block "content" .}}{{end}}
<script>
// See base.html for rationale; chromeless pages (e.g. pending host)
// also use the relTime helper, so they need the same ticker.
(function () {
function label(ms) {
var suffix = 'ago';
if (ms < 0) { ms = -ms; suffix = 'from now'; }
var s = Math.floor(ms / 1000);
if (s < 60) return s + 's ' + suffix;
var m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + suffix;
var h = Math.floor(m / 60);
if (h < 24) return h + 'h ' + suffix;
var d = Math.floor(h / 24);
if (d < 7) return d + 'd ' + suffix;
return Math.floor(d / 7) + 'w ' + suffix;
}
function tick() {
var now = Date.now();
document.querySelectorAll('time[data-rel-ts]').forEach(function (el) {
var t = Date.parse(el.getAttribute('data-rel-ts'));
if (!isNaN(t)) el.textContent = label(now - t);
});
}
tick();
setInterval(tick, 30000);
})();
</script>
</body>
</html>
{{end}}