Compare commits
7 Commits
v1.0.0
...
a4d705db6b
| Author | SHA1 | Date | |
|---|---|---|---|
| a4d705db6b | |||
| c6f73f790d | |||
| 068f08d96d | |||
| 28ef9750d3 | |||
| f4db0b17e8 | |||
| 8afda7cd8c | |||
| 123e4f4915 |
+15
-3
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,3 +45,7 @@ coverage.html
|
||||
# tooling already skips paths starting with _, but ignore explicitly
|
||||
# so an accidental `git add cmd/.` can't sneak them into a release.
|
||||
/cmd/_*/
|
||||
|
||||
# Local-only planning / scratch — never committed.
|
||||
/ask.md
|
||||
/docs/superpowers/
|
||||
|
||||
@@ -8,8 +8,10 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || ec
|
||||
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo none)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
VERSION_PKG := gitea.dcglab.co.uk/steve/restic-manager/internal/version
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) \
|
||||
-X $(VERSION_PKG).Version=$(VERSION) -X $(VERSION_PKG).Commit=$(COMMIT)
|
||||
LDFLAGS := -s -w \
|
||||
-X $(VERSION_PKG).Version=$(VERSION) \
|
||||
-X $(VERSION_PKG).Commit=$(COMMIT) \
|
||||
-X $(VERSION_PKG).Date=$(DATE)
|
||||
GOFLAGS := -trimpath
|
||||
DOCKER_IMAGE ?= gitea.dcglab.co.uk/steve/restic-manager
|
||||
DOCKER_TAG ?= dev
|
||||
|
||||
+6
-11
@@ -22,12 +22,7 @@ import (
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/wsclient"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -66,7 +61,7 @@ func run() error {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("restic-manager-agent %s (commit %s, built %s)\n", version, commit, date)
|
||||
fmt.Printf("restic-manager-agent %s (commit %s, built %s)\n", version.Version, version.Commit, version.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -82,14 +77,14 @@ func run() error {
|
||||
if *enrollServer == "" {
|
||||
return errors.New("enrollment: -enroll-server is required with -enroll-token")
|
||||
}
|
||||
return doEnroll(*enrollServer, *enrollToken, cfg, version)
|
||||
return doEnroll(*enrollServer, *enrollToken, cfg, version.Version)
|
||||
}
|
||||
|
||||
// Announce-and-approve: -enroll-server set, no token, agent not
|
||||
// yet enrolled. Run the announce flow inline; on success the cfg
|
||||
// has the bearer + host_id and we drop into the normal run loop.
|
||||
if !cfg.Enrolled() && *enrollServer != "" {
|
||||
if err := doAnnounce(*enrollServer, cfg, version); err != nil {
|
||||
if err := doAnnounce(*enrollServer, cfg, version.Version); err != nil {
|
||||
return fmt.Errorf("announce: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +101,7 @@ func run() error {
|
||||
return fmt.Errorf("sysinfo: %w", err)
|
||||
}
|
||||
slog.Info("agent starting",
|
||||
"version", version,
|
||||
"version", version.Version,
|
||||
"host_id", cfg.HostID,
|
||||
"server", cfg.ServerURL,
|
||||
"restic_version", snap.ResticVersion,
|
||||
@@ -136,7 +131,7 @@ func run() error {
|
||||
CertPinSHA256: cfg.CertPinSHA256,
|
||||
HelloPayload: api.HelloPayload{
|
||||
ProtocolVersion: snap.ProtocolVersion,
|
||||
AgentVersion: version,
|
||||
AgentVersion: version.Version,
|
||||
ResticVersion: snap.ResticVersion,
|
||||
Hostname: snap.Hostname,
|
||||
OS: snap.OS,
|
||||
|
||||
+4
-9
@@ -26,12 +26,7 @@ import (
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "none"
|
||||
date = "unknown"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -47,7 +42,7 @@ func run() error {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("restic-manager-server %s (commit %s, built %s)\n", version, commit, date)
|
||||
fmt.Printf("restic-manager-server %s (commit %s, built %s)\n", version.Version, version.Commit, version.Date)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -123,7 +118,7 @@ func run() error {
|
||||
NotificationHub: notifHub,
|
||||
UpdateWatcher: updateWatcher,
|
||||
UI: renderer,
|
||||
Version: version,
|
||||
Version: version.Version,
|
||||
OIDC: oidcClient,
|
||||
Metrics: metricsRegistry,
|
||||
}
|
||||
@@ -177,7 +172,7 @@ func run() error {
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("server listening", "addr", cfg.Listen, "version", version)
|
||||
slog.Info("server listening", "addr", cfg.Listen, "version", version.Version)
|
||||
errCh <- srv.Start()
|
||||
}()
|
||||
|
||||
|
||||
@@ -26,7 +26,11 @@ ARG DATE=unknown
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}"
|
||||
ENV VERSION_PKG="gitea.dcglab.co.uk/steve/restic-manager/internal/version"
|
||||
ENV LDFLAGS="-s -w \
|
||||
-X ${VERSION_PKG}.Version=${VERSION} \
|
||||
-X ${VERSION_PKG}.Commit=${COMMIT} \
|
||||
-X ${VERSION_PKG}.Date=${DATE}"
|
||||
|
||||
# Server: built for the image's runtime arch.
|
||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,8 @@ var (
|
||||
// Commit is the short git SHA. Informational only; surfaced via
|
||||
// /api/version but not used for any comparison.
|
||||
Commit = ""
|
||||
|
||||
// Date is the RFC3339 build timestamp. Informational only; printed
|
||||
// by `--version` but not used for any comparison.
|
||||
Date = "unknown"
|
||||
)
|
||||
|
||||
@@ -310,7 +310,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days.
|
||||
> **Sweep verified (smoke env):** admin adds operator → setup link generated → curl-as-new-user fetches /setup (200, page shows username) → POSTs password → 303 to / + Set-Cookie → operator authenticated → 200 on /, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables user → operator's next request is **401** + session row count drops to 0 → audit log shows `user.created` + `user.setup_completed` for the cycle. All 26 implementation tasks landed; full `go test ./...` green.
|
||||
- [x] **P4-05** (L) OIDC login (generic provider config, group → role mapping)
|
||||
|
||||
> **As shipped (2026-05-05):** Authorization Code + PKCE (S256) against any OIDC IdP advertising standard discovery. Config is YAML+env (`oidc.issuer`, `oidc.client_id`, `oidc.client_secret`/`_file`, `oidc.role_claim` default `groups`, `oidc.role_mapping`, `oidc.display_name`, `oidc.redirect_url`); empty issuer → OIDC disabled, no routes mounted. Migration 0019 adds `users.auth_source`/`oidc_subject` (partial unique index on `oidc_subject`), `sessions.id_token`, and a small `oidc_state` table for state+verifier round-trip (cleaned up every alert tick, 5 min TTL). Login page renders **Sign in with `<display_name>`** above the local form when OIDC is enabled; the SSO button kicks off a 303 to the IdP with state + S256 code_challenge persisted server-side. Callback verifies ID token, fetches `/userinfo` to merge claims (Authelia / many IdPs only put `sub` in the ID token and surface `preferred_username`/`email`/`groups` from userinfo), maps the first matching group to a role; **no match → deny banner**, no row created, audit `user.oidc_login_blocked`. Username-collision with an existing local user → same deny path with `username_taken`. New user → JIT-provisioned with `auth_source='oidc'`, `oidc_subject=<sub>`, `password_hash=''`. Returning user → looked up by `oidc_subject` (stable when usernames change at the IdP), role + email refreshed on every login. Local password login is rejected for `auth_source='oidc'` users. Logout posts to `/logout` and, when the IdP advertised `end_session_endpoint`, follows up with RP-initiated logout (carries `id_token_hint` + `post_logout_redirect_uri=BaseURL`); when not advertised (Authelia in our smoke env), the local session is cleared and the browser lands on `/login`. Users list shows a small **oidc** chip beside enabled/disabled; the edit page disables username/email/role for OIDC users (server-side guard mirrors UI, returns 403). Force-logout, disable, and the last-admin guard from P4-04 all still apply. **Live Authelia sweep verified all four paths against `https://auth.example.invalid`:** rm-admin → admin role + JIT row + chip + readonly edit; rm-operator → operator JIT, 403 on `/settings/users`; rm-viewer → viewer JIT, 403 on `/hosts/new`; rm-other (group not in role_mapping) → no_role_match banner, no row created, audit logged. Returning rm-admin login resolved to the same row by sub. Screenshots in `_diag/p4-05-sweep/`. Out-of-scope and on Phase 6 candidate list: refresh tokens, back-channel logout, multiple providers, post-login PKCE for the cookie itself.
|
||||
> **As shipped (2026-05-05):** Authorization Code + PKCE (S256) against any OIDC IdP advertising standard discovery. Config is YAML+env (`oidc.issuer`, `oidc.client_id`, `oidc.client_secret`/`_file`, `oidc.role_claim` default `groups`, `oidc.role_mapping`, `oidc.display_name`, `oidc.redirect_url`); empty issuer → OIDC disabled, no routes mounted. Migration 0019 adds `users.auth_source`/`oidc_subject` (partial unique index on `oidc_subject`), `sessions.id_token`, and a small `oidc_state` table for state+verifier round-trip (cleaned up every alert tick, 5 min TTL). Login page renders **Sign in with `<display_name>`** above the local form when OIDC is enabled; the SSO button kicks off a 303 to the IdP with state + S256 code_challenge persisted server-side. Callback verifies ID token, fetches `/userinfo` to merge claims (Authelia / many IdPs only put `sub` in the ID token and surface `preferred_username`/`email`/`groups` from userinfo), maps the first matching group to a role; **no match → deny banner**, no row created, audit `user.oidc_login_blocked`. Username-collision with an existing local user → same deny path with `username_taken`. New user → JIT-provisioned with `auth_source='oidc'`, `oidc_subject=<sub>`, `password_hash=''`. Returning user → looked up by `oidc_subject` (stable when usernames change at the IdP), role + email refreshed on every login. Local password login is rejected for `auth_source='oidc'` users. Logout posts to `/logout` and, when the IdP advertised `end_session_endpoint`, follows up with RP-initiated logout (carries `id_token_hint` + `post_logout_redirect_uri=BaseURL`); when not advertised (Authelia in our smoke env), the local session is cleared and the browser lands on `/login`. Users list shows a small **oidc** chip beside enabled/disabled; the edit page disables username/email/role for OIDC users (server-side guard mirrors UI, returns 403). Force-logout, disable, and the last-admin guard from P4-04 all still apply. **Live Authelia sweep verified all four paths against local auth:** rm-admin → admin role + JIT row + chip + readonly edit; rm-operator → operator JIT, 403 on `/settings/users`; rm-viewer → viewer JIT, 403 on `/hosts/new`; rm-other (group not in role_mapping) → no_role_match banner, no row created, audit logged. Returning rm-admin login resolved to the same row by sub. Screenshots in `_diag/p4-05-sweep/`. Out-of-scope and on Phase 6 candidate list: refresh tokens, back-channel logout, multiple providers, post-login PKCE for the cookie itself.
|
||||
|
||||
- [x] **P4-07** (S) Per-host tags + dashboard filtering by tag
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user