Files
terdut-server/internal/web/static/js/api.js
T
Niklas Ye 3ced069134 Open switches, sources and members in a details sheet
The three team lists carried bare buttons on every row (Remove, Rename,
Revoke, Edit), which crowds a row that is meant for scanning and puts a
destructive action one stray click from every entry. A row now opens a
sheet with the facts the row has no room for, and the actions live
there: Edit and Delete for a switch, Rename and Revoke for a source,
Edit and Remove for a member. The guards are unchanged: owners only, the
last owner and SSO-managed members still cannot be removed, and every
destructive action still asks first.

Switches can finally be edited in place. The PUT endpoint has existed
since the operator needed it; the UI simply never called it, so changing
a timeout meant deleting the switch and losing its history. The edit
form is the add form, prefilled.

The first column of all three lists is now headed Name. Web UI only: no
change to any endpoint or JSON shape, so nothing to mirror in the TUI.
2026-10-08 11:46:09 +02:00

186 lines
8.9 KiB
JavaScript

// The terdut-server client. The page is served by the server itself, so every
// call is same-origin and carries the session cookie.
export class ApiError extends Error {
constructor(status, message) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
// Called whenever the server says the session is gone, so the app can put the
// login form back up wherever the user happened to be.
let onUnauthorized = () => {};
export function setUnauthorizedHandler(fn) {
onUnauthorized = fn;
}
async function call(method, path, { query, body, signal } = {}) {
const url = new URL('/api' + path, location.origin);
for (const [k, v] of Object.entries(query || {})) {
if (v === '' || v == null) continue;
url.searchParams.set(k, v);
}
const headers = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
let resp;
try {
resp = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
credentials: 'same-origin',
signal,
});
} catch (err) {
if (err.name === 'AbortError') throw err;
throw new ApiError(0, 'Cannot reach the server.');
}
if (resp.status === 204) return null;
let data = null;
try {
data = await resp.json();
} catch {
/* non-JSON body: keep null */
}
if (!resp.ok) {
if (resp.status === 401 && path !== '/login') onUnauthorized();
const message = (data && data.error) || `Server answered ${resp.status}.`;
throw new ApiError(resp.status, message);
}
return data;
}
// session
export const me = () => call('GET', '/me');
// How this server can be signed in to: { password_login, oidc: { enabled, name } }.
export const authConfig = () => call('GET', '/auth/config');
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
export const logout = () => call('POST', '/logout');
// Approve or refuse a sign-in a terminal started; code is what it is showing.
export const approveDevice = (code) => call('POST', '/oidc/device/approve', { body: { user_code: code } });
export const denyDevice = (code) => call('POST', '/oidc/device/deny', { body: { user_code: code } });
export const setPassword = (userID, password, currentPassword) =>
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
// users
export const users = () => call('GET', '/users');
// What one person is in. /teams answers "what am I in" and cannot be asked
// about anybody else, which is what the admin page's per-user view needs.
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
// Where this user's pages go. An empty topic clears it, which the server
// treats as "no topic of their own" rather than an error.
export const setNotifyTarget = (id, ntfyTopic) =>
call('PUT', `/users/${id}/notify`, { body: { ntfy_topic: ntfyTopic } });
// incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const incident = (id) => call('GET', `/incidents/${id}`);
export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
export const acknowledge = (id) => call('POST', `/incidents/${id}/acknowledge`);
export const unacknowledge = (id) => call('DELETE', `/incidents/${id}/acknowledge`);
export const resolve = (id, resolution) => call('POST', `/incidents/${id}/resolve`, resolution ? { body: { resolution } } : {});
export const assign = (id, userID) => call('POST', `/incidents/${id}/assign`, { body: { user_id: userID } });
export const snooze = (id, spec) => call('POST', `/incidents/${id}/snooze`, { body: spec });
export const unsnooze = (id) => call('DELETE', `/incidents/${id}/snooze`);
export const archive = (id) => call('POST', `/incidents/${id}/archive`);
export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
export const addNote = (id, content, pinned = false) => call('POST', `/incidents/${id}/notes`, { body: { content, pinned } });
export const similar = (id) => call('GET', `/incidents/${id}/similar`);
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
// stats
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
// alerts
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
// schedule
// Sign-up, both halves unauthenticated: the caller has no account yet.
export const signupInfo = (invite) =>
call('GET', '/signup', { query: invite ? { invite } : {} });
export const signup = (body) => call('POST', '/signup', { body });
export const invites = (id) => call('GET', `/teams/${id}/invites`);
export const createInvite = (id, role, maxUses) =>
call('POST', `/teams/${id}/invites`, { body: { role, max_uses: maxUses } });
export const revokeInvite = (id, inviteID) => call('DELETE', `/teams/${id}/invites/${inviteID}`);
export const testNotification = () => call('POST', '/me/notify/test');
export const dismissOnboarding = (dismissed) =>
call('PUT', '/me/onboarding', { body: { dismissed } });
export const teams = () => call('GET', '/teams');
export const createTeam = (name) => call('POST', '/teams', { body: { name } });
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
export const deleteTeam = (id) => call('DELETE', `/teams/${id}`);
// A team's own settings. Every write is owner-only and every read is
// member-only; the server answers 403 and 404 respectively, so the UI shows
// what the role allows rather than guarding it.
export const teamMembers = (id) => call('GET', `/teams/${id}/members`);
export const addTeamMember = (id, userID, role) =>
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
// Which OIDC groups grant member and owner access to this team.
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
export const createIntegration = (id, name) =>
call('POST', `/teams/${id}/integrations`, { body: { name } });
export const renameIntegration = (id, integrationID, name) =>
call('PATCH', `/teams/${id}/integrations/${integrationID}`, { body: { name } });
export const deleteIntegration = (id, integrationID) =>
call('DELETE', `/teams/${id}/integrations/${integrationID}`);
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
export const createDeadmanSwitch = (id, body) =>
call('POST', `/teams/${id}/deadman/switches`, { body });
export const updateDeadmanSwitch = (id, switchID, body) =>
call('PUT', `/teams/${id}/deadman/switches/${switchID}`, { body });
export const deleteDeadmanSwitch = (id, switchID) =>
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
export const assignSchedule = (id, userID, dates, replace = false) =>
call('POST', `/teams/${id}/schedule`, { body: { user_id: userID, dates, replace } });
export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/schedule/${entryID}`);
// Administration. Every one of these is refused with 403 for anybody without
// the flag, so the UI hides the section rather than guarding it.
export const adminTeams = () => call('GET', '/admin/teams');
// One team and who is in it: { team, members }. /teams/{id}/members is
// member-only and answers 404 to an administrator from outside the team, which
// is the rule rather than an oversight -- this asks the other question.
export const adminTeam = (id) => call('GET', `/admin/teams/${id}`);
export const adminSettings = () => call('GET', '/admin/settings');
export const setAdminSettings = (body) => call('PUT', '/admin/settings', { body });
export const setUserAdmin = (id, isAdmin) =>
call('PUT', `/users/${id}/admin`, { body: { is_admin: isAdmin } });
export const setUserDisabled = (id, disabled) =>
call('PUT', `/users/${id}/disabled`, { body: { disabled } });
export const deleteUser = (id) => call('DELETE', `/users/${id}`);
export const schedule = (teamID, from, to) =>
call('GET', `/teams/${teamID}/schedule`, { query: { from, to } });
// One entry per team the viewer belongs to, for the teams that have somebody
// scheduled today. An empty array means nobody anywhere, which is a real answer
// rather than an error — unlike the pre-teams endpoint, which 404ed.
export const onCallNow = () => call('GET', '/schedule/current');