|
// 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;
|