Add production installer and observability tooling

This commit is contained in:
2026-05-07 12:11:15 -03:00
parent a19ac9f00c
commit 5d92a6c99a
30 changed files with 3493 additions and 1015 deletions
+405 -325
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function showAlert(message, type = "danger") {
const host = document.getElementById("alert-host");
if (!host) {
return;
}
host.innerHTML = `
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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>
+39 -37
View File
@@ -1,55 +1,57 @@
<!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>Login | 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>
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
background:
radial-gradient(circle at top, rgba(255, 90, 61, 0.2), transparent 30%),
linear-gradient(180deg, #0b1117 0%, #07090c 100%);
color: #eef2f7;
font-family: "Segoe UI", sans-serif;
}
.login-card {
width: min(100%, 380px);
padding: 2rem;
border-radius: 18px;
background: rgba(18, 23, 29, 0.96);
border: 1px solid #2a333d;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.28);
}
</style>
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
background:
radial-gradient(circle at top, rgba(255, 90, 61, 0.2), transparent 30%),
linear-gradient(180deg, #0b1117 0%, #07090c 100%);
color: #eef2f7;
font-family: "Segoe UI", sans-serif;
}
.login-card {
width: min(100%, 380px);
padding: 2rem;
border-radius: 18px;
background: rgba(18, 23, 29, 0.96);
border: 1px solid #2a333d;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.28);
}
</style>
</head>
<body>
<section class="login-card">
<div class="text-uppercase small text-secondary mb-2">Voltec</div>
<h1 class="h4 mb-4">Acesso ao V-Fire Monitor</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="alert alert-warning py-2" role="alert">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<div class="text-uppercase small text-secondary mb-2">Voltec</div>
<h1 class="h4 mb-4">Acesso ao V-Fire Monitor</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="alert alert-warning py-2" role="alert">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="username" class="form-label">Usuario</label>
<input id="username" type="text" name="username" class="form-control bg-dark text-light border-secondary" required autofocus>
</div>
<div class="mb-4">
<label for="password" class="form-label">Senha</label>
<input id="password" type="password" name="password" class="form-control bg-dark text-light border-secondary" required>
</div>
<button type="submit" class="btn btn-danger w-100">Entrar</button>
<div class="mb-4">
<label for="password" class="form-label">Senha</label>
<input id="password" type="password" name="password" class="form-control bg-dark text-light border-secondary" required>
</div>
<button type="submit" class="btn btn-danger w-100">Entrar</button>
</form>
</section>
<script src="{{ url_for('static', filename='js/vendor.js') }}"></script>
</body>
</html>