Add the Sandbar mobile companion PWA

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 17:02:39 -06:00
commit ad165ef6a5
18 files changed
+1143

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
// 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';
}