Software Recriado atravez do codex
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
VFM_DEFAULT_PASSWORD=admin
|
||||
VFM_APP_SECRET=
|
||||
VFM_LICENSE_MASTER_KEY=
|
||||
VFM_ENV=
|
||||
VFM_LOG_LEVEL=INFO
|
||||
PORT=8080
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
config_nodes.json
|
||||
mapa_dispositivos.json
|
||||
license.key
|
||||
app_secret.key
|
||||
@@ -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.
|
||||
* **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.
|
||||
## Estrutura
|
||||
|
||||
---
|
||||
- `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.
|
||||
* `generator.py`: Ferramenta administrativa da Voltec para gerar seriais de ativação criptografados.
|
||||
* `templates/`:
|
||||
* `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.
|
||||
- Python 3.10+
|
||||
- Conectividade com as centrais via Modbus/TCP
|
||||
- Acesso ao servidor Zabbix, quando a integracao estiver habilitada
|
||||
|
||||
---
|
||||
Instalacao:
|
||||
|
||||
## 🛠️ Instalação e Requisitos
|
||||
|
||||
### Dependências Python
|
||||
```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
@@ -1,16 +1,58 @@
|
||||
from cryptography.fernet import Fernet
|
||||
import json
|
||||
|
||||
# CHAVE MESTRA - DEVE SER A MESMA NO MONITOR.PY
|
||||
MASTER_KEY = b'vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4='
|
||||
|
||||
def gerar_serial(hardware_id, cliente, data_expiracao):
|
||||
f = Fernet(MASTER_KEY)
|
||||
dados = {"h": hardware_id.strip(), "c": cliente, "e": data_expiracao}
|
||||
return f.encrypt(json.dumps(dados).encode()).decode()
|
||||
|
||||
print("--- GERADOR DE LICENÇA VOLTEC ---")
|
||||
hwid = input("Hardware ID do cliente: ")
|
||||
nome = input("Nome do Cliente: ")
|
||||
validade = input("Validade (AAAA-MM-DD): ")
|
||||
print(f"\nSERIAL:\n{gerar_serial(hwid, nome, validade)}\n")
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
|
||||
|
||||
|
||||
def load_master_key() -> bytes:
|
||||
env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
|
||||
if env_key:
|
||||
return env_key.encode("utf-8")
|
||||
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
@@ -1,241 +1,10 @@
|
||||
import time, json, os, threading, subprocess
|
||||
from datetime import datetime
|
||||
from flask import Flask, render_template, jsonify, request, redirect, url_for
|
||||
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user
|
||||
from pyModbusTCP.client import ModbusClient
|
||||
from zabbix_utils import Sender, ItemValue
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'voltec_master_final_ultra_v6'
|
||||
|
||||
# --- 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)
|
||||
import os
|
||||
|
||||
from vfire_monitor import create_app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8080")), debug=False)
|
||||
|
||||
@@ -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
@@ -1,169 +1,429 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-br">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Notifier Master - Voltec</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #0b0b0b; color: #e0e0e0; font-family: 'Segoe UI', sans-serif; }
|
||||
.header-panel { background: #151515; padding: 20px; border-bottom: 2px solid #222; margin-bottom: 30px; }
|
||||
.node-group { background: #111; border: 1px solid #222; border-radius: 15px; padding: 20px; margin-bottom: 30px; }
|
||||
.node-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #333; margin-bottom: 20px; padding-bottom: 10px; }
|
||||
.node-title { border-left: 4px solid #d9534f; padding-left: 15px; color: #fff; font-weight: bold; margin: 0; }
|
||||
.node-status-badge { font-size: 0.7em; padding: 4px 10px; border-radius: 20px; text-transform: uppercase; font-weight: bold; }
|
||||
.card-sensor { background: #1a1a1a; border-radius: 12px; margin-bottom: 15px; padding: 15px; border-left: 5px solid #333; position: relative; }
|
||||
.badge-status { font-size: 0.75em; padding: 4px 10px; border-radius: 10px; font-weight: bold; }
|
||||
.edit-btn { position: absolute; top: 8px; right: 8px; cursor: pointer; opacity: 0.3; }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="loader" class="loading-overlay">
|
||||
<div class="spinner-border text-danger"></div>
|
||||
<h5 class="mt-3 text-white">Varrendo central (1-600)...</h5>
|
||||
</div>
|
||||
|
||||
<div class="header-panel shadow-sm">
|
||||
<div class="container d-flex justify-content-between align-items-center">
|
||||
<h2 class="mb-0 text-danger fw-bold">🔥 Notifier Voltec</h2>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-dark btn-sm" onclick="abrirModalNodes()">⚙️ Config</button>
|
||||
<a href="/logout" class="btn btn-outline-danger btn-sm">Sair</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
{% if not licenciado %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6 bg-dark p-5 rounded border border-danger text-center shadow-lg">
|
||||
<h2 class="text-danger">🔒 Sistema Bloqueado</h2>
|
||||
<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>
|
||||
</div>
|
||||
</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;">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="badge {% if dias_restantes < 15 %}bg-danger text-white{% else %}bg-secondary text-white{% endif %} p-2 shadow-sm">
|
||||
⏳ {{ dias_restantes }} dias restantes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
{{ lic_msg }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main-container"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="modalNodes" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content bg-dark border-secondary">
|
||||
<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>
|
||||
<div class="modal-body">
|
||||
<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>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-2"><h6>Nodes</h6><button class="btn btn-success btn-sm" onclick="addNodeRow()">+ Central</button></div>
|
||||
<div id="nodes-list"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary"><button class="btn btn-danger w-100" onclick="salvarTudo()">Salvar e Reiniciar</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 }};
|
||||
|
||||
async function ativar() {
|
||||
const serial = document.getElementById('serial-in').value.trim();
|
||||
const res = await fetch('/api/license', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serial}) });
|
||||
if(res.ok) { alert("Sucesso!"); location.reload(); } else { alert("Erro!"); }
|
||||
}
|
||||
|
||||
function abrirModalNodes() {
|
||||
const list = document.getElementById('nodes-list');
|
||||
list.innerHTML = '';
|
||||
nodesAtuais.forEach((node, idx) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'node-row';
|
||||
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>
|
||||
<div class="col-md-4"><input type="text" class="form-control form-control-sm n-ip" value="${node.ip}"></div>
|
||||
<div class="col-md-2"><input type="number" class="form-control form-control-sm n-unit" value="${node.unit}"></div>
|
||||
<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>
|
||||
</div>`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
modalNodes.show();
|
||||
}
|
||||
|
||||
function addNodeRow() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'node-row';
|
||||
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>`;
|
||||
document.getElementById('nodes-list').appendChild(div);
|
||||
}
|
||||
|
||||
async function salvarTudo() {
|
||||
const nodes = [];
|
||||
document.querySelectorAll('.node-row').forEach(r => {
|
||||
const n = r.querySelector('.n-nome').value;
|
||||
const i = r.querySelector('.n-ip').value;
|
||||
const u = r.querySelector('.n-unit').value;
|
||||
if(n && i) nodes.push({ nome: n, ip: i, unit: parseInt(u) });
|
||||
});
|
||||
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 }) });
|
||||
if(res.ok) location.reload();
|
||||
}
|
||||
|
||||
async function scanIndividual(idx) {
|
||||
const rows = document.querySelectorAll('.node-row');
|
||||
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();
|
||||
document.getElementById('loader').style.display = 'flex';
|
||||
await fetch('/api/rescan_node', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(node) });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function renomear(key, atual) {
|
||||
const novo = prompt("Novo nome:", atual);
|
||||
if(novo && novo !== atual) { await fetch('/api/rename', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({addr: key, label: novo}) }); atualizar(); }
|
||||
}
|
||||
|
||||
async function atualizar() {
|
||||
try {
|
||||
const res = await fetch('/api/data');
|
||||
const data = await res.json();
|
||||
const main = document.getElementById('main-container');
|
||||
if(!main) return;
|
||||
const nodesMap = {};
|
||||
data.sensores.forEach(s => { if(!nodesMap[s.node]) nodesMap[s.node] = []; nodesMap[s.node].push(s); });
|
||||
main.innerHTML = '';
|
||||
for(const n in nodesMap) {
|
||||
const st = data.nodes_status[n] || "Offline";
|
||||
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">`;
|
||||
nodesMap[n].forEach(s => {
|
||||
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>`;
|
||||
});
|
||||
main.innerHTML += h + `</div></div>`;
|
||||
}
|
||||
} catch(e){}
|
||||
}
|
||||
setInterval(atualizar, 3000); atualizar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<!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">
|
||||
<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>
|
||||
<div class="d-flex gap-2">
|
||||
<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 %}
|
||||
<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>
|
||||
<div class="fw-semibold">{{ lic_msg }}</div>
|
||||
</div>
|
||||
<span class="badge {% if dias_restantes < 15 %}bg-danger{% else %}bg-secondary{% endif %} fs-6">
|
||||
{{ dias_restantes }} dias restantes
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<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("&", "&")
|
||||
.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>
|
||||
</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
@@ -1,22 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Login - Notifier</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #0f0f0f; color: white; display: flex; align-items: center; height: 100vh; justify-content: center; }
|
||||
.login-card { background: #1a1a1a; padding: 30px; border-radius: 15px; border: 1px solid #333; width: 320px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card shadow">
|
||||
<h4 class="text-center text-danger mb-4">Acesso Notifier</h4>
|
||||
<form method="POST">
|
||||
<div class="mb-3"><label>Usuário</label><input type="text" name="username" class="form-control bg-dark text-white border-secondary" required></div>
|
||||
<div class="mb-3"><label>Senha</label><input type="password" name="password" class="form-control bg-dark text-white border-secondary" required></div>
|
||||
<button type="submit" class="btn btn-danger w-100">Entrar</button>
|
||||
{% with messages = get_flashed_messages() %}{% if messages %}<div class="text-warning mt-2 small text-center">{{ messages[0] }}</div>{% endif %}{% endwith %}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<!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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user