Compare commits

5 Commits
Author SHA1 Message Date
SyllasGS a61822e9e3 Codex 2026-05-07 14:23:31 -03:00
SyllasGS ed7e5f55cb Pos teste de instalação 2026-05-07 14:20:40 -03:00
SyllasGS 5d92a6c99a Add production installer and observability tooling 2026-05-07 12:11:15 -03:00
SyllasGS a19ac9f00c notas 2026-03-24 20:23:42 -03:00
SyllasGS 54ad365b4d Software Recriado atravez do codex 2026-03-24 20:18:23 -03:00
31 changed files with 3981 additions and 437 deletions
+10
View File
@@ -0,0 +1,10 @@
VFM_DEFAULT_PASSWORD=admin
VFM_APP_SECRET=
VFM_LICENSE_MASTER_KEY=
VFM_ENV=
VFM_LOG_LEVEL=INFO
VFM_SESSION_COOKIE_SECURE=
VFM_TRUST_PROXY=
VFM_MAX_CONTENT_LENGTH=1048576
HOST=0.0.0.0
PORT=8080
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
.codex
config_nodes.json
mapa_dispositivos.json
license.key
app_secret.key
installer/vfire-stack.env
+187 -27
View File
@@ -1,37 +1,197 @@
# 🔥 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.
- Alteracao de credenciais pelo proprio painel, sem editar arquivo manualmente.
- Chave secreta do Flask persistida localmente, sem valor fixo no codigo.
- Cookies de sessao e headers HTTP endurecidos para operacao web basica em producao.
- Protecao CSRF para login e APIs mutaveis.
- Validacao de payloads da API e respostas de erro consistentes.
- Validacao de IPs de nodes e validacao do serial antes de gravar a licenca.
- Logging basico para diagnostico, no lugar de falhas silenciosas.
- Polling Modbus e discovery por blocos, reduzindo chamadas individuais.
- Healthcheck publico e endpoint autenticado com status operacional do monitor.
- Shutdown mais limpo da thread de monitoramento.
- Frontend com assets locais, sem dependencia de CDN externa.
- 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.
- `simulator_nfs320.py`: simulador Modbus/TCP local de uma central NFS-320 para testes.
- `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.
- `static/`: assets locais de interface carregados pela aplicacao.
- `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. - Para instalacao automatizada da stack completa, Debian 13 (trixie)
* `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`.
- `VFM_SESSION_COOKIE_SECURE`: force cookie `Secure`, recomendado atras de HTTPS.
- `VFM_TRUST_PROXY`: habilita `ProxyFix` quando houver reverse proxy na frente.
- `VFM_MAX_CONTENT_LENGTH`: limite maximo do corpo HTTP em bytes.
- `HOST`: host HTTP do processo Flask.
- `PORT`: porta HTTP da aplicacao.
Cada central aceita:
- `nome`
- `ip`
- `unit`
- `port`: opcional na integracao Modbus, default `502`
## Execucao
```bash
python monitor.py
```
O sistema sobe em `http://0.0.0.0:8080` por padrao.
Healthcheck:
```bash
curl http://127.0.0.1:8080/healthz
```
Status operacional autenticado:
```bash
curl http://127.0.0.1:8080/api/system/status
```
## Instalador Debian 13
O repositorio inclui um instalador para Debian 13 que provisiona:
- `V-Fire Monitor` como servico `systemd`
- `PostgreSQL`
- `Zabbix Server + frontend Nginx`
- `Grafana`
- importacao automatica do template `Notifier NFS320`
- criacao automatica do host monitorado no Zabbix
- datasource do Grafana apontando para o Zabbix
- dashboard inicial de planta baixa e icones SVG locais
Arquivos:
- [installer/install_debian13.sh](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/installer/install_debian13.sh)
- [installer/vfire-stack.env.example](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/installer/vfire-stack.env.example)
- [serve.py](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/serve.py): entrypoint de producao do app com Waitress
- [docs/INSTALACAO_DEBIAN13.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/INSTALACAO_DEBIAN13.md)
- [docs/LICENCIAMENTO.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/LICENCIAMENTO.md)
- [docs/GRAFANA_PLANTA_BAIXA.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/GRAFANA_PLANTA_BAIXA.md)
- [floorplan/floorplan-map.example.json](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/floorplan/floorplan-map.example.json)
- [tools/build_floorplan_bundle.py](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/tools/build_floorplan_bundle.py)
Fluxo recomendado:
1. Copie `installer/vfire-stack.env.example` para `installer/vfire-stack.env`.
2. Ajuste senhas, hostname e `VFM_LICENSE_MASTER_KEY`.
3. Opcionalmente valide em dry-run:
```bash
./installer/install_debian13.sh --dry-run ./installer/vfire-stack.env
```
4. Execute como `root` no Debian 13:
```bash
chmod +x installer/install_debian13.sh
./installer/install_debian13.sh ./installer/vfire-stack.env
```
Portas padrao do instalador:
- `V-Fire Monitor`: `8088`
- `Zabbix frontend`: `8080`
- `Grafana`: `3000`
Ao final, o instalador grava um resumo em `/root/vfire-stack-summary.txt`.
## 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
```
## Simulador NFS-320
Para testar sem uma central fisica:
```bash
python simulator_nfs320.py --host 127.0.0.1 --port 1502
```
Depois, no painel do V-Fire Monitor, cadastre uma central com:
- `Nome`: `Sim NFS320`
- `IP`: `127.0.0.1`
- `Unit`: `3`
- `Porta`: `1502`
O simulador publica detectores, modulos e um painel repetidor com estados alternando entre normal, incidente, ack e removido.
## Observacoes operacionais
- A senha do painel fica armazenada como hash em `config_nodes.json`.
- O serial de licenca e validado antes de ser salvo em `license.key`.
- Em Linux, `app_secret.key` e `license.key` passam a ser gravados com permissao privada (`0600`).
- O painel exige token CSRF em login e chamadas mutaveis da API.
- 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.
- A interface web usa assets locais em `static/`, sem dependencia de internet para carregar CSS e JS.
## Testes
Executar:
```bash
python3 -m 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.
@@ -0,0 +1,65 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"content": "<h2>Starter de Planta Baixa</h2><p>Este dashboard foi provisionado para servir de base.</p><p>Use um painel <strong>Canvas</strong> no Grafana para montar a planta baixa.</p><p>URLs sugeridas de icones:</p><ul><li><code>http://SEU_HOST:8088/static/grafana-icons/detector-smoke.svg</code></li><li><code>http://SEU_HOST:8088/static/grafana-icons/detector-heat.svg</code></li><li><code>http://SEU_HOST:8088/static/grafana-icons/module-input.svg</code></li><li><code>http://SEU_HOST:8088/static/grafana-icons/siren.svg</code></li><li><code>http://SEU_HOST:8088/static/grafana-icons/panel-nfs320.svg</code></li></ul><p>Associe cada icone a itens do Zabbix via datasource <strong>Zabbix</strong>.</p>",
"mode": "html"
},
"pluginVersion": "11.0.0",
"title": "Como usar",
"type": "text"
}
],
"refresh": "30s",
"schemaVersion": 39,
"style": "dark",
"tags": [
"vfire",
"floorplan",
"starter"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "V-Fire Floorplan Starter",
"uid": "vfire-floorplan",
"version": 1,
"weekStart": ""
}
+123
View File
@@ -0,0 +1,123 @@
# Grafana e Planta Baixa
Este guia cobre o uso do Grafana com o `V-Fire Monitor` para dashboards baseados em planta baixa.
## Arquivos de apoio
- [floorplan/floorplan-map.example.json](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/floorplan/floorplan-map.example.json)
- [tools/build_floorplan_bundle.py](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/tools/build_floorplan_bundle.py)
## O que o instalador ja deixa pronto
Na instalacao Debian 13:
- plugin Zabbix do Grafana instalado
- datasource `Zabbix` provisionado
- dashboard inicial `V-Fire Floorplan Starter`
- icones SVG servidos pelo proprio `V-Fire Monitor`
## URLs dos icones
Substitua `SEU_HOST` pelo host do V-Fire Monitor.
- `http://SEU_HOST:8088/static/grafana-icons/detector-smoke.svg`
- `http://SEU_HOST:8088/static/grafana-icons/detector-heat.svg`
- `http://SEU_HOST:8088/static/grafana-icons/module-input.svg`
- `http://SEU_HOST:8088/static/grafana-icons/siren.svg`
- `http://SEU_HOST:8088/static/grafana-icons/panel-nfs320.svg`
## Dashboard inicial
O instalador provisiona a pasta `V-Fire` no Grafana e um dashboard chamado:
- `V-Fire Floorplan Starter`
Esse dashboard serve como ponto de partida e lembra as URLs dos icones.
## Formato versionavel da planta
Para nao depender apenas de arrastar elementos manualmente no Grafana, o repositorio agora inclui um formato JSON de mapa de planta.
Campos principais:
- `title`
- `grafana_dashboard_uid`
- `background_image_url`
- `vfire_base_url`
- `zabbix_host`
- `items`
Cada item descreve:
- `id`
- `label`
- `item_key`
- `icon`
- `x`
- `y`
- `width`
- `height`
- `rotation`
## Gerar bundle normalizado
Exemplo:
```bash
python3 tools/build_floorplan_bundle.py floorplan/floorplan-map.example.json
```
Isso gera um arquivo `.bundle.json` com:
- URLs completas dos icones
- referencia do host do Zabbix
- referencias dos `item_key`
- coordenadas prontas para uso em futuras automacoes
## Montando a planta baixa
Abordagem recomendada:
1. Abra o dashboard `V-Fire Floorplan Starter`
2. Crie ou edite um painel do tipo `Canvas`
3. Defina a imagem de fundo com a planta baixa do local
4. Adicione elementos visuais ou imagens representando cada ponto
5. Use os SVGs do `V-Fire Monitor` como base visual
6. Associe cada elemento ao item correspondente no datasource `Zabbix`
## Como mapear itens
Os itens que o app envia ao Zabbix seguem chaves como:
- `notifier.status[NomeDoPonto]`
- `notifier.comm[NomeDoPonto]`
- `node.status[NomeDaCentral]`
Os nomes dos pontos sao descobertos automaticamente a partir do monitoramento.
## Estrategia recomendada de planta baixa
- use a planta como imagem base
- posicione um icone por detector, modulo ou sirene
- use cor dinamica para refletir estado
- crie uma legenda simples com:
- normal
- incidente
- ack
- removido
- offline
## Observacao pratica
O instalador nao conhece a planta baixa real do cliente.
Por isso, ele consegue deixar:
- Grafana integrado
- datasource pronto
- dashboard inicial provisionado
- icones prontos
- formato versionavel da planta baixa
- bundle normalizado para futuras importacoes
Mas o posicionamento exato dos elementos na planta baixa ainda precisa ser feito conforme o layout real da instalacao.
+190
View File
@@ -0,0 +1,190 @@
# Instalacao no Debian 13
Este guia cobre a instalacao da stack completa em uma maquina dedicada com:
- `V-Fire Monitor`
- `PostgreSQL`
- `Zabbix Server + frontend`
- `Grafana`
## Visao geral
O instalador provisiona:
- app em `/opt/vfire-monitor`
- dados persistentes em `/var/lib/vfire-monitor`
- logs do app em `/var/log/vfire-monitor/app.log`
- servico `systemd` do V-Fire Monitor
- template do Zabbix importado automaticamente
- host monitorado do Zabbix criado automaticamente
- datasource do Grafana apontando para o Zabbix
- dashboard inicial do Grafana provisionado
Portas padrao:
- `V-Fire Monitor`: `8088`
- `Zabbix frontend`: `8080`
- `Grafana`: `3000`
## Requisitos
- Debian 13 (`trixie`)
- acesso `root`
- internet para baixar pacotes
- portas livres para os servicos
- chave `VFM_LICENSE_MASTER_KEY` se a instalacao for de producao
## Arquivos do instalador
- [installer/install_debian13.sh](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/installer/install_debian13.sh)
- [installer/vfire-stack.env.example](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/installer/vfire-stack.env.example)
## Passo 1: preparar configuracao
Copie o arquivo de exemplo:
```bash
cp installer/vfire-stack.env.example installer/vfire-stack.env
```
Edite `installer/vfire-stack.env`.
Observacoes importantes:
- `ZABBIX_ADMIN_PASSWORD` deve respeitar a politica da interface do Zabbix. Evite senhas contendo `zabbix`, `admin` ou `administrator`.
- Se quiser instalar em modo laboratorio usando a chave legada embutida, ajuste `ALLOW_LEGACY_LICENSE_KEY=1`. Nesse caso o instalador publica `VFM_ALLOW_LEGACY_LICENSE_KEY=1` no servico do app.
Campos minimos que voce deve revisar:
- `PUBLIC_HOSTNAME`
- `VFM_DEFAULT_PASSWORD`
- `VFM_LICENSE_MASTER_KEY`
- `ZABBIX_DB_PASSWORD`
- `ZABBIX_ADMIN_PASSWORD`
- `GRAFANA_ADMIN_PASSWORD`
Se voce quiser testar sem chave mestre, pode usar:
```env
ALLOW_LEGACY_LICENSE_KEY=1
```
Isso e aceitavel para laboratorio. Para producao, use `VFM_LICENSE_MASTER_KEY`.
## Passo 2: validar em dry-run
Antes de instalar de verdade:
```bash
chmod +x installer/install_debian13.sh
./installer/install_debian13.sh --dry-run ./installer/vfire-stack.env
```
Esse modo mostra as principais acoes sem executar a instalacao real.
## Passo 3: executar a instalacao
Como `root`:
```bash
./installer/install_debian13.sh ./installer/vfire-stack.env
```
## Passo 4: validar os servicos
Verifique:
```bash
systemctl status vfire-monitor
systemctl status zabbix-server
systemctl status grafana-server
systemctl status nginx
```
Healthchecks HTTP:
```bash
curl http://127.0.0.1:8088/healthz
curl -I http://127.0.0.1:8080/
curl -I http://127.0.0.1:3000/login
```
## Passo 5: acessar os paineis
Substitua `SEU_HOST` pelo hostname ou IP do servidor.
V-Fire Monitor:
```text
http://SEU_HOST:8088
```
Zabbix:
```text
http://SEU_HOST:8080
```
Grafana:
```text
http://SEU_HOST:3000
```
## Credenciais
O instalador grava um resumo em:
```text
/root/vfire-stack-summary.txt
```
Esse arquivo inclui:
- URL do V-Fire Monitor
- senha inicial do painel
- senha do Admin do Zabbix
- senha do admin do Grafana
- credenciais do banco do Zabbix
- nome do template do Zabbix
- nome do host do Zabbix
## Estrutura final
Arquivos principais apos instalar:
- codigo: `/opt/vfire-monitor`
- dados: `/var/lib/vfire-monitor`
- ambiente: `/etc/default/vfire-monitor`
- servico: `/etc/systemd/system/vfire-monitor.service`
## Operacoes comuns
Reiniciar o app:
```bash
systemctl restart vfire-monitor
```
Ver log do app:
```bash
tail -f /var/log/vfire-monitor/app.log
```
Reiniciar Zabbix:
```bash
systemctl restart zabbix-server nginx
```
Reiniciar Grafana:
```bash
systemctl restart grafana-server
```
## Observacoes
- o instalador foi desenhado para Debian 13
- o app roda com Waitress, nao com o servidor de desenvolvimento do Flask
- o frontend do app usa assets locais, sem depender de CDN
+113
View File
@@ -0,0 +1,113 @@
# Licenciamento
Este guia cobre a geracao e a instalacao de licenca do `V-Fire Monitor`.
## Como o licenciamento funciona
A licenca vincula:
- `HWID` da maquina
- nome do cliente
- data de expiracao
O serial e salvo em `license.key`.
## Modos de chave
Existem dois cenarios:
1. `Chave mestre via VFM_LICENSE_MASTER_KEY`
2. `Chave legada embutida`
Para producao, use `VFM_LICENSE_MASTER_KEY`.
## Identificar o HWID
Quando o app esta sem licenca, a tela inicial mostra o `Hardware ID`.
Voce tambem pode coletar no proprio sistema onde o app esta rodando:
```bash
python3 - <<'PY'
from vfire_monitor.core import get_hardware_id
print(get_hardware_id())
PY
```
## Gerar o serial
Uso por argumentos:
```bash
python3 generator.py --hwid "HWID-DA-MAQUINA" --cliente "Nome do Cliente" --expira 2027-01-01
```
Se `VFM_LICENSE_MASTER_KEY` estiver definida no ambiente, o gerador usa essa chave.
Se nao estiver definida, ele usa a chave legada embutida.
## Instalar a licenca pelo painel
1. Abra o painel do `V-Fire Monitor`
2. Na tela de bloqueio, copie o `HWID`
3. Gere o serial
4. Cole o serial no campo de ativacao
5. Clique em `Ativar licenca`
O backend valida:
- serial
- HWID
- data de expiracao
Se tudo estiver correto, o monitoramento e liberado.
## Instalar a licenca manualmente no servidor
Se voce ja tem o serial:
```bash
cat > /var/lib/vfire-monitor/license.key <<'EOF'
SEU_SERIAL_AQUI
EOF
chown vfire-monitor:vfire-monitor /var/lib/vfire-monitor/license.key
chmod 600 /var/lib/vfire-monitor/license.key
systemctl restart vfire-monitor
```
## Validar a licenca instalada
Exemplo local:
```bash
python3 - <<'PY'
from pathlib import Path
from vfire_monitor.core import LEGACY_LICENSE_KEY, LicenseService
service = LicenseService(Path("license.key"), LEGACY_LICENSE_KEY)
status = service.verify()
print(status.valid, status.message, status.days_remaining, status.hardware_id)
PY
```
Se a instalacao de producao usar `VFM_LICENSE_MASTER_KEY`, a validacao deve usar a mesma chave mestre.
## Quando a licenca falha
Os erros mais comuns sao:
- `Licenca ausente`
- `Serial invalido`
- `HWID incompativel`
- `Licenca expirada`
## Boas praticas
- use chave mestre por variavel de ambiente em producao
- nao distribua a chave legada em ambiente final
- mantenha backup de:
- `license.key`
- `app_secret.key`
- `config_nodes.json`
- `mapa_dispositivos.json`
+39
View File
@@ -0,0 +1,39 @@
{
"title": "Planta Baixa - Pavimento 1",
"grafana_dashboard_uid": "vfire-floorplan-pav1",
"background_image_url": "https://example.local/planta/pavimento-1.png",
"vfire_base_url": "http://127.0.0.1:8088",
"zabbix_host": "NFS-320",
"items": [
{
"id": "det-recepcao",
"label": "Detector Recepcao",
"item_key": "notifier.status[Detector Recepcao]",
"icon": "detector-smoke",
"x": 180,
"y": 120,
"width": 36,
"height": 36
},
{
"id": "mod-bomba",
"label": "Modulo Bomba Incendio",
"item_key": "notifier.status[Modulo Bomba Incendio]",
"icon": "module-input",
"x": 420,
"y": 260,
"width": 40,
"height": 40
},
{
"id": "sirene-p1",
"label": "Sirene Pavimento 1",
"item_key": "notifier.status[Modulo Sirene Pavimento 1]",
"icon": "siren",
"x": 680,
"y": 110,
"width": 42,
"height": 42
}
]
}
+54 -12
View File
@@ -1,16 +1,58 @@
from cryptography.fernet import Fernet import argparse
import json import json
import os
from datetime import datetime
# CHAVE MESTRA - DEVE SER A MESMA NO MONITOR.PY from cryptography.fernet import Fernet
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 ---") LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
hwid = input("Hardware ID do cliente: ")
nome = input("Nome do Cliente: ")
validade = input("Validade (AAAA-MM-DD): ") def load_master_key() -> bytes:
print(f"\nSERIAL:\n{gerar_serial(hwid, nome, validade)}\n") 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()
+684
View File
@@ -0,0 +1,684 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONFIG_FILE="${1:-${SCRIPT_DIR}/vfire-stack.env}"
DRY_RUN=0
APP_USER="vfire-monitor"
APP_GROUP="vfire-monitor"
APP_HOME="/opt/vfire-monitor"
APP_VENV="${APP_HOME}/.venv"
APP_STATE_DIR="/var/lib/vfire-monitor"
APP_LOG_DIR="/var/log/vfire-monitor"
APP_SERVICE="vfire-monitor.service"
APP_ENV_FILE="/etc/default/vfire-monitor"
APP_SYSTEMD_FILE="/etc/systemd/system/${APP_SERVICE}"
ZABBIX_APT_KEYRING="/usr/share/keyrings/zabbix.gpg"
ZABBIX_APT_LIST="/etc/apt/sources.list.d/zabbix.list"
GRAFANA_APT_KEYRING="/etc/apt/keyrings/grafana.asc"
GRAFANA_APT_LIST="/etc/apt/sources.list.d/grafana.list"
GRAFANA_DASHBOARD_DIR="/var/lib/grafana/dashboards/vfire"
SUMMARY_FILE="/root/vfire-stack-summary.txt"
log() {
printf '[vfire-installer] %s\n' "$*"
}
die() {
printf '[vfire-installer] ERROR: %s\n' "$*" >&2
exit 1
}
run_cmd() {
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] %s\n' "$*"
return 0
fi
"$@"
}
parse_args() {
local positional=()
while (($#)); do
case "$1" in
--dry-run)
DRY_RUN=1
shift
;;
*)
positional+=("$1")
shift
;;
esac
done
if ((${#positional[@]} > 0)); then
CONFIG_FILE="${positional[0]}"
fi
}
require_root() {
[[ "${EUID}" -eq 0 ]] || die "Execute como root."
}
require_supported_os() {
source /etc/os-release
[[ "${ID}" == "debian" ]] || die "Este instalador suporta apenas Debian."
[[ "${VERSION_CODENAME:-}" == "trixie" ]] || die "Este instalador foi preparado para Debian 13 (trixie)."
}
random_secret() {
tr -dc 'A-Za-z0-9' </dev/urandom | head -c 32
}
load_config() {
[[ -f "${CONFIG_FILE}" ]] || die "Arquivo de configuracao nao encontrado: ${CONFIG_FILE}. Copie installer/vfire-stack.env.example para installer/vfire-stack.env e ajuste os valores."
# shellcheck disable=SC1090
source "${CONFIG_FILE}"
PUBLIC_HOSTNAME="${PUBLIC_HOSTNAME:-$(hostname -f 2>/dev/null || hostname)}"
VFM_APP_PORT="${VFM_APP_PORT:-8088}"
ZABBIX_WEB_PORT="${ZABBIX_WEB_PORT:-8080}"
GRAFANA_PORT="${GRAFANA_PORT:-3000}"
VFM_DEFAULT_PASSWORD="${VFM_DEFAULT_PASSWORD:-admin}"
VFM_LOG_LEVEL="${VFM_LOG_LEVEL:-INFO}"
ZABBIX_VERSION="${ZABBIX_VERSION:-7.4}"
ZABBIX_DB_NAME="${ZABBIX_DB_NAME:-zabbix}"
ZABBIX_DB_USER="${ZABBIX_DB_USER:-zabbix}"
ZABBIX_ADMIN_USERNAME="${ZABBIX_ADMIN_USERNAME:-Admin}"
ZABBIX_ADMIN_PASSWORD="${ZABBIX_ADMIN_PASSWORD:-zabbix}"
ZABBIX_TEMPLATE_FILE="${ZABBIX_TEMPLATE_FILE:-${APP_HOME}/zbx_export_templates.yaml}"
ZABBIX_TEMPLATE_NAME="${ZABBIX_TEMPLATE_NAME:-Notifier NFS320}"
ZABBIX_HOST_GROUP="${ZABBIX_HOST_GROUP:-Notifier}"
ZABBIX_MONITORED_HOST="${ZABBIX_MONITORED_HOST:-NFS-320}"
GRAFANA_ADMIN_USER="${GRAFANA_ADMIN_USER:-admin}"
GRAFANA_INSTALL_ZABBIX_PLUGIN="${GRAFANA_INSTALL_ZABBIX_PLUGIN:-1}"
ALLOW_LEGACY_LICENSE_KEY="${ALLOW_LEGACY_LICENSE_KEY:-0}"
ZABBIX_DB_PASSWORD="${ZABBIX_DB_PASSWORD:-$(random_secret)}"
GRAFANA_ADMIN_PASSWORD="${GRAFANA_ADMIN_PASSWORD:-$(random_secret)}"
[[ "${ZABBIX_ADMIN_USERNAME}" == "Admin" ]] || die "O instalador atualmente suporta apenas o usuario administrativo padrao do Zabbix: Admin."
if [[ -z "${VFM_LICENSE_MASTER_KEY:-}" && "${ALLOW_LEGACY_LICENSE_KEY}" != "1" ]]; then
die "Defina VFM_LICENSE_MASTER_KEY no arquivo de configuracao ou ajuste ALLOW_LEGACY_LICENSE_KEY=1."
fi
}
assert_command_dependencies() {
local cmd
for cmd in apt-get python3 runuser systemctl; do
command -v "${cmd}" >/dev/null 2>&1 || die "Comando obrigatorio ausente: ${cmd}"
done
}
assert_ports_available() {
local port
[[ "${DRY_RUN}" == "1" ]] && return 0
command -v ss >/dev/null 2>&1 || return 0
if [[ "${VFM_APP_PORT}" == "${ZABBIX_WEB_PORT}" || "${VFM_APP_PORT}" == "${GRAFANA_PORT}" || "${ZABBIX_WEB_PORT}" == "${GRAFANA_PORT}" ]]; then
die "As portas configuradas para V-Fire Monitor, Zabbix e Grafana precisam ser distintas."
fi
for port in "${VFM_APP_PORT}" "${ZABBIX_WEB_PORT}" "${GRAFANA_PORT}"; do
if ss -ltn "( sport = :${port} )" | tail -n +2 | grep -q .; then
log "Porta ${port} ja esta em uso. O instalador vai prosseguir para permitir reexecucoes e atualizacoes."
fi
done
}
apt_install() {
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] DEBIAN_FRONTEND=noninteractive apt-get install -y %s\n' "$*"
return 0
fi
DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
}
write_zabbix_repo() {
run_cmd install -d -m 0755 /usr/share/keyrings
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] curl -fsSL https://repo.zabbix.com/zabbix-official-repo.key | gpg --dearmor --yes -o %s\n' "${ZABBIX_APT_KEYRING}"
else
curl -fsSL "https://repo.zabbix.com/zabbix-official-repo.key" | gpg --dearmor --yes -o "${ZABBIX_APT_KEYRING}"
fi
run_cmd chmod 0644 "${ZABBIX_APT_KEYRING}"
cat >"${ZABBIX_APT_LIST}" <<EOF
deb [signed-by=${ZABBIX_APT_KEYRING}] https://repo.zabbix.com/zabbix/${ZABBIX_VERSION}/stable/debian trixie main
EOF
}
write_grafana_repo() {
run_cmd install -d -m 0755 /etc/apt/keyrings
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] wget -q -O %s https://apt.grafana.com/gpg-full.key\n' "${GRAFANA_APT_KEYRING}"
else
wget -q -O "${GRAFANA_APT_KEYRING}" "https://apt.grafana.com/gpg-full.key"
fi
run_cmd chmod 0644 "${GRAFANA_APT_KEYRING}"
cat >"${GRAFANA_APT_LIST}" <<EOF
deb [signed-by=${GRAFANA_APT_KEYRING}] https://apt.grafana.com stable main
EOF
}
install_system_packages() {
log "Instalando pacotes base"
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] apt-get update\n'
else
apt-get update
fi
apt_install ca-certificates curl wget gnupg apt-transport-https rsync jq
apt_install python3 python3-venv python3-pip
apt_install postgresql nginx php-fpm php-pgsql php-mbstring php-gd php-xml php-bcmath php-ldap
write_zabbix_repo
write_grafana_repo
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] apt-get update\n'
else
apt-get update
fi
apt_install zabbix-server-pgsql zabbix-frontend-php zabbix-nginx-conf zabbix-sql-scripts zabbix-agent2
apt_install grafana
}
deploy_application() {
log "Publicando codigo da aplicacao em ${APP_HOME}"
getent group "${APP_GROUP}" >/dev/null || run_cmd groupadd --system "${APP_GROUP}"
id -u "${APP_USER}" >/dev/null 2>&1 || run_cmd useradd --system --gid "${APP_GROUP}" --home "${APP_HOME}" --shell /usr/sbin/nologin "${APP_USER}"
run_cmd install -d -o "${APP_USER}" -g "${APP_GROUP}" "${APP_HOME}" "${APP_STATE_DIR}" "${APP_LOG_DIR}"
run_cmd rsync -a \
--delete \
--exclude '.git' \
--exclude '.venv' \
--exclude '.pytest_cache' \
--exclude '__pycache__' \
--exclude '*.pyc' \
--exclude 'config_nodes.json' \
--exclude 'mapa_dispositivos.json' \
--exclude 'license.key' \
--exclude 'app_secret.key' \
"${REPO_ROOT}/" "${APP_HOME}/"
run_cmd python3 -m venv "${APP_VENV}"
run_cmd "${APP_VENV}/bin/python" -m pip install --upgrade pip
run_cmd "${APP_VENV}/bin/python" -m pip install -r "${APP_HOME}/requirements.txt"
for file_name in config_nodes.json mapa_dispositivos.json license.key app_secret.key; do
if [[ -f "${REPO_ROOT}/${file_name}" && ! -f "${APP_STATE_DIR}/${file_name}" ]]; then
run_cmd install -m 0600 -o "${APP_USER}" -g "${APP_GROUP}" "${REPO_ROOT}/${file_name}" "${APP_STATE_DIR}/${file_name}"
fi
done
run_cmd chown -R "${APP_USER}:${APP_GROUP}" "${APP_HOME}" "${APP_STATE_DIR}" "${APP_LOG_DIR}"
}
configure_vfire_service() {
log "Configurando servico systemd do V-Fire Monitor"
cat >"${APP_ENV_FILE}" <<EOF
VFM_BASE_DIR=${APP_STATE_DIR}
VFM_DEFAULT_PASSWORD=${VFM_DEFAULT_PASSWORD}
VFM_LOG_LEVEL=${VFM_LOG_LEVEL}
VFM_ENV=production
HOST=127.0.0.1
PORT=${VFM_APP_PORT}
VFM_TRUST_PROXY=0
VFM_SESSION_COOKIE_SECURE=0
VFM_WAITRESS_THREADS=8
VFM_ALLOW_LEGACY_LICENSE_KEY=${ALLOW_LEGACY_LICENSE_KEY}
${VFM_APP_SECRET:+VFM_APP_SECRET=${VFM_APP_SECRET}}
${VFM_LICENSE_MASTER_KEY:+VFM_LICENSE_MASTER_KEY=${VFM_LICENSE_MASTER_KEY}}
EOF
run_cmd chmod 0600 "${APP_ENV_FILE}"
cat >"${APP_SYSTEMD_FILE}" <<EOF
[Unit]
Description=V-Fire Monitor
After=network.target postgresql.service
Wants=network.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_GROUP}
WorkingDirectory=${APP_HOME}
EnvironmentFile=${APP_ENV_FILE}
ExecStart=${APP_VENV}/bin/python ${APP_HOME}/serve.py
Restart=always
RestartSec=5
StandardOutput=append:${APP_LOG_DIR}/app.log
StandardError=append:${APP_LOG_DIR}/app.log
[Install]
WantedBy=multi-user.target
EOF
}
ensure_postgres_role() {
local exists
exists="$(runuser -u postgres -- psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${ZABBIX_DB_USER}'")"
if [[ "${exists}" != "1" ]]; then
runuser -u postgres -- psql -v ON_ERROR_STOP=1 -c "CREATE USER ${ZABBIX_DB_USER} WITH PASSWORD '${ZABBIX_DB_PASSWORD}';"
else
runuser -u postgres -- psql -v ON_ERROR_STOP=1 -c "ALTER USER ${ZABBIX_DB_USER} WITH PASSWORD '${ZABBIX_DB_PASSWORD}';"
fi
}
ensure_postgres_database() {
local exists
exists="$(runuser -u postgres -- psql -tAc "SELECT 1 FROM pg_database WHERE datname='${ZABBIX_DB_NAME}'")"
if [[ "${exists}" != "1" ]]; then
runuser -u postgres -- psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE ${ZABBIX_DB_NAME} OWNER ${ZABBIX_DB_USER} ENCODING 'UTF8';"
fi
}
import_zabbix_schema_if_needed() {
local initialized
initialized="$(runuser -u postgres -- psql -d "${ZABBIX_DB_NAME}" -tAc "SELECT to_regclass('public.users') IS NOT NULL")"
if [[ "${initialized}" != "t" ]]; then
zcat /usr/share/zabbix/sql-scripts/postgresql/server.sql.gz | PGPASSWORD="${ZABBIX_DB_PASSWORD}" psql -h localhost -U "${ZABBIX_DB_USER}" "${ZABBIX_DB_NAME}"
fi
}
set_ini_value() {
local file="$1"
local key="$2"
local value="$3"
if grep -Eq "^[# ]*${key}=" "${file}"; then
sed -i -E "s|^[# ]*${key}=.*|${key}=${value}|" "${file}"
else
printf '%s=%s\n' "${key}" "${value}" >>"${file}"
fi
}
configure_zabbix() {
log "Configurando PostgreSQL e Zabbix"
run_cmd systemctl enable --now postgresql
ensure_postgres_role
ensure_postgres_database
import_zabbix_schema_if_needed
set_ini_value "/etc/zabbix/zabbix_server.conf" "DBHost" "localhost"
set_ini_value "/etc/zabbix/zabbix_server.conf" "DBName" "${ZABBIX_DB_NAME}"
set_ini_value "/etc/zabbix/zabbix_server.conf" "DBUser" "${ZABBIX_DB_USER}"
set_ini_value "/etc/zabbix/zabbix_server.conf" "DBPassword" "${ZABBIX_DB_PASSWORD}"
run_cmd install -d -m 0750 /etc/zabbix/web
cat > /etc/zabbix/web/zabbix.conf.php <<EOF
<?php
// Zabbix GUI configuration file generated by V-Fire installer.
\$DB['TYPE'] = 'POSTGRESQL';
\$DB['SERVER'] = 'localhost';
\$DB['PORT'] = '0';
\$DB['DATABASE'] = '${ZABBIX_DB_NAME}';
\$DB['USER'] = '${ZABBIX_DB_USER}';
\$DB['PASSWORD'] = '${ZABBIX_DB_PASSWORD}';
\$DB['SCHEMA'] = '';
\$ZBX_SERVER = 'localhost';
\$ZBX_SERVER_PORT = '10051';
\$ZBX_SERVER_NAME = '${ZABBIX_MONITORED_HOST}';
\$IMAGE_FORMAT_DEFAULT = IMAGE_FORMAT_PNG;
EOF
run_cmd chown root:www-data /etc/zabbix/web/zabbix.conf.php
run_cmd chmod 0640 /etc/zabbix/web/zabbix.conf.php
if [[ -f /etc/zabbix/nginx.conf ]]; then
sed -i -E "s|^[[:space:]]*#[[:space:]]*listen[[:space:]]+8080;| listen ${ZABBIX_WEB_PORT};|" /etc/zabbix/nginx.conf
sed -i -E "s|^[[:space:]]*#[[:space:]]*server_name[[:space:]]+example.com;| server_name _;|" /etc/zabbix/nginx.conf
fi
run_cmd ln -sf /etc/zabbix/nginx.conf /etc/nginx/conf.d/zabbix.conf
}
detect_php_fpm_service() {
systemctl list-unit-files 'php*-fpm.service' --no-legend | awk '{print $1}' | head -n 1
}
configure_grafana() {
log "Configurando Grafana"
run_cmd install -d -m 0755 /etc/grafana/provisioning/datasources
run_cmd install -d -m 0755 /etc/grafana/provisioning/dashboards
run_cmd install -d -m 0755 "${GRAFANA_DASHBOARD_DIR}"
run_cmd install -d -o grafana -g grafana -m 0755 /var/lib/grafana/plugins
if [[ "${GRAFANA_INSTALL_ZABBIX_PLUGIN}" == "1" ]]; then
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] grafana cli --homepath /usr/share/grafana --pluginsDir /var/lib/grafana/plugins plugins install alexanderzobnin-zabbix-app\n'
else
grafana cli --homepath /usr/share/grafana --pluginsDir /var/lib/grafana/plugins plugins install alexanderzobnin-zabbix-app || true
fi
fi
cat >/etc/grafana/provisioning/datasources/zabbix.yaml <<EOF
apiVersion: 1
datasources:
- name: Zabbix
type: alexanderzobnin-zabbix-datasource
access: proxy
url: http://127.0.0.1:${ZABBIX_WEB_PORT}/api_jsonrpc.php
isDefault: true
jsonData:
username: ${ZABBIX_ADMIN_USERNAME}
trends: true
timeout: 30
secureJsonData:
password: ${ZABBIX_ADMIN_PASSWORD}
EOF
run_cmd install -d -m 0755 /etc/systemd/system/grafana-server.service.d
cat >/etc/systemd/system/grafana-server.service.d/override.conf <<EOF
[Service]
Environment=GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER}
Environment=GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
Environment=GF_SERVER_HTTP_PORT=${GRAFANA_PORT}
EOF
cat >/etc/grafana/provisioning/dashboards/vfire.yaml <<EOF
apiVersion: 1
providers:
- name: V-Fire
orgId: 1
folder: V-Fire
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: ${GRAFANA_DASHBOARD_DIR}
EOF
run_cmd cp "${APP_HOME}/dashboards/grafana/vfire-floorplan-starter.json" "${GRAFANA_DASHBOARD_DIR}/vfire-floorplan-starter.json"
run_cmd chown -R grafana:grafana "${GRAFANA_DASHBOARD_DIR}"
run_cmd chown -R grafana:grafana /var/lib/grafana/plugins
}
restart_core_services() {
local php_fpm_service
php_fpm_service="$(detect_php_fpm_service)"
[[ -n "${php_fpm_service}" ]] || die "Servico php-fpm nao encontrado."
run_cmd systemctl daemon-reload
run_cmd systemctl enable --now "${php_fpm_service}"
run_cmd systemctl enable --now nginx
run_cmd systemctl restart nginx
run_cmd systemctl enable --now zabbix-server zabbix-agent2
run_cmd systemctl restart zabbix-server zabbix-agent2
}
wait_for_http() {
local url="$1"
local attempts="${2:-60}"
local delay="${3:-2}"
local attempt
[[ "${DRY_RUN}" == "1" ]] && return 0
for attempt in $(seq 1 "${attempts}"); do
if curl -fsS "${url}" >/dev/null 2>&1; then
return 0
fi
sleep "${delay}"
done
die "Servico indisponivel em ${url}"
}
zabbix_api_request() {
local payload="$1"
local auth_token="${2:-}"
local curl_args=(-fsS -H 'Content-Type: application/json-rpc')
[[ "${DRY_RUN}" == "1" ]] && return 0
if [[ -n "${auth_token}" ]]; then
curl_args+=(-H "Authorization: Bearer ${auth_token}")
fi
curl "${curl_args[@]}" -d "${payload}" "http://127.0.0.1:${ZABBIX_WEB_PORT}/api_jsonrpc.php"
}
zabbix_error_message() {
local response="$1"
printf '%s' "${response}" | jq -r '.error.data // .error.message // empty'
}
zabbix_require_success() {
local response="$1"
local context="$2"
local error_message
error_message="$(zabbix_error_message "${response}")"
if [[ -n "${error_message}" ]]; then
die "${context}: ${error_message}"
fi
}
configure_zabbix_admin_password() {
local auth_payload auth_response auth_token user_id
wait_for_http "http://127.0.0.1:${ZABBIX_WEB_PORT}/"
auth_payload='{"jsonrpc":"2.0","method":"user.login","params":{"username":"Admin","password":"zabbix","userData":true},"id":1}'
auth_response="$(zabbix_api_request "${auth_payload}" || true)"
auth_token="$(printf '%s' "${auth_response}" | jq -r '.result.sessionid // empty')"
user_id="$(printf '%s' "${auth_response}" | jq -r '.result.userid // empty')"
if [[ -n "${auth_token}" && -n "${user_id}" && "${ZABBIX_ADMIN_PASSWORD}" != "zabbix" ]]; then
auth_response="$(zabbix_api_request "$(jq -cn \
--arg userid "${user_id}" \
--arg current "zabbix" \
--arg passwd "${ZABBIX_ADMIN_PASSWORD}" \
'{jsonrpc:"2.0",method:"user.update",params:{userid:$userid,current_passwd:$current,passwd:$passwd},id:1}')" "${auth_token}" || true)"
zabbix_require_success "${auth_response}" "Falha ao atualizar a senha administrativa do Zabbix"
return 0
fi
auth_payload="$(jq -cn --arg username "${ZABBIX_ADMIN_USERNAME}" --arg password "${ZABBIX_ADMIN_PASSWORD}" '{jsonrpc:"2.0",method:"user.login",params:{username:$username,password:$password},id:1}')"
auth_response="$(zabbix_api_request "${auth_payload}" || true)"
zabbix_require_success "${auth_response}" "Falha ao autenticar na API do Zabbix com as credenciais administrativas configuradas"
auth_token="$(printf '%s' "${auth_response}" | jq -r '.result // empty')"
[[ -n "${auth_token}" ]] || die "Nao foi possivel autenticar na API do Zabbix com as credenciais administrativas configuradas."
}
zabbix_login() {
local response
response="$(zabbix_api_request "$(jq -cn --arg username "${ZABBIX_ADMIN_USERNAME}" --arg password "${ZABBIX_ADMIN_PASSWORD}" '{jsonrpc:"2.0",method:"user.login",params:{username:$username,password:$password},id:1}')")"
zabbix_require_success "${response}" "Falha ao obter token da API do Zabbix"
printf '%s' "${response}" | jq -r '.result // empty'
}
zabbix_get_single_id() {
local method="$1"
local filter_key="$2"
local filter_value="$3"
local id_key="$4"
local auth="$5"
zabbix_api_request "$(jq -cn \
--arg method "${method}" \
--arg key "${filter_key}" \
--arg value "${filter_value}" \
'{jsonrpc:"2.0",method:$method,params:{output:["'"${id_key}"'"],filter:{($key):[$value]}},id:1}')" "${auth}" \
| jq -r ".result[0].${id_key} // empty"
}
import_zabbix_template() {
local auth="$1"
local source
[[ -f "${ZABBIX_TEMPLATE_FILE}" ]] || die "Template do Zabbix nao encontrado: ${ZABBIX_TEMPLATE_FILE}"
source="$(cat "${ZABBIX_TEMPLATE_FILE}")"
zabbix_api_request "$(jq -cn \
--arg source "${source}" \
'{jsonrpc:"2.0",method:"configuration.import",params:{format:"yaml",source:$source,rules:{template_groups:{createMissing:true,updateExisting:true},templates:{createMissing:true,updateExisting:true},discoveryRules:{createMissing:true,updateExisting:true,deleteMissing:false},items:{createMissing:true,updateExisting:true,deleteMissing:false},triggers:{createMissing:true,updateExisting:true,deleteMissing:false},valueMaps:{createMissing:true,updateExisting:true,deleteMissing:false},templateDashboards:{createMissing:true,updateExisting:true,deleteMissing:false}}},id:1}')" "${auth}" >/dev/null
}
ensure_zabbix_host_group() {
local auth="$1"
local group_id
group_id="$(zabbix_get_single_id "hostgroup.get" "name" "${ZABBIX_HOST_GROUP}" "groupid" "${auth}")"
if [[ -n "${group_id}" ]]; then
printf '%s' "${group_id}"
return 0
fi
zabbix_api_request "$(jq -cn --arg name "${ZABBIX_HOST_GROUP}" '{jsonrpc:"2.0",method:"hostgroup.create",params:{name:$name},id:1}')" "${auth}" | jq -r '.result.groupids[0]'
}
ensure_zabbix_monitored_host() {
local auth="$1"
local group_id="$2"
local template_id host_id
template_id="$(zabbix_get_single_id "template.get" "host" "${ZABBIX_TEMPLATE_NAME}" "templateid" "${auth}")"
[[ -n "${template_id}" ]] || die "Template do Zabbix nao encontrado apos importacao: ${ZABBIX_TEMPLATE_NAME}"
host_id="$(zabbix_get_single_id "host.get" "host" "${ZABBIX_MONITORED_HOST}" "hostid" "${auth}")"
if [[ -n "${host_id}" ]]; then
zabbix_api_request "$(jq -cn \
--arg hostid "${host_id}" \
--arg host "${ZABBIX_MONITORED_HOST}" \
--arg group_id "${group_id}" \
--arg template_id "${template_id}" \
'{jsonrpc:"2.0",method:"host.update",params:{hostid:$hostid,host:$host,name:$host,status:0,groups:[{groupid:$group_id}],templates:[{templateid:$template_id}]},id:1}')" "${auth}" >/dev/null
printf '%s' "${host_id}"
return 0
fi
zabbix_api_request "$(jq -cn \
--arg host "${ZABBIX_MONITORED_HOST}" \
--arg group_id "${group_id}" \
--arg template_id "${template_id}" \
'{jsonrpc:"2.0",method:"host.create",params:{host:$host,name:$host,status:0,groups:[{groupid:$group_id}],templates:[{templateid:$template_id}],tags:[{tag:"application",value:"vfire-monitor"}]},id:1}')" "${auth}" | jq -r '.result.hostids[0]'
}
seed_vfire_runtime_config() {
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] seed runtime config at %s/config_nodes.json\n' "${APP_STATE_DIR}"
return 0
fi
"${APP_VENV}/bin/python" - <<PY
from pathlib import Path
import json
from werkzeug.security import generate_password_hash
config_path = Path("${APP_STATE_DIR}") / "config_nodes.json"
default_password = ${VFM_DEFAULT_PASSWORD@Q}
hostname_zabbix = ${ZABBIX_MONITORED_HOST@Q}
zabbix_server = "127.0.0.1"
if config_path.exists():
config = json.loads(config_path.read_text(encoding="utf-8"))
else:
config = {
"web_user": "admin",
"web_password_hash": generate_password_hash(default_password),
"nodes": [],
}
config.setdefault("web_user", "admin")
config.setdefault("web_password_hash", generate_password_hash(default_password))
config.setdefault("nodes", [])
config["hostname_zabbix"] = hostname_zabbix
config["zabbix_server"] = zabbix_server
config_path.write_text(json.dumps(config, indent=4, ensure_ascii=False), encoding="utf-8")
PY
chown "${APP_USER}:${APP_GROUP}" "${APP_STATE_DIR}/config_nodes.json"
chmod 0600 "${APP_STATE_DIR}/config_nodes.json"
}
provision_zabbix_objects() {
local auth group_id host_id
[[ "${DRY_RUN}" == "1" ]] && return 0
auth="$(zabbix_login)"
[[ -n "${auth}" ]] || die "Falha ao autenticar na API do Zabbix."
import_zabbix_template "${auth}"
group_id="$(ensure_zabbix_host_group "${auth}")"
[[ -n "${group_id}" ]] || die "Falha ao criar ou localizar host group do Zabbix."
host_id="$(ensure_zabbix_monitored_host "${auth}" "${group_id}")"
[[ -n "${host_id}" ]] || die "Falha ao criar ou localizar host monitorado do Zabbix."
seed_vfire_runtime_config
}
restart_remaining_services() {
run_cmd systemctl daemon-reload
run_cmd systemctl enable --now grafana-server
run_cmd systemctl restart grafana-server
run_cmd systemctl enable --now "${APP_SERVICE}"
run_cmd systemctl restart "${APP_SERVICE}"
}
write_summary() {
cat >"${SUMMARY_FILE}" <<EOF
V-Fire Stack instalado com sucesso.
Host: ${PUBLIC_HOSTNAME}
V-Fire Monitor
- URL: http://${PUBLIC_HOSTNAME}:${VFM_APP_PORT}
- Usuario inicial: admin
- Senha inicial: ${VFM_DEFAULT_PASSWORD}
- Servico: ${APP_SERVICE}
Zabbix
- URL: http://${PUBLIC_HOSTNAME}:${ZABBIX_WEB_PORT}
- Usuario: ${ZABBIX_ADMIN_USERNAME}
- Senha: ${ZABBIX_ADMIN_PASSWORD}
- Banco: ${ZABBIX_DB_NAME}
- Usuario DB: ${ZABBIX_DB_USER}
- Senha DB: ${ZABBIX_DB_PASSWORD}
Grafana
- URL: http://${PUBLIC_HOSTNAME}:${GRAFANA_PORT}
- Usuario: ${GRAFANA_ADMIN_USER}
- Senha: ${GRAFANA_ADMIN_PASSWORD}
- Datasource provisionado: Zabbix
- Dashboard provisionado: V-Fire Floorplan Starter
Arquivos importantes
- Codigo app: ${APP_HOME}
- Estado app: ${APP_STATE_DIR}
- Variaveis app: ${APP_ENV_FILE}
- Log app: ${APP_LOG_DIR}/app.log
- Template Zabbix: ${ZABBIX_TEMPLATE_NAME}
- Host Zabbix: ${ZABBIX_MONITORED_HOST}
EOF
run_cmd chmod 0600 "${SUMMARY_FILE}"
log "Resumo salvo em ${SUMMARY_FILE}"
}
verify_services_health() {
[[ "${DRY_RUN}" == "1" ]] && return 0
wait_for_http "http://127.0.0.1:${VFM_APP_PORT}/healthz"
wait_for_http "http://127.0.0.1:${ZABBIX_WEB_PORT}/"
wait_for_http "http://127.0.0.1:${GRAFANA_PORT}/login"
systemctl is-active --quiet "${APP_SERVICE}" || die "Servico do V-Fire Monitor inativo."
systemctl is-active --quiet zabbix-server || die "Servico zabbix-server inativo."
systemctl is-active --quiet grafana-server || die "Servico grafana-server inativo."
}
main() {
parse_args "$@"
require_root
require_supported_os
load_config
assert_command_dependencies
assert_ports_available
install_system_packages
deploy_application
configure_vfire_service
configure_zabbix
restart_core_services
configure_zabbix_admin_password
provision_zabbix_objects
configure_grafana
restart_remaining_services
verify_services_health
write_summary
log "Instalacao concluida"
}
main "$@"
+34
View File
@@ -0,0 +1,34 @@
# Debian 13 installer configuration for V-Fire Monitor + Zabbix + Grafana
# Networking
PUBLIC_HOSTNAME=
VFM_APP_PORT=8088
ZABBIX_WEB_PORT=8080
GRAFANA_PORT=3000
# V-Fire Monitor
VFM_DEFAULT_PASSWORD=admin
VFM_LICENSE_MASTER_KEY=
VFM_APP_SECRET=
VFM_LOG_LEVEL=INFO
# Zabbix
ZABBIX_VERSION=7.4
ZABBIX_DB_NAME=zabbix
ZABBIX_DB_USER=zabbix
ZABBIX_DB_PASSWORD=
ZABBIX_ADMIN_USERNAME=Admin
# Evite usar "zabbix", "admin" ou "administrator" na senha do Admin do Zabbix.
ZABBIX_ADMIN_PASSWORD=
ZABBIX_TEMPLATE_FILE=/opt/vfire-monitor/zbx_export_templates.yaml
ZABBIX_TEMPLATE_NAME="Notifier NFS320"
ZABBIX_HOST_GROUP=Notifier
ZABBIX_MONITORED_HOST=NFS-320
# Grafana
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=
GRAFANA_INSTALL_ZABBIX_PLUGIN=1
# Installer behaviour
ALLOW_LEGACY_LICENSE_KEY=0
+8 -235
View File
@@ -1,241 +1,14 @@
import time, json, os, threading, subprocess import os
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__) from vfire_monitor import create_app
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): app = create_app(base_dir=os.getenv("VFM_BASE_DIR"))
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__": if __name__ == "__main__":
threading.Thread(target=monitorar_background, daemon=True).start() app.run(
app.run(host='0.0.0.0', port=8080) host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8080")),
debug=False,
)
+1
View File
@@ -0,0 +1 @@
codex resume 019e02d0-2723-70e1-8649-939ea7b70047
+7
View File
@@ -0,0 +1,7 @@
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
waitress>=3.0,<4.0
pytest>=8.0,<9.0
+17
View File
@@ -0,0 +1,17 @@
import os
from waitress import serve
from vfire_monitor import create_app
app = create_app(base_dir=os.getenv("VFM_BASE_DIR"))
if __name__ == "__main__":
serve(
app,
host=os.getenv("HOST", "127.0.0.1"),
port=int(os.getenv("PORT", "8088")),
threads=int(os.getenv("VFM_WAITRESS_THREADS", "8")),
)
+103
View File
@@ -0,0 +1,103 @@
import argparse
import logging
import signal
import threading
import time
from dataclasses import dataclass
from pyModbusTCP.server import DataBank, ModbusServer
LOGGER = logging.getLogger("nfs320-simulator")
REGISTER_COUNT = 600
STATUS_NORMAL = 5120
STATUS_INCIDENTE = 3088
STATUS_ACK = 3344
STATUS_REMOVIDO = 13312
@dataclass(frozen=True)
class SimPoint:
address: int
label: str
values: tuple[int, ...]
SIM_POINTS = [
SimPoint(10, "Detector Recepcao", (STATUS_NORMAL, STATUS_NORMAL, STATUS_INCIDENTE, STATUS_ACK)),
SimPoint(11, "Detector CPD", (STATUS_NORMAL, STATUS_INCIDENTE, STATUS_INCIDENTE, STATUS_ACK)),
SimPoint(12, "Detector Almoxarifado", (STATUS_NORMAL, STATUS_NORMAL, STATUS_NORMAL, STATUS_REMOVIDO)),
SimPoint(165, "Modulo Bomba Incendio", (STATUS_NORMAL, STATUS_NORMAL, STATUS_ACK, STATUS_NORMAL)),
SimPoint(166, "Modulo Porta Corta-Fogo", (STATUS_NORMAL, STATUS_ACK, STATUS_NORMAL, STATUS_NORMAL)),
SimPoint(167, "Modulo Sirene Pavimento 1", (STATUS_NORMAL, STATUS_NORMAL, STATUS_NORMAL, STATUS_INCIDENTE)),
SimPoint(352, "Painel Repetidor", (STATUS_NORMAL, STATUS_NORMAL, STATUS_NORMAL, STATUS_NORMAL)),
]
def build_registers(step: int) -> list[int]:
registers = [0] * REGISTER_COUNT
for point in SIM_POINTS:
registers[point.address - 1] = point.values[step % len(point.values)]
return registers
def apply_step(data_bank: DataBank, step: int) -> None:
data_bank.set_holding_registers(1, build_registers(step))
status_summary = ", ".join(
f"{point.label}={point.values[step % len(point.values)]}" for point in SIM_POINTS
)
LOGGER.info("Ciclo %s aplicado: %s", step, status_summary)
def parse_args():
parser = argparse.ArgumentParser(description="Simulador Modbus/TCP de central Notifier NFS-320")
parser.add_argument("--host", default="127.0.0.1", help="Host de bind do simulador")
parser.add_argument("--port", default=1502, type=int, help="Porta TCP do simulador")
parser.add_argument("--interval", default=5.0, type=float, help="Intervalo entre mudancas de estado")
parser.add_argument("--log-level", default="INFO", help="Nivel de log")
return parser.parse_args()
def main():
args = parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level.upper(), logging.INFO),
format="%(asctime)s [%(levelname)s] %(message)s",
)
stop_event = threading.Event()
data_bank = DataBank()
apply_step(data_bank, 0)
server = ModbusServer(host=args.host, port=args.port, no_block=True, data_bank=data_bank)
def rotate_states():
step = 1
while not stop_event.wait(args.interval):
apply_step(data_bank, step)
step += 1
def shutdown(*_args):
LOGGER.info("Encerrando simulador NFS-320")
stop_event.set()
server.stop()
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
LOGGER.info("Subindo simulador NFS-320 em %s:%s", args.host, args.port)
LOGGER.info("Points simulados: %s", ", ".join(f"{point.address}:{point.label}" for point in SIM_POINTS))
server.start()
worker = threading.Thread(target=rotate_states, daemon=True, name="nfs320-state-rotator")
worker.start()
try:
while not stop_event.wait(1):
pass
finally:
shutdown()
if __name__ == "__main__":
main()
+369
View File
@@ -0,0 +1,369 @@
:root {
--white: #fff;
--black: #000;
--gray-100: #f8f9fa;
--gray-200: #e9ecef;
--gray-400: #ced4da;
--gray-500: #adb5bd;
--gray-600: #6c757d;
--gray-700: #495057;
--gray-800: #343a40;
--gray-900: #212529;
--red: #dc3545;
--orange: #fd7e14;
--yellow: #ffc107;
--green: #198754;
--teal: #20c997;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
line-height: 1.5;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.container {
width: min(100% - 2rem, 1200px);
margin: 0 auto;
}
.row {
display: flex;
flex-wrap: wrap;
margin: calc(var(--gutter-y, 0) * -0.5) calc(var(--gutter-x, 0) * -0.5);
}
.row > * {
width: 100%;
padding: calc(var(--gutter-y, 0) * 0.5) calc(var(--gutter-x, 0) * 0.5);
}
.g-2 {
--gutter-x: 0.5rem;
--gutter-y: 0.5rem;
}
.g-3 {
--gutter-x: 1rem;
--gutter-y: 1rem;
}
.col-md-2,
.col-md-3,
.col-md-4,
.col-md-6,
.col-lg-6,
.col-xl-3 {
flex: 0 0 100%;
max-width: 100%;
}
@media (min-width: 768px) {
.col-md-2 { flex: 0 0 16.666667%; max-width: 16.666667%; }
.col-md-3 { flex: 0 0 25%; max-width: 25%; }
.col-md-4 { flex: 0 0 33.333333%; max-width: 33.333333%; }
.col-md-6 { flex: 0 0 50%; max-width: 50%; }
}
@media (min-width: 992px) {
.col-lg-6 { flex: 0 0 50%; max-width: 50%; }
}
@media (min-width: 1200px) {
.col-xl-3 { flex: 0 0 25%; max-width: 25%; }
}
.d-block { display: block; }
.d-flex { display: flex; }
.flex-wrap { flex-wrap: wrap; }
.flex-grow-1 { flex-grow: 1; }
.justify-content-between { justify-content: space-between; }
.justify-content-center { justify-content: center; }
.align-items-center { align-items: center; }
.align-items-end { align-items: flex-end; }
.text-center { text-align: center; }
.text-end { text-align: end; }
.text-uppercase { text-transform: uppercase; }
.fw-semibold { font-weight: 600; }
.small { font-size: 0.875rem; }
.h3, .h4, .h6 { margin: 0; font-weight: 600; line-height: 1.2; }
.h3 { font-size: 1.75rem; }
.h4 { font-size: 1.5rem; }
.h6 { font-size: 1rem; }
.fs-5 { font-size: 1.25rem; }
.fs-6 { font-size: 1rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 1rem; }
.mb-0 { margin-bottom: 0; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 1rem; }
.mb-4 { margin-bottom: 1.5rem; }
.mt-3 { margin-top: 1rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-3 { padding-top: 1rem; padding-bottom: 1rem; }
.py-4 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.p-3 { padding: 1rem; }
.w-100 { width: 100%; }
.rounded { border-radius: 0.5rem; }
.sticky-top { position: sticky; top: 0; z-index: 1020; }
.border-bottom { border-bottom: 1px solid rgba(255, 255, 255, 0.08); }
.text-light { color: var(--gray-100); }
.text-secondary { color: var(--gray-500); }
.text-danger { color: #ff7b89; }
.bg-dark { background: rgba(0, 0, 0, 0.35); }
.bg-black { background: rgba(0, 0, 0, 0.55); }
.bg-danger { background: rgba(220, 53, 69, 0.9); color: var(--white); }
.bg-secondary { background: rgba(108, 117, 125, 0.95); color: var(--white); }
.bg-success { background: rgba(25, 135, 84, 0.95); color: var(--white); }
.border-secondary { border-color: rgba(255, 255, 255, 0.14); }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.65rem 1rem;
border-radius: 0.6rem;
border: 1px solid transparent;
background: transparent;
color: var(--white);
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-sm {
padding: 0.45rem 0.8rem;
font-size: 0.875rem;
}
.btn-danger {
background: var(--red);
border-color: var(--red);
}
.btn-success {
background: var(--green);
border-color: var(--green);
}
.btn-warning {
background: var(--yellow);
border-color: var(--yellow);
color: var(--gray-900);
}
.btn-outline-light {
border-color: rgba(255, 255, 255, 0.35);
}
.btn-outline-danger {
border-color: rgba(220, 53, 69, 0.75);
color: #ff9ba6;
}
.btn-outline-secondary {
border-color: rgba(173, 181, 189, 0.5);
color: var(--gray-200);
}
.form-label,
.form-text {
display: block;
}
.form-label {
margin-bottom: 0.35rem;
}
.form-text {
margin-top: 0.35rem;
font-size: 0.875rem;
}
.form-control {
width: 100%;
padding: 0.7rem 0.85rem;
border-radius: 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.35);
color: var(--gray-100);
}
.form-control:focus {
outline: 2px solid rgba(255, 90, 61, 0.35);
border-color: rgba(255, 90, 61, 0.55);
}
.form-control-sm {
padding: 0.55rem 0.7rem;
font-size: 0.875rem;
}
.alert {
position: relative;
padding: 0.8rem 1rem;
border-radius: 0.75rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-warning {
background: rgba(255, 193, 7, 0.14);
border-color: rgba(255, 193, 7, 0.35);
color: #ffe28a;
}
.alert-danger {
background: rgba(220, 53, 69, 0.14);
border-color: rgba(220, 53, 69, 0.35);
color: #ff9ba6;
}
.alert-success {
background: rgba(25, 135, 84, 0.14);
border-color: rgba(25, 135, 84, 0.35);
color: #8de5b6;
}
.badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-weight: 600;
}
.spinner-border {
display: inline-block;
width: 2rem;
height: 2rem;
border: 0.25em solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.modal {
position: fixed;
inset: 0;
z-index: 1050;
display: none;
overflow-y: auto;
padding: 1rem;
background: rgba(0, 0, 0, 0.6);
}
.modal.show {
display: block;
}
.modal-dialog {
width: min(100%, 560px);
margin: 2rem auto;
}
.modal-lg {
width: min(100%, 900px);
}
.modal-dialog-scrollable .modal-content {
max-height: calc(100vh - 4rem);
}
.modal-content {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1rem;
overflow: hidden;
}
.modal-header,
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.modal-body {
padding: 1rem 1.25rem;
overflow-y: auto;
}
.btn-close {
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--gray-100);
position: relative;
}
.btn-close::before,
.btn-close::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 1rem;
height: 2px;
background: currentColor;
transform-origin: center;
}
.btn-close::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.btn-close::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.btn-close-white {
color: var(--white);
}
textarea {
resize: vertical;
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="24" fill="#12171d" stroke="#ff9f1c" stroke-width="4"/>
<path d="M24 42c0-5 3-8 8-13 5 5 8 8 8 13a8 8 0 1 1-16 0Z" fill="#ff9f1c"/>
<path d="M24 18c0 3-2 4-2 7M32 16c0 4-2 5-2 8M40 18c0 3-2 4-2 7" stroke="#edf2f7" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<circle cx="32" cy="32" r="24" fill="#12171d" stroke="#ff5a3d" stroke-width="4"/>
<circle cx="32" cy="32" r="8" fill="#ff5a3d"/>
<path d="M18 20c2-4 5-6 9-7M37 13c4 1 7 3 9 7M16 32h32M20 44c6 5 18 5 24 0" stroke="#edf2f7" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect x="14" y="14" width="36" height="36" rx="8" fill="#12171d" stroke="#38bdf8" stroke-width="4"/>
<path d="M24 32h16M32 24v16" stroke="#38bdf8" stroke-width="4" stroke-linecap="round"/>
<circle cx="20" cy="20" r="2.5" fill="#edf2f7"/>
<circle cx="44" cy="20" r="2.5" fill="#edf2f7"/>
<circle cx="20" cy="44" r="2.5" fill="#edf2f7"/>
<circle cx="44" cy="44" r="2.5" fill="#edf2f7"/>
</svg>

After

Width:  |  Height:  |  Size: 477 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect x="12" y="8" width="40" height="48" rx="6" fill="#12171d" stroke="#22c55e" stroke-width="4"/>
<rect x="20" y="16" width="24" height="10" rx="2" fill="#0f172a" stroke="#22c55e" stroke-width="2"/>
<circle cx="24" cy="38" r="3" fill="#22c55e"/>
<circle cx="32" cy="38" r="3" fill="#ff9f1c"/>
<circle cx="40" cy="38" r="3" fill="#ef4444"/>
<path d="M22 48h20" stroke="#edf2f7" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 513 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<path d="M22 44h20l-3-18a7 7 0 0 0-14 0l-3 18Z" fill="#ef4444" stroke="#12171d" stroke-width="3"/>
<rect x="18" y="44" width="28" height="8" rx="3" fill="#edf2f7"/>
<path d="M18 20l-5-5M46 20l5-5M32 12V6" stroke="#edf2f7" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+53
View File
@@ -0,0 +1,53 @@
(function () {
class Modal {
constructor(element) {
this.element = element;
this.onBackdropClick = this.onBackdropClick.bind(this);
this.onKeydown = this.onKeydown.bind(this);
}
show() {
this.element.classList.add("show");
this.element.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
this.element.addEventListener("click", this.onBackdropClick);
document.addEventListener("keydown", this.onKeydown);
}
hide() {
this.element.classList.remove("show");
this.element.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
this.element.removeEventListener("click", this.onBackdropClick);
document.removeEventListener("keydown", this.onKeydown);
}
onBackdropClick(event) {
if (event.target === this.element || event.target.closest("[data-bs-dismiss='modal']")) {
this.hide();
}
}
onKeydown(event) {
if (event.key === "Escape") {
this.hide();
}
}
}
function installAlertDismiss() {
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-bs-dismiss='alert']");
if (!button) {
return;
}
const alert = button.closest(".alert");
if (alert) {
alert.remove();
}
});
}
installAlertDismiss();
window.bootstrap = { Modal };
})();
+461 -121
View File
@@ -1,169 +1,509 @@
<!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>
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor.css') }}">
<style> <style>
body { background: #0b0b0b; color: #e0e0e0; font-family: 'Segoe UI', sans-serif; } :root {
.header-panel { background: #151515; padding: 20px; border-bottom: 2px solid #222; margin-bottom: 30px; } --bg: #0c0f12;
.node-group { background: #111; border: 1px solid #222; border-radius: 15px; padding: 20px; margin-bottom: 30px; } --panel: #12171d;
.node-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #333; margin-bottom: 20px; padding-bottom: 10px; } --panel-soft: #1a2129;
.node-title { border-left: 4px solid #d9534f; padding-left: 15px; color: #fff; font-weight: bold; margin: 0; } --border: #2a333d;
.node-status-badge { font-size: 0.7em; padding: 4px 10px; border-radius: 20px; text-transform: uppercase; font-weight: bold; } --text: #edf2f7;
.card-sensor { background: #1a1a1a; border-radius: 12px; margin-bottom: 15px; padding: 15px; border-left: 5px solid #333; position: relative; } --muted: #94a3b8;
.badge-status { font-size: 0.75em; padding: 4px 10px; border-radius: 10px; font-weight: bold; } --accent: #ff5a3d;
.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; } body {
.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; } 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> </style>
</head> </head>
<body> <body>
<div id="loader" class="loading-overlay"> <div id="loader" class="loading-overlay">
<div class="spinner-border text-danger"></div> <div class="text-center">
<h5 class="mt-3 text-white">Varrendo central (1-600)...</h5> <div class="spinner-border text-danger"></div>
<p class="mt-3 mb-0 text-light">Executando varredura Modbus...</p>
</div>
</div> </div>
<div class="header-panel shadow-sm"> <header class="app-header border-bottom sticky-top">
<div class="container d-flex justify-content-between align-items-center"> <div class="container py-3 d-flex flex-wrap justify-content-between align-items-center gap-3">
<h2 class="mb-0 text-danger fw-bold">🔥 Notifier Voltec</h2> <div>
<div class="btn-group"> <div class="text-uppercase small text-secondary">Voltec</div>
<button class="btn btn-dark btn-sm" onclick="abrirModalNodes()">⚙️ Config</button> <h1 class="h3 mb-0">V-Fire Monitor</h1>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="openAccountModal()">Conta</button>
<button class="btn btn-outline-light btn-sm" onclick="openConfigModal()">Configurar</button>
<a href="/logout" class="btn btn-outline-danger btn-sm">Sair</a> <a href="/logout" class="btn btn-outline-danger btn-sm">Sair</a>
</div> </div>
</div> </div>
</div> </header>
<main class="container py-4">
<div id="alert-host"></div>
<div class="container">
{% if not licenciado %} {% if not licenciado %}
<div class="row justify-content-center mt-5"> <section class="row justify-content-center">
<div class="col-md-6 bg-dark p-5 rounded border border-danger text-center shadow-lg"> <div class="col-lg-6">
<h2 class="text-danger">🔒 Sistema Bloqueado</h2> <div class="panel text-center">
<p class="text-muted">ID: <code class="bg-black p-2 d-block my-2">{{ hwid }}</code></p> <h2 class="h4 text-danger">Sistema bloqueado</h2>
<textarea id="serial-in" class="form-control bg-black text-white mb-3" rows="3" placeholder="Serial"></textarea> <p class="text-secondary mb-3">Ative a licenca deste equipamento para liberar o monitoramento.</p>
<button class="btn btn-danger w-100" onclick="ativar()">ATIVAR</button> <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> </div>
</div> </section>
{% else %} {% 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;"> <section class="panel mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div class="flex-shrink-0"> <div>
<span class="badge {% if dias_restantes < 15 %}bg-danger text-white{% else %}bg-secondary text-white{% endif %} p-2 shadow-sm"> <div class="text-secondary small">Licenca</div>
⏳ {{ dias_restantes }} dias restantes <div class="fw-semibold">{{ lic_msg }}</div>
</span>
</div> </div>
<span class="badge {% if dias_restantes < 15 %}bg-danger{% else %}bg-secondary{% endif %} fs-6">
{{ dias_restantes }} dias restantes
</span>
</section>
<div class="flex-grow-1 text-end text-truncate" style="max-width: 400px;"> <section class="panel mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<span class="text-white fw-bold small" title="{{ lic_msg }}" style="letter-spacing: 0.5px; opacity: 0.9;"> <div>
{{ lic_msg }} <div class="text-secondary small">Monitor</div>
</span> <div class="fw-semibold">{{ "Em execucao" if monitor_status.running else "Parado" }}</div>
</div> </div>
</div> <div class="small text-secondary text-end">
<div>Ultimo ciclo: {{ monitor_status.last_cycle_completed_at or "--" }}</div>
<div>Ultima licenca: {{ monitor_status.last_license_message or "--" }}</div>
</div>
</section>
<div id="main-container"></div> <section id="main-container"></section>
{% endif %} {% endif %}
</div> </main>
<div class="modal fade" id="modalNodes" tabindex="-1"> <div class="modal fade" id="modalNodes" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark border-secondary"> <div class="modal-content bg-dark text-light 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-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="modal-body">
<div class="row mb-3"> <div class="row g-3 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">
<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> <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>
<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 id="nodes-list"></div>
</div> </div>
<div class="modal-footer border-secondary"><button class="btn btn-danger w-100" onclick="salvarTudo()">Salvar e Reiniciar</button></div> <div class="modal-footer border-secondary">
<button class="btn btn-danger w-100" onclick="saveConfig()">Salvar configuracao</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <div class="modal fade" id="modalAccount" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content bg-dark text-light border-secondary">
<div class="modal-header border-secondary">
<h2 class="modal-title fs-5">Conta</h2>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="account-username" class="form-label">Usuario</label>
<input type="text" id="account-username" class="form-control bg-black text-light border-secondary" value="{{ config.web_user }}">
</div>
<div class="mb-3">
<label for="account-current-password" class="form-label">Senha atual</label>
<input type="password" id="account-current-password" class="form-control bg-black text-light border-secondary">
</div>
<div class="mb-0">
<label for="account-new-password" class="form-label">Nova senha</label>
<input type="password" id="account-new-password" class="form-control bg-black text-light border-secondary">
<div class="form-text text-secondary">Use ao menos 8 caracteres.</div>
</div>
</div>
<div class="modal-footer border-secondary">
<button class="btn btn-danger w-100" onclick="saveAccount()">Salvar credenciais</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/vendor.js') }}"></script>
<script> <script>
const modalNodes = new bootstrap.Modal(document.getElementById('modalNodes')); const modalNodes = new bootstrap.Modal(document.getElementById("modalNodes"));
const modalAccount = new bootstrap.Modal(document.getElementById("modalAccount"));
const csrfToken = {{ csrf_token | tojson }};
let nodesAtuais = {{ config.nodes | tojson }}; let nodesAtuais = {{ config.nodes | tojson }};
async function ativar() { function escapeHtml(value) {
const serial = document.getElementById('serial-in').value.trim(); return String(value)
const res = await fetch('/api/license', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({serial}) }); .replaceAll("&", "&amp;")
if(res.ok) { alert("Sucesso!"); location.reload(); } else { alert("Erro!"); } .replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
} }
function abrirModalNodes() { function showAlert(message, type = "danger") {
const list = document.getElementById('nodes-list'); const host = document.getElementById("alert-host");
list.innerHTML = ''; if (!host) {
nodesAtuais.forEach((node, idx) => { return;
const div = document.createElement('div'); }
div.className = 'node-row'; host.innerHTML = `
div.innerHTML = ` <div class="alert alert-${type} alert-dismissible fade show" role="alert">
<div class="row g-2 align-items-end"> ${escapeHtml(message)}
<div class="col-md-3"><input type="text" class="form-control form-control-sm n-nome" value="${node.nome}"></div> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Fechar"></button>
<div class="col-md-4"><input type="text" class="form-control form-control-sm n-ip" value="${node.ip}"></div> </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>`; async function apiFetch(url, options = {}) {
list.appendChild(div); const headers = new Headers(options.headers || {});
}); if (!headers.has("X-CSRF-Token")) {
headers.set("X-CSRF-Token", csrfToken);
}
const response = await fetch(url, { ...options, headers });
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.message || "Falha ao processar a requisicao.");
}
return body;
}
function renderNodeRow(node = { nome: "", ip: "", unit: 3, port: 502 }) {
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-3">
<label class="form-label small text-secondary">IP</label>
<input type="text" class="form-control form-control-sm n-ip" value="${escapeHtml(node.ip)}" placeholder="192.168.0.10">
</div>
<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-2">
<label class="form-label small text-secondary">Porta</label>
<input type="number" class="form-control form-control-sm n-port" value="${Number(node.port || 502)}" min="1" max="65535">
</div>
<div class="col-md-2 d-flex gap-2">
<button class="btn btn-warning btn-sm flex-grow-1 scan-btn" type="button">Scan</button>
<button class="btn btn-outline-danger btn-sm remove-btn" type="button">Remover</button>
</div>
</div>`;
container.querySelector(".remove-btn").addEventListener("click", () => container.remove());
container.querySelector(".scan-btn").addEventListener("click", () => scanSingleNode(container));
return container;
}
function openConfigModal() {
const list = document.getElementById("nodes-list");
list.innerHTML = "";
nodesAtuais.forEach((node) => list.appendChild(renderNodeRow(node)));
modalNodes.show(); modalNodes.show();
} }
function addNodeRow() { function addNodeRow() {
const div = document.createElement('div'); document.getElementById("nodes-list").appendChild(renderNodeRow());
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() { function openAccountModal() {
document.getElementById("account-current-password").value = "";
document.getElementById("account-new-password").value = "";
modalAccount.show();
}
function collectNodes() {
const nodes = []; const nodes = [];
document.querySelectorAll('.node-row').forEach(r => { document.querySelectorAll(".node-row").forEach((row) => {
const n = r.querySelector('.n-nome').value; const nome = row.querySelector(".n-nome").value.trim();
const i = r.querySelector('.n-ip').value; const ip = row.querySelector(".n-ip").value.trim();
const u = r.querySelector('.n-unit').value; const unit = Number(row.querySelector(".n-unit").value);
if(n && i) nodes.push({ nome: n, ip: i, unit: parseInt(u) }); const port = Number(row.querySelector(".n-port").value);
}); if (nome && ip) {
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 }) }); nodes.push({ nome, ip, unit, port });
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){} });
return nodes;
} }
setInterval(atualizar, 3000); atualizar();
async function activateLicense() {
const serial = document.getElementById("serial-in").value.trim();
try {
const data = await apiFetch("/api/license", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serial })
});
showAlert(`${data.message}.`, "success");
location.reload();
} catch (error) {
showAlert(error.message);
}
}
async function saveConfig() {
const payload = {
zabbix_server: document.getElementById("zab-server").value.trim(),
hostname_zabbix: document.getElementById("zab-host").value.trim(),
nodes: collectNodes()
};
try {
await apiFetch("/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
location.reload();
} catch (error) {
showAlert(error.message);
}
}
async function scanSingleNode(row) {
const node = {
nome: row.querySelector(".n-nome").value.trim(),
ip: row.querySelector(".n-ip").value.trim(),
unit: Number(row.querySelector(".n-unit").value),
port: Number(row.querySelector(".n-port").value)
};
document.getElementById("loader").style.display = "flex";
modalNodes.hide();
try {
const data = await apiFetch("/api/rescan_node", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(node)
});
showAlert(`Scan concluido. ${data.count} pontos encontrados.`, "success");
setTimeout(() => location.reload(), 500);
} catch (error) {
document.getElementById("loader").style.display = "none";
showAlert(error.message);
}
}
async function renameDevice(key, currentLabel) {
const nextLabel = prompt("Novo nome do dispositivo:", currentLabel);
if (!nextLabel || nextLabel === currentLabel) {
return;
}
try {
await apiFetch("/api/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ addr: key, label: nextLabel })
});
await refreshData();
} catch (error) {
showAlert(error.message);
}
}
async function saveAccount() {
const payload = {
username: document.getElementById("account-username").value.trim(),
current_password: document.getElementById("account-current-password").value,
new_password: document.getElementById("account-new-password").value
};
try {
await apiFetch("/api/account", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
showAlert("Credenciais atualizadas. Entre novamente com a nova conta.", "success");
setTimeout(() => location.assign("/login"), 800);
} 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> </script>
</body> </body>
</html> </html>
+47 -12
View File
@@ -1,22 +1,57 @@
<!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">
<title>Login | V-Fire Monitor</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor.css') }}">
<style> <style>
body { background: #0f0f0f; color: white; display: flex; align-items: center; height: 100vh; justify-content: center; } body {
.login-card { background: #1a1a1a; padding: 30px; border-radius: 15px; border: 1px solid #333; width: 320px; } 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> </style>
</head> </head>
<body> <body>
<div class="login-card shadow"> <section class="login-card">
<h4 class="text-center text-danger mb-4">Acesso Notifier</h4> <div class="text-uppercase small text-secondary mb-2">Voltec</div>
<form method="POST"> <h1 class="h4 mb-4">Acesso ao V-Fire Monitor</h1>
<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> {% with messages = get_flashed_messages() %}
{% if messages %}
<div class="alert alert-warning py-2" role="alert">{{ messages[0] }}</div>
{% endif %}
{% endwith %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="username" class="form-label">Usuario</label>
<input id="username" type="text" name="username" class="form-control bg-dark text-light border-secondary" required autofocus>
</div>
<div class="mb-4">
<label for="password" class="form-label">Senha</label>
<input id="password" type="password" name="password" class="form-control bg-dark text-light border-secondary" required>
</div>
<button type="submit" class="btn btn-danger w-100">Entrar</button> <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> </form>
</div> </section>
<script src="{{ url_for('static', filename='js/vendor.js') }}"></script>
</body> </body>
</html> </html>
+203
View File
@@ -0,0 +1,203 @@
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_login_payload,
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, "port": 502}
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_validate_node_payload_rejects_invalid_ip():
with pytest.raises(ValueError):
validate_node_payload({"nome": "Central A", "ip": "999.1.1.1", "unit": 3})
def test_validate_node_payload_accepts_custom_port():
node = validate_node_payload({"nome": "Central A", "ip": "10.0.0.1", "unit": 3, "port": "1502"})
assert node["port"] == 1502
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_license_service_install_rejects_wrong_hwid(tmp_path, monkeypatch):
monkeypatch.setattr("vfire_monitor.core.get_hardware_id", lambda: "HW-123")
key = Fernet.generate_key()
payload = {"h": "HW-999", "c": "Cliente", "e": "2099-01-01"}
serial = Fernet(key).encrypt(json.dumps(payload).encode("utf-8")).decode("utf-8")
service = LicenseService(tmp_path / "license.key", key)
with pytest.raises(ValueError):
service.install(serial)
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)
def test_validate_login_payload_requires_minimum_password_length():
with pytest.raises(ValueError):
validate_login_payload("admin", "1234567")
@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 csrf_token(client, path="/login"):
client.get(path)
with client.session_transaction() as session:
return session["csrf_token"]
def login(client):
return client.post(
"/login",
data={"username": "admin", "password": "admin", "csrf_token": csrf_token(client)},
follow_redirects=False,
)
def test_login_success(client):
response = login(client)
assert response.status_code == 302
def test_login_requires_csrf(client):
response = client.post("/login", data={"username": "admin", "password": "admin"}, follow_redirects=False)
assert response.status_code == 302
def test_api_nodes_requires_auth(client):
response = client.post("/api/nodes", json={}, headers={"X-CSRF-Token": csrf_token(client)})
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, "port": 1502}],
},
headers={"X-CSRF-Token": csrf_token(client, "/")},
)
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"
assert config["nodes"][0]["port"] == 1502
def test_api_nodes_rejects_missing_csrf(client):
login(client)
response = client.post("/api/nodes", json={"zabbix_server": "127.0.0.1", "hostname_zabbix": "HOST-01", "nodes": []})
assert response.status_code == 403
def test_healthz_is_public(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.get_json()["status"] == "ok"
def test_api_account_updates_credentials(client, app):
login(client)
response = client.post(
"/api/account",
json={
"username": "operador",
"current_password": "admin",
"new_password": "senha-forte-123",
},
headers={"X-CSRF-Token": csrf_token(client, "/")},
)
assert response.status_code == 200
assert response.get_json()["status"] == "success"
runtime_state = app.extensions["runtime_state"]
assert runtime_state.authenticate("operador", "senha-forte-123") is True
def test_api_system_status_returns_monitor_snapshot(client):
login(client)
response = client.get("/api/system/status")
assert response.status_code == 200
body = response.get_json()
assert body["status"] == "success"
assert "monitor" in body
+120
View File
@@ -0,0 +1,120 @@
import argparse
import json
from pathlib import Path
VALID_ICONS = {
"detector-smoke",
"detector-heat",
"module-input",
"siren",
"panel-nfs320",
}
def parse_args():
parser = argparse.ArgumentParser(description="Gera bundle versionavel de planta baixa para o Grafana")
parser.add_argument("spec", help="Arquivo JSON com o mapa da planta")
parser.add_argument(
"--output",
help="Arquivo de saida. Default: mesmo nome com sufixo .bundle.json",
)
return parser.parse_args()
def validate_item(item: dict, seen_ids: set[str]) -> dict:
item_id = str(item.get("id", "")).strip()
if not item_id:
raise ValueError("Cada item precisa de id.")
if item_id in seen_ids:
raise ValueError(f"Item duplicado: {item_id}")
seen_ids.add(item_id)
icon = str(item.get("icon", "")).strip()
if icon not in VALID_ICONS:
raise ValueError(f"Icone invalido para item {item_id}: {icon}")
label = str(item.get("label", "")).strip()
item_key = str(item.get("item_key", "")).strip()
if not label:
raise ValueError(f"Label obrigatorio para item {item_id}.")
if not item_key:
raise ValueError(f"item_key obrigatorio para item {item_id}.")
for field in ("x", "y", "width", "height"):
value = item.get(field)
if not isinstance(value, int) or value < 0:
raise ValueError(f"Campo {field} invalido para item {item_id}.")
rotation = item.get("rotation", 0)
if not isinstance(rotation, int):
raise ValueError(f"rotation invalido para item {item_id}.")
return {
"id": item_id,
"label": label,
"item_key": item_key,
"icon": icon,
"x": item["x"],
"y": item["y"],
"width": item["width"],
"height": item["height"],
"rotation": rotation,
}
def build_bundle(spec: dict) -> dict:
title = str(spec.get("title", "")).strip()
dashboard_uid = str(spec.get("grafana_dashboard_uid", "")).strip()
background_image_url = str(spec.get("background_image_url", "")).strip()
vfire_base_url = str(spec.get("vfire_base_url", "")).rstrip("/")
zabbix_host = str(spec.get("zabbix_host", "")).strip()
if not title:
raise ValueError("title obrigatorio.")
if not dashboard_uid:
raise ValueError("grafana_dashboard_uid obrigatorio.")
if not background_image_url:
raise ValueError("background_image_url obrigatorio.")
if not vfire_base_url:
raise ValueError("vfire_base_url obrigatorio.")
if not zabbix_host:
raise ValueError("zabbix_host obrigatorio.")
raw_items = spec.get("items")
if not isinstance(raw_items, list) or not raw_items:
raise ValueError("items deve conter ao menos um elemento.")
seen_ids: set[str] = set()
items = [validate_item(item, seen_ids) for item in raw_items]
for item in items:
item["icon_url"] = f"{vfire_base_url}/static/grafana-icons/{item['icon']}.svg"
item["zabbix_ref"] = {
"host": zabbix_host,
"item_key": item["item_key"],
}
return {
"title": title,
"grafana_dashboard_uid": dashboard_uid,
"background_image_url": background_image_url,
"vfire_base_url": vfire_base_url,
"zabbix_host": zabbix_host,
"items": items,
}
def main():
args = parse_args()
spec_path = Path(args.spec)
output_path = Path(args.output) if args.output else spec_path.with_suffix(".bundle.json")
spec = json.loads(spec_path.read_text(encoding="utf-8"))
bundle = build_bundle(spec)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False), encoding="utf-8")
print(output_path)
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
import atexit
import logging
import os
from pathlib import Path
from flask import Flask
from flask_login import LoginManager
from werkzeug.middleware.proxy_fix import ProxyFix
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 env_flag(name: str, default: bool = False) -> bool:
raw_value = os.getenv(name)
if raw_value is None:
return default
return raw_value.strip().lower() in {"1", "true", "yes", "on"}
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
package_root = Path(__file__).resolve().parent.parent
template_dir = resolved_base_dir / "templates"
static_dir = resolved_base_dir / "static"
if not template_dir.exists():
template_dir = package_root / "templates"
if not static_dir.exists():
static_dir = package_root / "static"
secret_path = resolved_base_dir / "app_secret.key"
default_password = os.getenv("VFM_DEFAULT_PASSWORD", "admin")
app = Flask(
__name__,
template_folder=str(template_dir),
static_folder=str(static_dir),
)
app.secret_key = os.getenv("VFM_APP_SECRET") or ensure_secret_key(secret_path)
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=env_flag("VFM_SESSION_COOKIE_SECURE", is_production_mode()),
REMEMBER_COOKIE_HTTPONLY=True,
REMEMBER_COOKIE_SAMESITE="Lax",
MAX_CONTENT_LENGTH=int(os.getenv("VFM_MAX_CONTENT_LENGTH", str(1024 * 1024))),
)
if env_flag("VFM_TRUST_PROXY"):
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
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)
@app.after_request
def apply_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Cache-Control"] = "no-store"
return response
if start_monitor:
monitor_engine = MonitorEngine(runtime_state, license_service)
app.extensions["monitor_engine"] = monitor_engine
monitor_engine.start()
atexit.register(monitor_engine.stop)
return app
+635
View File
@@ -0,0 +1,635 @@
import json
import logging
import os
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from ipaddress import ip_address
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": [],
}
DEFAULT_MODBUS_PORT = 502
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 write_private_text(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
if os.name != "nt":
path.chmod(0o600)
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")
write_private_text(secret_path, secret)
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 {})
legacy_password = str((data or {}).get("web_password", "")).strip()
if legacy_password:
config["web_password_hash"] = generate_password_hash(legacy_password)
config.pop("web_password", None)
config["web_user"] = str(config.get("web_user", DEFAULT_CONFIG_TEMPLATE["web_user"])).strip() or DEFAULT_CONFIG_TEMPLATE[
"web_user"
]
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")
allow_legacy = os.getenv("VFM_ALLOW_LEGACY_LICENSE_KEY", os.getenv("ALLOW_LEGACY_LICENSE_KEY", "0")) == "1"
if env_key:
return env_key.encode("utf-8")
if require_env and not allow_legacy:
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:
ip_address(ip)
except ValueError:
raise ValueError(f"IP invalido para node {nome}.") from None
try:
unit = int(data.get("unit"))
except (TypeError, ValueError):
raise ValueError(f"Unit invalido para node {nome}.") from None
try:
port = int(data.get("port", DEFAULT_MODBUS_PORT))
except (TypeError, ValueError):
raise ValueError(f"Porta invalida para node {nome}.") from None
if unit < 0 or unit > 255:
raise ValueError(f"Unit fora do intervalo para node {nome}.")
if port < 1 or port > 65535:
raise ValueError(f"Porta fora do intervalo para node {nome}.")
return {"nome": nome, "ip": ip, "unit": unit, "port": port}
def validate_login_payload(username: str, password: str) -> tuple[str, str]:
normalized_username = username.strip()
if not normalized_username:
raise ValueError("Usuario e obrigatorio.")
if len(normalized_username) > 64:
raise ValueError("Usuario excede o limite de 64 caracteres.")
if len(password) < 8:
raise ValueError("Senha deve ter ao menos 8 caracteres.")
if len(password) > 128:
raise ValueError("Senha excede o limite de 128 caracteres.")
return normalized_username, password
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=node.get("port", DEFAULT_MODBUS_PORT),
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 install(self, serial: str) -> LicenseStatus:
cleaned = serial.strip()
if not cleaned:
raise ValueError("Serial vazio.")
try:
payload = json.loads(self.fernet.decrypt(cleaned.encode("utf-8")).decode("utf-8"))
licensed_hwid = str(payload["h"]).strip()
hardware_id = get_hardware_id().strip()
if licensed_hwid != hardware_id:
raise ValueError("HWID incompativel com este equipamento.")
expires_at = datetime.strptime(str(payload["e"]).strip(), "%Y-%m-%d")
days_remaining = (expires_at.date() - datetime.now().date()).days
if days_remaining < 0:
raise ValueError("Licenca expirada.")
except InvalidToken:
raise ValueError("Serial invalido.") from None
except (KeyError, json.JSONDecodeError, OSError, ValueError):
raise
write_private_text(self.license_path, cleaned)
return LicenseStatus(True, f"Licenciado para {payload['c']}", hardware_id, days_remaining)
@dataclass
class MonitorStatus:
running: bool
iteration: int
last_cycle_started_at: str | None
last_cycle_completed_at: str | None
last_error: str | None
last_license_message: str | None
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.monitor_status = MonitorStatus(
running=False,
iteration=0,
last_cycle_started_at=None,
last_cycle_completed_at=None,
last_error=None,
last_license_message=None,
)
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 get_monitor_status(self) -> MonitorStatus:
with self.lock:
return MonitorStatus(**self.monitor_status.__dict__)
def save_config(self) -> None:
with self.lock:
self.config_store.save(self.config)
def trigger_restart(self) -> None:
self.restart_event.set()
def stop(self) -> None:
self.stop_event.set()
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_credentials(self, username: str, password: str) -> None:
normalized_username, normalized_password = validate_login_payload(username, password)
with self.lock:
self.config["web_user"] = normalized_username
self.config["web_password_hash"] = generate_password_hash(normalized_password)
self.config_store.save(self.config)
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()
def update_monitor_status(
self,
*,
running: bool | None = None,
iteration: int | None = None,
last_cycle_started_at: str | None = None,
last_cycle_completed_at: str | None = None,
last_error: str | None = None,
last_license_message: str | None = None,
) -> None:
with self.lock:
if running is not None:
self.monitor_status.running = running
if iteration is not None:
self.monitor_status.iteration = iteration
if last_cycle_started_at is not None:
self.monitor_status.last_cycle_started_at = last_cycle_started_at
if last_cycle_completed_at is not None:
self.monitor_status.last_cycle_completed_at = last_cycle_completed_at
if last_error is not None:
self.monitor_status.last_error = last_error
if last_license_message is not None:
self.monitor_status.last_license_message = last_license_message
class MonitorEngine:
def __init__(self, runtime_state: RuntimeState, license_service: LicenseService):
self.runtime_state = runtime_state
self.license_service = license_service
self.thread: threading.Thread | None = None
def start(self) -> None:
if self.thread and self.thread.is_alive():
return
self.thread = threading.Thread(target=self.run, daemon=True, name="monitor-engine")
self.thread.start()
def stop(self, timeout: float = 5.0) -> None:
self.runtime_state.stop()
if self.thread and self.thread.is_alive():
self.thread.join(timeout=timeout)
def run(self) -> None:
self.runtime_state.update_monitor_status(running=True, last_error="")
iteration = 0
while not self.runtime_state.stop_event.is_set():
iteration += 1
self.runtime_state.update_monitor_status(
iteration=iteration,
last_cycle_started_at=datetime.now().isoformat(timespec="seconds"),
last_error="",
)
license_status = self.license_service.verify()
self.runtime_state.update_monitor_status(last_license_message=license_status.message)
if not license_status.valid:
with self.runtime_state.lock:
self.runtime_state.status_nodes_web = {"SISTEMA": "BLOQUEADO"}
self.runtime_state.dados_tempo_real = {}
self.runtime_state.update_monitor_status(
last_cycle_completed_at=datetime.now().isoformat(timespec="seconds"),
)
self.runtime_state.stop_event.wait(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)
self.runtime_state.update_monitor_status(
last_cycle_completed_at=datetime.now().isoformat(timespec="seconds"),
)
self.runtime_state.stop_event.wait(POLL_INTERVAL_SECONDS)
self.runtime_state.update_monitor_status(running=False)
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)
self.runtime_state.update_monitor_status(last_error=str(exc))
def _poll_node(self, node: dict, device_map: dict, hostname: str):
client = ModbusClient(
host=node["ip"],
port=node.get("port", DEFAULT_MODBUS_PORT),
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)
self.runtime_state.update_monitor_status(last_error=f"{node['nome']}: {exc}")
finally:
client.close()
return node_metrics, node_data, node_status
+243
View File
@@ -0,0 +1,243 @@
import secrets
from flask import current_app, flash, jsonify, redirect, render_template, request, session, 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_login_payload,
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 get_csrf_token() -> str:
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return token
def validate_csrf() -> bool:
expected = session.get("csrf_token")
provided = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token")
return bool(expected and provided and secrets.compare_digest(expected, provided))
def register_routes(app):
@app.route("/healthz")
def healthz():
return jsonify({"status": "ok"})
@app.before_request
def enforce_csrf():
if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.endpoint != "healthz":
if not validate_csrf():
if request.path.startswith("/api/"):
return json_error("CSRF invalido.", 403)
flash("Sessao expirada. Tente novamente.")
return redirect(url_for("login"))
@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))
session.permanent = True
session["csrf_token"] = secrets.token_urlsafe(32)
return redirect(url_for("index"))
flash("Usuario ou senha invalidos.")
return render_template("login.html", csrf_token=get_csrf_token())
@app.route("/")
@login_required
def index():
config, _, _, _ = get_runtime_state().snapshot()
license_status = get_license_service().verify()
monitor_status = get_runtime_state().get_monitor_status()
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,
csrf_token=get_csrf_token(),
monitor_status=monitor_status,
)
@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"])
@login_required
def api_license():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return json_error("JSON invalido.")
try:
status = get_license_service().install(str(payload.get("serial", "")))
except ValueError as exc:
return json_error(str(exc))
get_runtime_state().trigger_restart()
return jsonify({"status": "success", "message": status.message, "days_remaining": status.days_remaining})
@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("/api/system/status")
@login_required
def api_system_status():
config, device_map, realtime_data, node_statuses = get_runtime_state().snapshot()
license_status = get_license_service().verify()
monitor_status = get_runtime_state().get_monitor_status()
return jsonify(
{
"status": "success",
"license": {
"valid": license_status.valid,
"message": license_status.message,
"hardware_id": license_status.hardware_id,
"days_remaining": license_status.days_remaining,
},
"monitor": monitor_status.__dict__,
"counts": {
"nodes": len(config.get("nodes", [])),
"devices": len(device_map),
"realtime_points": len(realtime_data),
},
"nodes_status": node_statuses,
}
)
@app.route("/api/account", methods=["POST"])
@login_required
def api_account():
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return json_error("JSON invalido.")
username = str(payload.get("username", ""))
current_password = str(payload.get("current_password", ""))
new_password = str(payload.get("new_password", ""))
if not get_runtime_state().authenticate(current_user.get_id(), current_password):
return json_error("Senha atual invalida.", 403)
try:
validated_username, validated_password = validate_login_payload(username, new_password)
except ValueError as exc:
return json_error(str(exc))
get_runtime_state().update_credentials(validated_username, validated_password)
logout_user()
session.clear()
return jsonify({"status": "success"})
@app.route("/logout")
@login_required
def logout():
logout_user()
session.clear()
return redirect(url_for("login"))
+39
View File
@@ -0,0 +1,39 @@
zabbix_export:
version: '6.4'
template_groups:
- uuid: 3de34eedec1948f788caa6d60b91919f
name: Notifier
templates:
- uuid: 2f71f1ad1fe9497ca4a181954c8d7965
template: 'Notifier NFS320'
name: 'Notifier NFS320'
groups:
- name: Notifier
discovery_rules:
- uuid: c911a4cbcab645f393b7559c71c600a4
name: 'Descoberta de Pontos Notifier'
type: TRAP
key: notifier.discovery
delay: '0'
lifetime: 0d
item_prototypes:
- uuid: c529d9ef577b4f78b68d413dc19b402b
name: 'Status de Conexão: {#NODE_NOME}'
type: TRAP
key: 'node.status[{#NODE_NOME}]'
delay: '0'
- uuid: 81b80f0540ac487099fc9c68497df18a
name: 'Comunicação do Ponto: {#PONTO_LABEL}'
type: TRAP
key: 'notifier.comm[{#PONTO_LABEL}]'
delay: '0'
trigger_prototypes:
- uuid: e690b14af4f049649bd1cd36347b5246
expression: 'last(/Notifier NFS320/notifier.comm[{#PONTO_LABEL}])<>20'
name: 'INCIDENTE NO DISPOSITIVO {#PONTO_LABEL}'
priority: WARNING
- uuid: 7b3b024198cc4b6a94f7cb38b5368ff0
name: 'Status do Ponto: {#PONTO_LABEL}'
type: TRAP
key: 'notifier.status[{#PONTO_LABEL}]'
delay: '0'