Compare commits
5 Commits
f4db0b17e8
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 06748f5582 | |||
| a4d705db6b | |||
| c6f73f790d | |||
| 068f08d96d | |||
| 28ef9750d3 |
+15
-3
@@ -70,7 +70,11 @@ jobs:
|
|||||||
# one runner. The third shard ("rest") covers everything else.
|
# one runner. The third shard ("rest") covers everything else.
|
||||||
name: Test (${{ matrix.name }})
|
name: Test (${{ matrix.name }})
|
||||||
runs-on: ubuntu-latest
|
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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -105,7 +109,11 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: golangci/golangci-lint-action@v7
|
- uses: golangci/golangci-lint-action@v7
|
||||||
@@ -121,7 +129,11 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
|
name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
|
||||||
runs-on: ubuntu-latest
|
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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|||||||
@@ -12,18 +12,12 @@
|
|||||||
# plus install.sh / install.ps1 / the systemd unit baked in under
|
# plus install.sh / install.ps1 / the systemd unit baked in under
|
||||||
# /opt/restic-manager/dist (the read-only fallback path the server
|
# /opt/restic-manager/dist (the read-only fallback path the server
|
||||||
# handlers use when <DataDir>/... is empty).
|
# handlers use when <DataDir>/... is empty).
|
||||||
# * Pushes to this Gitea instance's container registry under
|
# * Pushes to zot OCI registry (docker.dcglab.co.uk).
|
||||||
# <gitea-host>/<owner>/restic-manager.
|
|
||||||
#
|
#
|
||||||
# Tag fan-out
|
# Tag fan-out
|
||||||
# * tag push: :vX.Y.Z, :X.Y, :X
|
# * tag push: :vX.Y.Z, :X.Y, :X
|
||||||
# * tag push and X >= 1: also :latest
|
# * tag push and X >= 1: also :latest
|
||||||
# * workflow_dispatch: only :snapshot-<shortsha>; nothing else moves.
|
# * 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
|
name: Release
|
||||||
|
|
||||||
@@ -34,8 +28,8 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: gitea.dcglab.co.uk
|
REGISTRY: docker.dcglab.co.uk
|
||||||
IMAGE_NAME: ${{ gitea.repository }}
|
IMAGE_NAME: restic-manager
|
||||||
|
|
||||||
# Force bash as the default shell — see ci.yml header.
|
# Force bash as the default shell — see ci.yml header.
|
||||||
defaults:
|
defaults:
|
||||||
@@ -46,19 +40,23 @@ jobs:
|
|||||||
image:
|
image:
|
||||||
name: Build + push image
|
name: Build + push image
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: docker/setup-qemu-action@v3
|
- uses: docker/setup-qemu-action@v3
|
||||||
- uses: docker/setup-buildx-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
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ gitea.actor }}
|
username: ${{ secrets.ZOT_USERNAME }}
|
||||||
password: ${{ secrets.DEV_TOKEN }}
|
password: ${{ secrets.ZOT_PASSWORD }}
|
||||||
|
|
||||||
- name: Compute tags + version
|
- name: Compute tags + version
|
||||||
id: meta
|
id: meta
|
||||||
|
|||||||
@@ -221,23 +221,40 @@ func formatBytes(n int64) template.HTML {
|
|||||||
// "in 5m"-style. Accepts *time.Time or time.Time so templates can
|
// "in 5m"-style. Accepts *time.Time or time.Time so templates can
|
||||||
// pass either without fighting Go's lack of an address-of operator.
|
// pass either without fighting Go's lack of an address-of operator.
|
||||||
// Anything else returns "—".
|
// 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
|
var t time.Time
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case time.Time:
|
case time.Time:
|
||||||
t = x
|
t = x
|
||||||
case *time.Time:
|
case *time.Time:
|
||||||
if x == nil {
|
if x == nil {
|
||||||
return "—"
|
return template.HTML("—")
|
||||||
}
|
}
|
||||||
t = *x
|
t = *x
|
||||||
default:
|
default:
|
||||||
return "—"
|
return template.HTML("—")
|
||||||
}
|
}
|
||||||
if t.IsZero() {
|
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"
|
suffix := "ago"
|
||||||
if d < 0 {
|
if d < 0 {
|
||||||
d = -d
|
d = -d
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,37 @@
|
|||||||
|
|
||||||
{{template "toast" .}}
|
{{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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -11,6 +11,34 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col">
|
<body class="min-h-screen flex flex-col">
|
||||||
{{block "content" .}}{{end}}
|
{{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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user