Compare commits

..

2 Commits

Author SHA1 Message Date
Niklas Ye 7665e5e52f Set the chart's placeholder version to 0.41.2
CI / chart (push) Successful in 1s
CI / security (push) Successful in 26s
CI / test (push) Successful in 5m42s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 39s
Release / image (push) Successful in 1m22s
Release / scan-image (push) Successful in 27s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 3a96b20 (0.41.1) and 065b557 (0.41.0) before it, because a
tree heading for v0.41.2 that still says 0.41.1 tells its reader
something false.
2026-10-08 14:53:15 +02:00
Niklas Ye bcf1a3e99b Give the sidebar groups, and tidy the tables, disclosures and tab bar
The desktop sidebar is now grouped: Queue, On-call, Alerts and Stats; Team
and Admin; then Account at the foot, shown as the signed-in person with an
avatar and their name. The active item has a bar as well as a tint. The
queue's row of team chips is gone: the team selector is the one place the
team is chosen, and the chips offered the same choice a second time.

Tables get 16px between columns, so a right-aligned count no longer touches
the text beside it. An empty SSO group reads "Not configured" instead of a
dash, and the main action on each of those pages (New source, New switch,
Add member, Assign, and the admin Add, Create invite and Create) is a filled
button, with Edit and Cancel staying secondary. The bare triangles on
"Grouped by" and the label lists are real disclosure buttons: a chevron
that turns and a 44px target on a touch screen.

On a phone the More tab was a <button> that kept the browser's grey box, so
it looked highlighted next to four plain links. It now matches them, the
labels are 12px and the open tab's icon is filled.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:51:50 +02:00
9 changed files with 96 additions and 58 deletions
+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.41.1
appVersion: "v0.41.1"
version: 0.41.2
appVersion: "v0.41.2"
+45 -8
View File
@@ -296,6 +296,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
border-top: 1px solid var(--border);
}
.nav-brand { display: none; }
.nav-sep, .nav-avatar { display: none; }
/* Hidden here (shown from 900px below): on the phone bar the team switcher
lives in the topbar instead, as #team-selector-mobile. */
.nav-team-selector { display: none; }
@@ -305,7 +306,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
block below cancels it back to a natural-width row item. */
flex: 1 1 0;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600;
color: var(--muted); font-size: 12px; font-weight: 600;
/* The More tab is a <button>; without this it keeps the browser's grey box
and reads as a highlighted tab beside four plain links. */
background: none; border: 0; font-family: inherit; cursor: pointer;
/* min-width lets a column shrink below its label's natural width, which is
what stops six tabs widening the bar past the screen. */
min-width: 0; padding: 0 2px;
@@ -320,6 +324,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.nav-link[aria-current="page"] { color: var(--accent); }
/* The open tab's icon is filled, so it is not told apart by colour alone. */
.nav-link[aria-current="page"] svg { fill: currentColor; fill-opacity: 0.16; }
.nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px);
min-width: 18px; height: 18px; padding: 0 5px;
@@ -440,7 +446,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
/* Which team's queue a row came from. Only rendered for somebody in more than
one team, so it never repeats the same word down the whole list. */
/* Separates the status chips from the team chips in the queue's filter row. */
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
.row-team {
padding: 1px 6px; border-radius: 4px;
@@ -564,11 +569,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
.alert-item-foot a { color: var(--accent); font-weight: 600; }
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; }
/* A disclosure is a button: a chevron that turns, and a 44px target on a
touch screen (the bare triangle it replaces was a few pixels wide). */
details > summary {
display: flex; align-items: center; gap: 8px;
min-height: 44px; margin: 0 -6px; padding: 0 6px; border-radius: var(--radius-sm);
cursor: pointer; color: var(--muted); font-size: 14px; font-weight: 600; list-style: none;
}
details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: "▸ "; }
details[open] > summary::before { content: "▾ "; }
details > summary::before {
content: ""; flex: none; width: 7px; height: 7px; margin: 0 5px 0 3px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(-45deg); transition: transform 0.12s;
}
details[open] > summary::before { transform: rotate(45deg); }
details > summary:hover { background: var(--surface-2); color: var(--text); }
details > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
details[open] > summary { margin-bottom: 8px; }
@media (hover: hover) and (pointer: fine) { details > summary { min-height: 32px; } }
@media (prefers-reduced-motion: reduce) { details > summary::before { transition: none; } }
/* One .tl-phase per status the incident has been through (see
timelinePhases() in incident.js) — each with its own .timeline <ol>, so
@@ -827,6 +846,17 @@ kbd {
.nav-team-selector { display: inline-flex; margin: -8px 10px 14px; width: calc(100% - 20px); }
.nav-link-secondary { display: flex; }
.nav-more-btn { display: none; }
.nav-sep { display: block; height: 1px; margin: 8px 10px; background: var(--border); }
/* The account link sits at the foot of the sidebar, as the signed-in person. */
.nav-sep-account { margin-top: auto; }
.nav-account .nav-avatar {
display: grid; place-items: center; flex: none;
width: 28px; height: 28px; margin: -4px 0 -4px -4px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent);
font-size: 13px; font-weight: 750; text-transform: uppercase;
}
.nav-account .nav-avatar:not([hidden]) ~ .nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-account svg:has(~ .nav-avatar:not([hidden])) { display: none; }
.nav-link {
flex: none; flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -834,6 +864,11 @@ kbd {
}
.nav-link:hover { background: var(--surface-2); }
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
/* A 3px bar on the active item, as well as the tint. */
.nav-link[aria-current="page"]::before {
content: ""; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 3px;
border-radius: 0 3px 3px 0; background: var(--accent);
}
.nav-link svg { width: 20px; height: 20px; }
.nav-badge { position: static; margin-left: auto; }
@@ -851,7 +886,6 @@ kbd {
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
is hidden, so the chips wrap here instead: Archived stays reachable. */
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
.pane-list .chip-sep { display: none; }
.chips-fade { display: none; }
/* With nothing selected there is no detail to show next to, so the list
takes the whole row instead of leaving the second column as dead space
@@ -901,9 +935,12 @@ kbd {
.admin-table th {
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
padding: 4px 16px 4px 0; border-bottom: 1px solid var(--border);
}
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
/* 16px between columns, so a right-aligned count never touches the
left-aligned text beside it ("5" against "16d ago"). */
.admin-table td { padding: 10px 16px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table th:last-child, .admin-table td:last-child { padding-right: 0; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
+10 -2
View File
@@ -110,6 +110,9 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
<span class="nav-label">Stats</span>
</a>
<!-- Dividers between the groups (Queue, On-call, Alerts, Stats | Team,
Admin | Account). Desktop sidebar only; the phone bar has no room. -->
<span class="nav-sep" aria-hidden="true"></span>
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span>
@@ -121,9 +124,14 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
<span class="nav-label">Admin</span>
</a>
<a class="nav-link nav-link-secondary" href="/more" data-section="more" aria-label="Account">
<span class="nav-sep nav-sep-account" aria-hidden="true"></span>
<!-- At the foot of the desktop sidebar, as the signed-in person: app.js
fills the avatar and the name from /api/me, and until then it reads
"Account". -->
<a class="nav-link nav-link-secondary nav-account" href="/more" data-section="more" aria-label="Account">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
<span class="nav-label">Account</span>
<span class="nav-avatar" id="nav-avatar" aria-hidden="true" hidden></span>
<span class="nav-label" id="nav-account-label">Account</span>
</a>
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same
sheet the old hamburger button did, for the sections the bottom
+1 -1
View File
@@ -170,7 +170,7 @@ function teamsCard() {
function newTeamForm() {
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
const form = h('form', { class: 'inline-form' }, name,
h('button', { class: 'btn', type: 'submit', text: 'Create' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
+8 -5
View File
@@ -152,16 +152,19 @@ function identityCard() {
// membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'),
fact('OIDC owner group', t.oidc_owner_group || '—'),
fact('OIDC member group', t.oidc_member_group || notConfigured()),
fact('OIDC owner group', t.oidc_owner_group || notConfigured()),
] : []),
),
form, err, ok,
);
}
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
// value is text or a node.
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
return [h('dt', { text: label }), h('dd', {}, value)];
}
// --- members ---------------------------------------------------------------
@@ -207,7 +210,7 @@ function membersCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
@@ -238,7 +241,7 @@ function invitesCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, role,
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create invite' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
+1 -1
View File
@@ -197,7 +197,7 @@ function teamsCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
+13
View File
@@ -16,6 +16,7 @@ import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
import * as device from './device.js';
import { initial } from './format.js';
const $ = (id) => document.getElementById(id);
@@ -467,7 +468,19 @@ export async function signOut() {
showLogin();
}
// The sidebar's Account link shows who is signed in: an avatar and the name,
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
// screen reader still hears "Account".
function renderAccountNav() {
const name = state.me?.user?.username;
if (!name) return;
$('nav-avatar').textContent = initial(name);
$('nav-avatar').hidden = false;
$('nav-account-label').textContent = name;
}
function showApp() {
renderAccountNav();
ssoErrorCode = null;
$('boot').hidden = true;
$('login').hidden = true;
+5 -32
View File
@@ -2,8 +2,8 @@
import * as api from './api.js';
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import { ago, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
@@ -132,36 +132,9 @@ function renderChips() {
}, count != null && h('span', { class: count === 0 ? 'count zero' : 'count', text: String(count) }));
});
// Somebody in one team has nothing to choose between, so the row of team
// chips appears only when there is more than one. The default is all of
// them: the combined queue is the point.
if (state.teams.length > 1) {
chips.push(h('span', { class: 'chip-sep' }));
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(state.selectedTeamID == null),
onclick: () => setSelectedTeam(null),
}, h('span', { class: 'team-dot' }), ' All teams'));
for (const team of state.teams) {
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(team.id === state.selectedTeamID),
onclick: () => setSelectedTeam(team.id),
},
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
' ' + team.name,
));
}
}
// A scroll hint for the phone-width row, where the chips can run off the
// right edge with nothing to suggest there's more; the desktop sidebar
// wraps instead of scrolling (see .pane-list .chips), so this fades out
// there via CSS rather than being left out here.
// There are no team chips here: the team selector in the sidebar (and in the
// phone's top bar) is the one place the team is chosen, and chips for it
// would be the same choice offered twice.
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
clear(el, chips);
+11 -7
View File
@@ -564,7 +564,7 @@ function assignForm() {
// API enforces: a plain assignment that silently moved a shift would move
// who gets paged without telling either of them.
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Assign' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
@@ -822,7 +822,7 @@ function integrationsCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Alert sources' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New source', onclick: openNewSource,
class: 'btn btn-primary', type: 'button', text: 'New source', onclick: openNewSource,
})),
h('p', { class: 'muted small' },
'Alerts arrive on an integration key, which says both that the sender may ',
@@ -1044,7 +1044,7 @@ function deadmanCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Dead man’s switches' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New switch', onclick: () => openSwitchForm(),
class: 'btn btn-primary', type: 'button', text: 'New switch', onclick: () => openSwitchForm(),
})),
h('p', { class: 'muted small' },
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
@@ -1196,23 +1196,27 @@ function shiftCell(m) {
function oidcGroupsCard() {
if (!state.auth?.oidc?.enabled) return null;
const g = data.oidcGroups || { member_group: '', owner_group: '' };
// With nothing set, Edit is the one thing to do here, so it leads.
const unset = !g.member_group && !g.owner_group;
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Single sign-on' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
class: unset ? 'btn btn-primary' : 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
})),
h('p', { class: 'muted small' },
'Members of the group below are added to this team automatically at ',
'sign-in; members of the owner group become owners. Leave a field ',
'blank to grant nothing this way.'),
h('dl', { class: 'user-facts' },
fact('Member group', g.member_group || '—'),
fact('Owner group', g.owner_group || '—'),
fact('Member group', g.member_group || notConfigured()),
fact('Owner group', g.owner_group || notConfigured()),
),
);
}
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
@@ -1277,7 +1281,7 @@ function membersCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
class: 'btn btn-primary', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',