Compare commits

...

9 Commits

Author SHA1 Message Date
Niklas Ye fcc4997dee Set the chart's placeholder version to 0.45.0
CI / chart (push) Successful in 1s
CI / test (push) Successful in 8s
CI / security (push) Successful in 20s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 14s
Release / image (push) Successful in 1m6s
Release / scan-image (push) Successful in 3s
make helm-package passes --version and --app-version from the tag, so
these fields decide nothing about what is published. They are moved
anyway because a tree heading for v0.45.0 that says 0.44.1 tells the
reader something false.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-10 14:36:15 +02:00
Niklas Ye b7d296f6e9 Create the first administrator with a generated password kept in a Secret
The bootstrap hook created `admin` with no password, so the account could
only use its API key and could not sign in on the web UI or the TUI. It
also won the one-shot /api/bootstrap against whoever ran it by hand, and
failed with exit 1 when the Secret already existed, which fails the Helm
release.

The hook now generates a 32-character password, stores username, password
and api-key in the <release>-admin-key Secret, and reuses the stored
password on later runs, so recreating the database brings the same
account back. The password is written before the account is created, so a
crash in between cannot leave an administrator nobody has the password
for. When the server was bootstrapped by something else, it checks the
stored password against /api/login and removes only the password it
generated if that does not sign in, then exits cleanly. bootstrap.enabled:
false still removes the hook and its role.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-10 14:36:15 +02:00
Niklas Ye c9f8494b00 Set the chart's placeholder version to 0.44.1
CI / chart (push) Successful in 1s
CI / test (push) Successful in 12s
CI / security (push) Successful in 22s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 8s
Release / binaries (push) Successful in 33s
Release / image (push) Successful in 1m39s
Release / scan-image (push) Successful in 2s
make helm-package passes --version and --app-version from the tag, so
these fields decide nothing about what is published. They are moved
anyway because a tree heading for v0.44.1 that says 0.44.0 tells the
reader something false.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-10 13:42:49 +02:00
Niklas Ye 3173abfba2 Build the image with Go 1.26.9
The Dockerfile builder was still golang:1.25-alpine, which resolves to
Go 1.25.14, so eb63e5e moved CI and the release workflow to 1.26.9 but the
published binary kept the standard library it was meant to leave. v0.44.0's
image scan reported CVE-2026-78667, CVE-2026-78669 and CVE-2026-97031 in
it, all fixed in 1.26.9. Pin the builder to the same version the
workflows use.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-10 13:42:49 +02:00
Niklas Ye 9360d909cb Set the chart's placeholder version to 0.44.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 2m21s
CI / test (push) Successful in 6m54s
Release / test (push) Successful in 14s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 2m32s
Release / scan-image (push) Failing after 3s
Release / binaries (push) Successful in 2m41s
make helm-package passes --version and --app-version from the tag, so
these fields decide nothing about what is published. They are moved
anyway because a tree heading for v0.44.0 that says 0.43.0 tells the
reader something false.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-10 13:30:31 +02:00
Niklas Ye f1754c583e Flag incidents that carry notes in the queue
A blue tag with the notebook icon and a count marks an incident with
working notes; a green one marks a note on what fixed it. Both let you
scan the queue for incidents that have more to say than their title,
without opening each one.

The incident list and detail JSON gain note_count and
resolution_note_count, counted in the same query that selects the
incident, so a list costs no extra request per row. The fields are
additive: terdut-tui and terdut-operator ignore them and need no change.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-10 13:29:08 +02:00
Niklas Ye c67dc80a8f Pad admin cards so content isn't flush against the border
The admin views used bare .card, which has no padding; add card-pad.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-10 13:29:08 +02:00
niklas 392eab4127 Update README.md
CI / chart (push) Successful in 1s
CI / test (push) Successful in 9s
CI / security (push) Successful in 16s
2026-10-09 13:38:19 +00:00
niklas 30b3f0ff75 Merge pull request 'Rewrite the README as highlights with screenshots; move the detail into docs/' (#43) from operator-key-and-docs into main
CI / chart (push) Successful in 2s
CI / test (push) Successful in 9s
CI / security (push) Successful in 18s
Reviewed-on: #43
2026-10-09 13:34:15 +00:00
16 changed files with 147 additions and 44 deletions
+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.25-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.9-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
+1 -1
View File
@@ -1,4 +1,4 @@
# Terminal Duty (terdut-server)
# terdut-server
Incident management for teams that already run Prometheus Alertmanager. Point Alertmanager at it,
and alerts become incidents that get assigned to whoever is on call, paged, escalated when nobody
+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.43.0
appVersion: "v0.43.0"
version: 0.45.0
appVersion: "v0.45.0"
@@ -42,10 +42,29 @@ spec:
- |
SERVICE_URL="http://{{ include "terdut-server.fullname" . }}:{{ .Values.service.port }}"
SECRET_NAME="{{ include "terdut-server.bootstrapSecretName" . }}"
USERNAME="{{ .Values.bootstrap.username }}"
EMAIL="{{ .Values.bootstrap.email }}"
K8S_API="https://kubernetes.default.svc"
SA_TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
CA_CERT="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
NAMESPACE="$(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)"
SECRETS="$K8S_API/api/v1/namespaces/$NAMESPACE/secrets"
# kapi METHOD URL [BODY]: sets CODE and BODY from the Kubernetes API.
kapi() {
_ctype="application/json"
[ "$1" = "PATCH" ] && _ctype="application/merge-patch+json"
if [ -n "$3" ]; then
_resp=$(printf '%s' "$3" | curl -s -w "\n%{http_code}" -X "$1" "$2" \
--cacert "$CA_CERT" -H "Authorization: Bearer $SA_TOKEN" \
-H "Content-Type: $_ctype" -d @-)
else
_resp=$(curl -s -w "\n%{http_code}" -X "$1" "$2" \
--cacert "$CA_CERT" -H "Authorization: Bearer $SA_TOKEN")
fi
CODE=$(echo "$_resp" | tail -1)
BODY=$(echo "$_resp" | sed '$d')
}
echo "Waiting for terdut-server to be ready..."
RETRIES=60
@@ -60,42 +79,86 @@ spec:
fi
echo "Server is ready."
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$SERVICE_URL/api/bootstrap" \
-H "Content-Type: application/json" \
-d '{"username":"{{ .Values.bootstrap.username }}","email":"{{ .Values.bootstrap.email }}"}')
# The Secret is the source of truth for the administrator's password: reuse
# the one it holds, so recreating the database brings the same account back.
PASSWORD=""
EXISTS=0
kapi GET "$SECRETS/$SECRET_NAME"
if [ "$CODE" = "200" ]; then
EXISTS=1
PASSWORD=$(echo "$BODY" | grep -o '"password": *"[^"]*"' | head -1 | cut -d'"' -f4 | base64 -d)
elif [ "$CODE" != "404" ]; then
echo "Failed to read secret '$SECRET_NAME' (HTTP $CODE)."
exit 1
fi
# Written BEFORE the server is asked to create the account, so a crash in
# between cannot leave an administrator whose password nobody has.
GENERATED=0
if [ -z "$PASSWORD" ]; then
PASSWORD=$(head -c 256 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 32)
if [ "${#PASSWORD}" -lt 32 ]; then
echo "Failed to generate a password."
exit 1
fi
GENERATED=1
DATA=$(printf '"username":"%s","password":"%s"' "$USERNAME" "$PASSWORD")
if [ "$EXISTS" = "1" ]; then
kapi PATCH "$SECRETS/$SECRET_NAME" "{\"stringData\":{$DATA}}"
else
kapi POST "$SECRETS" "{\"apiVersion\":\"v1\",\"kind\":\"Secret\",\"metadata\":{\"name\":\"$SECRET_NAME\"},\"stringData\":{$DATA}}"
fi
case "$CODE" in 200|201) ;; *)
echo "Failed to store the password in secret '$SECRET_NAME' (HTTP $CODE)."
exit 1 ;;
esac
fi
RESPONSE=$(printf '{"username":"%s","email":"%s","password":"%s"}' "$USERNAME" "$EMAIL" "$PASSWORD" \
| curl -s -w "\n%{http_code}" -X POST "$SERVICE_URL/api/bootstrap" \
-H "Content-Type: application/json" -d @-)
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | head -1)
RESP_BODY=$(echo "$RESPONSE" | head -1)
if [ "$HTTP_CODE" = "403" ]; then
echo "Server already bootstrapped, nothing to do."
# Somebody else made the first account. The Secret is only worth keeping
# if its password signs in.
LOGIN=$(printf '{"username":"%s","password":"%s"}' "$USERNAME" "$PASSWORD" \
| curl -s -o /dev/null -w "%{http_code}" -X POST "$SERVICE_URL/api/login" \
-H "Content-Type: application/json" -d @-)
if [ "$LOGIN" = "200" ]; then
echo "Server already bootstrapped; the password in '$SECRET_NAME' signs in."
exit 0
fi
echo "Server already bootstrapped by something else; '$USERNAME' does not sign in with the password in '$SECRET_NAME'."
if [ "$GENERATED" = "1" ]; then
if [ "$EXISTS" = "1" ]; then
kapi PATCH "$SECRETS/$SECRET_NAME" '{"data":{"username":null,"password":null}}'
else
kapi DELETE "$SECRETS/$SECRET_NAME"
fi
echo "Removed the password this run generated."
fi
exit 0
fi
if [ "$HTTP_CODE" != "201" ]; then
echo "Bootstrap failed (HTTP $HTTP_CODE): $BODY"
echo "Bootstrap failed (HTTP $HTTP_CODE): $RESP_BODY"
exit 1
fi
API_KEY=$(echo "$BODY" | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
API_KEY=$(echo "$RESP_BODY" | grep -o '"key":"[^"]*"' | cut -d'"' -f4)
if [ -z "$API_KEY" ]; then
echo "Failed to extract API key from response."
exit 1
fi
echo "Bootstrap succeeded. Storing API key in secret '$SECRET_NAME'."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$K8S_API/api/v1/namespaces/$NAMESPACE/secrets" \
--cacert "$CA_CERT" \
-H "Authorization: Bearer $SA_TOKEN" \
-H "Content-Type: application/json" \
-d "$(printf '{"apiVersion":"v1","kind":"Secret","metadata":{"name":"%s"},"stringData":{"api-key":"%s"}}' "$SECRET_NAME" "$API_KEY")")
if [ "$HTTP_CODE" != "201" ]; then
echo "Failed to create secret (HTTP $HTTP_CODE)."
echo "Bootstrap succeeded. Storing the API key in secret '$SECRET_NAME'."
kapi PATCH "$SECRETS/$SECRET_NAME" "{\"stringData\":{\"api-key\":\"$API_KEY\"}}"
if [ "$CODE" != "200" ]; then
echo "Failed to store the API key (HTTP $CODE)."
exit 1
fi
echo "Secret '$SECRET_NAME' created successfully."
echo "Secret '$SECRET_NAME' holds username, password and api-key."
{{- end }}
@@ -27,6 +27,12 @@ rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create"]
# Reading, filling in and (when the account turns out not to be ours) cleaning up the
# one Secret by name; `create` cannot be restricted to a name.
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["{{ include "terdut-server.bootstrapSecretName" . }}"]
verbs: ["get", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
+6 -1
View File
@@ -160,9 +160,14 @@ oidc:
# Backups are not this chart's business: Postgres is backed up where it runs,
# through a k8up.io/backupcommand pg_dump annotation on the database pod itself.
# Creates the first administrator after install/upgrade, with a generated password, and keeps
# the credentials in a Secret. Set enabled: false to create the first user yourself with
# POST /api/bootstrap (or on an SSO-only install that wants no local account).
bootstrap:
enabled: true
username: admin
email: admin@example.com
# secretName overrides the default of <fullname>-admin-key
# secretName overrides the default of <fullname>-admin-key. It holds username, password and
# api-key. The password is generated once and then reused: delete the Secret and the
# database to start over.
secretName: ""
+3 -1
View File
@@ -35,7 +35,9 @@ than an `Ingress`. TLS is terminated at the gateway, so the server itself never
| `networking.hostname` | `terdut.example.com` | Hostname the `HTTPRoute` serves |
| `networking.listener` | `""` | Gateway listener (`sectionName`) to bind to. Empty attaches to every matching listener, **including plaintext HTTP** — set it to the HTTPS listener's name to serve TLS only |
| `networking.servicePort` | `8080` | Port the route forwards to; keep in sync with `service.port` |
| `bootstrap.enabled` | `true` | Runs a post-install hook that creates the first user and stores its API key in the `<release>-admin-key` Secret. Already-bootstrapped servers are left alone |
| `bootstrap.enabled` | `true` | Runs a post-install/upgrade hook that creates the first administrator with a generated password and stores `username`, `password` and `api-key` in the `<release>-admin-key` Secret. The password is generated once and reused, so recreating the database brings the same account back. Already-bootstrapped servers are left alone. Set `false` to create the first user yourself with `POST /api/bootstrap` |
| `bootstrap.username` / `bootstrap.email` | `admin` / `admin@example.com` | Account the hook creates |
| `bootstrap.secretName` | `""` | Secret to keep the credentials in; empty means `<release>-admin-key` |
| `database.dsn` | `""` | **Required.** Postgres DSN, with no password in it. The chart provisions no database |
| `database.passwordSecret.name` | `""` | Secret supplying `PGPASSWORD`. With the Zalando postgres operator, the Secret it generates for the role |
| `database.passwordSecret.key` | `password` | Key within that Secret |
+2 -2
View File
@@ -101,5 +101,5 @@ A login expires after 10 minutes. `GET /api/auth/config` reports `device_login`.
use) and password login is the way in. With `TERDUT_PASSWORD_LOGIN=false` that way
is closed: set it back to `true`. The first administrator comes from the bootstrap
endpoint, and stays a manual administrator that no group can revoke; on an SSO-only
install set `bootstrap.enabled: false` in the chart if you don't want that account,
or keep it and never give it a password.
install set `bootstrap.enabled: false` in the chart if you don't want that account;
left on, the chart creates it with a generated password kept in the `<release>-admin-key` Secret.
+6 -1
View File
@@ -69,7 +69,11 @@ const incidentSelectFrom = `
i.acknowledged_by, i.acknowledged_at, ack.username,
i.acknowledged_by_service_account_id, acksa.name,
i.assigned_to, asg.username, i.snoozed_until,
i.resolved_at, i.resolution_source, i.archived_at
i.resolved_at, i.resolution_source, i.archived_at,
-- How many notes, so a list can flag the incidents that carry extra
-- information without fetching each timeline.
(SELECT count(*) FROM incident_events e WHERE e.incident_id = i.id AND e.type = 'note'),
(SELECT count(*) FROM incident_events e WHERE e.incident_id = i.id AND e.type = 'resolution_note')
FROM incidents i
JOIN teams t ON t.id = i.team_id
LEFT JOIN users ack ON ack.id = i.acknowledged_by
@@ -90,6 +94,7 @@ func scanIncident(s scanner) (models.Incident, error) {
&i.AcknowledgedByServiceAccountID, &i.AcknowledgedByServiceAccountName,
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
&resolvedAt, &i.ResolutionSource, &archivedAt,
&i.NoteCount, &i.ResolutionNoteCount,
); err != nil {
return i, err
}
+6
View File
@@ -66,6 +66,12 @@ type Incident struct {
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// NoteCount and ResolutionNoteCount count the plain working notes and the
// "what fixed it" notes on the timeline, so a list can flag the incidents
// that carry extra information.
NoteCount int `json:"note_count"`
ResolutionNoteCount int `json:"resolution_note_count"`
// Alerts is populated by GET /api/incidents/{id} only.
Alerts []Alert `json:"alerts,omitempty"`
}
+2
View File
@@ -522,6 +522,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); }
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
.badge.has-note { background: var(--info-soft); color: var(--info); }
.badge.has-fix { background: var(--ok-soft); color: var(--ok); }
.badge.sev-info { background: var(--info-soft); color: var(--info); }
/* ---------- incident detail ---------- */
+4 -4
View File
@@ -76,7 +76,7 @@ async function load() {
function render() {
if (!state.me?.user?.is_admin) {
clear(view(), h('div', { class: 'card' },
clear(view(), h('div', { class: 'card card-pad' },
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
return;
}
@@ -152,7 +152,7 @@ function teamsCard() {
h('td', { class: 'num', text: String(t.open_incidents) }),
));
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('h2', { text: 'Teams' }),
h('p', { class: 'muted small' },
'Open a team for who is in it, the invites into it, and renaming or ',
@@ -229,7 +229,7 @@ function usersCard() {
);
});
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('h2', { text: 'Users' }),
h('p', { class: 'muted small' },
'Disabling an account stops it signing in and stops its API keys, and keeps ',
@@ -368,7 +368,7 @@ function settingsCard() {
h('td', {}, h('code', { text: k })),
h('td', { class: 'muted', text: v === '' ? '(unset)' : v })));
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('h2', { text: 'Settings' }),
h('p', { class: 'muted small', text: 'Saved changes take effect on the next sweep — no restart.' }),
form,
+6 -6
View File
@@ -72,7 +72,7 @@ export async function refresh() {
function render() {
const el = view();
if (!state.me?.user?.is_admin) {
clear(el, backLink(), h('div', { class: 'card' },
clear(el, backLink(), h('div', { class: 'card card-pad' },
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
return;
}
@@ -81,7 +81,7 @@ function render() {
return;
}
if (!data.team) {
clear(el, backLink(), h('div', { class: 'card' },
clear(el, backLink(), h('div', { class: 'card card-pad' },
h('p', { class: 'muted', text: 'No such team. It may have just been deleted.' })));
return;
}
@@ -142,7 +142,7 @@ function identityCard() {
await refresh();
});
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('div', { class: 'user-head' }, h('h2', { text: t.name })),
h('dl', { class: 'user-facts' },
fact('Created', when(t.created_at)),
@@ -216,7 +216,7 @@ function membersCard() {
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
});
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('h2', { text: 'Members' }),
data.members.length === 0 && h('p', { class: 'muted small' },
'Nobody is in this team. Its queue has no one to work it and its ',
@@ -275,7 +275,7 @@ function invitesCard() {
);
});
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('h2', { text: 'Invites' }),
h('p', { class: 'muted small' },
'An invite link puts somebody in this team and lets them choose their ',
@@ -303,7 +303,7 @@ function inviteState(inv) {
function dangerCard() {
const t = data.team;
const blocked = t.open_incidents > 0;
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('h2', { text: 'Delete' }),
h('p', { class: 'muted small' },
'Its alerts, incidents, schedule and integrations go with it. This ',
+5 -5
View File
@@ -60,7 +60,7 @@ export async function refresh() {
function render() {
const el = view();
if (!state.me?.user?.is_admin) {
clear(el, backLink(), h('div', { class: 'card' },
clear(el, backLink(), h('div', { class: 'card card-pad' },
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
return;
}
@@ -69,7 +69,7 @@ function render() {
return;
}
if (!data.user) {
clear(el, backLink(), h('div', { class: 'card' },
clear(el, backLink(), h('div', { class: 'card card-pad' },
h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' })));
return;
}
@@ -94,7 +94,7 @@ function identityCard() {
const u = data.user;
const self = u.id === myID();
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('div', { class: 'user-head' },
h('h2', { text: u.username }),
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
@@ -203,7 +203,7 @@ function teamsCard() {
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
});
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('h2', { text: 'Teams' }),
data.teams.length === 0 && h('p', { class: 'muted small' },
'In no team. They can sign in, but there is no queue for them to work ',
@@ -244,7 +244,7 @@ function accountCard() {
await refresh();
});
return h('div', { class: 'card' },
return h('div', { class: 'card card-pad' },
h('h2', { text: 'Account' }),
h('p', { class: 'muted small' },
'Setting a password here is how somebody gets their first one, or a new ',
+2 -1
View File
@@ -1,7 +1,7 @@
// The incident queue: filter chips and a list of incident rows.
import * as api from './api.js';
import { h, clear, icon, badge, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
import { h, clear, icon, badge, noteTags, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, originOf, titleWithoutOrigin } from './format.js';
import { state, myID, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js';
@@ -309,6 +309,7 @@ function row(inc, index) {
// The left-border colour alone doesn't say what it means; spell it out
// too, same badge the incident detail page uses for severity.
inc.severity && severityBadge(inc.severity),
...(noteTags(inc) || []),
assignee,
team,
labels && h('span', { class: 'labels', text: labels }),
+13
View File
@@ -194,6 +194,19 @@ export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text });
}
// A tag saying an incident has notes: blue for working notes, green for a note
// on what fixed it. Null when there are none, so it can sit in a row unguarded.
export function noteTags(inc) {
const tag = (n, cls, label) => n > 0 && h('span', {
class: `badge plain ${cls}`, title: `${n} ${label}${n === 1 ? '' : 's'}`,
}, icon('note', 'icon badge-icon'), String(n));
const tags = [
tag(inc.note_count, 'has-note', 'note'),
tag(inc.resolution_note_count, 'has-fix', 'resolution note'),
].filter(Boolean);
return tags.length ? tags : null;
}
// Where it came from (the cluster): a chip in that origin's colour, with a
// server icon so it reads as a place and not as a status.
export function originChip(value) {