// Sandbar API client. Pure functions over fetch, so it runs in the page, the service worker and tests. export class ApiError extends Error { constructor(status, message) { super(message); this.status = status; } } export function normalizeServer(input) { let s = String(input || '').trim(); if (!s) throw new Error('Enter the address of your Sandbar server.'); if (!/^https?:\/\//i.test(s)) s = (/^(localhost|127\.|\[::1\])/.test(s) ? 'http://' : 'https://') + s; const u = new URL(s); return u.origin; } export class Client { constructor(server, token, fetchImpl) { this.server = server; this.token = token; this.fetch = fetchImpl || ((...a) => fetch(...a)); } async req(method, path, body) { const headers = {}; if (this.token) headers.Authorization = 'Bearer ' + this.token; if (body !== undefined) headers['Content-Type'] = 'application/json'; let res; try { res = await this.fetch(this.server + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); } catch { throw new ApiError(0, 'Could not reach ' + this.server + '. Check the address and your connection.'); } const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ } if (!res.ok) throw new ApiError(res.status, (data && data.error) || 'The server answered ' + res.status + '.'); return data; } get(p) { return this.req('GET', p); } post(p, b) { return this.req('POST', p, b === undefined ? {} : b); } del(p) { return this.req('DELETE', p); } static async login(server, email, password, device, fetchImpl) { const c = new Client(server, '', fetchImpl); const r = await c.post('/api/v1/mobile/login', { email, password, device }); return { token: r.token, keyId: r.keyId, user: r.user }; } alerts(status) { return this.get('/api/v1/alerts?limit=100' + (status ? '&status=' + status : '')); } ack(id) { return this.post('/api/v1/alerts/' + id + '/ack'); } snooze(id, minutes) { return this.post('/api/v1/alerts/' + id + '/snooze', { minutes }); } escalate(id) { return this.post('/api/v1/alerts/' + id + '/escalate'); } resolve(id) { return this.post('/api/v1/alerts/' + id + '/resolve'); } alert(id) { return this.get('/api/v1/alerts/' + id); } onCall() { return this.get('/api/v1/oncall'); } monitors() { return this.get('/api/v1/monitors'); } monitorStates() { return this.get('/api/v1/monitor-states'); } edges() { return this.get('/api/v1/hammerhead/edges'); } messages(topic, since) { return this.get('/api/v1/topics/' + encodeURIComponent(topic) + '/messages?since=' + (since || 0)); } publish(topic, message, title, priority) { const headers = { 'Content-Type': 'text/plain' }; if (this.token) headers.Authorization = 'Bearer ' + this.token; if (title) headers.Title = title; if (priority) headers.Priority = String(priority); return this.fetch(this.server + '/' + encodeURIComponent(topic), { method: 'POST', headers, body: message }); } pushKey() { return this.get('/api/v1/push/key'); } pushSubs() { return this.get('/api/v1/push/subscriptions'); } savePush(sub, topics, device) { const j = sub.toJSON ? sub.toJSON() : sub; return this.post('/api/v1/push/subscriptions', { endpoint: j.endpoint, keys: j.keys, topics, device }); } deletePush(id) { return this.del('/api/v1/push/subscriptions/' + id); } testPush() { return this.post('/api/v1/push/test'); } logout(keyId) { return keyId ? this.del('/api/v1/apikeys/' + keyId) : Promise.resolve(); } // Streams a topic as newline-delimited JSON until the signal aborts, calling onMessage for each // message. Resolves when the stream ends, so the caller decides whether to reconnect. async stream(topic, since, onMessage, signal) { const headers = this.token ? { Authorization: 'Bearer ' + this.token } : {}; const res = await this.fetch(this.server + '/' + encodeURIComponent(topic) + '/json?since=' + encodeURIComponent(since || 'all'), { headers, signal }); if (!res.ok) throw new ApiError(res.status, 'Could not follow ' + topic + '.'); const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; for (;;) { const { done, value } = await reader.read(); if (done) return; buf += dec.decode(value, { stream: true }); let i; while ((i = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (!line) continue; try { const m = JSON.parse(line); if (m.event === 'message') onMessage(m); } catch { /* skip a bad line */ } } } } } export function urlBase64ToUint8Array(b64) { const pad = '='.repeat((4 - (b64.length % 4)) % 4); const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from(raw, (c) => c.charCodeAt(0)); } export function ago(sec, now = Date.now() / 1000) { const d = Math.max(0, Math.round(now - sec)); if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d / 60) + ' min ago'; if (d < 86400) return Math.floor(d / 3600) + ' h ago'; return Math.floor(d / 86400) + ' d ago'; }