120 lines
5.1 KiB
JavaScript
120 lines
5.1 KiB
JavaScript
// 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';
|
|
}
|