Software Recriado atravez do codex

This commit is contained in:
2026-03-24 20:18:23 -03:00
parent 1c99bd9ea7
commit 54ad365b4d
12 changed files with 1489 additions and 475 deletions
+6
View File
@@ -0,0 +1,6 @@
VFM_DEFAULT_PASSWORD=admin
VFM_APP_SECRET=
VFM_LICENSE_MASTER_KEY=
VFM_ENV=
VFM_LOG_LEVEL=INFO
PORT=8080
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.pytest_cache/
config_nodes.json
mapa_dispositivos.json
license.key
app_secret.key
+87 -27
View File
@@ -1,37 +1,97 @@
# 🔥 V-Fire Monitor - Notifier Master (v1.0) # V-Fire Monitor
O **V-Fire Monitor** é uma solução robusta de monitoramento e gestão para centrais de alarme de incêndio da linha **Notifier (NFS-320, NFS-640, NFS-3030)**. Desenvolvido para a **Voltec**, o software atua como um gateway inteligente entre o protocolo Modbus/TCP das placas BACNET-GW-3 e o sistema de monitoramento Zabbix, oferecendo uma interface web intuitiva e um sistema de licenciamento proprietário. Aplicacao Flask para monitoramento de centrais Notifier via Modbus/TCP, com painel web, descoberta de pontos, integracao com Zabbix e licenciamento por hardware.
--- ## O que mudou nesta refatoracao
## 🚀 Funcionalidades Principais - Persistencia JSON com escrita atomica e migracao de configuracao legada.
- Login com hash de senha em vez de senha em texto puro no arquivo de configuracao.
- Chave secreta do Flask persistida localmente, sem valor fixo no codigo.
- Validacao de payloads da API e respostas de erro consistentes.
- Logging basico para diagnostico, no lugar de falhas silenciosas.
- Polling Modbus e discovery por blocos, reduzindo chamadas individuais.
- Frontend com tratamento de erro e textos corrigidos.
- Gerador de licenca com argumentos de linha de comando e validacao de data.
* **Monitoramento Multi-Node:** Gerenciamento de múltiplas centrais simultaneamente via IP/Modbus. ## Estrutura
* **Auto-Discovery Ultra:** Varredura automática de endereços Modbus (registradores 1-600) para identificação instantânea de dispositivos.
* **Integração Nativa Zabbix:** * Envio de **LLD (Low Level Discovery)** para autocadastro de itens.
* Monitoramento de **Status de Evento** (Alarme, Normal, Removido).
* Monitoramento de **Integridade de Comunicação** (Link de dados do ponto).
* **Gestão de Licenciamento:** * Proteção por **Hardware ID (HWID)** vinculada à máquina física.
* Controle de expiração por data com contador regressivo no painel.
* **Persistência de Dados:** Nomes personalizados de sensores são preservados no banco de dados local (`.json`), mesmo após novos scans.
* **Interface Web Profissional:** Painel em *Dark Mode* com indicadores visuais coloridos e status em tempo real.
--- - `monitor.py`: entrypoint simples da aplicacao Flask.
- `vfire_monitor/__init__.py`: app factory e bootstrap da aplicacao.
- `vfire_monitor/core.py`: regras de negocio, persistencia, licenca e engine de monitoramento.
- `vfire_monitor/routes.py`: rotas web e APIs.
- `generator.py`: gerador de serial de licenca.
- `templates/`: telas do login e dashboard.
- `tests/`: suite inicial de testes automatizados.
- `config_nodes.json`: configuracao persistida da aplicacao.
- `mapa_dispositivos.json`: mapa de dispositivos descobertos.
- `license.key`: serial instalado localmente.
- `app_secret.key`: segredo de sessao gerado automaticamente na primeira execucao.
## 📂 Estrutura do Projeto ## Requisitos
* `monitor.py`: Core do sistema. Gerencia o loop de leitura Modbus, o servidor Flask e o envio de métricas via Zabbix Sender. - Python 3.10+
* `generator.py`: Ferramenta administrativa da Voltec para gerar seriais de ativação criptografados. - Conectividade com as centrais via Modbus/TCP
* `templates/`: - Acesso ao servidor Zabbix, quando a integracao estiver habilitada
* `login.html`: Interface de autenticação segura.
* `index.html`: Dashboard principal com monitoramento de nós, sensores e licença.
* `config_nodes.json`: Arquivo de persistência para IPs das centrais e endereços do servidor Zabbix.
* `mapa_dispositivos.json`: Mapeamento detalhado de cada endereço Modbus e seu rótulo personalizado.
--- Instalacao:
## 🛠️ Instalação e Requisitos
### Dependências Python
```bash ```bash
pip install flask flask-login pyModbusTCP zabbix-utils cryptography pip install -r requirements.txt
```
## Variaveis de ambiente
Veja `.env.example`.
As principais:
- `VFM_DEFAULT_PASSWORD`: senha inicial do usuario `admin` na primeira carga do sistema.
- `VFM_APP_SECRET`: opcional, substitui o segredo salvo em `app_secret.key`.
- `VFM_LICENSE_MASTER_KEY`: chave mestre do licenciamento. Em producao, use esta variavel e remova a dependencia da chave legada.
- `VFM_ENV`: use `production` para obrigar `VFM_LICENSE_MASTER_KEY` no startup.
- `VFM_LOG_LEVEL`: nivel de log, por exemplo `INFO` ou `DEBUG`.
- `PORT`: porta HTTP da aplicacao.
## Execucao
```bash
python monitor.py
```
O sistema sobe em `http://0.0.0.0:8080` por padrao.
## Geracao de licenca
Modo interativo:
```bash
python generator.py
```
Modo por argumentos:
```bash
python generator.py --hwid "UUID-DO-CLIENTE" --cliente "Cliente" --expira 2026-12-31
```
## Observacoes operacionais
- A senha do painel fica armazenada como hash em `config_nodes.json`.
- Se existir configuracao antiga com `web_password`, ela e migrada automaticamente para `web_password_hash`.
- Em ambiente de desenvolvimento, o sistema ainda aceita a chave de licenca legada embutida para manter compatibilidade.
- Em ambiente de producao (`VFM_ENV=production`), `VFM_LICENSE_MASTER_KEY` passa a ser obrigatoria e o sistema falha no startup sem ela.
- Bootstrap continua sendo carregado via CDN. Se o ambiente nao tiver acesso externo, copie os assets localmente e ajuste os templates.
## Testes
Executar:
```bash
pytest
```
## Proximos passos recomendados
- Adicionar testes cobrindo polling Modbus e integracao com Zabbix com doubles dedicados.
- Separar configuracao e logging em modulos proprios se a aplicacao continuar crescendo.
- Trocar Bootstrap via CDN por assets locais se o ambiente alvo nao tiver acesso externo.
+58 -16
View File
@@ -1,16 +1,58 @@
from cryptography.fernet import Fernet import argparse
import json import json
import os
# CHAVE MESTRA - DEVE SER A MESMA NO MONITOR.PY from datetime import datetime
MASTER_KEY = b'vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4='
from cryptography.fernet import Fernet
def gerar_serial(hardware_id, cliente, data_expiracao):
f = Fernet(MASTER_KEY)
dados = {"h": hardware_id.strip(), "c": cliente, "e": data_expiracao} LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
return f.encrypt(json.dumps(dados).encode()).decode()
print("--- GERADOR DE LICENÇA VOLTEC ---") def load_master_key() -> bytes:
hwid = input("Hardware ID do cliente: ") env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
nome = input("Nome do Cliente: ") if env_key:
validade = input("Validade (AAAA-MM-DD): ") return env_key.encode("utf-8")
print(f"\nSERIAL:\n{gerar_serial(hwid, nome, validade)}\n") return LEGACY_LICENSE_KEY
def parse_args():
parser = argparse.ArgumentParser(description="Gerador de licenca do V-Fire Monitor")
parser.add_argument("--hwid", help="Hardware ID do cliente")
parser.add_argument("--cliente", help="Nome do cliente")
parser.add_argument("--expira", help="Data de expiracao no formato AAAA-MM-DD")
return parser.parse_args()
def ask_if_missing(value: str, prompt: str) -> str:
return value if value else input(prompt).strip()
def validate_expiration(date_text: str) -> str:
datetime.strptime(date_text, "%Y-%m-%d")
return date_text
def gerar_serial(hardware_id: str, cliente: str, data_expiracao: str) -> str:
fernet = Fernet(load_master_key())
payload = {"h": hardware_id.strip(), "c": cliente.strip(), "e": validate_expiration(data_expiracao)}
return fernet.encrypt(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("utf-8")
def main():
args = parse_args()
hardware_id = ask_if_missing(args.hwid, "Hardware ID do cliente: ")
cliente = ask_if_missing(args.cliente, "Nome do cliente: ")
expiracao = ask_if_missing(args.expira, "Validade (AAAA-MM-DD): ")
if not hardware_id:
raise SystemExit("Hardware ID obrigatorio.")
if not cliente:
raise SystemExit("Nome do cliente obrigatorio.")
print(gerar_serial(hardware_id, cliente, expiracao))
if __name__ == "__main__":
main()
+10 -241
View File
@@ -1,241 +1,10 @@
import time, json, os, threading, subprocess import os
from datetime import datetime
from flask import Flask, render_template, jsonify, request, redirect, url_for from vfire_monitor import create_app
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user
from pyModbusTCP.client import ModbusClient
from zabbix_utils import Sender, ItemValue app = create_app()
from cryptography.fernet import Fernet
app = Flask(__name__) if __name__ == "__main__":
app.secret_key = 'voltec_master_final_ultra_v6' app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8080")), debug=False)
# --- 1. DEFINIÇÕES GLOBAIS E PERSISTÊNCIA (Devem vir antes das rotas) ---
ARQUIVO_CONFIG = 'config_nodes.json'
ARQUIVO_MAPA = 'mapa_dispositivos.json'
ARQUIVO_LICENSA = 'license.key'
MASTER_KEY = b'vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4='
def carregar_dados(arquivo, padrao):
if os.path.exists(arquivo):
try:
with open(arquivo, 'r') as f: return json.load(f)
except: return padrao
return padrao
def salvar_dados(arquivo, dados):
with open(arquivo, 'w') as f: json.dump(dados, f, indent=4)
# Carregamos a config aqui para o login não dar erro
config_global = carregar_dados(ARQUIVO_CONFIG, {
"zabbix_server": "172.16.33.6",
"hostname_zabbix": "NFS-320",
"web_user": "admin",
"web_password": "admin",
"nodes": []
})
mapa_dispositivos = carregar_dados(ARQUIVO_MAPA, {})
dados_tempo_real = {}
status_nodes_web = {}
deve_reiniciar = False
MAPA_STATUS_BASE = {
5120: {"label": "NORMAL / OK", "color": "#28a745"},
3088: {"label": "INCIDENTE", "color": "#dc3545"},
3344: {"label": "INCIDENTE ACK", "color": "#007bff"},
13312: {"label": "REMOVIDO", "color": "#fd7e14"},
0: {"label": "VAZIO", "color": "#6c757d"}
}
# --- 2. GESTÃO DE ACESSO ---
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'
class User(UserMixin):
def __init__(self, id): self.id = id
@login_manager.user_loader
def load_user(user_id): return User(user_id)
# --- 3. AUXILIARES E LICENCIAMENTO (VERSÃO COM DIAS) ---
def get_status_info(valor):
lsb = valor & 0xFF
return MAPA_STATUS_BASE.get(valor, MAPA_STATUS_BASE.get(lsb, {"label": f"ST {valor}", "color": "#6c757d"}))
def get_hardware_id():
try:
if os.name == 'nt':
cmd = 'powershell "(Get-CimInstance Win32_ComputerSystemProduct).UUID.Trim()"'
return subprocess.check_output(cmd, shell=True).decode().strip()
return subprocess.check_output('cat /etc/machine-id', shell=True).decode().strip()[:32]
except: return "ID-ERRO-VOLTEC-001"
def verificar_licenca():
if not os.path.exists(ARQUIVO_LICENSA):
return False, "Licença ausente", get_hardware_id(), 0
try:
f = Fernet(MASTER_KEY)
with open(ARQUIVO_LICENSA, 'r') as file:
serial = file.read().strip()
dados = json.loads(f.decrypt(serial.encode()).decode())
if dados['h'].strip() != get_hardware_id().strip():
return False, "HWID Incompatível", get_hardware_id(), 0
expira = datetime.strptime(dados['e'], '%Y-%m-%d')
dias_restantes = (expira - datetime.now()).days
if dias_restantes < 0:
return False, "Expirada", get_hardware_id(), 0
return True, f"Licenciado para {dados['c']}", get_hardware_id(), dias_restantes
except:
return False, "Serial Inválido", get_hardware_id(), 0
# --- 4. MOTOR MODBUS E BACKGROUND ---
def realizar_discovery_node(node, mapa_atual):
client = ModbusClient(host=node['ip'], port=502, unit_id=int(node['unit']), auto_open=True, timeout=0.5)
mapa_node = {}
for addr in range(1, 601):
res = client.read_holding_registers(addr, 1)
if res and res[0] not in [0, 255, 65535]:
key = f"{node['ip']}_{node['unit']}_{addr}"
if key in mapa_atual: label = mapa_atual[key]['label']
else:
tipo = 'D' if addr < 160 else ('M' if addr < 350 else 'P')
label = f"{node['nome']}-{tipo}{addr:03}"
mapa_node[key] = {"label": label, "status": res[0], "node_nome": node['nome']}
client.close()
return mapa_node
def monitorar_background():
global dados_tempo_real, deve_reiniciar, config_global, mapa_dispositivos, status_nodes_web
while True:
licenciado, _, _, _ = verificar_licenca()
if not licenciado:
status_nodes_web = {"SISTEMA": "BLOQUEADO"}
time.sleep(5); continue
config_global = carregar_dados(ARQUIVO_CONFIG, config_global)
mapa_dispositivos = carregar_dados(ARQUIVO_MAPA, mapa_dispositivos)
sender = Sender(server=config_global['zabbix_server'])
hostname = config_global['hostname_zabbix']
# Discovery LLD (Zabbix re-cadastra nomes se houver mudança)
if mapa_dispositivos:
lld = {"data": []}
for k, info in mapa_dispositivos.items(): lld["data"].append({"{#PONTO_LABEL}": info["label"]})
for n in config_global['nodes']: lld["data"].append({"{#NODE_NOME}": n["nome"]})
try: sender.send([ItemValue(hostname, "notifier.discovery", json.dumps(lld))])
except: pass
while not deve_reiniciar:
metrics = []
for node in config_global['nodes']:
c = ModbusClient(host=node['ip'], port=502, unit_id=int(node['unit']), auto_open=True, timeout=1)
res = c.read_holding_registers(1, 1)
status_conn = 1 if res is not None else 0
status_nodes_web[node['nome']] = "Online" if status_conn == 1 else "Offline"
metrics.append(ItemValue(hostname, f'node.status[{node["nome"]}]', status_conn))
if status_conn == 1:
for key, info in list(mapa_dispositivos.items()):
if key.startswith(f"{node['ip']}_{node['unit']}_"):
addr = int(key.split('_')[-1])
r = c.read_holding_registers(addr, 1)
if r:
val = r[0]
dados_tempo_real[key] = {"label": info['label'], "status": val, "node": node['nome'], "last": time.strftime('%H:%M:%S')}
metrics.append(ItemValue(hostname, f'notifier.status[{info["label"]}]', val & 0xFF))
metrics.append(ItemValue(hostname, f'notifier.comm[{info["label"]}]', (val >> 8) & 0xFF))
c.close()
if metrics:
try: sender.send(metrics)
except: pass
time.sleep(2)
deve_reiniciar = False
# --- 5. ROTAS ---
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# Aqui o config_global agora existe!
if request.form.get('username') == config_global['web_user'] and \
request.form.get('password') == config_global['web_password']:
login_user(User(config_global['web_user']))
return redirect(url_for('index'))
return render_template('login.html')
@app.route('/')
@login_required
def index():
# Agora recebemos 4 valores da função
lic, msg, hwid, dias = verificar_licenca()
return render_template('index.html',
config=config_global,
licenciado=lic,
lic_msg=msg,
hwid=hwid,
dias_restantes=dias)
@app.route('/api/nodes', methods=['POST'])
@login_required
def api_nodes():
global deve_reiniciar, config_global, mapa_dispositivos
data = request.json
config_global.update({'nodes': data['nodes'], 'zabbix_server': data['zabbix_server'], 'hostname_zabbix': data['hostname_zabbix']})
salvar_dados(ARQUIVO_CONFIG, config_global)
ips_ativos = [f"{n['ip']}_{n['unit']}_" for n in data['nodes']]
mapa_dispositivos = {k: v for k, v in mapa_dispositivos.items() if any(k.startswith(p) for p in ips_ativos)}
salvar_dados(ARQUIVO_MAPA, mapa_dispositivos)
deve_reiniciar = True
return jsonify({"status": "success"})
@app.route('/api/rename', methods=['POST'])
@login_required
def api_rename():
global deve_reiniciar
data = request.json
if data['addr'] in mapa_dispositivos:
mapa_dispositivos[data['addr']]['label'] = data['label']
salvar_dados(ARQUIVO_MAPA, mapa_dispositivos)
deve_reiniciar = True # Força envio do novo nome ao Zabbix
return jsonify({"status": "success"})
return jsonify({"status": "error"}), 404
@app.route('/api/license', methods=['POST'])
def api_license():
global deve_reiniciar
with open(ARQUIVO_LICENSA, 'w') as f: f.write(request.json['serial'].strip())
deve_reiniciar = True
return jsonify({"status": "success"})
@app.route('/api/rescan_node', methods=['POST'])
@login_required
def api_rescan():
global deve_reiniciar, mapa_dispositivos
node_data = request.json
res_mapa = realizar_discovery_node(node_data, mapa_dispositivos)
prefixo = f"{node_data['ip']}_{node_data['unit']}_"
mapa_dispositivos = {k: v for k, v in mapa_dispositivos.items() if not k.startswith(prefixo)}
mapa_dispositivos.update(res_mapa)
salvar_dados(ARQUIVO_MAPA, mapa_dispositivos)
deve_reiniciar = True
return jsonify({"status": "success"})
@app.route('/api/data')
@login_required
def api_data():
lista = []
for key, info in mapa_dispositivos.items():
tr = dados_tempo_real.get(key, {"status": 0, "last": "--:--:--", "node": info.get('node_nome', '...')})
st_info = get_status_info(tr['status'])
lista.append({"key": key, "label": info['label'], "node": tr.get('node', '...'), "status_text": st_info['label'], "color": st_info['color'], "last": tr['last']})
return jsonify({"sensores": lista, "nodes_status": status_nodes_web})
@app.route('/logout')
def logout(): logout_user(); return redirect(url_for('login'))
if __name__ == "__main__":
threading.Thread(target=monitorar_background, daemon=True).start()
app.run(host='0.0.0.0', port=8080)
+6
View File
@@ -0,0 +1,6 @@
Flask>=3.0,<4.0
Flask-Login>=0.6,<1.0
pyModbusTCP>=0.3,<1.0
zabbix-utils>=2.0,<3.0
cryptography>=42.0,<45.0
pytest>=8.0,<9.0
+429 -169
View File
@@ -1,169 +1,429 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="pt-br"> <html lang="pt-BR">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Notifier Master - Voltec</title> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <title>V-Fire Monitor</title>
<style> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
body { background: #0b0b0b; color: #e0e0e0; font-family: 'Segoe UI', sans-serif; } <style>
.header-panel { background: #151515; padding: 20px; border-bottom: 2px solid #222; margin-bottom: 30px; } :root {
.node-group { background: #111; border: 1px solid #222; border-radius: 15px; padding: 20px; margin-bottom: 30px; } --bg: #0c0f12;
.node-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #333; margin-bottom: 20px; padding-bottom: 10px; } --panel: #12171d;
.node-title { border-left: 4px solid #d9534f; padding-left: 15px; color: #fff; font-weight: bold; margin: 0; } --panel-soft: #1a2129;
.node-status-badge { font-size: 0.7em; padding: 4px 10px; border-radius: 20px; text-transform: uppercase; font-weight: bold; } --border: #2a333d;
.card-sensor { background: #1a1a1a; border-radius: 12px; margin-bottom: 15px; padding: 15px; border-left: 5px solid #333; position: relative; } --text: #edf2f7;
.badge-status { font-size: 0.75em; padding: 4px 10px; border-radius: 10px; font-weight: bold; } --muted: #94a3b8;
.edit-btn { position: absolute; top: 8px; right: 8px; cursor: pointer; opacity: 0.3; } --accent: #ff5a3d;
.edit-btn:hover { opacity: 1; } }
.node-row { background: #1a1a1a; padding: 15px; border-radius: 10px; margin-bottom: 10px; border: 1px solid #333; }
.loading-overlay { position: fixed; top:0; left:0; width:100%; height:100%; background: rgba(0,0,0,0.9); display:none; z-index:9999; justify-content:center; align-items:center; flex-direction:column; } body {
</style> min-height: 100vh;
</head> background:
<body> radial-gradient(circle at top, rgba(255, 90, 61, 0.15), transparent 30%),
linear-gradient(180deg, #091017 0%, var(--bg) 45%, #07090c 100%);
<div id="loader" class="loading-overlay"> color: var(--text);
<div class="spinner-border text-danger"></div> font-family: "Segoe UI", sans-serif;
<h5 class="mt-3 text-white">Varrendo central (1-600)...</h5> }
</div>
.app-header,
<div class="header-panel shadow-sm"> .panel,
<div class="container d-flex justify-content-between align-items-center"> .sensor-card,
<h2 class="mb-0 text-danger fw-bold">🔥 Notifier Voltec</h2> .node-row {
<div class="btn-group"> background: rgba(18, 23, 29, 0.95);
<button class="btn btn-dark btn-sm" onclick="abrirModalNodes()">⚙️ Config</button> border: 1px solid var(--border);
<a href="/logout" class="btn btn-outline-danger btn-sm">Sair</a> box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
</div> }
</div>
</div> .app-header {
backdrop-filter: blur(8px);
<div class="container"> }
{% if not licenciado %}
<div class="row justify-content-center mt-5"> .panel {
<div class="col-md-6 bg-dark p-5 rounded border border-danger text-center shadow-lg"> border-radius: 18px;
<h2 class="text-danger">🔒 Sistema Bloqueado</h2> padding: 1rem 1.25rem;
<p class="text-muted">ID: <code class="bg-black p-2 d-block my-2">{{ hwid }}</code></p> }
<textarea id="serial-in" class="form-control bg-black text-white mb-3" rows="3" placeholder="Serial"></textarea>
<button class="btn btn-danger w-100" onclick="ativar()">ATIVAR</button> .node-group {
</div> margin-bottom: 1.5rem;
</div> }
{% else %}
<div class="d-flex justify-content-between align-items-center mb-4 p-2 bg-dark rounded border border-secondary" style="gap: 15px; background-color: #151515 !important;"> .node-title {
<div class="flex-shrink-0"> margin: 0;
<span class="badge {% if dias_restantes < 15 %}bg-danger text-white{% else %}bg-secondary text-white{% endif %} p-2 shadow-sm"> padding-left: 0.9rem;
⏳ {{ dias_restantes }} dias restantes border-left: 4px solid var(--accent);
</span> font-size: 1.05rem;
</div> font-weight: 700;
}
<div class="flex-grow-1 text-end text-truncate" style="max-width: 400px;">
<span class="text-white fw-bold small" title="{{ lic_msg }}" style="letter-spacing: 0.5px; opacity: 0.9;"> .sensor-card {
{{ lic_msg }} height: 100%;
</span> border-radius: 14px;
</div> padding: 1rem;
</div> position: relative;
transition: transform 0.16s ease, border-color 0.16s ease;
<div id="main-container"></div> }
{% endif %}
</div> .sensor-card:hover {
transform: translateY(-2px);
<div class="modal fade" id="modalNodes" tabindex="-1"> }
<div class="modal-dialog modal-lg">
<div class="modal-content bg-dark border-secondary"> .sensor-label {
<div class="modal-header border-secondary"><h5 class="modal-title">Config</h5><button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button></div> padding-right: 2rem;
<div class="modal-body"> word-break: break-word;
<div class="row mb-3"> }
<div class="col-md-6"><label>Zabbix Server</label><input type="text" id="zab-server" class="form-control bg-black text-white border-secondary" value="{{config.zabbix_server}}"></div>
<div class="col-md-6"><label>Hostname Zabbix</label><input type="text" id="zab-host" class="form-control bg-black text-white border-secondary" value="{{config.hostname_zabbix}}"></div> .edit-btn {
</div> position: absolute;
<div class="d-flex justify-content-between mb-2"><h6>Nodes</h6><button class="btn btn-success btn-sm" onclick="addNodeRow()">+ Central</button></div> top: 0.75rem;
<div id="nodes-list"></div> right: 0.75rem;
</div> border: 0;
<div class="modal-footer border-secondary"><button class="btn btn-danger w-100" onclick="salvarTudo()">Salvar e Reiniciar</button></div> background: transparent;
</div> color: var(--muted);
</div> }
</div>
.edit-btn:hover {
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> color: var(--text);
<script> }
const modalNodes = new bootstrap.Modal(document.getElementById('modalNodes'));
let nodesAtuais = {{ config.nodes | tojson }}; .badge-status {
display: inline-flex;
async function ativar() { align-items: center;
const serial = document.getElementById('serial-in').value.trim(); gap: 0.35rem;
const res = await fetch('/api/license', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serial}) }); padding: 0.35rem 0.7rem;
if(res.ok) { alert("Sucesso!"); location.reload(); } else { alert("Erro!"); } border-radius: 999px;
} font-size: 0.8rem;
font-weight: 700;
function abrirModalNodes() { }
const list = document.getElementById('nodes-list');
list.innerHTML = ''; .node-row {
nodesAtuais.forEach((node, idx) => { border-radius: 12px;
const div = document.createElement('div'); padding: 0.9rem;
div.className = 'node-row'; margin-bottom: 0.75rem;
div.innerHTML = ` }
<div class="row g-2 align-items-end">
<div class="col-md-3"><input type="text" class="form-control form-control-sm n-nome" value="${node.nome}"></div> .loading-overlay {
<div class="col-md-4"><input type="text" class="form-control form-control-sm n-ip" value="${node.ip}"></div> position: fixed;
<div class="col-md-2"><input type="number" class="form-control form-control-sm n-unit" value="${node.unit}"></div> inset: 0;
<div class="col-md-3 d-flex gap-1"><button class="btn btn-warning btn-sm flex-grow-1" onclick="scanIndividual(${idx})">Scan</button><button class="btn btn-danger btn-sm" onclick="this.closest('.node-row').remove()">❌</button></div> display: none;
</div>`; align-items: center;
list.appendChild(div); justify-content: center;
}); background: rgba(7, 9, 12, 0.85);
modalNodes.show(); z-index: 9999;
} }
</style>
function addNodeRow() { </head>
const div = document.createElement('div'); <body>
div.className = 'node-row'; <div id="loader" class="loading-overlay">
div.innerHTML = `<div class="row g-2 align-items-end"><div class="col-md-3"><input type="text" class="form-control form-control-sm n-nome" placeholder="Nome"></div><div class="col-md-4"><input type="text" class="form-control form-control-sm n-ip" placeholder="IP"></div><div class="col-md-2"><input type="number" class="form-control form-control-sm n-unit" value="3"></div><div class="col-md-3"><button class="btn btn-danger btn-sm w-100" onclick="this.closest('.node-row').remove()">❌</button></div></div>`; <div class="text-center">
document.getElementById('nodes-list').appendChild(div); <div class="spinner-border text-danger"></div>
} <p class="mt-3 mb-0 text-light">Executando varredura Modbus...</p>
</div>
async function salvarTudo() { </div>
const nodes = [];
document.querySelectorAll('.node-row').forEach(r => { <header class="app-header border-bottom sticky-top">
const n = r.querySelector('.n-nome').value; <div class="container py-3 d-flex flex-wrap justify-content-between align-items-center gap-3">
const i = r.querySelector('.n-ip').value; <div>
const u = r.querySelector('.n-unit').value; <div class="text-uppercase small text-secondary">Voltec</div>
if(n && i) nodes.push({ nome: n, ip: i, unit: parseInt(u) }); <h1 class="h3 mb-0">V-Fire Monitor</h1>
}); </div>
const res = await fetch('/api/nodes', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ zabbix_server: document.getElementById('zab-server').value, hostname_zabbix: document.getElementById('zab-host').value, nodes: nodes }) }); <div class="d-flex gap-2">
if(res.ok) location.reload(); <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>
async function scanIndividual(idx) { </div>
const rows = document.querySelectorAll('.node-row'); </header>
const node = { nome: rows[idx].querySelector('.n-nome').value, ip: rows[idx].querySelector('.n-ip').value, unit: parseInt(rows[idx].querySelector('.n-unit').value) };
modalNodes.hide(); <main class="container py-4">
document.getElementById('loader').style.display = 'flex'; <div id="alert-host"></div>
await fetch('/api/rescan_node', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(node) });
location.reload(); {% if not licenciado %}
} <section class="row justify-content-center">
<div class="col-lg-6">
async function renomear(key, atual) { <div class="panel text-center">
const novo = prompt("Novo nome:", atual); <h2 class="h4 text-danger">Sistema bloqueado</h2>
if(novo && novo !== atual) { await fetch('/api/rename', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({addr: key, label: novo}) }); atualizar(); } <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>
async function atualizar() { <textarea id="serial-in" class="form-control bg-dark text-light border-secondary mb-3" rows="4" placeholder="Cole o serial de licenca"></textarea>
try { <button class="btn btn-danger w-100" onclick="activateLicense()">Ativar licenca</button>
const res = await fetch('/api/data'); </div>
const data = await res.json(); </div>
const main = document.getElementById('main-container'); </section>
if(!main) return; {% else %}
const nodesMap = {}; <section class="panel mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
data.sensores.forEach(s => { if(!nodesMap[s.node]) nodesMap[s.node] = []; nodesMap[s.node].push(s); }); <div>
main.innerHTML = ''; <div class="text-secondary small">Licenca</div>
for(const n in nodesMap) { <div class="fw-semibold">{{ lic_msg }}</div>
const st = data.nodes_status[n] || "Offline"; </div>
let h = `<div class="node-group"><div class="node-header"><h4 class="node-title">📍 ${n}</h4><span class="node-status-badge ${st==='Online'?'bg-success':'bg-danger'}">${st}</span></div><div class="row">`; <span class="badge {% if dias_restantes < 15 %}bg-danger{% else %}bg-secondary{% endif %} fs-6">
nodesMap[n].forEach(s => { {{ dias_restantes }} dias restantes
h += `<div class="col-md-3"><div class="card-sensor" style="border-left-color: ${s.color}"><span class="edit-btn" onclick="renomear('${s.key}', '${s.label}')">✏️</span><div class="fw-bold text-white small">${s.label}</div><div class="mt-2"><span class="badge-status" style="background: ${s.color}22; color: ${s.color}; border: 1px solid ${s.color}">${s.status_text}</span></div><div class="time-upd text-end">⏱ ${s.last}</div></div></div>`; </span>
}); </section>
main.innerHTML += h + `</div></div>`;
} <section id="main-container"></section>
} catch(e){} {% endif %}
} </main>
setInterval(atualizar, 3000); atualizar();
</script> <div class="modal fade" id="modalNodes" tabindex="-1" aria-hidden="true">
</body> <div class="modal-dialog modal-lg modal-dialog-scrollable">
</html> <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>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const modalNodes = new bootstrap.Modal(document.getElementById("modalNodes"));
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 = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>`;
}
async function apiFetch(url, options = {}) {
const response = await fetch(url, options);
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 }) {
const container = document.createElement("div");
container.className = "node-row";
container.innerHTML = `
<div class="row g-2 align-items-end">
<div class="col-md-3">
<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">
<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>
<div class="col-md-2">
<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">
<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();
}
function addNodeRow() {
document.getElementById("nodes-list").appendChild(renderNodeRow());
}
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);
if (nome && ip) {
nodes.push({ nome, ip, unit });
}
});
return nodes;
}
async function activateLicense() {
const serial = document.getElementById("serial-in").value.trim();
try {
await apiFetch("/api/license", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serial })
});
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)
};
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>
+55 -22
View File
@@ -1,22 +1,55 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="pt-BR">
<head> <head>
<title>Login - Notifier</title> <meta charset="UTF-8">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <meta name="viewport" content="width=device-width, initial-scale=1">
<style> <title>Login | V-Fire Monitor</title>
body { background: #0f0f0f; color: white; display: flex; align-items: center; height: 100vh; justify-content: center; } <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
.login-card { background: #1a1a1a; padding: 30px; border-radius: 15px; border: 1px solid #333; width: 320px; } <style>
</style> body {
</head> min-height: 100vh;
<body> margin: 0;
<div class="login-card shadow"> display: grid;
<h4 class="text-center text-danger mb-4">Acesso Notifier</h4> place-items: center;
<form method="POST"> background:
<div class="mb-3"><label>Usuário</label><input type="text" name="username" class="form-control bg-dark text-white border-secondary" required></div> radial-gradient(circle at top, rgba(255, 90, 61, 0.2), transparent 30%),
<div class="mb-3"><label>Senha</label><input type="password" name="password" class="form-control bg-dark text-white border-secondary" required></div> linear-gradient(180deg, #0b1117 0%, #07090c 100%);
<button type="submit" class="btn btn-danger w-100">Entrar</button> color: #eef2f7;
{% with messages = get_flashed_messages() %}{% if messages %}<div class="text-warning mt-2 small text-center">{{ messages[0] }}</div>{% endif %}{% endwith %} font-family: "Segoe UI", sans-serif;
</form> }
</div>
</body> .login-card {
</html> 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 %}
<form method="POST" novalidate>
<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>
</form>
</section>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
import json
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from werkzeug.security import check_password_hash
from vfire_monitor import create_app
from vfire_monitor.core import (
LicenseService,
contiguous_ranges,
load_license_key,
normalize_config,
validate_node_payload,
)
def test_validate_node_payload_normalizes_fields():
node = validate_node_payload({"nome": " Central A ", "ip": " 10.0.0.1 ", "unit": "3"})
assert node == {"nome": "Central A", "ip": "10.0.0.1", "unit": 3}
def test_validate_node_payload_rejects_invalid_unit():
with pytest.raises(ValueError):
validate_node_payload({"nome": "Central A", "ip": "10.0.0.1", "unit": 999})
def test_normalize_config_migrates_legacy_password():
config = normalize_config(
{
"web_user": "admin",
"web_password": "segredo",
"nodes": [{"nome": "Node 1", "ip": "1.1.1.1", "unit": 1}],
},
default_password="admin",
)
assert "web_password" not in config
assert check_password_hash(config["web_password_hash"], "segredo")
assert config["nodes"][0]["nome"] == "Node 1"
def test_contiguous_ranges_splits_large_blocks():
ranges = contiguous_ranges(list(range(1, 125)))
assert ranges == [(1, 120), (121, 124)]
def test_license_service_validates_serial(tmp_path, monkeypatch):
monkeypatch.setattr("vfire_monitor.core.get_hardware_id", lambda: "HW-123")
key = Fernet.generate_key()
payload = {"h": "HW-123", "c": "Cliente", "e": "2099-01-01"}
serial = Fernet(key).encrypt(json.dumps(payload).encode("utf-8")).decode("utf-8")
license_path = tmp_path / "license.key"
license_path.write_text(serial, encoding="utf-8")
service = LicenseService(license_path, key)
status = service.verify()
assert status.valid is True
assert "Cliente" in status.message
def test_load_license_key_requires_env_in_production(monkeypatch):
monkeypatch.delenv("VFM_LICENSE_MASTER_KEY", raising=False)
with pytest.raises(RuntimeError):
load_license_key(require_env=True)
@pytest.fixture
def app(tmp_path, monkeypatch):
monkeypatch.setenv("VFM_DEFAULT_PASSWORD", "admin")
monkeypatch.setenv("VFM_LICENSE_MASTER_KEY", Fernet.generate_key().decode("utf-8"))
application = create_app(base_dir=tmp_path, start_monitor=False)
application.config["TESTING"] = True
runtime_state = application.extensions["runtime_state"]
runtime_state.config["web_user"] = "admin"
runtime_state.config["web_password_hash"] = normalize_config({}, "admin")["web_password_hash"]
runtime_state.save_config()
return application
@pytest.fixture
def client(app):
return app.test_client()
def login(client):
return client.post("/login", data={"username": "admin", "password": "admin"}, follow_redirects=False)
def test_login_success(client):
response = login(client)
assert response.status_code == 302
def test_api_nodes_requires_auth(client):
response = client.post("/api/nodes", json={})
assert response.status_code == 302
def test_api_nodes_updates_configuration(client, app):
login(client)
response = client.post(
"/api/nodes",
json={
"zabbix_server": "127.0.0.1",
"hostname_zabbix": "HOST-01",
"nodes": [{"nome": "Central A", "ip": "10.0.0.5", "unit": 3}],
},
)
assert response.status_code == 200
assert response.get_json()["status"] == "success"
config_path = Path(app.extensions["runtime_state"].config_store.path)
config = json.loads(config_path.read_text(encoding="utf-8"))
assert config["hostname_zabbix"] == "HOST-01"
assert config["nodes"][0]["nome"] == "Central A"
+68
View File
@@ -0,0 +1,68 @@
import logging
import os
from pathlib import Path
from flask import Flask
from flask_login import LoginManager
from .core import (
LicenseService,
MonitorEngine,
RuntimeState,
User,
ensure_secret_key,
load_license_key,
)
from .routes import register_routes
def configure_logging() -> None:
logging.basicConfig(
level=os.getenv("VFM_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s [%(levelname)s] %(message)s",
)
def is_production_mode() -> bool:
value = os.getenv("VFM_ENV", "").strip().lower()
return value in {"prod", "production"}
def create_app(base_dir: str | Path | None = None, start_monitor: bool = True) -> Flask:
configure_logging()
resolved_base_dir = Path(base_dir) if base_dir else Path(__file__).resolve().parent.parent
secret_path = resolved_base_dir / "app_secret.key"
default_password = os.getenv("VFM_DEFAULT_PASSWORD", "admin")
app = Flask(
__name__,
template_folder=str(resolved_base_dir / "templates"),
)
app.secret_key = os.getenv("VFM_APP_SECRET") or ensure_secret_key(secret_path)
runtime_state = RuntimeState(resolved_base_dir, default_password)
license_service = LicenseService(
resolved_base_dir / "license.key",
load_license_key(require_env=is_production_mode()),
)
app.extensions["runtime_state"] = runtime_state
app.extensions["license_service"] = license_service
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = "login"
@login_manager.user_loader
def load_user(user_id):
return User(user_id)
register_routes(app)
if start_monitor:
monitor_engine = MonitorEngine(runtime_state, license_service)
app.extensions["monitor_engine"] = monitor_engine
monitor_engine.start()
return app
+494
View File
@@ -0,0 +1,494 @@
import json
import logging
import os
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple
from cryptography.fernet import Fernet, InvalidToken
from pyModbusTCP.client import ModbusClient
from werkzeug.security import check_password_hash, generate_password_hash
from zabbix_utils import ItemValue, Sender
DEFAULT_CONFIG_TEMPLATE = {
"zabbix_server": "172.16.33.6",
"hostname_zabbix": "NFS-320",
"web_user": "admin",
"nodes": [],
}
LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
MAX_DISCOVERY_ADDR = 600
MODBUS_BLOCK_SIZE = 120
DISCOVERY_IGNORED_VALUES = {0, 255, 65535}
POLL_INTERVAL_SECONDS = 2
RETRY_INTERVAL_SECONDS = 5
MAPA_STATUS_BASE = {
5120: {"label": "NORMAL / OK", "color": "#28a745"},
3088: {"label": "INCIDENTE", "color": "#dc3545"},
3344: {"label": "INCIDENTE ACK", "color": "#007bff"},
13312: {"label": "REMOVIDO", "color": "#fd7e14"},
0: {"label": "VAZIO", "color": "#6c757d"},
}
LOGGER = logging.getLogger("v-fire-monitor")
class User:
def __init__(self, user_id: str):
self.id = user_id
@property
def is_authenticated(self) -> bool:
return True
@property
def is_active(self) -> bool:
return True
@property
def is_anonymous(self) -> bool:
return False
def get_id(self) -> str:
return self.id
class JsonStore:
def __init__(self, path: Path):
self.path = path
def load(self, default):
if not self.path.exists():
return default
try:
with self.path.open("r", encoding="utf-8") as handle:
return json.load(handle)
except (json.JSONDecodeError, OSError) as exc:
LOGGER.warning("Falha ao carregar %s: %s", self.path.name, exc)
return default
def save(self, data) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temp_path = self.path.with_suffix(f"{self.path.suffix}.tmp")
with temp_path.open("w", encoding="utf-8") as handle:
json.dump(data, handle, indent=4, ensure_ascii=False)
temp_path.replace(self.path)
def build_default_config(default_password: str) -> dict:
config = dict(DEFAULT_CONFIG_TEMPLATE)
config["web_password_hash"] = generate_password_hash(default_password)
return config
def ensure_secret_key(secret_path: Path) -> str:
if secret_path.exists():
return secret_path.read_text(encoding="utf-8").strip()
secret = Fernet.generate_key().decode("ascii")
secret_path.write_text(secret, encoding="utf-8")
LOGGER.info("Chave de sessão criada em %s", secret_path.name)
return secret
def normalize_config(data: dict, default_password: str) -> dict:
config = build_default_config(default_password)
config.update(data or {})
if config.get("web_password") and not config.get("web_password_hash"):
config["web_password_hash"] = generate_password_hash(config["web_password"])
config.pop("web_password", None)
normalized_nodes = []
for raw_node in config.get("nodes", []):
try:
normalized_nodes.append(validate_node_payload(raw_node))
except ValueError as exc:
LOGGER.warning("Node ignorado durante normalizacao: %s", exc)
config["nodes"] = normalized_nodes
return config
def load_license_key(require_env: bool) -> bytes:
env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
if env_key:
return env_key.encode("utf-8")
if require_env:
raise RuntimeError(
"VFM_LICENSE_MASTER_KEY e obrigatoria em producao. Defina a variavel de ambiente antes de iniciar o sistema."
)
LOGGER.warning(
"VFM_LICENSE_MASTER_KEY nao definido. Usando chave legada embutida; mova a chave para variavel de ambiente."
)
return LEGACY_LICENSE_KEY
def get_status_info(value: int) -> dict:
low_byte = value & 0xFF
return MAPA_STATUS_BASE.get(
value,
MAPA_STATUS_BASE.get(low_byte, {"label": f"ST {value}", "color": "#6c757d"}),
)
def get_hardware_id() -> str:
try:
if os.name == "nt":
import subprocess
output = subprocess.check_output(
[
"powershell",
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystemProduct).UUID.Trim()",
],
text=True,
)
return output.strip()
machine_id = Path("/etc/machine-id")
if machine_id.exists():
return machine_id.read_text(encoding="utf-8").strip()[:32]
except Exception as exc: # pragma: no cover
LOGGER.warning("Nao foi possivel obter hardware id: %s", exc)
return "ID-ERRO-VOLTEC-001"
def validate_node_payload(data: dict) -> dict:
if not isinstance(data, dict):
raise ValueError("Formato de node invalido.")
nome = str(data.get("nome", "")).strip()
ip = str(data.get("ip", "")).strip()
if not nome:
raise ValueError("Nome do node e obrigatorio.")
if not ip:
raise ValueError("IP do node e obrigatorio.")
try:
unit = int(data.get("unit"))
except (TypeError, ValueError):
raise ValueError(f"Unit invalido para node {nome}.") from None
if unit < 0 or unit > 255:
raise ValueError(f"Unit fora do intervalo para node {nome}.")
return {"nome": nome, "ip": ip, "unit": unit}
def contiguous_ranges(addresses: List[int]) -> List[Tuple[int, int]]:
if not addresses:
return []
sorted_addresses = sorted(set(addresses))
ranges: List[Tuple[int, int]] = []
start = sorted_addresses[0]
previous = start
for address in sorted_addresses[1:]:
if address == previous + 1 and (address - start + 1) <= MODBUS_BLOCK_SIZE:
previous = address
continue
ranges.append((start, previous))
start = previous = address
ranges.append((start, previous))
return ranges
def discover_node(node: dict, current_map: dict) -> dict:
client = ModbusClient(
host=node["ip"],
port=502,
unit_id=node["unit"],
auto_open=True,
timeout=1,
)
discovered = {}
try:
for start in range(1, MAX_DISCOVERY_ADDR + 1, MODBUS_BLOCK_SIZE):
count = min(MODBUS_BLOCK_SIZE, MAX_DISCOVERY_ADDR - start + 1)
registers = client.read_holding_registers(start, count)
if not registers:
continue
for offset, value in enumerate(registers):
if value in DISCOVERY_IGNORED_VALUES:
continue
address = start + offset
key = f"{node['ip']}_{node['unit']}_{address}"
existing = current_map.get(key, {})
if existing.get("label"):
label = existing["label"]
else:
device_type = "D" if address < 160 else ("M" if address < 350 else "P")
label = f"{node['nome']}-{device_type}{address:03}"
discovered[key] = {
"label": label,
"status": value,
"node_nome": node["nome"],
}
finally:
client.close()
return discovered
@dataclass
class LicenseStatus:
valid: bool
message: str
hardware_id: str
days_remaining: int
class LicenseService:
def __init__(self, license_path: Path, master_key: bytes):
self.license_path = license_path
self.fernet = Fernet(master_key)
def verify(self) -> LicenseStatus:
hardware_id = get_hardware_id()
if not self.license_path.exists():
return LicenseStatus(False, "Licenca ausente", hardware_id, 0)
try:
serial = self.license_path.read_text(encoding="utf-8").strip()
payload = json.loads(self.fernet.decrypt(serial.encode("utf-8")).decode("utf-8"))
licensed_hwid = str(payload["h"]).strip()
if licensed_hwid != hardware_id.strip():
return LicenseStatus(False, "HWID incompativel", hardware_id, 0)
expires_at = datetime.strptime(payload["e"], "%Y-%m-%d")
days_remaining = (expires_at.date() - datetime.now().date()).days
if days_remaining < 0:
return LicenseStatus(False, "Licenca expirada", hardware_id, 0)
return LicenseStatus(True, f"Licenciado para {payload['c']}", hardware_id, days_remaining)
except (InvalidToken, KeyError, ValueError, OSError, json.JSONDecodeError) as exc:
LOGGER.warning("Falha ao verificar licenca: %s", exc)
return LicenseStatus(False, "Serial invalido", hardware_id, 0)
def save(self, serial: str) -> None:
cleaned = serial.strip()
if not cleaned:
raise ValueError("Serial vazio.")
self.license_path.write_text(cleaned, encoding="utf-8")
class RuntimeState:
def __init__(self, base_dir: Path, default_password: str):
self.lock = threading.RLock()
self.config_store = JsonStore(base_dir / "config_nodes.json")
self.map_store = JsonStore(base_dir / "mapa_dispositivos.json")
self.config = normalize_config(self.config_store.load(build_default_config(default_password)), default_password)
self.mapa_dispositivos = self.map_store.load({})
self.dados_tempo_real: Dict[str, dict] = {}
self.status_nodes_web: Dict[str, str] = {}
self.restart_event = threading.Event()
self.stop_event = threading.Event()
self.save_config()
def snapshot(self) -> Tuple[dict, dict, dict, dict]:
with self.lock:
return (
dict(self.config),
dict(self.mapa_dispositivos),
dict(self.dados_tempo_real),
dict(self.status_nodes_web),
)
def save_config(self) -> None:
with self.lock:
self.config_store.save(self.config)
def trigger_restart(self) -> None:
self.restart_event.set()
def consume_restart(self) -> bool:
if self.restart_event.is_set():
self.restart_event.clear()
return True
return False
def authenticate(self, username: str, password: str) -> bool:
with self.lock:
if username != self.config["web_user"]:
return False
return check_password_hash(self.config["web_password_hash"], password)
def update_config(self, zabbix_server: str, hostname_zabbix: str, nodes: List[dict]) -> None:
with self.lock:
self.config["zabbix_server"] = zabbix_server.strip()
self.config["hostname_zabbix"] = hostname_zabbix.strip()
self.config["nodes"] = nodes
active_prefixes = [f"{node['ip']}_{node['unit']}_" for node in nodes]
self.mapa_dispositivos = {
key: value
for key, value in self.mapa_dispositivos.items()
if any(key.startswith(prefix) for prefix in active_prefixes)
}
self.config_store.save(self.config)
self.map_store.save(self.mapa_dispositivos)
self.restart_event.set()
def rename_device(self, address_key: str, label: str) -> bool:
with self.lock:
if address_key not in self.mapa_dispositivos:
return False
self.mapa_dispositivos[address_key]["label"] = label.strip()
self.map_store.save(self.mapa_dispositivos)
self.restart_event.set()
return True
def replace_node_devices(self, node: dict, discovered_map: dict) -> None:
prefix = f"{node['ip']}_{node['unit']}_"
with self.lock:
self.mapa_dispositivos = {
key: value for key, value in self.mapa_dispositivos.items() if not key.startswith(prefix)
}
self.mapa_dispositivos.update(discovered_map)
self.map_store.save(self.mapa_dispositivos)
self.restart_event.set()
class MonitorEngine:
def __init__(self, runtime_state: RuntimeState, license_service: LicenseService):
self.runtime_state = runtime_state
self.license_service = license_service
def start(self) -> None:
threading.Thread(target=self.run, daemon=True, name="monitor-engine").start()
def run(self) -> None:
while not self.runtime_state.stop_event.is_set():
license_status = self.license_service.verify()
if not license_status.valid:
with self.runtime_state.lock:
self.runtime_state.status_nodes_web = {"SISTEMA": "BLOQUEADO"}
self.runtime_state.dados_tempo_real = {}
time.sleep(RETRY_INTERVAL_SECONDS)
continue
self.runtime_state.consume_restart()
config, device_map, _, _ = self.runtime_state.snapshot()
hostname = config.get("hostname_zabbix", "").strip()
sender = self._build_sender(config.get("zabbix_server", "").strip())
self._send_discovery(sender, hostname, config.get("nodes", []), device_map)
while not self.runtime_state.stop_event.is_set():
if self.runtime_state.consume_restart():
break
self._poll_once(sender, hostname)
time.sleep(POLL_INTERVAL_SECONDS)
def _build_sender(self, server: str):
if not server:
return None
try:
return Sender(server=server)
except Exception as exc:
LOGGER.warning("Nao foi possivel iniciar sender do Zabbix: %s", exc)
return None
def _send_discovery(self, sender, hostname: str, nodes: List[dict], device_map: dict) -> None:
if not sender or not hostname or not device_map:
return
lld = {"data": []}
for info in device_map.values():
lld["data"].append({"{#PONTO_LABEL}": info["label"]})
for node in nodes:
lld["data"].append({"{#NODE_NOME}": node["nome"]})
try:
sender.send([ItemValue(hostname, "notifier.discovery", json.dumps(lld, ensure_ascii=False))])
except Exception as exc:
LOGGER.warning("Falha ao enviar discovery ao Zabbix: %s", exc)
def _poll_once(self, sender, hostname: str) -> None:
config, device_map, _, _ = self.runtime_state.snapshot()
nodes = config.get("nodes", [])
metrics: List[ItemValue] = []
latest_data: Dict[str, dict] = {}
node_statuses: Dict[str, str] = {}
for node in nodes:
node_metrics, node_data, node_status = self._poll_node(node, device_map, hostname)
metrics.extend(node_metrics)
latest_data.update(node_data)
node_statuses[node["nome"]] = node_status
with self.runtime_state.lock:
self.runtime_state.dados_tempo_real.update(latest_data)
self.runtime_state.status_nodes_web = node_statuses
if sender and hostname and metrics:
try:
sender.send(metrics)
except Exception as exc:
LOGGER.warning("Falha ao enviar metricas ao Zabbix: %s", exc)
def _poll_node(self, node: dict, device_map: dict, hostname: str):
client = ModbusClient(
host=node["ip"],
port=502,
unit_id=node["unit"],
auto_open=True,
timeout=1,
)
node_metrics: List[ItemValue] = []
node_data: Dict[str, dict] = {}
node_status = "Offline"
try:
alive = client.read_holding_registers(1, 1)
status_conn = 1 if alive is not None else 0
node_status = "Online" if status_conn else "Offline"
if hostname:
node_metrics.append(ItemValue(hostname, f"node.status[{node['nome']}]", status_conn))
if not status_conn:
return node_metrics, node_data, node_status
node_keys = [
key for key in device_map.keys() if key.startswith(f"{node['ip']}_{node['unit']}_")
]
node_addresses = [int(key.rsplit("_", 1)[-1]) for key in node_keys]
for start, end in contiguous_ranges(node_addresses):
registers = client.read_holding_registers(start, end - start + 1)
if not registers:
continue
for offset, value in enumerate(registers):
address = start + offset
key = f"{node['ip']}_{node['unit']}_{address}"
if key not in device_map:
continue
label = device_map[key]["label"]
node_data[key] = {
"label": label,
"status": value,
"node": node["nome"],
"last": time.strftime("%H:%M:%S"),
}
if hostname:
node_metrics.append(ItemValue(hostname, f"notifier.status[{label}]", value & 0xFF))
node_metrics.append(ItemValue(hostname, f"notifier.comm[{label}]", (value >> 8) & 0xFF))
except Exception as exc:
LOGGER.warning("Falha ao consultar node %s (%s): %s", node["nome"], node["ip"], exc)
finally:
client.close()
return node_metrics, node_data, node_status
+150
View File
@@ -0,0 +1,150 @@
from flask import current_app, flash, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
from .core import LicenseService, RuntimeState, User, discover_node, get_status_info, validate_node_payload
def get_runtime_state() -> RuntimeState:
return current_app.extensions["runtime_state"]
def get_license_service() -> LicenseService:
return current_app.extensions["license_service"]
def json_error(message: str, status_code: int = 400):
return jsonify({"status": "error", "message": message}), status_code
def register_routes(app):
@app.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("index"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if get_runtime_state().authenticate(username, password):
login_user(User(username))
return redirect(url_for("index"))
flash("Usuario ou senha invalidos.")
return render_template("login.html")
@app.route("/")
@login_required
def index():
config, _, _, _ = get_runtime_state().snapshot()
license_status = get_license_service().verify()
return render_template(
"index.html",
config=config,
licenciado=license_status.valid,
lic_msg=license_status.message,
hwid=license_status.hardware_id,
dias_restantes=license_status.days_remaining,
)
@app.route("/api/nodes", methods=["POST"])
@login_required
def api_nodes():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return json_error("JSON invalido.")
try:
zabbix_server = str(payload.get("zabbix_server", "")).strip()
hostname_zabbix = str(payload.get("hostname_zabbix", "")).strip()
nodes = [validate_node_payload(node) for node in payload.get("nodes", [])]
except ValueError as exc:
return json_error(str(exc))
if not zabbix_server:
return json_error("Zabbix Server e obrigatorio.")
if not hostname_zabbix:
return json_error("Hostname Zabbix e obrigatorio.")
get_runtime_state().update_config(zabbix_server, hostname_zabbix, nodes)
return jsonify({"status": "success"})
@app.route("/api/rename", methods=["POST"])
@login_required
def api_rename():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return json_error("JSON invalido.")
address_key = str(payload.get("addr", "")).strip()
label = str(payload.get("label", "")).strip()
if not address_key:
return json_error("Endereco do dispositivo e obrigatorio.")
if not label:
return json_error("Nome do dispositivo e obrigatorio.")
if not get_runtime_state().rename_device(address_key, label):
return json_error("Dispositivo nao encontrado.", 404)
return jsonify({"status": "success"})
@app.route("/api/license", methods=["POST"])
def api_license():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return json_error("JSON invalido.")
try:
get_license_service().save(str(payload.get("serial", "")))
except ValueError as exc:
return json_error(str(exc))
get_runtime_state().trigger_restart()
return jsonify({"status": "success"})
@app.route("/api/rescan_node", methods=["POST"])
@login_required
def api_rescan():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return json_error("JSON invalido.")
try:
node = validate_node_payload(payload)
except ValueError as exc:
return json_error(str(exc))
_, current_map, _, _ = get_runtime_state().snapshot()
discovered_map = discover_node(node, current_map)
get_runtime_state().replace_node_devices(node, discovered_map)
return jsonify({"status": "success", "count": len(discovered_map)})
@app.route("/api/data")
@login_required
def api_data():
_, device_map, realtime_data, node_statuses = get_runtime_state().snapshot()
sensors = []
for key, info in device_map.items():
data = realtime_data.get(
key,
{"status": 0, "last": "--:--:--", "node": info.get("node_nome", "...")},
)
status_info = get_status_info(int(data["status"]))
sensors.append(
{
"key": key,
"label": info["label"],
"node": data.get("node", "..."),
"status_text": status_info["label"],
"color": status_info["color"],
"last": data["last"],
}
)
return jsonify({"sensores": sensors, "nodes_status": node_statuses})
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))