Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77f04b55e | |||
| e536fdd2c0 |
@@ -48,10 +48,10 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
|
||||
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
||||
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
||||
archive), who is on call, the alert feed, and an *Account* tab for your own
|
||||
password and the ntfy topic your pages go to. It is built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
||||
bar, it follows the system's dark mode, and it can be added to the home screen.
|
||||
From 900px wide it switches to a sidebar with the queue and the incident side by
|
||||
side. Statistics remain in
|
||||
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
|
||||
it navigates through a hamburger menu and has a sticky action bar, it follows the
|
||||
system's dark mode, and it can be added to the home screen. From 900px wide it switches
|
||||
to a sidebar with the queue and the incident side by side. Statistics remain in
|
||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
||||
|
||||
You sign in with a username and password. Users have no password until one is
|
||||
|
||||
@@ -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.19.0
|
||||
appVersion: "v0.19.0"
|
||||
version: 0.20.0
|
||||
appVersion: "v0.20.0"
|
||||
|
||||
+16
-21
@@ -48,7 +48,11 @@
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
|
||||
--topbar-h: 52px;
|
||||
--tabbar-h: 58px;
|
||||
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
|
||||
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
|
||||
variable rather than deleted since .view, .toast and .nav's own height
|
||||
calc still read it. */
|
||||
--tabbar-h: 0px;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
@@ -196,7 +200,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.topbar-title {
|
||||
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||
}
|
||||
#menu-btn { position: relative; }
|
||||
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
|
||||
|
||||
.open-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
@@ -209,15 +219,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.open-pill.has-triggered::before { background: var(--crit); }
|
||||
.open-pill.all-acked::before { background: var(--warn); }
|
||||
|
||||
/* Bottom tab bar on phones. */
|
||||
/* Hidden on phones — mobile navigates through the hamburger menu in the
|
||||
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
|
||||
left sidebar from 900px, where the desktop block below redeclares display. */
|
||||
.nav {
|
||||
display: none;
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
/* One column per link, however many there are. This was repeat(4, 1fr) when
|
||||
there were four tabs; Team and Admin arriving pushed six items into four
|
||||
columns, which on a phone is how they stopped fitting. Auto columns mean
|
||||
the next tab cannot break the row either — and Admin is only rendered for
|
||||
an administrator, so the count genuinely varies between viewers. */
|
||||
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
@@ -239,16 +246,6 @@ 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; }
|
||||
|
||||
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
|
||||
the widest label, "On-call", is about 38px at this size, so nothing has to
|
||||
be hidden. The ellipsis above is the backstop if a future tab is named
|
||||
something longer. */
|
||||
@media (max-width: 420px) {
|
||||
.nav-link { font-size: 10px; gap: 1px; }
|
||||
.nav-link svg { width: 21px; height: 21px; }
|
||||
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
|
||||
}
|
||||
|
||||
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||
.nav-badge {
|
||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||
@@ -601,8 +598,6 @@ kbd {
|
||||
/* ---------- desktop ---------- */
|
||||
|
||||
@media (min-width: 900px) {
|
||||
:root { --tabbar-h: 0px; }
|
||||
|
||||
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
|
||||
|
||||
.nav {
|
||||
|
||||
@@ -108,7 +108,13 @@
|
||||
</nav>
|
||||
|
||||
<header class="topbar">
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
<div class="topbar-left">
|
||||
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||
</button>
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
</div>
|
||||
<span class="open-pill" id="open-pill" hidden></span>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -30,6 +30,17 @@ const SECTIONS = {
|
||||
more: { title: 'Account', view: account },
|
||||
};
|
||||
|
||||
// The mobile hamburger menu's contents — the same sections the desktop
|
||||
// sidebar's .nav-link list carries in index.html, in the same order.
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
||||
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
||||
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
||||
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
||||
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
||||
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
||||
];
|
||||
|
||||
function parseRoute(pathname) {
|
||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
||||
@@ -132,6 +143,35 @@ function render() {
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
// ---------- nav menu ----------
|
||||
|
||||
// The mobile hamburger menu: same shape as the sheet-based action menus in
|
||||
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
|
||||
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
|
||||
function openNavMenu() {
|
||||
const current = SECTIONS[route.section].nav || route.section;
|
||||
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
||||
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
|
||||
ui.openSheet(() => [
|
||||
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
|
||||
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
|
||||
ui.h('li', {}, ui.h('button', {
|
||||
class: 'menu-item',
|
||||
type: 'button',
|
||||
role: 'menuitemradio',
|
||||
'aria-checked': String(n.section === current),
|
||||
onclick: () => ui.closeSheet(n.path),
|
||||
},
|
||||
ui.icon(n.icon),
|
||||
n.label,
|
||||
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
|
||||
))),
|
||||
),
|
||||
]).then((path) => {
|
||||
if (path) navigate(path);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- refresh + badges ----------
|
||||
|
||||
async function refresh() {
|
||||
@@ -158,9 +198,12 @@ function updateBadges() {
|
||||
pill.classList.toggle('has-triggered', triggered > 0);
|
||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||
|
||||
const badge = document.querySelector('[data-badge]');
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
|
||||
// hamburger button (mobile) — only one of the two is ever visible at once.
|
||||
for (const badge of document.querySelectorAll('[data-badge]')) {
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
}
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
@@ -180,6 +223,7 @@ async function boot() {
|
||||
document.addEventListener('keydown', onKey);
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
$('menu-btn').addEventListener('click', openNavMenu);
|
||||
|
||||
// /signup is the one route that works without a session.
|
||||
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||
|
||||
@@ -34,6 +34,10 @@ export function clear(el, ...children) {
|
||||
const ICONS = {
|
||||
back: ['M15 18l-6-6 6-6'],
|
||||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||||
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
||||
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
|
||||
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
|
||||
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
|
||||
check: ['M5 12.5l4.5 4.5L19 7'],
|
||||
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
||||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||||
@@ -64,6 +68,14 @@ export function icon(name, cls = 'icon') {
|
||||
node.setAttribute('cx', cx);
|
||||
node.setAttribute('cy', cy);
|
||||
node.setAttribute('r', r);
|
||||
} else if (d.startsWith('rect:')) {
|
||||
const [x, y, w, hgt, rx] = d.slice(5).split(',');
|
||||
node = document.createElementNS(SVG, 'rect');
|
||||
node.setAttribute('x', x);
|
||||
node.setAttribute('y', y);
|
||||
node.setAttribute('width', w);
|
||||
node.setAttribute('height', hgt);
|
||||
if (rx) node.setAttribute('rx', rx);
|
||||
} else {
|
||||
node = document.createElementNS(SVG, 'path');
|
||||
node.setAttribute('d', d);
|
||||
|
||||
Reference in New Issue
Block a user