Compare commits

...

6 Commits

Author SHA1 Message Date
Niklas Ye 94c394e8f1 Build and scan with Go 1.26.10
CI / chart (push) Successful in 0s
CI / security (push) Successful in 2m41s
CI / test (push) Successful in 7m7s
Release / test (push) Successful in 10s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 2m37s
Release / scan-image (push) Successful in 8s
Release / binaries (push) Successful in 2m51s
govulncheck started failing CI on GO-2026-6809, a crypto/x509 flaw in the
standard library that is fixed in 1.26.10 and reachable through
http.Server.ListenAndServe. golang:1.26.9 is what ci.yaml, release.yaml and
the Dockerfile build with, so all five pins move together: the scan and the
shipped binary must agree on the toolchain, as in 3173abf.

No code change; go.mod still says go 1.25.9.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-21 21:55:04 +02:00
Niklas Ye 12535e0e9a Set the chart's placeholder version to 0.48.0
CI / chart (push) Successful in 1s
CI / security (push) Failing after 2m4s
CI / test (push) Has been cancelled
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these fields decide nothing about what is published. They are kept
in step so the tree heading for v0.48.0 does not say 0.47.1, as in
220eb97 and fcd8f83.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-21 21:47:50 +02:00
Niklas Ye 178c723475 Break the flat background with a fading dot grid
The web UI sat on one flat colour. A dot grid on body::before, solid for the
top quarter of the screen and faded out by 88%, gives the page some texture
without adding contrast behind text.

It is a pseudo-element at z-index -1 rather than a wrapper div, so index.html
and the JS are untouched: the layer sits above the canvas background and below
all in-flow content. Cards, rows, the sidebar and sheets keep their solid
surfaces; the top and bottom bars are translucent and let it show faintly.
The sticky filter strip over the queue is opaque and hides the dots behind it.

--dot is a new colour token, light on dark and dark on light, declared in all
three palette blocks (the two dark ones stay identical, as the file requires).

No API, schema or CSP change, and no change to terdut-tui.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-21 21:47:45 +02:00
Niklas Ye 8b9adb4910 Let the instance service account list a team's deadman switches
CI / chart (push) Successful in 1s
CI / security (push) Successful in 34s
CI / test (push) Successful in 6m26s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 15s
Release / scan-image (push) Successful in 6s
Release / test (push) Successful in 12s
Release / image (push) Successful in 1m8s
GET /api/teams/{id}/deadman/switches used requireTeamMember, which 404s
for the instance-scoped service account because it belongs to no team.
terdut-operator lists switches by name on every reconcile, so TerdutTeams
never became ready. Instance service accounts now pass, as they already do
for the write endpoints.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-20 15:34:31 +02:00
Niklas Ye 220eb97665 Set the chart's placeholder version to 0.47.1
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 1m9s
Release / scan-image (push) Successful in 1s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 40s
CI / test (push) Successful in 6m18s
Release / test (push) Successful in 10s
Release / chart (push) Successful in 2s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these fields decide nothing about what is published. They are kept
in step so the tree heading for v0.47.1 does not say 0.47.0, as in
fcd8f83 and a314083.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-19 08:18:56 +02:00
Niklas Ye 8521bdcef7 Fold the stats pages to fit a phone
The stats pages were laid out for a desktop width and did not fit a phone.
Below 600px the top-alerts rows now take three lines (name, count and
sparkline, last seen and mean fix) instead of six columns, the heatmap
shrinks to the screen with fewer hour labels, and the drilldown's
time-to-resolve bars and occurrences table fold into stacked lines and
cards rather than scrolling sideways. Grid children on the stats pages may
now shrink below their content width, which is what let the heatmap push
the page wider than the screen.

CSS and one data-label attribute only: no API, schema or behaviour change
at wider widths.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-19 08:18:56 +02:00
8 changed files with 101 additions and 13 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
# Runs inside the toolchain image rather than installing Go per job. Note this puts
# the job on the dind bridge, which cannot reach github.com or get.helm.sh --
# proxy.golang.org and git.ryuvia.com are reachable, which is all this job needs.
image: golang:1.26.9-bookworm
image: golang:1.26.10-bookworm
# act_runner destroys a job's own volumes when it finishes, so without these every
# run re-downloads the whole module graph. The names must appear in the runner's
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
@@ -101,7 +101,7 @@ jobs:
security:
runs-on: ubuntu-latest
container:
image: golang:1.26.9-bookworm
image: golang:1.26.10-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
test:
runs-on: ubuntu-latest
container:
image: golang:1.26.9-bookworm
image: golang:1.26.10-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
@@ -71,7 +71,7 @@ jobs:
needs: test
runs-on: ubuntu-latest
container:
image: golang:1.26.9-bookworm
image: golang:1.26.10-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
+1 -1
View File
@@ -4,7 +4,7 @@
# in per platform. The CI runner has no binfmt registration and no way to get one (the
# JS action that used to install it cannot run there), so this is not just an
# optimisation -- it is what makes the arm64 image buildable at all.
FROM --platform=$BUILDPLATFORM golang:1.26.9-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.10-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.47.0
appVersion: "v0.47.0"
version: 0.48.0
appVersion: "v0.48.0"
+28
View File
@@ -490,3 +490,31 @@ func TestDeadman_UpdateInPlacePreservesID(t *testing.T) {
t.Errorf("expected the update to replace in place, not add a row, got %d switches", len(list))
}
}
// The operator reconciles a team's dead man's switches by listing them, and its
// instance-scoped account is not a member of any team it manages.
func TestServiceAccount_InstanceScopeListsATeamsDeadmanSwitches(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
team := createTeamAs(t, s, instanceKey, "team-a")
path := "/api/teams/" + id64(team) + "/deadman/switches"
resp := s.reqAs(t, instanceKey, http.MethodPost, path,
map[string]any{"name": "watchdog", "matcher": "alertname=Watchdog", "timeout_seconds": 900, "severity": "critical"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create switch: %d", resp.StatusCode)
}
resp.Body.Close()
resp = s.reqAs(t, instanceKey, http.MethodGet, path, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list switches as the instance account: %d", resp.StatusCode)
}
var list []struct {
Name string `json:"name"`
}
decode(t, resp, &list)
if len(list) != 1 || list[0].Name != "watchdog" {
t.Errorf("expected the one switch just created, got %+v", list)
}
}
+4 -1
View File
@@ -826,7 +826,10 @@ func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
if !ok {
return
}
if !requireTeamMember(w, r, teamID) {
// The operator's instance-scoped account reconciles the switches by name,
// so it has to read them without being a member: that is owner reach,
// the same as the writes below, and still reads no incidents.
if !isInstanceServiceAccount(r.Context()) && !requireTeamMember(w, r, teamID) {
return
}
+54
View File
@@ -7,6 +7,7 @@
:root {
--bg: #f5f6f8;
--dot: rgba(60, 70, 95, 0.16);
--surface: #ffffff;
--surface-2: #eff1f4;
--surface-hover: #f7f8fa;
@@ -79,6 +80,7 @@
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1115;
--dot: rgba(148, 156, 180, 0.26);
--surface: #1a1e26;
--surface-2: #232834;
--surface-hover: #20252e;
@@ -114,6 +116,7 @@
}
:root[data-theme="dark"] {
--bg: #0f1115;
--dot: rgba(148, 156, 180, 0.26);
--surface: #1a1e26;
--surface-2: #232834;
--surface-hover: #20252e;
@@ -163,6 +166,17 @@ body {
-webkit-tap-highlight-color: transparent;
}
/* Dot grid behind everything, fading out down the screen. z-index -1 sits above
the canvas background but below all in-flow content, so no wrapper element
is needed. */
body::before {
content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
background-image: radial-gradient(circle, var(--dot) 1.25px, transparent 1.75px);
background-size: 28px 28px;
-webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 25%, transparent 88%);
mask-image: linear-gradient(to bottom, #000 0%, #000 25%, transparent 88%);
}
a { color: inherit; text-decoration: none; }
button, input, textarea, select { font: inherit; color: inherit; }
code { font-family: var(--mono); font-size: 0.9em; }
@@ -1386,6 +1400,46 @@ a.top-row:hover { background: var(--surface-2); }
.stats-split { grid-template-columns: 3fr 1fr; align-items: stretch; }
}
/* Phones: nothing on the stats pages may be wider than the screen, so the
wide rows fold into two or three lines instead of scrolling sideways. */
.stats > *, .stats-split > * { min-width: 0; }
@media (max-width: 599px) {
.top-head { display: none; }
.toplist { overflow-x: visible; }
.top-row {
min-width: 0; grid-template-columns: minmax(0, 1fr) auto; row-gap: 4px; padding: 10px 4px;
}
.top-row > :nth-child(1) { grid-column: 1; grid-row: 1; font-weight: 600; }
.top-row > :nth-child(6) { grid-column: 2; grid-row: 1; }
.top-row > :nth-child(2) { grid-column: 1; grid-row: 2; }
.top-row > :nth-child(3) { grid-column: 2; grid-row: 2; }
.top-row > :nth-child(4) { grid-column: 1; grid-row: 3; font-size: 12px; }
.top-row > :nth-child(5) { grid-column: 2; grid-row: 3; font-size: 12px; text-align: right; }
.top-row > :nth-child(4)::before { content: "Last seen "; }
.top-row > :nth-child(5)::before { content: "Fix "; }
.heatmap > * { min-width: 0; }
.heat-row, .heat-cells { gap: 2px; }
.heat-day { width: 26px; }
.heat-cell { height: 18px; border-radius: 3px; }
.heat-hour { font-size: 9px; overflow: visible; white-space: nowrap; }
.heat-hour:not(:nth-child(6n+1)) { visibility: hidden; }
.fix-row { flex-wrap: wrap; row-gap: 2px; }
.fix-label { width: auto; flex: 1; }
.fix-total { width: auto; }
.fix-track { flex: 1 1 100%; order: 3; }
.occ-wrap { overflow-x: visible; }
.occ { min-width: 0; }
.occ thead { display: none; }
.occ, .occ tbody { display: block; }
.occ tr { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; padding: 10px 0; border-top: 1px solid var(--border); }
.occ td { border-top: 0; padding: 0; overflow-wrap: anywhere; }
.occ td:first-child { grid-column: 1 / -1; font-weight: 600; }
.occ td[data-label]::before { content: attr(data-label) ": "; color: var(--muted); font-size: 12px; }
}
/* On-call, wide: the hero and the week on the left, your shifts on the right.
.view-page > * caps its children at 760px, so the grid lifts that. */
@media (min-width: 900px) {
+8 -5
View File
@@ -103,17 +103,20 @@ function fixBars(d) {
}));
}
// On a phone each row folds into a card; the labels come from data-label.
const OCC_COLS = ['Fired (UTC)', 'Time to ack', 'Duration', 'Acknowledged by', 'Status'];
function table(d) {
return h('div', { class: 'occ-wrap' },
h('table', { class: 'occ' },
h('thead', {}, h('tr', {}, ['Fired (UTC)', 'Time to ack', 'Duration', 'Acknowledged by', 'Status'].map((t) => h('th', { text: t })))),
h('thead', {}, h('tr', {}, OCC_COLS.map((t) => h('th', { text: t })))),
h('tbody', {}, d.occurrences.map((o) => h('tr', {},
h('td', {}, h('a', { href: `/incidents/${o.incident_id}`, text: fmtTime(o.triggered_at) }),
` ${WEEKDAYS[new Date(o.triggered_at * 1000).getUTCDay()]}`),
h('td', { text: sec(o.ack_seconds) }),
h('td', { text: sec(o.resolve_seconds) }),
h('td', { text: o.acknowledged_by || '—' }),
h('td', { text: o.status }))))));
h('td', { 'data-label': OCC_COLS[1], text: sec(o.ack_seconds) }),
h('td', { 'data-label': OCC_COLS[2], text: sec(o.resolve_seconds) }),
h('td', { 'data-label': OCC_COLS[3], text: o.acknowledged_by || '—' }),
h('td', { 'data-label': OCC_COLS[4], text: o.status }))))));
}
export function body(name, d, error, onRange) {