- FastAPI + SQLite backend: tenants, jobs and runs, with an idempotent /api/v1/ingest endpoint authenticated by per-collector API keys - Runs store started_at and finished_at; "Last Run" is the most recent finish, and in-progress runs show as Running - Job modal shows a 30-day daily strip, success rate, and a filterable run list with per-run messages - nginx: HTTPS plus basic auth, ingest endpoint exempt; systemd unit - manage.py for API keys, job cleanup and demo data; pytest suite Co-Authored-By: Claude Opus 5.5 <[email protected]>
527 lines
19 KiB
JavaScript
527 lines
19 KiB
JavaScript
import { CONFIG } from './config.js';
|
||
import { getApi } from './api.js';
|
||
import {
|
||
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
||
} from './format.js';
|
||
|
||
const api = await getApi();
|
||
|
||
// ---------- Domain ----------
|
||
// rank drives default sort: worst first
|
||
const STATUS = {
|
||
failed: { label: 'Failed', icon: '✕', rank: 0 },
|
||
warning: { label: 'Warning', icon: '!', rank: 1 },
|
||
unknown: { label: 'Unknown', icon: '?', rank: 2 },
|
||
running: { label: 'Running', icon: '↻', rank: 3 },
|
||
success: { label: 'Success', icon: '✓', rank: 4 },
|
||
};
|
||
|
||
// Anything the backend sends that we don't recognise becomes "unknown" rather than breaking the UI
|
||
function normalizeStatus(raw) {
|
||
const s = String(raw ?? '').toLowerCase();
|
||
return Object.hasOwn(STATUS, s) ? s : 'unknown';
|
||
}
|
||
|
||
function normalizeJob(j) {
|
||
return {
|
||
id: String(j.id),
|
||
company: j.company_name ?? '',
|
||
name: j.job_name ?? '',
|
||
status: normalizeStatus(j.status),
|
||
message: j.message ?? '',
|
||
runningSince: parseDate(j.running_since),
|
||
// When the most recent run *finished*; a job that's running keeps showing its previous finish
|
||
lastRun: parseDate(j.last_run_at),
|
||
nextRun: parseDate(j.next_run_at),
|
||
};
|
||
}
|
||
|
||
// A running job isn't silently dead, so it's never overdue (a long run can pass its next slot)
|
||
const isOverdue = (job, now = Date.now()) =>
|
||
job.status !== 'running' && !!job.nextRun && job.nextRun.getTime() + CONFIG.overdueGraceMinutes * 60_000 < now;
|
||
|
||
// ---------- State ----------
|
||
const state = {
|
||
jobs: [],
|
||
filter: 'all',
|
||
search: '',
|
||
sort: { key: 'status', dir: 1 },
|
||
lastSuccess: null,
|
||
};
|
||
|
||
// ---------- DOM ----------
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const dom = {
|
||
tbody: $('#jobs-body'),
|
||
empty: $('#empty-state'),
|
||
search: $('#search'),
|
||
refreshBtn: $('#refresh-btn'),
|
||
lastUpdated: $('#last-updated'),
|
||
errorBanner: $('#error-banner'),
|
||
dialog: $('#detail-dialog'),
|
||
detailTitle: $('#detail-title'),
|
||
detailSubtitle: $('#detail-subtitle'),
|
||
detailBody: $('#detail-body'),
|
||
};
|
||
|
||
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
|
||
function el(tag, props = {}, ...children) {
|
||
const node = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(props)) {
|
||
if (v == null || v === false) continue;
|
||
if (k === 'class') node.className = v;
|
||
else if (k === 'dataset') Object.assign(node.dataset, v);
|
||
else node.setAttribute(k, v === true ? '' : v);
|
||
}
|
||
node.append(...children.flat().filter((c) => c != null && c !== false));
|
||
return node;
|
||
}
|
||
|
||
// ---------- Rendering ----------
|
||
function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
|
||
const { label, icon } = STATUS[status];
|
||
const content = [el('span', { class: 'badge__icon', 'aria-hidden': 'true' }, icon), label];
|
||
return asButton
|
||
? el('button', {
|
||
type: 'button',
|
||
class: `badge badge--${status}`,
|
||
'aria-haspopup': opensDialog ? 'dialog' : null,
|
||
'aria-label': `${label} – view details`,
|
||
title,
|
||
dataset,
|
||
}, content)
|
||
: el('span', { class: `badge badge--${status}`, title }, content);
|
||
}
|
||
|
||
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
|
||
|
||
function timeCell(date, extra = null) {
|
||
if (!date) return el('td', {}, el('span', { class: 'muted' }, 'Never'));
|
||
return el('td', {},
|
||
el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
|
||
el('span', {}, formatDateTime(date), extra),
|
||
el('span', { class: 'time__rel' }, formatRelative(date))));
|
||
}
|
||
|
||
function renderRow(job) {
|
||
const overdueTag = isOverdue(job) ? el('span', { class: 'tag-overdue' }, 'Overdue') : null;
|
||
return el('tr', {},
|
||
el('td', {}, job.company),
|
||
el('td', {}, job.name),
|
||
el('td', {}, statusBadge(job.status, {
|
||
asButton: true, opensDialog: true, dataset: { jobId: job.id }, title: runningTitle(job.runningSince),
|
||
})),
|
||
timeCell(job.lastRun),
|
||
timeCell(job.nextRun, overdueTag));
|
||
}
|
||
|
||
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
||
|
||
function cmpDate(a, b) {
|
||
if (!a && !b) return 0;
|
||
if (!a) return 1;
|
||
if (!b) return -1;
|
||
return a - b;
|
||
}
|
||
|
||
const SORTERS = {
|
||
company: (a, b) => collator.compare(a.company, b.company),
|
||
name: (a, b) => collator.compare(a.name, b.name),
|
||
status: (a, b) => STATUS[a.status].rank - STATUS[b.status].rank,
|
||
lastRun: (a, b) => cmpDate(a.lastRun, b.lastRun),
|
||
nextRun: (a, b) => cmpDate(a.nextRun, b.nextRun),
|
||
};
|
||
|
||
function visibleJobs() {
|
||
const q = state.search.trim().toLowerCase();
|
||
const now = Date.now();
|
||
const { key, dir } = state.sort;
|
||
return state.jobs
|
||
.filter((j) => {
|
||
if (state.filter === 'overdue') return isOverdue(j, now);
|
||
return state.filter === 'all' || j.status === state.filter;
|
||
})
|
||
.filter((j) => !q || j.company.toLowerCase().includes(q) || j.name.toLowerCase().includes(q))
|
||
// Stable tie-breakers keep row order from jumping around on each refresh
|
||
.sort((a, b) => SORTERS[key](a, b) * dir || SORTERS.company(a, b) || SORTERS.name(a, b));
|
||
}
|
||
|
||
function renderSummary() {
|
||
const now = Date.now();
|
||
const counts = { all: state.jobs.length, failed: 0, warning: 0, running: 0, success: 0, overdue: 0 };
|
||
for (const j of state.jobs) {
|
||
if (j.status in counts) counts[j.status]++;
|
||
if (isOverdue(j, now)) counts.overdue++;
|
||
}
|
||
for (const [k, v] of Object.entries(counts)) {
|
||
const node = document.querySelector(`[data-count="${k}"]`);
|
||
if (node) node.textContent = v;
|
||
}
|
||
document.querySelectorAll('.summary-card').forEach((card) => {
|
||
card.setAttribute('aria-pressed', String(card.dataset.filter === state.filter));
|
||
});
|
||
}
|
||
|
||
function renderSortHeaders() {
|
||
document.querySelectorAll('th[aria-sort]').forEach((th) => {
|
||
const key = th.querySelector('.sort').dataset.sort;
|
||
th.setAttribute('aria-sort',
|
||
key === state.sort.key ? (state.sort.dir === 1 ? 'ascending' : 'descending') : 'none');
|
||
});
|
||
}
|
||
|
||
function render() {
|
||
const rows = visibleJobs();
|
||
dom.tbody.replaceChildren(...rows.map(renderRow));
|
||
dom.empty.hidden = rows.length > 0;
|
||
renderSummary();
|
||
renderSortHeaders();
|
||
}
|
||
|
||
// ---------- Data loading ----------
|
||
let inFlight = null;
|
||
let pollTimer = null;
|
||
|
||
function refresh() {
|
||
if (inFlight) return inFlight; // de-dupe overlapping manual + scheduled refreshes
|
||
dom.refreshBtn.disabled = true;
|
||
inFlight = (async () => {
|
||
try {
|
||
const data = await api.listJobs();
|
||
state.jobs = data.jobs.map(normalizeJob);
|
||
state.lastSuccess = new Date();
|
||
dom.errorBanner.hidden = true;
|
||
dom.lastUpdated.textContent = `Updated ${formatTime(state.lastSuccess)}`;
|
||
} catch (err) {
|
||
// Keep showing stale data, but make it obvious it's stale
|
||
dom.errorBanner.textContent = state.lastSuccess
|
||
? `Couldn't refresh (${err.message}). Showing data from ${formatTime(state.lastSuccess)}.`
|
||
: `Couldn't load backup status (${err.message}).`;
|
||
dom.errorBanner.hidden = false;
|
||
dom.lastUpdated.textContent = 'Update failed';
|
||
console.error(err);
|
||
} finally {
|
||
render();
|
||
dom.refreshBtn.disabled = false;
|
||
inFlight = null;
|
||
}
|
||
})();
|
||
return inFlight;
|
||
}
|
||
|
||
function schedulePoll() {
|
||
clearTimeout(pollTimer);
|
||
pollTimer = setTimeout(async () => {
|
||
await refresh();
|
||
schedulePoll();
|
||
}, CONFIG.refreshIntervalMs);
|
||
}
|
||
|
||
// Don't poll from background tabs (e.g. a wall display that's been switched away); catch up on return
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden) clearTimeout(pollTimer);
|
||
else refresh().then(schedulePoll);
|
||
});
|
||
|
||
// ---------- Detail modal ----------
|
||
// Every async result checks reqId first, so a slow response for job A can never paint over job B.
|
||
const modal = {
|
||
reqId: 0,
|
||
job: null,
|
||
detail: null,
|
||
runs: null, // null = loading, [] = none
|
||
runsError: null,
|
||
dayFilter: null, // local YYYY-MM-DD, or null for all days
|
||
selectedRunId: null, // null = the job's latest finished run (from the detail call)
|
||
runMessage: null, // { status, text, startedAt } for the selected run
|
||
runMessageError: null,
|
||
};
|
||
|
||
// Colour of a day cell: the worst thing that happened that day
|
||
const DAY_SEVERITY = ['failed', 'warning', 'unknown', 'running', 'success'];
|
||
|
||
const pad2 = (n) => String(n).padStart(2, '0');
|
||
const localDayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||
const dayLabelFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||
const dayTickFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
|
||
|
||
function normalizeRun(r) {
|
||
return {
|
||
id: String(r.id),
|
||
status: normalizeStatus(r.status),
|
||
startedAt: parseDate(r.started_at),
|
||
finishedAt: parseDate(r.finished_at),
|
||
duration: r.duration_seconds,
|
||
size: r.size_bytes,
|
||
};
|
||
}
|
||
|
||
function buildDays(runs, count) {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const days = [];
|
||
for (let i = count - 1; i >= 0; i--) {
|
||
const date = new Date(today);
|
||
date.setDate(date.getDate() - i);
|
||
days.push({ key: localDayKey(date), date, counts: {}, total: 0 });
|
||
}
|
||
const byKey = new Map(days.map((d) => [d.key, d]));
|
||
for (const run of runs) {
|
||
const day = byKey.get(localDayKey(run.startedAt));
|
||
if (!day) continue;
|
||
day.counts[run.status] = (day.counts[run.status] ?? 0) + 1;
|
||
day.total++;
|
||
}
|
||
for (const day of days) day.status = day.total ? DAY_SEVERITY.find((s) => day.counts[s]) : 'none';
|
||
return days;
|
||
}
|
||
|
||
function describeCounts(counts) {
|
||
return DAY_SEVERITY.filter((s) => counts[s])
|
||
.map((s) => `${counts[s]} ${STATUS[s].label.toLowerCase()}`)
|
||
.join(', ');
|
||
}
|
||
|
||
function detailRow(label, value) {
|
||
if (value == null || value === '') return [];
|
||
return [el('dt', {}, label), el('dd', {}, value)];
|
||
}
|
||
|
||
function renderSummarySection() {
|
||
const { job, detail } = modal;
|
||
const status = detail ? normalizeStatus(detail.status) : job.status;
|
||
const runningSince = detail ? parseDate(detail.running_since) : job.runningSince;
|
||
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
|
||
|
||
dom.detailTitle.textContent = detail?.job_name ?? job.name;
|
||
dom.detailSubtitle.textContent = detail?.company_name ?? job.company;
|
||
|
||
return el('section', {},
|
||
el('div', { class: 'detail-status' },
|
||
statusBadge(status),
|
||
runningSince ? el('span', { class: 'muted' }, `since ${formatDateTime(runningSince)}`) : null),
|
||
el('dl', { class: 'detail-grid' },
|
||
// While a job is running, its "last run" is the previous one — say how that went
|
||
status === 'running' && lastStatus ? detailRow('Previous result', STATUS[lastStatus].label) : [],
|
||
detailRow('Last finished', formatDateTime(detail ? parseDate(detail.last_run_at) : job.lastRun)),
|
||
detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun)),
|
||
detailRow('Duration', formatDuration(detail?.duration_seconds)),
|
||
detailRow('Size', formatBytes(detail?.size_bytes)),
|
||
detailRow('Source', detail?.source_system)));
|
||
}
|
||
|
||
function renderHistorySection() {
|
||
const section = el('section', { id: 'detail-history' },
|
||
el('h3', { class: 'detail-section-title' }, `Last ${CONFIG.historyDays} days`));
|
||
|
||
if (modal.runsError) {
|
||
section.append(el('p', { class: 'banner banner--error' }, `Couldn't load run history: ${modal.runsError}`));
|
||
return section;
|
||
}
|
||
if (!modal.runs) {
|
||
section.append(el('p', { class: 'muted' }, 'Loading history…'));
|
||
return section;
|
||
}
|
||
|
||
const days = buildDays(modal.runs, CONFIG.historyDays);
|
||
const finished = modal.runs.filter((r) => r.finishedAt);
|
||
const ok = finished.filter((r) => r.status === 'success').length;
|
||
const totals = {};
|
||
for (const r of modal.runs) totals[r.status] = (totals[r.status] ?? 0) + 1;
|
||
|
||
section.append(
|
||
el('p', { class: 'history-stats' },
|
||
modal.runs.length
|
||
? `${modal.runs.length} runs · ${finished.length ? Math.round((ok / finished.length) * 100) : 0}% successful · ${describeCounts(totals)}`
|
||
: 'No runs recorded in this period.'),
|
||
el('div', { class: 'day-strip', role: 'group', 'aria-label': 'Daily results, oldest to newest. Select a day to filter runs.' },
|
||
days.map((d) => {
|
||
const label = `${dayLabelFmt.format(d.date)}: ${d.total ? describeCounts(d.counts) : 'no runs'}`;
|
||
return el('button', {
|
||
type: 'button',
|
||
class: `day day--${d.status}`,
|
||
title: label,
|
||
'aria-label': label,
|
||
'aria-pressed': String(modal.dayFilter === d.key),
|
||
disabled: !d.total,
|
||
dataset: { day: d.key },
|
||
});
|
||
})),
|
||
el('div', { class: 'day-strip__ticks muted', 'aria-hidden': 'true' },
|
||
el('span', {}, dayTickFmt.format(days[0].date)),
|
||
el('span', {}, 'Today')),
|
||
);
|
||
|
||
const rows = modal.dayFilter
|
||
? modal.runs.filter((r) => localDayKey(r.startedAt) === modal.dayFilter)
|
||
: modal.runs;
|
||
if (!rows.length) return section;
|
||
|
||
if (modal.dayFilter) {
|
||
const date = days.find((d) => d.key === modal.dayFilter)?.date;
|
||
section.append(el('p', { class: 'muted history-filter' },
|
||
`Showing ${dayLabelFmt.format(date)} · `,
|
||
el('button', { type: 'button', class: 'link', dataset: { day: modal.dayFilter } }, 'Show all days')));
|
||
}
|
||
|
||
section.append(el('div', { class: 'runs-wrap' },
|
||
el('table', { class: 'runs' },
|
||
el('thead', {}, el('tr', {},
|
||
['Status', 'Started', 'Finished', 'Duration', 'Size'].map((h) => el('th', { scope: 'col' }, h)))),
|
||
el('tbody', {}, rows.map((r) => el('tr', { class: r.id === modal.selectedRunId ? 'is-selected' : null },
|
||
el('td', {}, statusBadge(r.status, { asButton: true, dataset: { runId: r.id } })),
|
||
el('td', {}, formatDateTime(r.startedAt)),
|
||
el('td', {}, r.finishedAt ? formatDateTime(r.finishedAt) : el('span', { class: 'muted' }, 'In progress')),
|
||
el('td', {}, formatDuration(r.duration) ?? '—'),
|
||
el('td', {}, formatBytes(r.size) ?? '—')))))));
|
||
return section;
|
||
}
|
||
|
||
function renderMessageSection() {
|
||
const { detail, job } = modal;
|
||
let status;
|
||
let text;
|
||
let heading = 'Message';
|
||
if (modal.selectedRunId) {
|
||
const run = modal.runs?.find((r) => r.id === modal.selectedRunId);
|
||
heading = `Message · run started ${formatDateTime(run?.startedAt)}`;
|
||
status = run?.status ?? 'unknown';
|
||
text = modal.runMessageError
|
||
? `Couldn't load this run: ${modal.runMessageError}`
|
||
: modal.runMessage?.text ?? 'Loading…';
|
||
} else {
|
||
status = detail?.last_status ? normalizeStatus(detail.last_status) : job.status;
|
||
text = detail?.message ?? job.message;
|
||
}
|
||
|
||
const extras = !modal.selectedRunId && detail?.details && typeof detail.details === 'object'
|
||
? Object.entries(detail.details).flatMap(([k, v]) =>
|
||
detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v)))
|
||
: modal.runMessage?.extras ?? [];
|
||
|
||
return el('section', { id: 'detail-message' },
|
||
el('h3', { class: 'detail-section-title' }, heading),
|
||
el('pre', { class: `message message--${status}` }, text || 'No message reported.'),
|
||
extras.length ? el('h3', { class: 'detail-section-title' }, 'Additional details') : null,
|
||
extras.length ? el('dl', { class: 'detail-grid' }, extras) : null);
|
||
}
|
||
|
||
function renderModal({ loading = false, error = null } = {}) {
|
||
dom.detailBody.replaceChildren(...[
|
||
renderSummarySection(),
|
||
loading ? el('p', { class: 'muted' }, 'Loading full details…') : null,
|
||
error ? el('p', { class: 'banner banner--error' }, `Couldn't load full details: ${error}`) : null,
|
||
renderMessageSection(),
|
||
renderHistorySection(),
|
||
].filter(Boolean));
|
||
}
|
||
|
||
// Re-render just the history section, keeping the runs table's scroll position
|
||
function renderHistory() {
|
||
const wrap = dom.detailBody.querySelector('.runs-wrap');
|
||
const scrollTop = wrap?.scrollTop ?? 0;
|
||
dom.detailBody.querySelector('#detail-history')?.replaceWith(renderHistorySection());
|
||
const newWrap = dom.detailBody.querySelector('.runs-wrap');
|
||
if (newWrap) newWrap.scrollTop = scrollTop;
|
||
}
|
||
|
||
function renderMessage() {
|
||
dom.detailBody.querySelector('#detail-message')?.replaceWith(renderMessageSection());
|
||
}
|
||
|
||
async function openDetail(jobId) {
|
||
const job = state.jobs.find((j) => j.id === jobId);
|
||
if (!job) return;
|
||
const reqId = ++modal.reqId;
|
||
Object.assign(modal, {
|
||
job, detail: null, runs: null, runsError: null, dayFilter: null,
|
||
selectedRunId: null, runMessage: null, runMessageError: null,
|
||
});
|
||
renderModal({ loading: true }); // show what we have immediately
|
||
if (!dom.dialog.open) dom.dialog.showModal();
|
||
|
||
const current = () => reqId === modal.reqId && dom.dialog.open;
|
||
const detailP = api.getJob(jobId).then(
|
||
(detail) => { if (current()) { modal.detail = detail; renderModal(); } },
|
||
(err) => { if (current()) renderModal({ error: err.message }); },
|
||
);
|
||
const runsP = api.listRuns(jobId, CONFIG.historyDays).then(
|
||
(data) => {
|
||
if (!current()) return;
|
||
modal.runs = (data.runs ?? []).map(normalizeRun).filter((r) => r.startedAt);
|
||
renderHistory();
|
||
},
|
||
(err) => { if (current()) { modal.runsError = err.message; renderHistory(); } },
|
||
);
|
||
await Promise.all([detailP, runsP]);
|
||
}
|
||
|
||
async function selectRun(runId) {
|
||
const reqId = modal.reqId;
|
||
modal.selectedRunId = runId;
|
||
modal.runMessage = null;
|
||
modal.runMessageError = null;
|
||
renderHistory();
|
||
renderMessage();
|
||
try {
|
||
const run = await api.getRun(runId);
|
||
if (reqId !== modal.reqId || modal.selectedRunId !== runId) return;
|
||
modal.runMessage = {
|
||
text: run.message,
|
||
extras: Object.entries(run.details ?? {}).flatMap(([k, v]) =>
|
||
detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v))),
|
||
};
|
||
} catch (err) {
|
||
if (reqId !== modal.reqId || modal.selectedRunId !== runId) return;
|
||
modal.runMessageError = err.message;
|
||
}
|
||
renderMessage();
|
||
dom.detailBody.querySelector('#detail-message')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
}
|
||
|
||
// ---------- Events ----------
|
||
dom.tbody.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-job-id]');
|
||
if (btn) openDetail(btn.dataset.jobId);
|
||
});
|
||
|
||
dom.dialog.addEventListener('click', (e) => {
|
||
// Click on the backdrop (the dialog element itself, outside its content) or the close button
|
||
if (e.target === dom.dialog || e.target.closest('[data-close]')) dom.dialog.close();
|
||
});
|
||
|
||
dom.detailBody.addEventListener('click', (e) => {
|
||
const day = e.target.closest('button[data-day]');
|
||
if (day) {
|
||
modal.dayFilter = modal.dayFilter === day.dataset.day ? null : day.dataset.day;
|
||
renderHistory();
|
||
return;
|
||
}
|
||
const run = e.target.closest('button[data-run-id]');
|
||
if (run) selectRun(run.dataset.runId);
|
||
});
|
||
|
||
document.querySelector('.summary').addEventListener('click', (e) => {
|
||
const card = e.target.closest('.summary-card');
|
||
if (!card) return;
|
||
state.filter = card.dataset.filter;
|
||
render();
|
||
});
|
||
|
||
document.querySelector('.jobs thead').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.sort');
|
||
if (!btn) return;
|
||
const key = btn.dataset.sort;
|
||
state.sort = state.sort.key === key ? { key, dir: -state.sort.dir } : { key, dir: 1 };
|
||
render();
|
||
});
|
||
|
||
dom.search.addEventListener('input', () => {
|
||
state.search = dom.search.value;
|
||
render();
|
||
});
|
||
|
||
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
||
|
||
// ---------- Boot ----------
|
||
refresh().then(schedulePoll); |