Add production installer and observability tooling
This commit is contained in:
+405
-325
@@ -1,155 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>V-Fire Monitor</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor.css') }}">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0c0f12;
|
||||
--panel: #12171d;
|
||||
--panel-soft: #1a2129;
|
||||
--border: #2a333d;
|
||||
--text: #edf2f7;
|
||||
--muted: #94a3b8;
|
||||
--accent: #ff5a3d;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 90, 61, 0.15), transparent 30%),
|
||||
linear-gradient(180deg, #091017 0%, var(--bg) 45%, #07090c 100%);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.app-header,
|
||||
.panel,
|
||||
.sensor-card,
|
||||
.node-row {
|
||||
background: rgba(18, 23, 29, 0.95);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 18px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.node-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
margin: 0;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 4px solid var(--accent);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sensor-card {
|
||||
height: 100%;
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease;
|
||||
}
|
||||
|
||||
.sensor-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.sensor-label {
|
||||
padding-right: 2rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(7, 9, 12, 0.85);
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loader" class="loading-overlay">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-danger"></div>
|
||||
<p class="mt-3 mb-0 text-light">Executando varredura Modbus...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="app-header border-bottom sticky-top">
|
||||
<div class="container py-3 d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<div class="text-uppercase small text-secondary">Voltec</div>
|
||||
<h1 class="h3 mb-0">V-Fire Monitor</h1>
|
||||
</div>
|
||||
:root {
|
||||
--bg: #0c0f12;
|
||||
--panel: #12171d;
|
||||
--panel-soft: #1a2129;
|
||||
--border: #2a333d;
|
||||
--text: #edf2f7;
|
||||
--muted: #94a3b8;
|
||||
--accent: #ff5a3d;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 90, 61, 0.15), transparent 30%),
|
||||
linear-gradient(180deg, #091017 0%, var(--bg) 45%, #07090c 100%);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.app-header,
|
||||
.panel,
|
||||
.sensor-card,
|
||||
.node-row {
|
||||
background: rgba(18, 23, 29, 0.95);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 18px;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.node-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.node-title {
|
||||
margin: 0;
|
||||
padding-left: 0.9rem;
|
||||
border-left: 4px solid var(--accent);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sensor-card {
|
||||
height: 100%;
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease;
|
||||
}
|
||||
|
||||
.sensor-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.sensor-label {
|
||||
padding-right: 2rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.node-row {
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(7, 9, 12, 0.85);
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="loader" class="loading-overlay">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-danger"></div>
|
||||
<p class="mt-3 mb-0 text-light">Executando varredura Modbus...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="app-header border-bottom sticky-top">
|
||||
<div class="container py-3 d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<div class="text-uppercase small text-secondary">Voltec</div>
|
||||
<h1 class="h3 mb-0">V-Fire Monitor</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="openAccountModal()">Conta</button>
|
||||
<button class="btn btn-outline-light btn-sm" onclick="openConfigModal()">Configurar</button>
|
||||
<a href="/logout" class="btn btn-outline-danger btn-sm">Sair</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container py-4">
|
||||
<div id="alert-host"></div>
|
||||
|
||||
{% if not licenciado %}
|
||||
<section class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="panel text-center">
|
||||
<h2 class="h4 text-danger">Sistema bloqueado</h2>
|
||||
<p class="text-secondary mb-3">Ative a licenca deste equipamento para liberar o monitoramento.</p>
|
||||
<p class="text-secondary mb-2">Hardware ID</p>
|
||||
<code class="d-block p-3 rounded bg-dark text-light mb-3">{{ hwid }}</code>
|
||||
<textarea id="serial-in" class="form-control bg-dark text-light border-secondary mb-3" rows="4" placeholder="Cole o serial de licenca"></textarea>
|
||||
<button class="btn btn-danger w-100" onclick="activateLicense()">Ativar licenca</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container py-4">
|
||||
<div id="alert-host"></div>
|
||||
|
||||
{% if not licenciado %}
|
||||
<section class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="panel text-center">
|
||||
<h2 class="h4 text-danger">Sistema bloqueado</h2>
|
||||
<p class="text-secondary mb-3">Ative a licenca deste equipamento para liberar o monitoramento.</p>
|
||||
<p class="text-secondary mb-2">Hardware ID</p>
|
||||
<code class="d-block p-3 rounded bg-dark text-light mb-3">{{ hwid }}</code>
|
||||
<textarea id="serial-in" class="form-control bg-dark text-light border-secondary mb-3" rows="4" placeholder="Cole o serial de licenca"></textarea>
|
||||
<button class="btn btn-danger w-100" onclick="activateLicense()">Ativar licenca</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="panel mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<div class="text-secondary small">Licenca</div>
|
||||
@@ -160,77 +161,123 @@
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section class="panel mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
|
||||
<div>
|
||||
<div class="text-secondary small">Monitor</div>
|
||||
<div class="fw-semibold">{{ "Em execucao" if monitor_status.running else "Parado" }}</div>
|
||||
</div>
|
||||
<div class="small text-secondary text-end">
|
||||
<div>Ultimo ciclo: {{ monitor_status.last_cycle_completed_at or "--" }}</div>
|
||||
<div>Ultima licenca: {{ monitor_status.last_license_message or "--" }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="main-container"></section>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<div class="modal fade" id="modalNodes" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content bg-dark text-light border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h2 class="modal-title fs-5">Configuracao</h2>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="zab-server" class="form-label">Zabbix Server</label>
|
||||
<input type="text" id="zab-server" class="form-control bg-black text-light border-secondary" value="{{ config.zabbix_server }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="zab-host" class="form-label">Hostname Zabbix</label>
|
||||
<input type="text" id="zab-host" class="form-control bg-black text-light border-secondary" value="{{ config.hostname_zabbix }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">Centrais</h3>
|
||||
<button class="btn btn-success btn-sm" onclick="addNodeRow()">Adicionar central</button>
|
||||
</div>
|
||||
<div id="nodes-list"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button class="btn btn-danger w-100" onclick="saveConfig()">Salvar configuracao</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="modalNodes" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal fade" id="modalAccount" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark text-light border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h2 class="modal-title fs-5">Configuracao</h2>
|
||||
<h2 class="modal-title fs-5">Conta</h2>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="zab-server" class="form-label">Zabbix Server</label>
|
||||
<input type="text" id="zab-server" class="form-control bg-black text-light border-secondary" value="{{ config.zabbix_server }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="zab-host" class="form-label">Hostname Zabbix</label>
|
||||
<input type="text" id="zab-host" class="form-control bg-black text-light border-secondary" value="{{ config.hostname_zabbix }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="account-username" class="form-label">Usuario</label>
|
||||
<input type="text" id="account-username" class="form-control bg-black text-light border-secondary" value="{{ config.web_user }}">
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">Centrais</h3>
|
||||
<button class="btn btn-success btn-sm" onclick="addNodeRow()">Adicionar central</button>
|
||||
<div class="mb-3">
|
||||
<label for="account-current-password" class="form-label">Senha atual</label>
|
||||
<input type="password" id="account-current-password" class="form-control bg-black text-light border-secondary">
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="account-new-password" class="form-label">Nova senha</label>
|
||||
<input type="password" id="account-new-password" class="form-control bg-black text-light border-secondary">
|
||||
<div class="form-text text-secondary">Use ao menos 8 caracteres.</div>
|
||||
</div>
|
||||
<div id="nodes-list"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button class="btn btn-danger w-100" onclick="saveConfig()">Salvar configuracao</button>
|
||||
<button class="btn btn-danger w-100" onclick="saveAccount()">Salvar credenciais</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/vendor.js') }}"></script>
|
||||
<script>
|
||||
const modalNodes = new bootstrap.Modal(document.getElementById("modalNodes"));
|
||||
const modalAccount = new bootstrap.Modal(document.getElementById("modalAccount"));
|
||||
const csrfToken = {{ csrf_token | tojson }};
|
||||
let nodesAtuais = {{ config.nodes | tojson }};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function showAlert(message, type = "danger") {
|
||||
const host = document.getElementById("alert-host");
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
host.innerHTML = `
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function showAlert(message, type = "danger") {
|
||||
const host = document.getElementById("alert-host");
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
host.innerHTML = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Fechar"></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function apiFetch(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (!headers.has("X-CSRF-Token")) {
|
||||
headers.set("X-CSRF-Token", csrfToken);
|
||||
}
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body.message || "Falha ao processar a requisicao.");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function renderNodeRow(node = { nome: "", ip: "", unit: 3 }) {
|
||||
}
|
||||
|
||||
function renderNodeRow(node = { nome: "", ip: "", unit: 3, port: 502 }) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "node-row";
|
||||
container.innerHTML = `
|
||||
@@ -239,7 +286,7 @@
|
||||
<label class="form-label small text-secondary">Nome</label>
|
||||
<input type="text" class="form-control form-control-sm n-nome" value="${escapeHtml(node.nome)}" placeholder="Nome">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-secondary">IP</label>
|
||||
<input type="text" class="form-control form-control-sm n-ip" value="${escapeHtml(node.ip)}" placeholder="192.168.0.10">
|
||||
</div>
|
||||
@@ -247,183 +294,216 @@
|
||||
<label class="form-label small text-secondary">Unit</label>
|
||||
<input type="number" class="form-control form-control-sm n-unit" value="${Number(node.unit || 3)}" min="0" max="255">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex gap-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-secondary">Porta</label>
|
||||
<input type="number" class="form-control form-control-sm n-port" value="${Number(node.port || 502)}" min="1" max="65535">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button class="btn btn-warning btn-sm flex-grow-1 scan-btn" type="button">Scan</button>
|
||||
<button class="btn btn-outline-danger btn-sm remove-btn" type="button">Remover</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
container.querySelector(".remove-btn").addEventListener("click", () => container.remove());
|
||||
container.querySelector(".scan-btn").addEventListener("click", () => scanSingleNode(container));
|
||||
return container;
|
||||
}
|
||||
|
||||
function openConfigModal() {
|
||||
const list = document.getElementById("nodes-list");
|
||||
list.innerHTML = "";
|
||||
nodesAtuais.forEach((node) => list.appendChild(renderNodeRow(node)));
|
||||
modalNodes.show();
|
||||
}
|
||||
|
||||
|
||||
container.querySelector(".remove-btn").addEventListener("click", () => container.remove());
|
||||
container.querySelector(".scan-btn").addEventListener("click", () => scanSingleNode(container));
|
||||
return container;
|
||||
}
|
||||
|
||||
function openConfigModal() {
|
||||
const list = document.getElementById("nodes-list");
|
||||
list.innerHTML = "";
|
||||
nodesAtuais.forEach((node) => list.appendChild(renderNodeRow(node)));
|
||||
modalNodes.show();
|
||||
}
|
||||
|
||||
function addNodeRow() {
|
||||
document.getElementById("nodes-list").appendChild(renderNodeRow());
|
||||
}
|
||||
|
||||
function collectNodes() {
|
||||
const nodes = [];
|
||||
document.querySelectorAll(".node-row").forEach((row) => {
|
||||
function openAccountModal() {
|
||||
document.getElementById("account-current-password").value = "";
|
||||
document.getElementById("account-new-password").value = "";
|
||||
modalAccount.show();
|
||||
}
|
||||
|
||||
function collectNodes() {
|
||||
const nodes = [];
|
||||
document.querySelectorAll(".node-row").forEach((row) => {
|
||||
const nome = row.querySelector(".n-nome").value.trim();
|
||||
const ip = row.querySelector(".n-ip").value.trim();
|
||||
const unit = Number(row.querySelector(".n-unit").value);
|
||||
const port = Number(row.querySelector(".n-port").value);
|
||||
if (nome && ip) {
|
||||
nodes.push({ nome, ip, unit });
|
||||
nodes.push({ nome, ip, unit, port });
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function activateLicense() {
|
||||
const serial = document.getElementById("serial-in").value.trim();
|
||||
try {
|
||||
await apiFetch("/api/license", {
|
||||
const data = await apiFetch("/api/license", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ serial })
|
||||
});
|
||||
showAlert(`${data.message}.`, "success");
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const payload = {
|
||||
zabbix_server: document.getElementById("zab-server").value.trim(),
|
||||
hostname_zabbix: document.getElementById("zab-host").value.trim(),
|
||||
nodes: collectNodes()
|
||||
};
|
||||
|
||||
try {
|
||||
await apiFetch("/api/nodes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSingleNode(row) {
|
||||
const node = {
|
||||
nome: row.querySelector(".n-nome").value.trim(),
|
||||
ip: row.querySelector(".n-ip").value.trim(),
|
||||
unit: Number(row.querySelector(".n-unit").value),
|
||||
port: Number(row.querySelector(".n-port").value)
|
||||
};
|
||||
|
||||
document.getElementById("loader").style.display = "flex";
|
||||
modalNodes.hide();
|
||||
|
||||
try {
|
||||
const data = await apiFetch("/api/rescan_node", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(node)
|
||||
});
|
||||
showAlert(`Scan concluido. ${data.count} pontos encontrados.`, "success");
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} catch (error) {
|
||||
document.getElementById("loader").style.display = "none";
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameDevice(key, currentLabel) {
|
||||
const nextLabel = prompt("Novo nome do dispositivo:", currentLabel);
|
||||
if (!nextLabel || nextLabel === currentLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiFetch("/api/rename", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ addr: key, label: nextLabel })
|
||||
});
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
async function saveAccount() {
|
||||
const payload = {
|
||||
zabbix_server: document.getElementById("zab-server").value.trim(),
|
||||
hostname_zabbix: document.getElementById("zab-host").value.trim(),
|
||||
nodes: collectNodes()
|
||||
username: document.getElementById("account-username").value.trim(),
|
||||
current_password: document.getElementById("account-current-password").value,
|
||||
new_password: document.getElementById("account-new-password").value
|
||||
};
|
||||
|
||||
try {
|
||||
await apiFetch("/api/nodes", {
|
||||
await apiFetch("/api/account", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
location.reload();
|
||||
showAlert("Credenciais atualizadas. Entre novamente com a nova conta.", "success");
|
||||
setTimeout(() => location.assign("/login"), 800);
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSingleNode(row) {
|
||||
const node = {
|
||||
nome: row.querySelector(".n-nome").value.trim(),
|
||||
ip: row.querySelector(".n-ip").value.trim(),
|
||||
unit: Number(row.querySelector(".n-unit").value)
|
||||
};
|
||||
|
||||
document.getElementById("loader").style.display = "flex";
|
||||
modalNodes.hide();
|
||||
|
||||
try {
|
||||
const data = await apiFetch("/api/rescan_node", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(node)
|
||||
});
|
||||
showAlert(`Scan concluido. ${data.count} pontos encontrados.`, "success");
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} catch (error) {
|
||||
document.getElementById("loader").style.display = "none";
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameDevice(key, currentLabel) {
|
||||
const nextLabel = prompt("Novo nome do dispositivo:", currentLabel);
|
||||
if (!nextLabel || nextLabel === currentLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiFetch("/api/rename", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ addr: key, label: nextLabel })
|
||||
});
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodes(data) {
|
||||
const main = document.getElementById("main-container");
|
||||
if (!main) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
data.sensores.forEach((sensor) => {
|
||||
if (!grouped[sensor.node]) {
|
||||
grouped[sensor.node] = [];
|
||||
}
|
||||
grouped[sensor.node].push(sensor);
|
||||
});
|
||||
|
||||
const html = Object.entries(grouped).map(([nodeName, sensors]) => {
|
||||
const nodeStatus = data.nodes_status[nodeName] || "Offline";
|
||||
const statusClass = nodeStatus === "Online" ? "bg-success" : "bg-danger";
|
||||
|
||||
const sensorCards = sensors.map((sensor) => `
|
||||
<div class="col-md-4 col-xl-3">
|
||||
<article class="sensor-card" style="border-left: 5px solid ${escapeHtml(sensor.color)}">
|
||||
<button class="edit-btn" type="button" data-key="${escapeHtml(sensor.key)}" data-label="${escapeHtml(sensor.label)}">Editar</button>
|
||||
<div class="sensor-label fw-semibold">${escapeHtml(sensor.label)}</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge-status" style="background: ${escapeHtml(sensor.color)}22; color: ${escapeHtml(sensor.color)}; border: 1px solid ${escapeHtml(sensor.color)};">
|
||||
${escapeHtml(sensor.status_text)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-secondary mt-3 text-end">Atualizado as ${escapeHtml(sensor.last)}</div>
|
||||
</article>
|
||||
</div>`).join("");
|
||||
|
||||
return `
|
||||
<section class="panel node-group">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||
<h2 class="node-title">${escapeHtml(nodeName)}</h2>
|
||||
<span class="badge ${statusClass}">${escapeHtml(nodeStatus)}</span>
|
||||
</div>
|
||||
<div class="row g-3">${sensorCards}</div>
|
||||
</section>`;
|
||||
}).join("");
|
||||
|
||||
main.innerHTML = html || `
|
||||
<section class="panel text-center text-secondary">
|
||||
Nenhum dispositivo encontrado ainda. Execute um scan em uma central configurada.
|
||||
</section>`;
|
||||
|
||||
document.querySelectorAll(".edit-btn").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
renameDevice(button.dataset.key, button.dataset.label);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
try {
|
||||
const data = await apiFetch("/api/data");
|
||||
renderNodes(data);
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
{% if licenciado %}
|
||||
setInterval(refreshData, 3000);
|
||||
refreshData();
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
function renderNodes(data) {
|
||||
const main = document.getElementById("main-container");
|
||||
if (!main) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped = {};
|
||||
data.sensores.forEach((sensor) => {
|
||||
if (!grouped[sensor.node]) {
|
||||
grouped[sensor.node] = [];
|
||||
}
|
||||
grouped[sensor.node].push(sensor);
|
||||
});
|
||||
|
||||
const html = Object.entries(grouped).map(([nodeName, sensors]) => {
|
||||
const nodeStatus = data.nodes_status[nodeName] || "Offline";
|
||||
const statusClass = nodeStatus === "Online" ? "bg-success" : "bg-danger";
|
||||
|
||||
const sensorCards = sensors.map((sensor) => `
|
||||
<div class="col-md-4 col-xl-3">
|
||||
<article class="sensor-card" style="border-left: 5px solid ${escapeHtml(sensor.color)}">
|
||||
<button class="edit-btn" type="button" data-key="${escapeHtml(sensor.key)}" data-label="${escapeHtml(sensor.label)}">Editar</button>
|
||||
<div class="sensor-label fw-semibold">${escapeHtml(sensor.label)}</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge-status" style="background: ${escapeHtml(sensor.color)}22; color: ${escapeHtml(sensor.color)}; border: 1px solid ${escapeHtml(sensor.color)};">
|
||||
${escapeHtml(sensor.status_text)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-secondary mt-3 text-end">Atualizado as ${escapeHtml(sensor.last)}</div>
|
||||
</article>
|
||||
</div>`).join("");
|
||||
|
||||
return `
|
||||
<section class="panel node-group">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||
<h2 class="node-title">${escapeHtml(nodeName)}</h2>
|
||||
<span class="badge ${statusClass}">${escapeHtml(nodeStatus)}</span>
|
||||
</div>
|
||||
<div class="row g-3">${sensorCards}</div>
|
||||
</section>`;
|
||||
}).join("");
|
||||
|
||||
main.innerHTML = html || `
|
||||
<section class="panel text-center text-secondary">
|
||||
Nenhum dispositivo encontrado ainda. Execute um scan em uma central configurada.
|
||||
</section>`;
|
||||
|
||||
document.querySelectorAll(".edit-btn").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
renameDevice(button.dataset.key, button.dataset.label);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
try {
|
||||
const data = await apiFetch("/api/data");
|
||||
renderNodes(data);
|
||||
} catch (error) {
|
||||
showAlert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
{% if licenciado %}
|
||||
setInterval(refreshData, 3000);
|
||||
refreshData();
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user