// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
import { Poller } from "./Poller.js";
import { Toast } from "./Toast.js";
import { DateFormat } from "./DateFormat.js";
class ServiceMonitor {
constructor() {
this.pollInterval = 5000;
this.detail = document.querySelector("[data-service-detail]");
this.detailName = this.detail ? this.detail.dataset.service : null;
this.list = document.getElementById("services-list");
}
start() {
const container = this.detail || this.list;
if (!container) return;
this.bindActions(container);
this.bindConfigForms(container);
this.poller = new Poller(() => this.poll(), 20000, { pauseHidden: true });
const pubsub = window.app && window.app.pubsub;
if (pubsub) {
const topic = this.detail ? `admin.services.${this.detailName}` : "admin.services";
pubsub.subscribe(topic, (data) => this.applyData(data));
if (this.detail) {
pubsub.subscribe(`admin.services.${this.detailName}.logs`, (data) => this.appendLive(data));
}
}
}
appendLive(data) {
if (!data || !data.line || !this.detail) return;
const pre = this.detail.querySelector("[data-live-log]");
if (!pre) return;
const lines = pre.textContent === "No live output yet." ? [] : pre.textContent.split("\n");
lines.push(data.line);
pre.textContent = lines.slice(-300).join("\n");
pre.scrollTop = pre.scrollHeight;
}
applyData(data) {
if (!data) return;
if (this.detail) {
if (data.service) this.update(this.detail, data.service);
} else if (this.list && Array.isArray(data.services)) {
for (const svc of data.services) {
const row = this.list.querySelector(`[data-service="${svc.name}"]`);
if (row) this.update(row, svc);
}
}
}
bindActions(container) {
container.addEventListener("click", (event) => {
const button = event.target.closest("[data-action]");
if (!button) return;
event.preventDefault();
this.runAction(button);
});
}
async runAction(button) {
const action = button.dataset.action;
const name = button.dataset.service;
button.disabled = true;
try {
await Http.sendForm(`/admin/services/${name}/${action}`, {});
await this.poll();
} catch {
Toast.flash(button, "Error", 1500);
} finally {
button.disabled = false;
}
}
bindConfigForms(container) {
container.querySelectorAll("[data-config-form]").forEach((form) => {
form.addEventListener("submit", (event) => {
event.preventDefault();
this.saveConfig(form);
});
const markDirty = (event) => {
if (event.target.name) event.target.dataset.dirty = "1";
};
form.addEventListener("input", markDirty);
form.addEventListener("change", markDirty);
});
}
async saveConfig(form) {
const name = form.dataset.service;
const statusEl = form.querySelector("[data-config-status]");
form.querySelectorAll("[data-error]").forEach((el) => {
el.textContent = "";
});
const params = new URLSearchParams();
form.querySelectorAll("input, select, textarea").forEach((input) => {
if (input.type === "password" && input.value === "") return;
params.append(input.name, input.value);
});
try {
const data = await Http.sendForm(`/admin/services/${name}/config`, params);
(data.saved || []).forEach((key) => {
const el = form.querySelector(`[name="${key}"]`);
if (el) delete el.dataset.dirty;
});
if (data.ok) {
if (statusEl) Toast.flash(statusEl, "Saved", 2000, "");
await this.poll();
return;
}
for (const [key, message] of Object.entries(data.errors || {})) {
const el = form.querySelector(`[data-error="${key}"]`);
if (el) el.textContent = message;
}
if (statusEl) Toast.flash(statusEl, "Check the errors below", 3000, "");
} catch {
if (statusEl) Toast.flash(statusEl, "Request failed", 2000, "");
}
}
async poll() {
try {
if (this.detail) {
this.applyData(await Http.getJson(`/admin/services/${this.detailName}/data`));
} else if (this.list) {
this.applyData(await Http.getJson("/admin/services/data"));
}
} catch {
// silently ignore poll errors
}
}
update(root, svc) {
const statusEl = root.querySelector(".service-status");
if (statusEl) {
statusEl.textContent = svc.status;
statusEl.className = "service-status " + svc.status;
}
this.setMeta(root, "uptime", svc.uptime || "-");
this.setMeta(root, "last_run", svc.last_run ? DateFormat.format(svc.last_run, true) : "-");
this.setMeta(root, "next_run", svc.next_run ? DateFormat.format(svc.next_run, true) : "-");
this.setMeta(root, "heartbeat", svc.heartbeat ? DateFormat.format(svc.heartbeat, true) : "-");
this.setMeta(root, "interval", "every " + svc.interval_seconds + "s");
const logPre = root.querySelector(".log-output");
if (logPre) {
logPre.textContent = (svc.log_buffer || []).map((line) => line + "\n").join("") || "No log entries yet.\n";
}
const startBtn = root.querySelector('[data-action="start"]');
const stopBtn = root.querySelector('[data-action="stop"]');
if (startBtn) startBtn.disabled = svc.enabled;
if (stopBtn) stopBtn.disabled = !svc.enabled;
const metricsEl = root.querySelector("[data-metrics]");
if (metricsEl) this.renderMetrics(metricsEl, svc.metrics);
this.syncConfigFields(root, svc.fields);
}
syncConfigFields(root, fields) {
if (!Array.isArray(fields)) return;
const form = root.querySelector("[data-config-form]");
if (!form) return;
for (const field of fields) {
if (field.secret) continue;
const input = form.querySelector(`[name="${field.key}"]`);
if (!input) continue;
if (input === document.activeElement) continue;
if (input.dataset.dirty === "1") continue;
const value = field.value == null ? "" : String(field.value);
if (input.value !== value) input.value = value;
}
}
setMeta(root, key, value) {
const el = root.querySelector(`[data-meta="${key}"]`);
if (el) el.textContent = value;
}
renderMetrics(container, metrics) {
container.textContent = "";
if (!metrics || (!metrics.stats && !metrics.table)) return;
const stats = metrics.stats || [];
if (stats.length) {
const grid = document.createElement("div");
grid.className = "metric-cards";
for (const stat of stats) {
const cell = document.createElement("div");
cell.className = "metric-card";
const value = document.createElement("span");
value.className = "metric-value";
value.textContent = String(stat.value);
const label = document.createElement("span");
label.className = "metric-label";
label.textContent = stat.label;
cell.append(value, label);
grid.appendChild(cell);
}
container.appendChild(grid);
}
const table = metrics.table;
if (table && table.rows && table.rows.length) {
const el = document.createElement("table");
el.className = "metric-table";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
for (const col of table.columns || []) {
const th = document.createElement("th");
th.textContent = col;
headRow.appendChild(th);
}
thead.appendChild(headRow);
el.appendChild(thead);
const tbody = document.createElement("tbody");
for (const row of table.rows) {
const tr = document.createElement("tr");
for (const cell of row) {
const td = document.createElement("td");
this.renderCell(td, cell);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
el.appendChild(tbody);
container.appendChild(el);
}
}
renderCell(td, cell) {
if (cell && typeof cell === "object" && cell.href) {
const link = document.createElement("a");
link.href = cell.href;
link.className = "metric-link";
if (cell.avatar) {
const avatar = document.createElement("dp-avatar");
avatar.setAttribute("username", String(cell.avatar));
avatar.setAttribute("size", "20");
link.appendChild(avatar);
}
link.appendChild(document.createTextNode(String(cell.text)));
td.appendChild(link);
return;
}
td.textContent = String(cell);
}
}
window.ServiceMonitor = ServiceMonitor;