Add production installer and observability tooling

This commit is contained in:
2026-05-07 12:11:15 -03:00
parent a19ac9f00c
commit 5d92a6c99a
30 changed files with 3493 additions and 1015 deletions
+4
View File
@@ -3,4 +3,8 @@ VFM_APP_SECRET=
VFM_LICENSE_MASTER_KEY= VFM_LICENSE_MASTER_KEY=
VFM_ENV= VFM_ENV=
VFM_LOG_LEVEL=INFO VFM_LOG_LEVEL=INFO
VFM_SESSION_COOKIE_SECURE=
VFM_TRUST_PROXY=
VFM_MAX_CONTENT_LENGTH=1048576
HOST=0.0.0.0
PORT=8080 PORT=8080
+2
View File
@@ -1,6 +1,8 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
.venv/
.codex
config_nodes.json config_nodes.json
mapa_dispositivos.json mapa_dispositivos.json
license.key license.key
+155 -55
View File
@@ -1,97 +1,197 @@
# V-Fire Monitor # V-Fire Monitor
Aplicacao Flask para monitoramento de centrais Notifier via Modbus/TCP, com painel web, descoberta de pontos, integracao com Zabbix e licenciamento por hardware. 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 ## O que mudou nesta refatoracao
- Persistencia JSON com escrita atomica e migracao de configuracao legada. - 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. - 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. - 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 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. - Logging basico para diagnostico, no lugar de falhas silenciosas.
- Polling Modbus e discovery por blocos, reduzindo chamadas individuais. - Polling Modbus e discovery por blocos, reduzindo chamadas individuais.
- Frontend com tratamento de erro e textos corrigidos. - 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. - Gerador de licenca com argumentos de linha de comando e validacao de data.
## Estrutura ## Estrutura
- `monitor.py`: entrypoint simples da aplicacao Flask. - `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/__init__.py`: app factory e bootstrap da aplicacao.
- `vfire_monitor/core.py`: regras de negocio, persistencia, licenca e engine de monitoramento. - `vfire_monitor/core.py`: regras de negocio, persistencia, licenca e engine de monitoramento.
- `vfire_monitor/routes.py`: rotas web e APIs. - `vfire_monitor/routes.py`: rotas web e APIs.
- `generator.py`: gerador de serial de licenca. - `generator.py`: gerador de serial de licenca.
- `static/`: assets locais de interface carregados pela aplicacao.
- `templates/`: telas do login e dashboard. - `templates/`: telas do login e dashboard.
- `tests/`: suite inicial de testes automatizados. - `tests/`: suite inicial de testes automatizados.
- `config_nodes.json`: configuracao persistida da aplicacao. - `config_nodes.json`: configuracao persistida da aplicacao.
- `mapa_dispositivos.json`: mapa de dispositivos descobertos. - `mapa_dispositivos.json`: mapa de dispositivos descobertos.
- `license.key`: serial instalado localmente. - `license.key`: serial instalado localmente.
- `app_secret.key`: segredo de sessao gerado automaticamente na primeira execucao. - `app_secret.key`: segredo de sessao gerado automaticamente na primeira execucao.
## Requisitos ## Requisitos
- Python 3.10+ - Python 3.10+
- Conectividade com as centrais via Modbus/TCP - Conectividade com as centrais via Modbus/TCP
- Acesso ao servidor Zabbix, quando a integracao estiver habilitada - Acesso ao servidor Zabbix, quando a integracao estiver habilitada
- Para instalacao automatizada da stack completa, Debian 13 (trixie)
Instalacao:
Instalacao:
```bash
pip install -r requirements.txt ```bash
``` pip install -r requirements.txt
```
## Variaveis de ambiente
## Variaveis de ambiente
Veja `.env.example`.
Veja `.env.example`.
As principais:
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_DEFAULT_PASSWORD`: senha inicial do usuario `admin` na primeira carga do sistema.
- `VFM_LICENSE_MASTER_KEY`: chave mestre do licenciamento. Em producao, use esta variavel e remova a dependencia da chave legada. - `VFM_APP_SECRET`: opcional, substitui o segredo salvo em `app_secret.key`.
- `VFM_ENV`: use `production` para obrigar `VFM_LICENSE_MASTER_KEY` no startup. - `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_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. - `PORT`: porta HTTP da aplicacao.
Cada central aceita:
- `nome`
- `ip`
- `unit`
- `port`: opcional na integracao Modbus, default `502`
## Execucao ## Execucao
```bash ```bash
python monitor.py python monitor.py
``` ```
O sistema sobe em `http://0.0.0.0:8080` por padrao. O sistema sobe em `http://0.0.0.0:8080` por padrao.
## Geracao de licenca Healthcheck:
Modo interativo:
```bash ```bash
python generator.py curl http://127.0.0.1:8080/healthz
``` ```
Modo por argumentos: Status operacional autenticado:
```bash ```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 python generator.py --hwid "UUID-DO-CLIENTE" --cliente "Cliente" --expira 2026-12-31
``` ```
## Observacoes operacionais ## 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`. - 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`. - 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 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. - Em ambiente de producao (`VFM_ENV=production`), `VFM_LICENSE_MASTER_KEY` passa a ser obrigatoria e o sistema falha no startup sem ela.
- Bootstrap continua sendo carregado via CDN. Se o ambiente nao tiver acesso externo, copie os assets localmente e ajuste os templates. - A interface web usa assets locais em `static/`, sem dependencia de internet para carregar CSS e JS.
## Testes ## Testes
Executar: Executar:
```bash ```bash
pytest python3 -m pytest
``` ```
## Proximos passos recomendados ## Proximos passos recomendados
- Adicionar testes cobrindo polling Modbus e integracao com Zabbix com doubles dedicados. - Adicionar testes cobrindo polling Modbus e integracao com Zabbix com doubles dedicados.
- Separar configuracao e logging em modulos proprios se a aplicacao continuar crescendo. - Separar configuracao e logging em modulos proprios se a aplicacao continuar crescendo.
- Trocar Bootstrap via CDN por assets locais se o ambiente alvo nao tiver acesso externo.
@@ -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.
+186
View File
@@ -0,0 +1,186 @@
# 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`.
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
}
]
}
+58 -58
View File
@@ -1,58 +1,58 @@
import argparse import argparse
import json import json
import os import os
from datetime import datetime from datetime import datetime
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4=" LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
def load_master_key() -> bytes: def load_master_key() -> bytes:
env_key = os.getenv("VFM_LICENSE_MASTER_KEY") env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
if env_key: if env_key:
return env_key.encode("utf-8") return env_key.encode("utf-8")
return LEGACY_LICENSE_KEY return LEGACY_LICENSE_KEY
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(description="Gerador de licenca do V-Fire Monitor") parser = argparse.ArgumentParser(description="Gerador de licenca do V-Fire Monitor")
parser.add_argument("--hwid", help="Hardware ID do cliente") parser.add_argument("--hwid", help="Hardware ID do cliente")
parser.add_argument("--cliente", help="Nome do cliente") parser.add_argument("--cliente", help="Nome do cliente")
parser.add_argument("--expira", help="Data de expiracao no formato AAAA-MM-DD") parser.add_argument("--expira", help="Data de expiracao no formato AAAA-MM-DD")
return parser.parse_args() return parser.parse_args()
def ask_if_missing(value: str, prompt: str) -> str: def ask_if_missing(value: str, prompt: str) -> str:
return value if value else input(prompt).strip() return value if value else input(prompt).strip()
def validate_expiration(date_text: str) -> str: def validate_expiration(date_text: str) -> str:
datetime.strptime(date_text, "%Y-%m-%d") datetime.strptime(date_text, "%Y-%m-%d")
return date_text return date_text
def gerar_serial(hardware_id: str, cliente: str, data_expiracao: str) -> str: def gerar_serial(hardware_id: str, cliente: str, data_expiracao: str) -> str:
fernet = Fernet(load_master_key()) fernet = Fernet(load_master_key())
payload = {"h": hardware_id.strip(), "c": cliente.strip(), "e": validate_expiration(data_expiracao)} 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") return fernet.encrypt(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("utf-8")
def main(): def main():
args = parse_args() args = parse_args()
hardware_id = ask_if_missing(args.hwid, "Hardware ID do cliente: ") hardware_id = ask_if_missing(args.hwid, "Hardware ID do cliente: ")
cliente = ask_if_missing(args.cliente, "Nome do cliente: ") cliente = ask_if_missing(args.cliente, "Nome do cliente: ")
expiracao = ask_if_missing(args.expira, "Validade (AAAA-MM-DD): ") expiracao = ask_if_missing(args.expira, "Validade (AAAA-MM-DD): ")
if not hardware_id: if not hardware_id:
raise SystemExit("Hardware ID obrigatorio.") raise SystemExit("Hardware ID obrigatorio.")
if not cliente: if not cliente:
raise SystemExit("Nome do cliente obrigatorio.") raise SystemExit("Nome do cliente obrigatorio.")
print(gerar_serial(hardware_id, cliente, expiracao)) print(gerar_serial(hardware_id, cliente, expiracao))
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+640
View File
@@ -0,0 +1,640 @@
#!/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 curl jq gpg wget rsync python3 psql runuser systemctl ss; do
command -v "${cmd}" >/dev/null 2>&1 || die "Comando obrigatorio ausente: ${cmd}"
done
}
assert_ports_available() {
local port
[[ "${DRY_RUN}" == "1" ]] && return 0
for port in "${VFM_APP_PORT}" "${ZABBIX_WEB_PORT}" "${GRAFANA_PORT}"; do
if ss -ltn "( sport = :${port} )" | tail -n +2 | grep -q .; then
die "Porta ${port} ja esta em uso."
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 -o %s\n' "${ZABBIX_APT_KEYRING}"
else
curl -fsSL "https://repo.zabbix.com/zabbix-official-repo.key" | gpg --dearmor -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_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}"
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}"
if [[ "${GRAFANA_INSTALL_ZABBIX_PLUGIN}" == "1" ]]; then
if [[ "${DRY_RUN}" == "1" ]]; then
printf '[dry-run] grafana-cli plugins install alexanderzobnin-zabbix-app\n'
else
grafana-cli 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}"
}
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"
[[ "${DRY_RUN}" == "1" ]] && return 0
curl -fsS -H 'Content-Type: application/json-rpc' -d "${payload}" "http://127.0.0.1:${ZABBIX_WEB_PORT}/api_jsonrpc.php"
}
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
zabbix_api_request "$(jq -cn \
--arg auth "${auth_token}" \
--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},auth:$auth,id:1}')" >/dev/null
zabbix_api_request "$(jq -cn --arg auth "${auth_token}" '{jsonrpc:"2.0",method:"user.logout",params:[],auth:$auth,id:1}')" >/dev/null || true
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)"
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_api_request "$(jq -cn --arg auth "${auth_token}" '{jsonrpc:"2.0",method:"user.logout",params:[],auth:$auth,id:1}')" >/dev/null || true
}
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}')")"
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}" \
--arg auth "${auth}" \
'{jsonrpc:"2.0",method:$method,params:{output:["'"${id_key}"'"],filter:{($key):[$value]}},auth:$auth,id:1}')" \
| 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 auth "${auth}" \
--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}}},auth:$auth,id:1}')" >/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 auth "${auth}" --arg name "${ZABBIX_HOST_GROUP}" '{jsonrpc:"2.0",method:"hostgroup.create",params:{name:$name},auth:$auth,id:1}')" | 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 auth "${auth}" \
--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}]},auth:$auth,id:1}')" >/dev/null
printf '%s' "${host_id}"
return 0
fi
zabbix_api_request "$(jq -cn \
--arg auth "${auth}" \
--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"}]},auth:$auth,id:1}')" | 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."
zabbix_api_request "$(jq -cn --arg auth "${auth}" '{jsonrpc:"2.0",method:"user.logout",params:[],auth:$auth,id:1}')" >/dev/null || true
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 "$@"
+33
View File
@@ -0,0 +1,33 @@
# 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
ZABBIX_ADMIN_PASSWORD=zabbix
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
+6 -2
View File
@@ -3,8 +3,12 @@ import os
from vfire_monitor import create_app from vfire_monitor import create_app
app = create_app() app = create_app(base_dir=os.getenv("VFM_BASE_DIR"))
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8080")), debug=False) app.run(
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8080")),
debug=False,
)
+1
View File
@@ -3,4 +3,5 @@ Flask-Login>=0.6,<1.0
pyModbusTCP>=0.3,<1.0 pyModbusTCP>=0.3,<1.0
zabbix-utils>=2.0,<3.0 zabbix-utils>=2.0,<3.0
cryptography>=42.0,<45.0 cryptography>=42.0,<45.0
waitress>=3.0,<4.0
pytest>=8.0,<9.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 };
})();
+405 -325
View File
@@ -1,155 +1,156 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="pt-BR"> <html lang="pt-BR">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>V-Fire Monitor</title> <title>V-Fire Monitor</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link rel="stylesheet" href="{{ url_for('static', filename='css/vendor.css') }}">
<style> <style>
:root { :root {
--bg: #0c0f12; --bg: #0c0f12;
--panel: #12171d; --panel: #12171d;
--panel-soft: #1a2129; --panel-soft: #1a2129;
--border: #2a333d; --border: #2a333d;
--text: #edf2f7; --text: #edf2f7;
--muted: #94a3b8; --muted: #94a3b8;
--accent: #ff5a3d; --accent: #ff5a3d;
} }
body { body {
min-height: 100vh; min-height: 100vh;
background: background:
radial-gradient(circle at top, rgba(255, 90, 61, 0.15), transparent 30%), radial-gradient(circle at top, rgba(255, 90, 61, 0.15), transparent 30%),
linear-gradient(180deg, #091017 0%, var(--bg) 45%, #07090c 100%); linear-gradient(180deg, #091017 0%, var(--bg) 45%, #07090c 100%);
color: var(--text); color: var(--text);
font-family: "Segoe UI", sans-serif; font-family: "Segoe UI", sans-serif;
} }
.app-header, .app-header,
.panel, .panel,
.sensor-card, .sensor-card,
.node-row { .node-row {
background: rgba(18, 23, 29, 0.95); background: rgba(18, 23, 29, 0.95);
border: 1px solid var(--border); border: 1px solid var(--border);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
} }
.app-header { .app-header {
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.panel { .panel {
border-radius: 18px; border-radius: 18px;
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
} }
.node-group { .node-group {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.node-title { .node-title {
margin: 0; margin: 0;
padding-left: 0.9rem; padding-left: 0.9rem;
border-left: 4px solid var(--accent); border-left: 4px solid var(--accent);
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
} }
.sensor-card { .sensor-card {
height: 100%; height: 100%;
border-radius: 14px; border-radius: 14px;
padding: 1rem; padding: 1rem;
position: relative; position: relative;
transition: transform 0.16s ease, border-color 0.16s ease; transition: transform 0.16s ease, border-color 0.16s ease;
} }
.sensor-card:hover { .sensor-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
.sensor-label { .sensor-label {
padding-right: 2rem; padding-right: 2rem;
word-break: break-word; word-break: break-word;
} }
.edit-btn { .edit-btn {
position: absolute; position: absolute;
top: 0.75rem; top: 0.75rem;
right: 0.75rem; right: 0.75rem;
border: 0; border: 0;
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
} }
.edit-btn:hover { .edit-btn:hover {
color: var(--text); color: var(--text);
} }
.badge-status { .badge-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
padding: 0.35rem 0.7rem; padding: 0.35rem 0.7rem;
border-radius: 999px; border-radius: 999px;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 700;
} }
.node-row { .node-row {
border-radius: 12px; border-radius: 12px;
padding: 0.9rem; padding: 0.9rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.loading-overlay { .loading-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
display: none; display: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(7, 9, 12, 0.85); background: rgba(7, 9, 12, 0.85);
z-index: 9999; z-index: 9999;
} }
</style> </style>
</head> </head>
<body> <body>
<div id="loader" class="loading-overlay"> <div id="loader" class="loading-overlay">
<div class="text-center"> <div class="text-center">
<div class="spinner-border text-danger"></div> <div class="spinner-border text-danger"></div>
<p class="mt-3 mb-0 text-light">Executando varredura Modbus...</p> <p class="mt-3 mb-0 text-light">Executando varredura Modbus...</p>
</div> </div>
</div> </div>
<header class="app-header border-bottom sticky-top"> <header class="app-header border-bottom sticky-top">
<div class="container py-3 d-flex flex-wrap justify-content-between align-items-center gap-3"> <div class="container py-3 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div> <div>
<div class="text-uppercase small text-secondary">Voltec</div> <div class="text-uppercase small text-secondary">Voltec</div>
<h1 class="h3 mb-0">V-Fire Monitor</h1> <h1 class="h3 mb-0">V-Fire Monitor</h1>
</div> </div>
<div class="d-flex gap-2"> <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> <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>
</header> </header>
<main class="container py-4"> <main class="container py-4">
<div id="alert-host"></div> <div id="alert-host"></div>
{% if not licenciado %} {% if not licenciado %}
<section class="row justify-content-center"> <section class="row justify-content-center">
<div class="col-lg-6"> <div class="col-lg-6">
<div class="panel text-center"> <div class="panel text-center">
<h2 class="h4 text-danger">Sistema bloqueado</h2> <h2 class="h4 text-danger">Sistema bloqueado</h2>
<p class="text-secondary mb-3">Ative a licenca deste equipamento para liberar o monitoramento.</p> <p class="text-secondary mb-3">Ative a licenca deste equipamento para liberar o monitoramento.</p>
<p class="text-secondary mb-2">Hardware ID</p> <p class="text-secondary mb-2">Hardware ID</p>
<code class="d-block p-3 rounded bg-dark text-light mb-3">{{ hwid }}</code> <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> <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> <button class="btn btn-danger w-100" onclick="activateLicense()">Ativar licenca</button>
</div> </div>
</div> </div>
</section> </section>
{% else %} {% else %}
<section class="panel mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3"> <section class="panel mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div> <div>
<div class="text-secondary small">Licenca</div> <div class="text-secondary small">Licenca</div>
@@ -160,77 +161,123 @@
</span> </span>
</section> </section>
<section class="panel mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<div class="text-secondary small">Monitor</div>
<div class="fw-semibold">{{ "Em execucao" if monitor_status.running else "Parado" }}</div>
</div>
<div class="small text-secondary text-end">
<div>Ultimo ciclo: {{ monitor_status.last_cycle_completed_at or "--" }}</div>
<div>Ultima licenca: {{ monitor_status.last_license_message or "--" }}</div>
</div>
</section>
<section id="main-container"></section> <section id="main-container"></section>
{% endif %} {% endif %}
</main> </main>
<div class="modal fade" id="modalNodes" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content bg-dark text-light border-secondary">
<div class="modal-header border-secondary">
<h2 class="modal-title fs-5">Configuracao</h2>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="zab-server" class="form-label">Zabbix Server</label>
<input type="text" id="zab-server" class="form-control bg-black text-light border-secondary" value="{{ config.zabbix_server }}">
</div>
<div class="col-md-6">
<label for="zab-host" class="form-label">Hostname Zabbix</label>
<input type="text" id="zab-host" class="form-control bg-black text-light border-secondary" value="{{ config.hostname_zabbix }}">
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h6 mb-0">Centrais</h3>
<button class="btn btn-success btn-sm" onclick="addNodeRow()">Adicionar central</button>
</div>
<div id="nodes-list"></div>
</div>
<div class="modal-footer border-secondary">
<button class="btn btn-danger w-100" onclick="saveConfig()">Salvar configuracao</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="modalNodes" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="modalAccount" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog">
<div class="modal-content bg-dark text-light border-secondary"> <div class="modal-content bg-dark text-light border-secondary">
<div class="modal-header border-secondary"> <div class="modal-header border-secondary">
<h2 class="modal-title fs-5">Configuracao</h2> <h2 class="modal-title fs-5">Conta</h2>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="row g-3 mb-3"> <div class="mb-3">
<div class="col-md-6"> <label for="account-username" class="form-label">Usuario</label>
<label for="zab-server" class="form-label">Zabbix Server</label> <input type="text" id="account-username" class="form-control bg-black text-light border-secondary" value="{{ config.web_user }}">
<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>
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="mb-3">
<h3 class="h6 mb-0">Centrais</h3> <label for="account-current-password" class="form-label">Senha atual</label>
<button class="btn btn-success btn-sm" onclick="addNodeRow()">Adicionar central</button> <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 id="nodes-list"></div>
</div> </div>
<div class="modal-footer border-secondary"> <div class="modal-footer border-secondary">
<button class="btn btn-danger w-100" onclick="saveConfig()">Salvar configuracao</button> <button class="btn btn-danger w-100" onclick="saveAccount()">Salvar credenciais</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="{{ url_for('static', filename='js/vendor.js') }}"></script>
<script> <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 }};
function escapeHtml(value) { function escapeHtml(value) {
return String(value) return String(value)
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
.replaceAll(">", "&gt;") .replaceAll(">", "&gt;")
.replaceAll('"', "&quot;") .replaceAll('"', "&quot;")
.replaceAll("'", "&#39;"); .replaceAll("'", "&#39;");
} }
function showAlert(message, type = "danger") { function showAlert(message, type = "danger") {
const host = document.getElementById("alert-host"); const host = document.getElementById("alert-host");
if (!host) { if (!host) {
return; return;
} }
host.innerHTML = ` host.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert"> <div class="alert alert-${type} alert-dismissible fade show" role="alert">
${escapeHtml(message)} ${escapeHtml(message)}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Fechar"></button>
</div>`; </div>`;
} }
async function apiFetch(url, options = {}) { async function apiFetch(url, options = {}) {
const response = await fetch(url, options); const headers = new Headers(options.headers || {});
if (!headers.has("X-CSRF-Token")) {
headers.set("X-CSRF-Token", csrfToken);
}
const response = await fetch(url, { ...options, headers });
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new Error(body.message || "Falha ao processar a requisicao."); throw new Error(body.message || "Falha ao processar a requisicao.");
} }
return body; return body;
} }
function renderNodeRow(node = { nome: "", ip: "", unit: 3 }) { function renderNodeRow(node = { nome: "", ip: "", unit: 3, port: 502 }) {
const container = document.createElement("div"); const container = document.createElement("div");
container.className = "node-row"; container.className = "node-row";
container.innerHTML = ` container.innerHTML = `
@@ -239,7 +286,7 @@
<label class="form-label small text-secondary">Nome</label> <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"> <input type="text" class="form-control form-control-sm n-nome" value="${escapeHtml(node.nome)}" placeholder="Nome">
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label class="form-label small text-secondary">IP</label> <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"> <input type="text" class="form-control form-control-sm n-ip" value="${escapeHtml(node.ip)}" placeholder="192.168.0.10">
</div> </div>
@@ -247,183 +294,216 @@
<label class="form-label small text-secondary">Unit</label> <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"> <input type="number" class="form-control form-control-sm n-unit" value="${Number(node.unit || 3)}" min="0" max="255">
</div> </div>
<div class="col-md-3 d-flex gap-2"> <div class="col-md-2">
<label class="form-label small text-secondary">Porta</label>
<input type="number" class="form-control form-control-sm n-port" value="${Number(node.port || 502)}" min="1" max="65535">
</div>
<div class="col-md-2 d-flex gap-2">
<button class="btn btn-warning btn-sm flex-grow-1 scan-btn" type="button">Scan</button> <button class="btn btn-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> <button class="btn btn-outline-danger btn-sm remove-btn" type="button">Remover</button>
</div> </div>
</div>`; </div>`;
container.querySelector(".remove-btn").addEventListener("click", () => container.remove()); container.querySelector(".remove-btn").addEventListener("click", () => container.remove());
container.querySelector(".scan-btn").addEventListener("click", () => scanSingleNode(container)); container.querySelector(".scan-btn").addEventListener("click", () => scanSingleNode(container));
return container; return container;
} }
function openConfigModal() { function openConfigModal() {
const list = document.getElementById("nodes-list"); const list = document.getElementById("nodes-list");
list.innerHTML = ""; list.innerHTML = "";
nodesAtuais.forEach((node) => list.appendChild(renderNodeRow(node))); nodesAtuais.forEach((node) => list.appendChild(renderNodeRow(node)));
modalNodes.show(); modalNodes.show();
} }
function addNodeRow() { function addNodeRow() {
document.getElementById("nodes-list").appendChild(renderNodeRow()); document.getElementById("nodes-list").appendChild(renderNodeRow());
} }
function collectNodes() { function openAccountModal() {
const nodes = []; document.getElementById("account-current-password").value = "";
document.querySelectorAll(".node-row").forEach((row) => { document.getElementById("account-new-password").value = "";
modalAccount.show();
}
function collectNodes() {
const nodes = [];
document.querySelectorAll(".node-row").forEach((row) => {
const nome = row.querySelector(".n-nome").value.trim(); const nome = row.querySelector(".n-nome").value.trim();
const ip = row.querySelector(".n-ip").value.trim(); const ip = row.querySelector(".n-ip").value.trim();
const unit = Number(row.querySelector(".n-unit").value); const unit = Number(row.querySelector(".n-unit").value);
const port = Number(row.querySelector(".n-port").value);
if (nome && ip) { if (nome && ip) {
nodes.push({ nome, ip, unit }); nodes.push({ nome, ip, unit, port });
} }
}); });
return nodes; return nodes;
} }
async function activateLicense() { async function activateLicense() {
const serial = document.getElementById("serial-in").value.trim(); const serial = document.getElementById("serial-in").value.trim();
try { try {
await apiFetch("/api/license", { const data = await apiFetch("/api/license", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serial }) body: JSON.stringify({ serial })
}); });
showAlert(`${data.message}.`, "success");
location.reload(); location.reload();
} catch (error) { } catch (error) {
showAlert(error.message); showAlert(error.message);
} }
} }
async function saveConfig() {
const payload = {
zabbix_server: document.getElementById("zab-server").value.trim(),
hostname_zabbix: document.getElementById("zab-host").value.trim(),
nodes: collectNodes()
};
try {
await apiFetch("/api/nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
location.reload();
} catch (error) {
showAlert(error.message);
}
}
async function scanSingleNode(row) {
const node = {
nome: row.querySelector(".n-nome").value.trim(),
ip: row.querySelector(".n-ip").value.trim(),
unit: Number(row.querySelector(".n-unit").value),
port: Number(row.querySelector(".n-port").value)
};
document.getElementById("loader").style.display = "flex";
modalNodes.hide();
try {
const data = await apiFetch("/api/rescan_node", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(node)
});
showAlert(`Scan concluido. ${data.count} pontos encontrados.`, "success");
setTimeout(() => location.reload(), 500);
} catch (error) {
document.getElementById("loader").style.display = "none";
showAlert(error.message);
}
}
async function renameDevice(key, currentLabel) {
const nextLabel = prompt("Novo nome do dispositivo:", currentLabel);
if (!nextLabel || nextLabel === currentLabel) {
return;
}
try {
await apiFetch("/api/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ addr: key, label: nextLabel })
});
await refreshData();
} catch (error) {
showAlert(error.message);
}
}
async function saveConfig() { async function saveAccount() {
const payload = { const payload = {
zabbix_server: document.getElementById("zab-server").value.trim(), username: document.getElementById("account-username").value.trim(),
hostname_zabbix: document.getElementById("zab-host").value.trim(), current_password: document.getElementById("account-current-password").value,
nodes: collectNodes() new_password: document.getElementById("account-new-password").value
}; };
try { try {
await apiFetch("/api/nodes", { await apiFetch("/api/account", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
location.reload(); showAlert("Credenciais atualizadas. Entre novamente com a nova conta.", "success");
setTimeout(() => location.assign("/login"), 800);
} catch (error) { } catch (error) {
showAlert(error.message); showAlert(error.message);
} }
} }
async function scanSingleNode(row) { function renderNodes(data) {
const node = { const main = document.getElementById("main-container");
nome: row.querySelector(".n-nome").value.trim(), if (!main) {
ip: row.querySelector(".n-ip").value.trim(), return;
unit: Number(row.querySelector(".n-unit").value) }
};
const grouped = {};
document.getElementById("loader").style.display = "flex"; data.sensores.forEach((sensor) => {
modalNodes.hide(); if (!grouped[sensor.node]) {
grouped[sensor.node] = [];
try { }
const data = await apiFetch("/api/rescan_node", { grouped[sensor.node].push(sensor);
method: "POST", });
headers: { "Content-Type": "application/json" },
body: JSON.stringify(node) const html = Object.entries(grouped).map(([nodeName, sensors]) => {
}); const nodeStatus = data.nodes_status[nodeName] || "Offline";
showAlert(`Scan concluido. ${data.count} pontos encontrados.`, "success"); const statusClass = nodeStatus === "Online" ? "bg-success" : "bg-danger";
setTimeout(() => location.reload(), 500);
} catch (error) { const sensorCards = sensors.map((sensor) => `
document.getElementById("loader").style.display = "none"; <div class="col-md-4 col-xl-3">
showAlert(error.message); <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">
async function renameDevice(key, currentLabel) { <span class="badge-status" style="background: ${escapeHtml(sensor.color)}22; color: ${escapeHtml(sensor.color)}; border: 1px solid ${escapeHtml(sensor.color)};">
const nextLabel = prompt("Novo nome do dispositivo:", currentLabel); ${escapeHtml(sensor.status_text)}
if (!nextLabel || nextLabel === currentLabel) { </span>
return; </div>
} <div class="small text-secondary mt-3 text-end">Atualizado as ${escapeHtml(sensor.last)}</div>
</article>
try { </div>`).join("");
await apiFetch("/api/rename", {
method: "POST", return `
headers: { "Content-Type": "application/json" }, <section class="panel node-group">
body: JSON.stringify({ addr: key, label: nextLabel }) <div class="d-flex justify-content-between align-items-center mb-3 gap-3">
}); <h2 class="node-title">${escapeHtml(nodeName)}</h2>
await refreshData(); <span class="badge ${statusClass}">${escapeHtml(nodeStatus)}</span>
} catch (error) { </div>
showAlert(error.message); <div class="row g-3">${sensorCards}</div>
} </section>`;
} }).join("");
function renderNodes(data) { main.innerHTML = html || `
const main = document.getElementById("main-container"); <section class="panel text-center text-secondary">
if (!main) { Nenhum dispositivo encontrado ainda. Execute um scan em uma central configurada.
return; </section>`;
}
document.querySelectorAll(".edit-btn").forEach((button) => {
const grouped = {}; button.addEventListener("click", () => {
data.sensores.forEach((sensor) => { renameDevice(button.dataset.key, button.dataset.label);
if (!grouped[sensor.node]) { });
grouped[sensor.node] = []; });
} }
grouped[sensor.node].push(sensor);
}); async function refreshData() {
try {
const html = Object.entries(grouped).map(([nodeName, sensors]) => { const data = await apiFetch("/api/data");
const nodeStatus = data.nodes_status[nodeName] || "Offline"; renderNodes(data);
const statusClass = nodeStatus === "Online" ? "bg-success" : "bg-danger"; } catch (error) {
showAlert(error.message);
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> {% if licenciado %}
<div class="sensor-label fw-semibold">${escapeHtml(sensor.label)}</div> setInterval(refreshData, 3000);
<div class="mt-3"> refreshData();
<span class="badge-status" style="background: ${escapeHtml(sensor.color)}22; color: ${escapeHtml(sensor.color)}; border: 1px solid ${escapeHtml(sensor.color)};"> {% endif %}
${escapeHtml(sensor.status_text)} </script>
</span> </body>
</div> </html>
<div class="small text-secondary mt-3 text-end">Atualizado as ${escapeHtml(sensor.last)}</div>
</article>
</div>`).join("");
return `
<section class="panel node-group">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<h2 class="node-title">${escapeHtml(nodeName)}</h2>
<span class="badge ${statusClass}">${escapeHtml(nodeStatus)}</span>
</div>
<div class="row g-3">${sensorCards}</div>
</section>`;
}).join("");
main.innerHTML = html || `
<section class="panel text-center text-secondary">
Nenhum dispositivo encontrado ainda. Execute um scan em uma central configurada.
</section>`;
document.querySelectorAll(".edit-btn").forEach((button) => {
button.addEventListener("click", () => {
renameDevice(button.dataset.key, button.dataset.label);
});
});
}
async function refreshData() {
try {
const data = await apiFetch("/api/data");
renderNodes(data);
} catch (error) {
showAlert(error.message);
}
}
{% if licenciado %}
setInterval(refreshData, 3000);
refreshData();
{% endif %}
</script>
</body>
</html>
+39 -37
View File
@@ -1,55 +1,57 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="pt-BR"> <html lang="pt-BR">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login | V-Fire Monitor</title> <title>Login | V-Fire Monitor</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link rel="stylesheet" href="{{ url_for('static', filename='css/vendor.css') }}">
<style> <style>
body { body {
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
display: grid; display: grid;
place-items: center; place-items: center;
background: background:
radial-gradient(circle at top, rgba(255, 90, 61, 0.2), transparent 30%), radial-gradient(circle at top, rgba(255, 90, 61, 0.2), transparent 30%),
linear-gradient(180deg, #0b1117 0%, #07090c 100%); linear-gradient(180deg, #0b1117 0%, #07090c 100%);
color: #eef2f7; color: #eef2f7;
font-family: "Segoe UI", sans-serif; font-family: "Segoe UI", sans-serif;
} }
.login-card { .login-card {
width: min(100%, 380px); width: min(100%, 380px);
padding: 2rem; padding: 2rem;
border-radius: 18px; border-radius: 18px;
background: rgba(18, 23, 29, 0.96); background: rgba(18, 23, 29, 0.96);
border: 1px solid #2a333d; border: 1px solid #2a333d;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.28); box-shadow: 0 20px 45px rgba(0, 0, 0, 0.28);
} }
</style> </style>
</head> </head>
<body> <body>
<section class="login-card"> <section class="login-card">
<div class="text-uppercase small text-secondary mb-2">Voltec</div> <div class="text-uppercase small text-secondary mb-2">Voltec</div>
<h1 class="h4 mb-4">Acesso ao V-Fire Monitor</h1> <h1 class="h4 mb-4">Acesso ao V-Fire Monitor</h1>
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
{% if messages %} {% if messages %}
<div class="alert alert-warning py-2" role="alert">{{ messages[0] }}</div> <div class="alert alert-warning py-2" role="alert">{{ messages[0] }}</div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="POST" novalidate> <form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label">Usuario</label> <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> <input id="username" type="text" name="username" class="form-control bg-dark text-light border-secondary" required autofocus>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label for="password" class="form-label">Senha</label> <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> <input id="password" type="password" name="password" class="form-control bg-dark text-light border-secondary" required>
</div> </div>
<button type="submit" class="btn btn-danger w-100">Entrar</button> <button type="submit" class="btn btn-danger w-100">Entrar</button>
</form> </form>
</section> </section>
<script src="{{ url_for('static', filename='js/vendor.js') }}"></script>
</body> </body>
</html> </html>
+137 -53
View File
@@ -3,90 +3,127 @@ from pathlib import Path
import pytest import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from vfire_monitor import create_app from vfire_monitor import create_app
from vfire_monitor.core import ( from vfire_monitor.core import (
LicenseService, LicenseService,
contiguous_ranges, contiguous_ranges,
load_license_key, load_license_key,
normalize_config, normalize_config,
validate_login_payload,
validate_node_payload, validate_node_payload,
) )
def test_validate_node_payload_normalizes_fields(): def test_validate_node_payload_normalizes_fields():
node = validate_node_payload({"nome": " Central A ", "ip": " 10.0.0.1 ", "unit": "3"}) 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} assert node == {"nome": "Central A", "ip": "10.0.0.1", "unit": 3, "port": 502}
def test_validate_node_payload_rejects_invalid_unit(): def test_validate_node_payload_rejects_invalid_unit():
with pytest.raises(ValueError): with pytest.raises(ValueError):
validate_node_payload({"nome": "Central A", "ip": "10.0.0.1", "unit": 999}) 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(): def test_normalize_config_migrates_legacy_password():
config = normalize_config( config = normalize_config(
{ {
"web_user": "admin", "web_user": "admin",
"web_password": "segredo", "web_password": "segredo",
"nodes": [{"nome": "Node 1", "ip": "1.1.1.1", "unit": 1}], "nodes": [{"nome": "Node 1", "ip": "1.1.1.1", "unit": 1}],
}, },
default_password="admin", default_password="admin",
) )
assert "web_password" not in config assert "web_password" not in config
assert check_password_hash(config["web_password_hash"], "segredo") assert check_password_hash(config["web_password_hash"], "segredo")
assert config["nodes"][0]["nome"] == "Node 1" assert config["nodes"][0]["nome"] == "Node 1"
def test_contiguous_ranges_splits_large_blocks(): def test_contiguous_ranges_splits_large_blocks():
ranges = contiguous_ranges(list(range(1, 125))) ranges = contiguous_ranges(list(range(1, 125)))
assert ranges == [(1, 120), (121, 124)] assert ranges == [(1, 120), (121, 124)]
def test_license_service_validates_serial(tmp_path, monkeypatch): def test_license_service_validates_serial(tmp_path, monkeypatch):
monkeypatch.setattr("vfire_monitor.core.get_hardware_id", lambda: "HW-123") monkeypatch.setattr("vfire_monitor.core.get_hardware_id", lambda: "HW-123")
key = Fernet.generate_key() key = Fernet.generate_key()
payload = {"h": "HW-123", "c": "Cliente", "e": "2099-01-01"} payload = {"h": "HW-123", "c": "Cliente", "e": "2099-01-01"}
serial = Fernet(key).encrypt(json.dumps(payload).encode("utf-8")).decode("utf-8") serial = Fernet(key).encrypt(json.dumps(payload).encode("utf-8")).decode("utf-8")
license_path = tmp_path / "license.key" license_path = tmp_path / "license.key"
license_path.write_text(serial, encoding="utf-8") license_path.write_text(serial, encoding="utf-8")
service = LicenseService(license_path, key) service = LicenseService(license_path, key)
status = service.verify() status = service.verify()
assert status.valid is True assert status.valid is True
assert "Cliente" in status.message 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): def test_load_license_key_requires_env_in_production(monkeypatch):
monkeypatch.delenv("VFM_LICENSE_MASTER_KEY", raising=False) monkeypatch.delenv("VFM_LICENSE_MASTER_KEY", raising=False)
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
load_license_key(require_env=True) 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 @pytest.fixture
def app(tmp_path, monkeypatch): def app(tmp_path, monkeypatch):
monkeypatch.setenv("VFM_DEFAULT_PASSWORD", "admin") monkeypatch.setenv("VFM_DEFAULT_PASSWORD", "admin")
monkeypatch.setenv("VFM_LICENSE_MASTER_KEY", Fernet.generate_key().decode("utf-8")) monkeypatch.setenv("VFM_LICENSE_MASTER_KEY", Fernet.generate_key().decode("utf-8"))
application = create_app(base_dir=tmp_path, start_monitor=False) application = create_app(base_dir=tmp_path, start_monitor=False)
application.config["TESTING"] = True application.config["TESTING"] = True
runtime_state = application.extensions["runtime_state"] runtime_state = application.extensions["runtime_state"]
runtime_state.config["web_user"] = "admin" runtime_state.config["web_user"] = "admin"
runtime_state.config["web_password_hash"] = normalize_config({}, "admin")["web_password_hash"] runtime_state.config["web_password_hash"] = normalize_config({}, "admin")["web_password_hash"]
runtime_state.save_config() runtime_state.save_config()
return application return application
@pytest.fixture @pytest.fixture
def client(app): def client(app):
return app.test_client() 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): def login(client):
return client.post("/login", data={"username": "admin", "password": "admin"}, follow_redirects=False) return client.post(
"/login",
data={"username": "admin", "password": "admin", "csrf_token": csrf_token(client)},
follow_redirects=False,
)
def test_login_success(client): def test_login_success(client):
@@ -94,26 +131,73 @@ def test_login_success(client):
assert response.status_code == 302 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): def test_api_nodes_requires_auth(client):
response = client.post("/api/nodes", json={}) response = client.post("/api/nodes", json={}, headers={"X-CSRF-Token": csrf_token(client)})
assert response.status_code == 302 assert response.status_code == 302
def test_api_nodes_updates_configuration(client, app): def test_api_nodes_updates_configuration(client, app):
login(client) login(client)
response = client.post( response = client.post(
"/api/nodes", "/api/nodes",
json={ json={
"zabbix_server": "127.0.0.1", "zabbix_server": "127.0.0.1",
"hostname_zabbix": "HOST-01", "hostname_zabbix": "HOST-01",
"nodes": [{"nome": "Central A", "ip": "10.0.0.5", "unit": 3}], "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.status_code == 200
assert response.get_json()["status"] == "success" assert response.get_json()["status"] == "success"
config_path = Path(app.extensions["runtime_state"].config_store.path) runtime_state = app.extensions["runtime_state"]
config = json.loads(config_path.read_text(encoding="utf-8")) assert runtime_state.authenticate("operador", "senha-forte-123") is True
assert config["hostname_zabbix"] == "HOST-01"
assert config["nodes"][0]["nome"] == "Central A"
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()
+73 -36
View File
@@ -1,68 +1,105 @@
import atexit
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from flask import Flask from flask import Flask
from flask_login import LoginManager from flask_login import LoginManager
from werkzeug.middleware.proxy_fix import ProxyFix
from .core import (
LicenseService, from .core import (
MonitorEngine, LicenseService,
RuntimeState, MonitorEngine,
User, RuntimeState,
ensure_secret_key, User,
load_license_key, ensure_secret_key,
) load_license_key,
from .routes import register_routes )
from .routes import register_routes
def configure_logging() -> None:
logging.basicConfig( def configure_logging() -> None:
level=os.getenv("VFM_LOG_LEVEL", "INFO").upper(), logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s", level=os.getenv("VFM_LOG_LEVEL", "INFO").upper(),
) format="%(asctime)s [%(levelname)s] %(message)s",
)
def is_production_mode() -> bool: def is_production_mode() -> bool:
value = os.getenv("VFM_ENV", "").strip().lower() value = os.getenv("VFM_ENV", "").strip().lower()
return value in {"prod", "production"} 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: def create_app(base_dir: str | Path | None = None, start_monitor: bool = True) -> Flask:
configure_logging() configure_logging()
resolved_base_dir = Path(base_dir) if base_dir else Path(__file__).resolve().parent.parent 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" secret_path = resolved_base_dir / "app_secret.key"
default_password = os.getenv("VFM_DEFAULT_PASSWORD", "admin") default_password = os.getenv("VFM_DEFAULT_PASSWORD", "admin")
app = Flask( app = Flask(
__name__, __name__,
template_folder=str(resolved_base_dir / "templates"), 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.secret_key = os.getenv("VFM_APP_SECRET") or ensure_secret_key(secret_path)
app.config.update(
runtime_state = RuntimeState(resolved_base_dir, default_password) SESSION_COOKIE_HTTPONLY=True,
license_service = LicenseService( SESSION_COOKIE_SAMESITE="Lax",
resolved_base_dir / "license.key", SESSION_COOKIE_SECURE=env_flag("VFM_SESSION_COOKIE_SECURE", is_production_mode()),
load_license_key(require_env=is_production_mode()), REMEMBER_COOKIE_HTTPONLY=True,
REMEMBER_COOKIE_SAMESITE="Lax",
MAX_CONTENT_LENGTH=int(os.getenv("VFM_MAX_CONTENT_LENGTH", str(1024 * 1024))),
) )
app.extensions["runtime_state"] = runtime_state if env_flag("VFM_TRUST_PROXY"):
app.extensions["license_service"] = license_service app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
login_manager = LoginManager() runtime_state = RuntimeState(resolved_base_dir, default_password)
login_manager.init_app(app) license_service = LicenseService(
login_manager.login_view = "login" resolved_base_dir / "license.key",
load_license_key(require_env=is_production_mode()),
@login_manager.user_loader )
def load_user(user_id):
return User(user_id) 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) 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: if start_monitor:
monitor_engine = MonitorEngine(runtime_state, license_service) monitor_engine = MonitorEngine(runtime_state, license_service)
app.extensions["monitor_engine"] = monitor_engine app.extensions["monitor_engine"] = monitor_engine
monitor_engine.start() monitor_engine.start()
atexit.register(monitor_engine.stop)
return app return app
+498 -358
View File
@@ -5,73 +5,75 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from ipaddress import ip_address
from pathlib import Path from pathlib import Path
from typing import Dict, List, Tuple from typing import Dict, List, Tuple
from cryptography.fernet import Fernet, InvalidToken from cryptography.fernet import Fernet, InvalidToken
from pyModbusTCP.client import ModbusClient from pyModbusTCP.client import ModbusClient
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from zabbix_utils import ItemValue, Sender from zabbix_utils import ItemValue, Sender
DEFAULT_CONFIG_TEMPLATE = { DEFAULT_CONFIG_TEMPLATE = {
"zabbix_server": "172.16.33.6", "zabbix_server": "172.16.33.6",
"hostname_zabbix": "NFS-320", "hostname_zabbix": "NFS-320",
"web_user": "admin", "web_user": "admin",
"nodes": [], "nodes": [],
} }
LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4=" DEFAULT_MODBUS_PORT = 502
MAX_DISCOVERY_ADDR = 600 LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
MODBUS_BLOCK_SIZE = 120 MAX_DISCOVERY_ADDR = 600
DISCOVERY_IGNORED_VALUES = {0, 255, 65535} MODBUS_BLOCK_SIZE = 120
POLL_INTERVAL_SECONDS = 2 DISCOVERY_IGNORED_VALUES = {0, 255, 65535}
RETRY_INTERVAL_SECONDS = 5 POLL_INTERVAL_SECONDS = 2
RETRY_INTERVAL_SECONDS = 5
MAPA_STATUS_BASE = {
5120: {"label": "NORMAL / OK", "color": "#28a745"}, MAPA_STATUS_BASE = {
3088: {"label": "INCIDENTE", "color": "#dc3545"}, 5120: {"label": "NORMAL / OK", "color": "#28a745"},
3344: {"label": "INCIDENTE ACK", "color": "#007bff"}, 3088: {"label": "INCIDENTE", "color": "#dc3545"},
13312: {"label": "REMOVIDO", "color": "#fd7e14"}, 3344: {"label": "INCIDENTE ACK", "color": "#007bff"},
0: {"label": "VAZIO", "color": "#6c757d"}, 13312: {"label": "REMOVIDO", "color": "#fd7e14"},
} 0: {"label": "VAZIO", "color": "#6c757d"},
}
LOGGER = logging.getLogger("v-fire-monitor") LOGGER = logging.getLogger("v-fire-monitor")
class User: class User:
def __init__(self, user_id: str): def __init__(self, user_id: str):
self.id = user_id self.id = user_id
@property @property
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
return True return True
@property @property
def is_active(self) -> bool: def is_active(self) -> bool:
return True return True
@property @property
def is_anonymous(self) -> bool: def is_anonymous(self) -> bool:
return False return False
def get_id(self) -> str: def get_id(self) -> str:
return self.id return self.id
class JsonStore: class JsonStore:
def __init__(self, path: Path): def __init__(self, path: Path):
self.path = path self.path = path
def load(self, default): def load(self, default):
if not self.path.exists(): if not self.path.exists():
return default return default
try: try:
with self.path.open("r", encoding="utf-8") as handle: with self.path.open("r", encoding="utf-8") as handle:
return json.load(handle) return json.load(handle)
except (json.JSONDecodeError, OSError) as exc: except (json.JSONDecodeError, OSError) as exc:
LOGGER.warning("Falha ao carregar %s: %s", self.path.name, exc) LOGGER.warning("Falha ao carregar %s: %s", self.path.name, exc)
return default return default
def save(self, data) -> None: def save(self, data) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
temp_path = self.path.with_suffix(f"{self.path.suffix}.tmp") temp_path = self.path.with_suffix(f"{self.path.suffix}.tmp")
@@ -80,303 +82,435 @@ class JsonStore:
temp_path.replace(self.path) temp_path.replace(self.path)
def build_default_config(default_password: str) -> dict: def write_private_text(path: Path, content: str) -> None:
config = dict(DEFAULT_CONFIG_TEMPLATE) path.parent.mkdir(parents=True, exist_ok=True)
config["web_password_hash"] = generate_password_hash(default_password) path.write_text(content, encoding="utf-8")
return config 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: def ensure_secret_key(secret_path: Path) -> str:
if secret_path.exists(): if secret_path.exists():
return secret_path.read_text(encoding="utf-8").strip() return secret_path.read_text(encoding="utf-8").strip()
secret = Fernet.generate_key().decode("ascii") secret = Fernet.generate_key().decode("ascii")
secret_path.write_text(secret, encoding="utf-8") write_private_text(secret_path, secret)
LOGGER.info("Chave de sessão criada em %s", secret_path.name) LOGGER.info("Chave de sessão criada em %s", secret_path.name)
return secret return secret
def normalize_config(data: dict, default_password: str) -> dict: def normalize_config(data: dict, default_password: str) -> dict:
config = build_default_config(default_password) config = build_default_config(default_password)
config.update(data or {}) config.update(data or {})
if config.get("web_password") and not config.get("web_password_hash"): legacy_password = str((data or {}).get("web_password", "")).strip()
config["web_password_hash"] = generate_password_hash(config["web_password"]) if legacy_password:
config["web_password_hash"] = generate_password_hash(legacy_password)
config.pop("web_password", None) config.pop("web_password", None)
config["web_user"] = str(config.get("web_user", DEFAULT_CONFIG_TEMPLATE["web_user"])).strip() or DEFAULT_CONFIG_TEMPLATE[
normalized_nodes = [] "web_user"
for raw_node in config.get("nodes", []): ]
try:
normalized_nodes.append(validate_node_payload(raw_node)) normalized_nodes = []
except ValueError as exc: for raw_node in config.get("nodes", []):
LOGGER.warning("Node ignorado durante normalizacao: %s", exc) try:
config["nodes"] = normalized_nodes normalized_nodes.append(validate_node_payload(raw_node))
return config except ValueError as exc:
LOGGER.warning("Node ignorado durante normalizacao: %s", exc)
config["nodes"] = normalized_nodes
def load_license_key(require_env: bool) -> bytes: return config
env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
if env_key:
return env_key.encode("utf-8") def load_license_key(require_env: bool) -> bytes:
if require_env: env_key = os.getenv("VFM_LICENSE_MASTER_KEY")
raise RuntimeError( if env_key:
"VFM_LICENSE_MASTER_KEY e obrigatoria em producao. Defina a variavel de ambiente antes de iniciar o sistema." return env_key.encode("utf-8")
) if require_env:
LOGGER.warning( raise RuntimeError(
"VFM_LICENSE_MASTER_KEY nao definido. Usando chave legada embutida; mova a chave para variavel de ambiente." "VFM_LICENSE_MASTER_KEY e obrigatoria em producao. Defina a variavel de ambiente antes de iniciar o sistema."
) )
return LEGACY_LICENSE_KEY LOGGER.warning(
"VFM_LICENSE_MASTER_KEY nao definido. Usando chave legada embutida; mova a chave para variavel de ambiente."
)
def get_status_info(value: int) -> dict: return LEGACY_LICENSE_KEY
low_byte = value & 0xFF
return MAPA_STATUS_BASE.get(
value, def get_status_info(value: int) -> dict:
MAPA_STATUS_BASE.get(low_byte, {"label": f"ST {value}", "color": "#6c757d"}), 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 def get_hardware_id() -> str:
try:
output = subprocess.check_output( if os.name == "nt":
[ import subprocess
"powershell",
"-NoProfile", output = subprocess.check_output(
"-Command", [
"(Get-CimInstance Win32_ComputerSystemProduct).UUID.Trim()", "powershell",
], "-NoProfile",
text=True, "-Command",
) "(Get-CimInstance Win32_ComputerSystemProduct).UUID.Trim()",
return output.strip() ],
text=True,
machine_id = Path("/etc/machine-id") )
if machine_id.exists(): return output.strip()
return machine_id.read_text(encoding="utf-8").strip()[:32]
except Exception as exc: # pragma: no cover machine_id = Path("/etc/machine-id")
LOGGER.warning("Nao foi possivel obter hardware id: %s", exc) if machine_id.exists():
return "ID-ERRO-VOLTEC-001" 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: def validate_node_payload(data: dict) -> dict:
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("Formato de node invalido.") raise ValueError("Formato de node invalido.")
nome = str(data.get("nome", "")).strip() nome = str(data.get("nome", "")).strip()
ip = str(data.get("ip", "")).strip() ip = str(data.get("ip", "")).strip()
if not nome: if not nome:
raise ValueError("Nome do node e obrigatorio.") raise ValueError("Nome do node e obrigatorio.")
if not ip: if not ip:
raise ValueError("IP do node e obrigatorio.") raise ValueError("IP do node e obrigatorio.")
try:
ip_address(ip)
except ValueError:
raise ValueError(f"IP invalido para node {nome}.") from None
try: try:
unit = int(data.get("unit")) unit = int(data.get("unit"))
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError(f"Unit invalido para node {nome}.") from None 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: if unit < 0 or unit > 255:
raise ValueError(f"Unit fora do intervalo para node {nome}.") 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} return {"nome": nome, "ip": ip, "unit": unit, "port": port}
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 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: def discover_node(node: dict, current_map: dict) -> dict:
client = ModbusClient( client = ModbusClient(
host=node["ip"], host=node["ip"],
port=502, port=node.get("port", DEFAULT_MODBUS_PORT),
unit_id=node["unit"], unit_id=node["unit"],
auto_open=True, auto_open=True,
timeout=1, timeout=1,
) )
discovered = {} discovered = {}
try: try:
for start in range(1, MAX_DISCOVERY_ADDR + 1, MODBUS_BLOCK_SIZE): for start in range(1, MAX_DISCOVERY_ADDR + 1, MODBUS_BLOCK_SIZE):
count = min(MODBUS_BLOCK_SIZE, MAX_DISCOVERY_ADDR - start + 1) count = min(MODBUS_BLOCK_SIZE, MAX_DISCOVERY_ADDR - start + 1)
registers = client.read_holding_registers(start, count) registers = client.read_holding_registers(start, count)
if not registers: if not registers:
continue continue
for offset, value in enumerate(registers): for offset, value in enumerate(registers):
if value in DISCOVERY_IGNORED_VALUES: if value in DISCOVERY_IGNORED_VALUES:
continue continue
address = start + offset address = start + offset
key = f"{node['ip']}_{node['unit']}_{address}" key = f"{node['ip']}_{node['unit']}_{address}"
existing = current_map.get(key, {}) existing = current_map.get(key, {})
if existing.get("label"): if existing.get("label"):
label = existing["label"] label = existing["label"]
else: else:
device_type = "D" if address < 160 else ("M" if address < 350 else "P") device_type = "D" if address < 160 else ("M" if address < 350 else "P")
label = f"{node['nome']}-{device_type}{address:03}" label = f"{node['nome']}-{device_type}{address:03}"
discovered[key] = { discovered[key] = {
"label": label, "label": label,
"status": value, "status": value,
"node_nome": node["nome"], "node_nome": node["nome"],
} }
finally: finally:
client.close() client.close()
return discovered return discovered
@dataclass @dataclass
class LicenseStatus: class LicenseStatus:
valid: bool valid: bool
message: str message: str
hardware_id: str hardware_id: str
days_remaining: int days_remaining: int
class LicenseService: class LicenseService:
def __init__(self, license_path: Path, master_key: bytes): def __init__(self, license_path: Path, master_key: bytes):
self.license_path = license_path self.license_path = license_path
self.fernet = Fernet(master_key) self.fernet = Fernet(master_key)
def verify(self) -> LicenseStatus: def verify(self) -> LicenseStatus:
hardware_id = get_hardware_id() hardware_id = get_hardware_id()
if not self.license_path.exists(): if not self.license_path.exists():
return LicenseStatus(False, "Licenca ausente", hardware_id, 0) return LicenseStatus(False, "Licenca ausente", hardware_id, 0)
try: try:
serial = self.license_path.read_text(encoding="utf-8").strip() serial = self.license_path.read_text(encoding="utf-8").strip()
payload = json.loads(self.fernet.decrypt(serial.encode("utf-8")).decode("utf-8")) payload = json.loads(self.fernet.decrypt(serial.encode("utf-8")).decode("utf-8"))
licensed_hwid = str(payload["h"]).strip() licensed_hwid = str(payload["h"]).strip()
if licensed_hwid != hardware_id.strip(): if licensed_hwid != hardware_id.strip():
return LicenseStatus(False, "HWID incompativel", hardware_id, 0) return LicenseStatus(False, "HWID incompativel", hardware_id, 0)
expires_at = datetime.strptime(payload["e"], "%Y-%m-%d") expires_at = datetime.strptime(payload["e"], "%Y-%m-%d")
days_remaining = (expires_at.date() - datetime.now().date()).days days_remaining = (expires_at.date() - datetime.now().date()).days
if days_remaining < 0: if days_remaining < 0:
return LicenseStatus(False, "Licenca expirada", hardware_id, 0) return LicenseStatus(False, "Licenca expirada", hardware_id, 0)
return LicenseStatus(True, f"Licenciado para {payload['c']}", hardware_id, days_remaining) return LicenseStatus(True, f"Licenciado para {payload['c']}", hardware_id, days_remaining)
except (InvalidToken, KeyError, ValueError, OSError, json.JSONDecodeError) as exc: except (InvalidToken, KeyError, ValueError, OSError, json.JSONDecodeError) as exc:
LOGGER.warning("Falha ao verificar licenca: %s", exc) LOGGER.warning("Falha ao verificar licenca: %s", exc)
return LicenseStatus(False, "Serial invalido", hardware_id, 0) return LicenseStatus(False, "Serial invalido", hardware_id, 0)
def save(self, serial: str) -> None: def install(self, serial: str) -> LicenseStatus:
cleaned = serial.strip() cleaned = serial.strip()
if not cleaned: if not cleaned:
raise ValueError("Serial vazio.") raise ValueError("Serial vazio.")
self.license_path.write_text(cleaned, encoding="utf-8")
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: class RuntimeState:
def __init__(self, base_dir: Path, default_password: str): def __init__(self, base_dir: Path, default_password: str):
self.lock = threading.RLock() self.lock = threading.RLock()
self.config_store = JsonStore(base_dir / "config_nodes.json") self.config_store = JsonStore(base_dir / "config_nodes.json")
self.map_store = JsonStore(base_dir / "mapa_dispositivos.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.config = normalize_config(self.config_store.load(build_default_config(default_password)), default_password)
self.mapa_dispositivos = self.map_store.load({}) self.mapa_dispositivos = self.map_store.load({})
self.dados_tempo_real: Dict[str, dict] = {} self.dados_tempo_real: Dict[str, dict] = {}
self.status_nodes_web: Dict[str, str] = {} self.status_nodes_web: Dict[str, str] = {}
self.restart_event = threading.Event() self.restart_event = threading.Event()
self.stop_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() self.save_config()
def snapshot(self) -> Tuple[dict, dict, dict, dict]: def snapshot(self) -> Tuple[dict, dict, dict, dict]:
with self.lock: with self.lock:
return ( return (
dict(self.config), dict(self.config),
dict(self.mapa_dispositivos), dict(self.mapa_dispositivos),
dict(self.dados_tempo_real), dict(self.dados_tempo_real),
dict(self.status_nodes_web), dict(self.status_nodes_web),
) )
def save_config(self) -> None: def get_monitor_status(self) -> MonitorStatus:
with self.lock: with self.lock:
self.config_store.save(self.config) 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: def trigger_restart(self) -> None:
self.restart_event.set() self.restart_event.set()
def consume_restart(self) -> bool: def stop(self) -> None:
if self.restart_event.is_set(): self.stop_event.set()
self.restart_event.clear() self.restart_event.set()
return True
return False 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: def authenticate(self, username: str, password: str) -> bool:
with self.lock: with self.lock:
if username != self.config["web_user"]: if username != self.config["web_user"]:
return False return False
return check_password_hash(self.config["web_password_hash"], password) return check_password_hash(self.config["web_password_hash"], password)
def update_config(self, zabbix_server: str, hostname_zabbix: str, nodes: List[dict]) -> None: def update_credentials(self, username: str, password: str) -> None:
normalized_username, normalized_password = validate_login_payload(username, password)
with self.lock: with self.lock:
self.config["zabbix_server"] = zabbix_server.strip() self.config["web_user"] = normalized_username
self.config["hostname_zabbix"] = hostname_zabbix.strip() self.config["web_password_hash"] = generate_password_hash(normalized_password)
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.config_store.save(self.config)
self.map_store.save(self.mapa_dispositivos)
self.restart_event.set() def update_config(self, zabbix_server: str, hostname_zabbix: str, nodes: List[dict]) -> None:
with self.lock:
def rename_device(self, address_key: str, label: str) -> bool: self.config["zabbix_server"] = zabbix_server.strip()
with self.lock: self.config["hostname_zabbix"] = hostname_zabbix.strip()
if address_key not in self.mapa_dispositivos: self.config["nodes"] = nodes
return False
self.mapa_dispositivos[address_key]["label"] = label.strip() active_prefixes = [f"{node['ip']}_{node['unit']}_" for node in nodes]
self.map_store.save(self.mapa_dispositivos) self.mapa_dispositivos = {
self.restart_event.set() key: value
return True 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: def replace_node_devices(self, node: dict, discovered_map: dict) -> None:
prefix = f"{node['ip']}_{node['unit']}_" prefix = f"{node['ip']}_{node['unit']}_"
with self.lock: with self.lock:
self.mapa_dispositivos = { self.mapa_dispositivos = {
key: value for key, value in self.mapa_dispositivos.items() if not key.startswith(prefix) key: value for key, value in self.mapa_dispositivos.items() if not key.startswith(prefix)
} }
self.mapa_dispositivos.update(discovered_map) self.mapa_dispositivos.update(discovered_map)
self.map_store.save(self.mapa_dispositivos) self.map_store.save(self.mapa_dispositivos)
self.restart_event.set() 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: class MonitorEngine:
def __init__(self, runtime_state: RuntimeState, license_service: LicenseService): def __init__(self, runtime_state: RuntimeState, license_service: LicenseService):
self.runtime_state = runtime_state self.runtime_state = runtime_state
self.license_service = license_service self.license_service = license_service
self.thread: threading.Thread | None = None
def start(self) -> None: def start(self) -> None:
threading.Thread(target=self.run, daemon=True, name="monitor-engine").start() 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: 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(): 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() license_status = self.license_service.verify()
self.runtime_state.update_monitor_status(last_license_message=license_status.message)
if not license_status.valid: if not license_status.valid:
with self.runtime_state.lock: with self.runtime_state.lock:
self.runtime_state.status_nodes_web = {"SISTEMA": "BLOQUEADO"} self.runtime_state.status_nodes_web = {"SISTEMA": "BLOQUEADO"}
self.runtime_state.dados_tempo_real = {} self.runtime_state.dados_tempo_real = {}
time.sleep(RETRY_INTERVAL_SECONDS) 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 continue
self.runtime_state.consume_restart() self.runtime_state.consume_restart()
@@ -390,105 +524,111 @@ class MonitorEngine:
if self.runtime_state.consume_restart(): if self.runtime_state.consume_restart():
break break
self._poll_once(sender, hostname) self._poll_once(sender, hostname)
time.sleep(POLL_INTERVAL_SECONDS) self.runtime_state.update_monitor_status(
last_cycle_completed_at=datetime.now().isoformat(timespec="seconds"),
def _build_sender(self, server: str): )
if not server: self.runtime_state.stop_event.wait(POLL_INTERVAL_SECONDS)
return None self.runtime_state.update_monitor_status(running=False)
try:
return Sender(server=server) def _build_sender(self, server: str):
except Exception as exc: if not server:
LOGGER.warning("Nao foi possivel iniciar sender do Zabbix: %s", exc) return None
return None try:
return Sender(server=server)
def _send_discovery(self, sender, hostname: str, nodes: List[dict], device_map: dict) -> None: except Exception as exc:
if not sender or not hostname or not device_map: LOGGER.warning("Nao foi possivel iniciar sender do Zabbix: %s", exc)
return return None
lld = {"data": []} def _send_discovery(self, sender, hostname: str, nodes: List[dict], device_map: dict) -> None:
for info in device_map.values(): if not sender or not hostname or not device_map:
lld["data"].append({"{#PONTO_LABEL}": info["label"]}) return
for node in nodes:
lld["data"].append({"{#NODE_NOME}": node["nome"]}) lld = {"data": []}
for info in device_map.values():
try: lld["data"].append({"{#PONTO_LABEL}": info["label"]})
sender.send([ItemValue(hostname, "notifier.discovery", json.dumps(lld, ensure_ascii=False))]) for node in nodes:
except Exception as exc: lld["data"].append({"{#NODE_NOME}": node["nome"]})
LOGGER.warning("Falha ao enviar discovery ao Zabbix: %s", exc)
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: def _poll_once(self, sender, hostname: str) -> None:
config, device_map, _, _ = self.runtime_state.snapshot() config, device_map, _, _ = self.runtime_state.snapshot()
nodes = config.get("nodes", []) nodes = config.get("nodes", [])
metrics: List[ItemValue] = [] metrics: List[ItemValue] = []
latest_data: Dict[str, dict] = {} latest_data: Dict[str, dict] = {}
node_statuses: Dict[str, str] = {} node_statuses: Dict[str, str] = {}
for node in nodes: for node in nodes:
node_metrics, node_data, node_status = self._poll_node(node, device_map, hostname) node_metrics, node_data, node_status = self._poll_node(node, device_map, hostname)
metrics.extend(node_metrics) metrics.extend(node_metrics)
latest_data.update(node_data) latest_data.update(node_data)
node_statuses[node["nome"]] = node_status node_statuses[node["nome"]] = node_status
with self.runtime_state.lock: with self.runtime_state.lock:
self.runtime_state.dados_tempo_real.update(latest_data) self.runtime_state.dados_tempo_real.update(latest_data)
self.runtime_state.status_nodes_web = node_statuses self.runtime_state.status_nodes_web = node_statuses
if sender and hostname and metrics: if sender and hostname and metrics:
try: try:
sender.send(metrics) sender.send(metrics)
except Exception as exc: except Exception as exc:
LOGGER.warning("Falha ao enviar metricas ao Zabbix: %s", 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):
def _poll_node(self, node: dict, device_map: dict, hostname: str):
client = ModbusClient( client = ModbusClient(
host=node["ip"], host=node["ip"],
port=502, port=node.get("port", DEFAULT_MODBUS_PORT),
unit_id=node["unit"], unit_id=node["unit"],
auto_open=True, auto_open=True,
timeout=1, timeout=1,
) )
node_metrics: List[ItemValue] = [] node_metrics: List[ItemValue] = []
node_data: Dict[str, dict] = {} node_data: Dict[str, dict] = {}
node_status = "Offline" node_status = "Offline"
try: try:
alive = client.read_holding_registers(1, 1) alive = client.read_holding_registers(1, 1)
status_conn = 1 if alive is not None else 0 status_conn = 1 if alive is not None else 0
node_status = "Online" if status_conn else "Offline" node_status = "Online" if status_conn else "Offline"
if hostname: if hostname:
node_metrics.append(ItemValue(hostname, f"node.status[{node['nome']}]", status_conn)) node_metrics.append(ItemValue(hostname, f"node.status[{node['nome']}]", status_conn))
if not status_conn: if not status_conn:
return node_metrics, node_data, node_status return node_metrics, node_data, node_status
node_keys = [ node_keys = [
key for key in device_map.keys() if key.startswith(f"{node['ip']}_{node['unit']}_") 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] node_addresses = [int(key.rsplit("_", 1)[-1]) for key in node_keys]
for start, end in contiguous_ranges(node_addresses): for start, end in contiguous_ranges(node_addresses):
registers = client.read_holding_registers(start, end - start + 1) registers = client.read_holding_registers(start, end - start + 1)
if not registers: if not registers:
continue continue
for offset, value in enumerate(registers): for offset, value in enumerate(registers):
address = start + offset address = start + offset
key = f"{node['ip']}_{node['unit']}_{address}" key = f"{node['ip']}_{node['unit']}_{address}"
if key not in device_map: if key not in device_map:
continue continue
label = device_map[key]["label"] label = device_map[key]["label"]
node_data[key] = { node_data[key] = {
"label": label, "label": label,
"status": value, "status": value,
"node": node["nome"], "node": node["nome"],
"last": time.strftime("%H:%M:%S"), "last": time.strftime("%H:%M:%S"),
} }
if hostname: if hostname:
node_metrics.append(ItemValue(hostname, f"notifier.status[{label}]", value & 0xFF)) node_metrics.append(ItemValue(hostname, f"notifier.status[{label}]", value & 0xFF))
node_metrics.append(ItemValue(hostname, f"notifier.comm[{label}]", (value >> 8) & 0xFF)) node_metrics.append(ItemValue(hostname, f"notifier.comm[{label}]", (value >> 8) & 0xFF))
except Exception as exc: except Exception as exc:
LOGGER.warning("Falha ao consultar node %s (%s): %s", node["nome"], node["ip"], 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: finally:
client.close() client.close()
return node_metrics, node_data, node_status return node_metrics, node_data, node_status
+184 -91
View File
@@ -1,22 +1,59 @@
from flask import current_app, flash, jsonify, redirect, render_template, request, url_for 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 flask_login import current_user, login_required, login_user, logout_user
from .core import LicenseService, RuntimeState, User, discover_node, get_status_info, validate_node_payload from .core import (
LicenseService,
RuntimeState,
def get_runtime_state() -> RuntimeState: User,
return current_app.extensions["runtime_state"] discover_node,
get_status_info,
validate_login_payload,
def get_license_service() -> LicenseService: validate_node_payload,
return current_app.extensions["license_service"] )
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): def json_error(message: str, status_code: int = 400):
return jsonify({"status": "error", "message": message}), status_code 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): 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"]) @app.route("/login", methods=["GET", "POST"])
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
@@ -27,15 +64,18 @@ def register_routes(app):
password = request.form.get("password", "") password = request.form.get("password", "")
if get_runtime_state().authenticate(username, password): if get_runtime_state().authenticate(username, password):
login_user(User(username)) login_user(User(username))
session.permanent = True
session["csrf_token"] = secrets.token_urlsafe(32)
return redirect(url_for("index")) return redirect(url_for("index"))
flash("Usuario ou senha invalidos.") flash("Usuario ou senha invalidos.")
return render_template("login.html") return render_template("login.html", csrf_token=get_csrf_token())
@app.route("/") @app.route("/")
@login_required @login_required
def index(): def index():
config, _, _, _ = get_runtime_state().snapshot() config, _, _, _ = get_runtime_state().snapshot()
license_status = get_license_service().verify() license_status = get_license_service().verify()
monitor_status = get_runtime_state().get_monitor_status()
return render_template( return render_template(
"index.html", "index.html",
config=config, config=config,
@@ -43,108 +83,161 @@ def register_routes(app):
lic_msg=license_status.message, lic_msg=license_status.message,
hwid=license_status.hardware_id, hwid=license_status.hardware_id,
dias_restantes=license_status.days_remaining, dias_restantes=license_status.days_remaining,
csrf_token=get_csrf_token(),
monitor_status=monitor_status,
) )
@app.route("/api/nodes", methods=["POST"]) @app.route("/api/nodes", methods=["POST"])
@login_required @login_required
def api_nodes(): def api_nodes():
payload = request.get_json(silent=True) payload = request.get_json(silent=True)
if not isinstance(payload, dict): if not isinstance(payload, dict):
return json_error("JSON invalido.") return json_error("JSON invalido.")
try: try:
zabbix_server = str(payload.get("zabbix_server", "")).strip() zabbix_server = str(payload.get("zabbix_server", "")).strip()
hostname_zabbix = str(payload.get("hostname_zabbix", "")).strip() hostname_zabbix = str(payload.get("hostname_zabbix", "")).strip()
nodes = [validate_node_payload(node) for node in payload.get("nodes", [])] nodes = [validate_node_payload(node) for node in payload.get("nodes", [])]
except ValueError as exc: except ValueError as exc:
return json_error(str(exc)) return json_error(str(exc))
if not zabbix_server: if not zabbix_server:
return json_error("Zabbix Server e obrigatorio.") return json_error("Zabbix Server e obrigatorio.")
if not hostname_zabbix: if not hostname_zabbix:
return json_error("Hostname Zabbix e obrigatorio.") return json_error("Hostname Zabbix e obrigatorio.")
get_runtime_state().update_config(zabbix_server, hostname_zabbix, nodes) get_runtime_state().update_config(zabbix_server, hostname_zabbix, nodes)
return jsonify({"status": "success"}) return jsonify({"status": "success"})
@app.route("/api/rename", methods=["POST"]) @app.route("/api/rename", methods=["POST"])
@login_required @login_required
def api_rename(): def api_rename():
payload = request.get_json(silent=True) payload = request.get_json(silent=True)
if not isinstance(payload, dict): if not isinstance(payload, dict):
return json_error("JSON invalido.") return json_error("JSON invalido.")
address_key = str(payload.get("addr", "")).strip() address_key = str(payload.get("addr", "")).strip()
label = str(payload.get("label", "")).strip() label = str(payload.get("label", "")).strip()
if not address_key: if not address_key:
return json_error("Endereco do dispositivo e obrigatorio.") return json_error("Endereco do dispositivo e obrigatorio.")
if not label: if not label:
return json_error("Nome do dispositivo e obrigatorio.") return json_error("Nome do dispositivo e obrigatorio.")
if not get_runtime_state().rename_device(address_key, label): if not get_runtime_state().rename_device(address_key, label):
return json_error("Dispositivo nao encontrado.", 404) return json_error("Dispositivo nao encontrado.", 404)
return jsonify({"status": "success"}) return jsonify({"status": "success"})
@app.route("/api/license", methods=["POST"]) @app.route("/api/license", methods=["POST"])
@login_required
def api_license(): def api_license():
payload = request.get_json(silent=True) payload = request.get_json(silent=True)
if not isinstance(payload, dict): if not isinstance(payload, dict):
return json_error("JSON invalido.") return json_error("JSON invalido.")
try: try:
get_license_service().save(str(payload.get("serial", ""))) status = get_license_service().install(str(payload.get("serial", "")))
except ValueError as exc: except ValueError as exc:
return json_error(str(exc)) return json_error(str(exc))
get_runtime_state().trigger_restart() get_runtime_state().trigger_restart()
return jsonify({"status": "success"}) return jsonify({"status": "success", "message": status.message, "days_remaining": status.days_remaining})
@app.route("/api/rescan_node", methods=["POST"]) @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 @login_required
def api_rescan(): 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) payload = request.get_json(silent=True)
if not isinstance(payload, dict): if not isinstance(payload, dict):
return json_error("JSON invalido.") 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: try:
node = validate_node_payload(payload) validated_username, validated_password = validate_login_payload(username, new_password)
except ValueError as exc: except ValueError as exc:
return json_error(str(exc)) return json_error(str(exc))
_, current_map, _, _ = get_runtime_state().snapshot() get_runtime_state().update_credentials(validated_username, validated_password)
discovered_map = discover_node(node, current_map) logout_user()
get_runtime_state().replace_node_devices(node, discovered_map) session.clear()
return jsonify({"status": "success", "count": len(discovered_map)}) return jsonify({"status": "success"})
@app.route("/api/data")
@login_required
def api_data():
_, device_map, realtime_data, node_statuses = get_runtime_state().snapshot()
sensors = []
for key, info in device_map.items():
data = realtime_data.get(
key,
{"status": 0, "last": "--:--:--", "node": info.get("node_nome", "...")},
)
status_info = get_status_info(int(data["status"]))
sensors.append(
{
"key": key,
"label": info["label"],
"node": data.get("node", "..."),
"status_text": status_info["label"],
"color": status_info["color"],
"last": data["last"],
}
)
return jsonify({"sensores": sensors, "nodes_status": node_statuses})
@app.route("/logout") @app.route("/logout")
@login_required @login_required
def logout(): def logout():
logout_user() logout_user()
session.clear()
return redirect(url_for("login")) 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'