Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7665e5e52f | |||
| bcf1a3e99b |
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ',
|
||||
|
||||
Reference in New Issue
Block a user