Add the Sandbar mobile companion PWA
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ad165ef6a5
18 files changed
+1143
No files matched your search
+119
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user