Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94c394e8f1 | |||
| 12535e0e9a | |||
| 178c723475 | |||
| 8b9adb4910 | |||
| 220eb97665 | |||
| 8521bdcef7 |
@@ -35,7 +35,7 @@ jobs:
|
|||||||
# Runs inside the toolchain image rather than installing Go per job. Note this puts
|
# 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 --
|
# 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.
|
# 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
|
# 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
|
# 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
|
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
security:
|
security:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.9-bookworm
|
image: golang:1.26.10-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.9-bookworm
|
image: golang:1.26.10-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: golang:1.26.9-bookworm
|
image: golang:1.26.10-bookworm
|
||||||
volumes:
|
volumes:
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# in per platform. The CI runner has no binfmt registration and no way to get one (the
|
# 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
|
# 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.
|
# 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
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# 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
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.47.0
|
version: 0.48.0
|
||||||
appVersion: "v0.47.0"
|
appVersion: "v0.48.0"
|
||||||
|
|||||||
@@ -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))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -826,7 +826,10 @@ func handleListTeamDeadman(db *sql.DB) http.HandlerFunc {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #f5f6f8;
|
--bg: #f5f6f8;
|
||||||
|
--dot: rgba(60, 70, 95, 0.16);
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-2: #eff1f4;
|
--surface-2: #eff1f4;
|
||||||
--surface-hover: #f7f8fa;
|
--surface-hover: #f7f8fa;
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]) {
|
:root:not([data-theme="light"]) {
|
||||||
--bg: #0f1115;
|
--bg: #0f1115;
|
||||||
|
--dot: rgba(148, 156, 180, 0.26);
|
||||||
--surface: #1a1e26;
|
--surface: #1a1e26;
|
||||||
--surface-2: #232834;
|
--surface-2: #232834;
|
||||||
--surface-hover: #20252e;
|
--surface-hover: #20252e;
|
||||||
@@ -114,6 +116,7 @@
|
|||||||
}
|
}
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--bg: #0f1115;
|
--bg: #0f1115;
|
||||||
|
--dot: rgba(148, 156, 180, 0.26);
|
||||||
--surface: #1a1e26;
|
--surface: #1a1e26;
|
||||||
--surface-2: #232834;
|
--surface-2: #232834;
|
||||||
--surface-hover: #20252e;
|
--surface-hover: #20252e;
|
||||||
@@ -163,6 +166,17 @@ body {
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-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; }
|
a { color: inherit; text-decoration: none; }
|
||||||
button, input, textarea, select { font: inherit; color: inherit; }
|
button, input, textarea, select { font: inherit; color: inherit; }
|
||||||
code { font-family: var(--mono); font-size: 0.9em; }
|
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; }
|
.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.
|
/* 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. */
|
.view-page > * caps its children at 760px, so the grid lifts that. */
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
|
|||||||
@@ -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) {
|
function table(d) {
|
||||||
return h('div', { class: 'occ-wrap' },
|
return h('div', { class: 'occ-wrap' },
|
||||||
h('table', { class: 'occ' },
|
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('tbody', {}, d.occurrences.map((o) => h('tr', {},
|
||||||
h('td', {}, h('a', { href: `/incidents/${o.incident_id}`, text: fmtTime(o.triggered_at) }),
|
h('td', {}, h('a', { href: `/incidents/${o.incident_id}`, text: fmtTime(o.triggered_at) }),
|
||||||
` ${WEEKDAYS[new Date(o.triggered_at * 1000).getUTCDay()]}`),
|
` ${WEEKDAYS[new Date(o.triggered_at * 1000).getUTCDay()]}`),
|
||||||
h('td', { text: sec(o.ack_seconds) }),
|
h('td', { 'data-label': OCC_COLS[1], text: sec(o.ack_seconds) }),
|
||||||
h('td', { text: sec(o.resolve_seconds) }),
|
h('td', { 'data-label': OCC_COLS[2], text: sec(o.resolve_seconds) }),
|
||||||
h('td', { text: o.acknowledged_by || '—' }),
|
h('td', { 'data-label': OCC_COLS[3], text: o.acknowledged_by || '—' }),
|
||||||
h('td', { text: o.status }))))));
|
h('td', { 'data-label': OCC_COLS[4], text: o.status }))))));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function body(name, d, error, onRange) {
|
export function body(name, d, error, onRange) {
|
||||||
|
|||||||
Reference in New Issue
Block a user