From 5d92a6c99a7dff7519193ba3a81b0d91452bc626 Mon Sep 17 00:00:00 2001 From: SyllasGS Date: Thu, 7 May 2026 12:11:15 -0300 Subject: [PATCH] Add production installer and observability tooling --- .env.example | 4 + .gitignore | 2 + README.md | 210 +++-- .../grafana/vfire-floorplan-starter.json | 65 ++ docs/GRAFANA_PLANTA_BAIXA.md | 123 +++ docs/INSTALACAO_DEBIAN13.md | 186 ++++ docs/LICENCIAMENTO.md | 113 +++ floorplan/floorplan-map.example.json | 39 + generator.py | 116 +-- installer/install_debian13.sh | 640 +++++++++++++ installer/vfire-stack.env.example | 33 + monitor.py | 8 +- requirements.txt | 1 + serve.py | 17 + simulator_nfs320.py | 103 +++ static/css/vendor.css | 369 ++++++++ static/grafana-icons/detector-heat.svg | 5 + static/grafana-icons/detector-smoke.svg | 5 + static/grafana-icons/module-input.svg | 8 + static/grafana-icons/panel-nfs320.svg | 8 + static/grafana-icons/siren.svg | 5 + static/js/vendor.js | 53 ++ templates/index.html | 730 ++++++++------- templates/login.html | 76 +- tests/test_core.py | 190 ++-- tools/build_floorplan_bundle.py | 120 +++ vfire_monitor/__init__.py | 109 ++- vfire_monitor/core.py | 856 ++++++++++-------- vfire_monitor/routes.py | 275 ++++-- zbx_export_templates.yaml | 39 + 30 files changed, 3493 insertions(+), 1015 deletions(-) create mode 100644 dashboards/grafana/vfire-floorplan-starter.json create mode 100644 docs/GRAFANA_PLANTA_BAIXA.md create mode 100644 docs/INSTALACAO_DEBIAN13.md create mode 100644 docs/LICENCIAMENTO.md create mode 100644 floorplan/floorplan-map.example.json create mode 100644 installer/install_debian13.sh create mode 100644 installer/vfire-stack.env.example create mode 100644 serve.py create mode 100644 simulator_nfs320.py create mode 100644 static/css/vendor.css create mode 100644 static/grafana-icons/detector-heat.svg create mode 100644 static/grafana-icons/detector-smoke.svg create mode 100644 static/grafana-icons/module-input.svg create mode 100644 static/grafana-icons/panel-nfs320.svg create mode 100644 static/grafana-icons/siren.svg create mode 100644 static/js/vendor.js create mode 100644 tools/build_floorplan_bundle.py create mode 100644 zbx_export_templates.yaml diff --git a/.env.example b/.env.example index 70ae8d1..8bfd9f7 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,8 @@ VFM_APP_SECRET= VFM_LICENSE_MASTER_KEY= VFM_ENV= VFM_LOG_LEVEL=INFO +VFM_SESSION_COOKIE_SECURE= +VFM_TRUST_PROXY= +VFM_MAX_CONTENT_LENGTH=1048576 +HOST=0.0.0.0 PORT=8080 diff --git a/.gitignore b/.gitignore index f5bc35a..f9f99e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ __pycache__/ *.pyc .pytest_cache/ +.venv/ +.codex config_nodes.json mapa_dispositivos.json license.key diff --git a/README.md b/README.md index 449a56c..a512fb6 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,197 @@ -# 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. - +# 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. + ## O que mudou nesta refatoracao - Persistencia JSON com escrita atomica e migracao de configuracao legada. - Login com hash de senha em vez de senha em texto puro no arquivo de configuracao. +- Alteracao de credenciais pelo proprio painel, sem editar arquivo manualmente. - Chave secreta do Flask persistida localmente, sem valor fixo no codigo. +- Cookies de sessao e headers HTTP endurecidos para operacao web basica em producao. +- Protecao CSRF para login e APIs mutaveis. - Validacao de payloads da API e respostas de erro consistentes. +- Validacao de IPs de nodes e validacao do serial antes de gravar a licenca. - Logging basico para diagnostico, no lugar de falhas silenciosas. - Polling Modbus e discovery por blocos, reduzindo chamadas individuais. -- 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. - -## Estrutura - + +## Estrutura + - `monitor.py`: entrypoint simples da aplicacao Flask. +- `simulator_nfs320.py`: simulador Modbus/TCP local de uma central NFS-320 para testes. - `vfire_monitor/__init__.py`: app factory e bootstrap da aplicacao. -- `vfire_monitor/core.py`: regras de negocio, persistencia, licenca e engine de monitoramento. -- `vfire_monitor/routes.py`: rotas web e APIs. +- `vfire_monitor/core.py`: regras de negocio, persistencia, licenca e engine de monitoramento. +- `vfire_monitor/routes.py`: rotas web e APIs. - `generator.py`: gerador de serial de licenca. +- `static/`: assets locais de interface carregados pela aplicacao. - `templates/`: telas do login e dashboard. -- `tests/`: suite inicial de testes automatizados. -- `config_nodes.json`: configuracao persistida da aplicacao. -- `mapa_dispositivos.json`: mapa de dispositivos descobertos. -- `license.key`: serial instalado localmente. -- `app_secret.key`: segredo de sessao gerado automaticamente na primeira execucao. - +- `tests/`: suite inicial de testes automatizados. +- `config_nodes.json`: configuracao persistida da aplicacao. +- `mapa_dispositivos.json`: mapa de dispositivos descobertos. +- `license.key`: serial instalado localmente. +- `app_secret.key`: segredo de sessao gerado automaticamente na primeira execucao. + ## Requisitos - Python 3.10+ - Conectividade com as centrais via Modbus/TCP - Acesso ao servidor Zabbix, quando a integracao estiver habilitada - -Instalacao: - -```bash -pip install -r requirements.txt -``` - -## Variaveis de ambiente - -Veja `.env.example`. - -As principais: - -- `VFM_DEFAULT_PASSWORD`: senha inicial do usuario `admin` na primeira carga do sistema. -- `VFM_APP_SECRET`: opcional, substitui o segredo salvo em `app_secret.key`. -- `VFM_LICENSE_MASTER_KEY`: chave mestre do licenciamento. Em producao, use esta variavel e remova a dependencia da chave legada. -- `VFM_ENV`: use `production` para obrigar `VFM_LICENSE_MASTER_KEY` no startup. +- Para instalacao automatizada da stack completa, Debian 13 (trixie) + +Instalacao: + +```bash +pip install -r requirements.txt +``` + +## Variaveis de ambiente + +Veja `.env.example`. + +As principais: + +- `VFM_DEFAULT_PASSWORD`: senha inicial do usuario `admin` na primeira carga do sistema. +- `VFM_APP_SECRET`: opcional, substitui o segredo salvo em `app_secret.key`. +- `VFM_LICENSE_MASTER_KEY`: chave mestre do licenciamento. Em producao, use esta variavel e remova a dependencia da chave legada. +- `VFM_ENV`: use `production` para obrigar `VFM_LICENSE_MASTER_KEY` no startup. - `VFM_LOG_LEVEL`: nivel de log, por exemplo `INFO` ou `DEBUG`. +- `VFM_SESSION_COOKIE_SECURE`: force cookie `Secure`, recomendado atras de HTTPS. +- `VFM_TRUST_PROXY`: habilita `ProxyFix` quando houver reverse proxy na frente. +- `VFM_MAX_CONTENT_LENGTH`: limite maximo do corpo HTTP em bytes. +- `HOST`: host HTTP do processo Flask. - `PORT`: porta HTTP da aplicacao. +Cada central aceita: + +- `nome` +- `ip` +- `unit` +- `port`: opcional na integracao Modbus, default `502` + ## Execucao - -```bash -python monitor.py -``` - + +```bash +python monitor.py +``` + O sistema sobe em `http://0.0.0.0:8080` por padrao. -## Geracao de licenca - -Modo interativo: +Healthcheck: ```bash -python generator.py +curl http://127.0.0.1:8080/healthz ``` -Modo por argumentos: +Status operacional autenticado: ```bash +curl http://127.0.0.1:8080/api/system/status +``` + +## Instalador Debian 13 + +O repositorio inclui um instalador para Debian 13 que provisiona: + +- `V-Fire Monitor` como servico `systemd` +- `PostgreSQL` +- `Zabbix Server + frontend Nginx` +- `Grafana` +- importacao automatica do template `Notifier NFS320` +- criacao automatica do host monitorado no Zabbix +- datasource do Grafana apontando para o Zabbix +- dashboard inicial de planta baixa e icones SVG locais + +Arquivos: + +- [installer/install_debian13.sh](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/installer/install_debian13.sh) +- [installer/vfire-stack.env.example](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/installer/vfire-stack.env.example) +- [serve.py](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/serve.py): entrypoint de producao do app com Waitress +- [docs/INSTALACAO_DEBIAN13.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/INSTALACAO_DEBIAN13.md) +- [docs/LICENCIAMENTO.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/LICENCIAMENTO.md) +- [docs/GRAFANA_PLANTA_BAIXA.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/GRAFANA_PLANTA_BAIXA.md) +- [floorplan/floorplan-map.example.json](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/floorplan/floorplan-map.example.json) +- [tools/build_floorplan_bundle.py](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/tools/build_floorplan_bundle.py) + +Fluxo recomendado: + +1. Copie `installer/vfire-stack.env.example` para `installer/vfire-stack.env`. +2. Ajuste senhas, hostname e `VFM_LICENSE_MASTER_KEY`. +3. Opcionalmente valide em dry-run: + +```bash +./installer/install_debian13.sh --dry-run ./installer/vfire-stack.env +``` + +4. Execute como `root` no Debian 13: + +```bash +chmod +x installer/install_debian13.sh +./installer/install_debian13.sh ./installer/vfire-stack.env +``` + +Portas padrao do instalador: + +- `V-Fire Monitor`: `8088` +- `Zabbix frontend`: `8080` +- `Grafana`: `3000` + +Ao final, o instalador grava um resumo em `/root/vfire-stack-summary.txt`. + +## Geracao de licenca + +Modo interativo: + +```bash +python generator.py +``` + +Modo por argumentos: + +```bash python generator.py --hwid "UUID-DO-CLIENTE" --cliente "Cliente" --expira 2026-12-31 ``` -## 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`. +- O serial de licenca e validado antes de ser salvo em `license.key`. +- Em Linux, `app_secret.key` e `license.key` passam a ser gravados com permissao privada (`0600`). +- O painel exige token CSRF em login e chamadas mutaveis da API. - Se existir configuracao antiga com `web_password`, ela e migrada automaticamente para `web_password_hash`. - Em ambiente de desenvolvimento, o sistema ainda aceita a chave de licenca legada embutida para manter compatibilidade. - Em ambiente de producao (`VFM_ENV=production`), `VFM_LICENSE_MASTER_KEY` passa a ser obrigatoria e o sistema falha no startup sem ela. -- Bootstrap continua sendo carregado via CDN. Se o ambiente nao tiver acesso externo, copie os assets localmente e ajuste os templates. - -## Testes - +- A interface web usa assets locais em `static/`, sem dependencia de internet para carregar CSS e JS. + +## Testes + Executar: ```bash -pytest +python3 -m pytest ``` - -## Proximos passos recomendados - -- Adicionar testes cobrindo polling Modbus e integracao com Zabbix com doubles dedicados. -- Separar configuracao e logging em modulos proprios se a aplicacao continuar crescendo. -- Trocar Bootstrap via CDN por assets locais se o ambiente alvo nao tiver acesso externo. + +## Proximos passos recomendados + +- Adicionar testes cobrindo polling Modbus e integracao com Zabbix com doubles dedicados. +- Separar configuracao e logging em modulos proprios se a aplicacao continuar crescendo. diff --git a/dashboards/grafana/vfire-floorplan-starter.json b/dashboards/grafana/vfire-floorplan-starter.json new file mode 100644 index 0000000..de28de8 --- /dev/null +++ b/dashboards/grafana/vfire-floorplan-starter.json @@ -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": "

Starter de Planta Baixa

Este dashboard foi provisionado para servir de base.

Use um painel Canvas no Grafana para montar a planta baixa.

URLs sugeridas de icones:

Associe cada icone a itens do Zabbix via datasource Zabbix.

", + "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": "" +} diff --git a/docs/GRAFANA_PLANTA_BAIXA.md b/docs/GRAFANA_PLANTA_BAIXA.md new file mode 100644 index 0000000..45b463f --- /dev/null +++ b/docs/GRAFANA_PLANTA_BAIXA.md @@ -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. diff --git a/docs/INSTALACAO_DEBIAN13.md b/docs/INSTALACAO_DEBIAN13.md new file mode 100644 index 0000000..8d11b3a --- /dev/null +++ b/docs/INSTALACAO_DEBIAN13.md @@ -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 diff --git a/docs/LICENCIAMENTO.md b/docs/LICENCIAMENTO.md new file mode 100644 index 0000000..ccba9d3 --- /dev/null +++ b/docs/LICENCIAMENTO.md @@ -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` diff --git a/floorplan/floorplan-map.example.json b/floorplan/floorplan-map.example.json new file mode 100644 index 0000000..2913a44 --- /dev/null +++ b/floorplan/floorplan-map.example.json @@ -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 + } + ] +} diff --git a/generator.py b/generator.py index a5b63eb..55e1f98 100644 --- a/generator.py +++ b/generator.py @@ -1,58 +1,58 @@ -import argparse -import json -import os -from datetime import datetime - -from cryptography.fernet import Fernet - - -LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4=" - - -def load_master_key() -> bytes: - env_key = os.getenv("VFM_LICENSE_MASTER_KEY") - if env_key: - return env_key.encode("utf-8") - return LEGACY_LICENSE_KEY - - -def parse_args(): - parser = argparse.ArgumentParser(description="Gerador de licenca do V-Fire Monitor") - parser.add_argument("--hwid", help="Hardware ID do cliente") - parser.add_argument("--cliente", help="Nome do cliente") - parser.add_argument("--expira", help="Data de expiracao no formato AAAA-MM-DD") - return parser.parse_args() - - -def ask_if_missing(value: str, prompt: str) -> str: - return value if value else input(prompt).strip() - - -def validate_expiration(date_text: str) -> str: - datetime.strptime(date_text, "%Y-%m-%d") - return date_text - - -def gerar_serial(hardware_id: str, cliente: str, data_expiracao: str) -> str: - fernet = Fernet(load_master_key()) - payload = {"h": hardware_id.strip(), "c": cliente.strip(), "e": validate_expiration(data_expiracao)} - return fernet.encrypt(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("utf-8") - - -def main(): - args = parse_args() - - hardware_id = ask_if_missing(args.hwid, "Hardware ID do cliente: ") - cliente = ask_if_missing(args.cliente, "Nome do cliente: ") - expiracao = ask_if_missing(args.expira, "Validade (AAAA-MM-DD): ") - - if not hardware_id: - raise SystemExit("Hardware ID obrigatorio.") - if not cliente: - raise SystemExit("Nome do cliente obrigatorio.") - - print(gerar_serial(hardware_id, cliente, expiracao)) - - -if __name__ == "__main__": - main() +import argparse +import json +import os +from datetime import datetime + +from cryptography.fernet import Fernet + + +LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4=" + + +def load_master_key() -> bytes: + env_key = os.getenv("VFM_LICENSE_MASTER_KEY") + if env_key: + return env_key.encode("utf-8") + return LEGACY_LICENSE_KEY + + +def parse_args(): + parser = argparse.ArgumentParser(description="Gerador de licenca do V-Fire Monitor") + parser.add_argument("--hwid", help="Hardware ID do cliente") + parser.add_argument("--cliente", help="Nome do cliente") + parser.add_argument("--expira", help="Data de expiracao no formato AAAA-MM-DD") + return parser.parse_args() + + +def ask_if_missing(value: str, prompt: str) -> str: + return value if value else input(prompt).strip() + + +def validate_expiration(date_text: str) -> str: + datetime.strptime(date_text, "%Y-%m-%d") + return date_text + + +def gerar_serial(hardware_id: str, cliente: str, data_expiracao: str) -> str: + fernet = Fernet(load_master_key()) + payload = {"h": hardware_id.strip(), "c": cliente.strip(), "e": validate_expiration(data_expiracao)} + return fernet.encrypt(json.dumps(payload, ensure_ascii=False).encode("utf-8")).decode("utf-8") + + +def main(): + args = parse_args() + + hardware_id = ask_if_missing(args.hwid, "Hardware ID do cliente: ") + cliente = ask_if_missing(args.cliente, "Nome do cliente: ") + expiracao = ask_if_missing(args.expira, "Validade (AAAA-MM-DD): ") + + if not hardware_id: + raise SystemExit("Hardware ID obrigatorio.") + if not cliente: + raise SystemExit("Nome do cliente obrigatorio.") + + print(gerar_serial(hardware_id, cliente, expiracao)) + + +if __name__ == "__main__": + main() diff --git a/installer/install_debian13.sh b/installer/install_debian13.sh new file mode 100644 index 0000000..563d038 --- /dev/null +++ b/installer/install_debian13.sh @@ -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/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}" <"${GRAFANA_APT_LIST}" </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}" <"${APP_SYSTEMD_FILE}" <>"${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 </etc/systemd/system/grafana-server.service.d/override.conf </etc/grafana/provisioning/dashboards/vfire.yaml </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" - </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}" <=0.6,<1.0 pyModbusTCP>=0.3,<1.0 zabbix-utils>=2.0,<3.0 cryptography>=42.0,<45.0 +waitress>=3.0,<4.0 pytest>=8.0,<9.0 diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..f9f4219 --- /dev/null +++ b/serve.py @@ -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")), + ) diff --git a/simulator_nfs320.py b/simulator_nfs320.py new file mode 100644 index 0000000..84dc57e --- /dev/null +++ b/simulator_nfs320.py @@ -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() diff --git a/static/css/vendor.css b/static/css/vendor.css new file mode 100644 index 0000000..5f212e1 --- /dev/null +++ b/static/css/vendor.css @@ -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; +} diff --git a/static/grafana-icons/detector-heat.svg b/static/grafana-icons/detector-heat.svg new file mode 100644 index 0000000..2ffc668 --- /dev/null +++ b/static/grafana-icons/detector-heat.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/grafana-icons/detector-smoke.svg b/static/grafana-icons/detector-smoke.svg new file mode 100644 index 0000000..0e008d3 --- /dev/null +++ b/static/grafana-icons/detector-smoke.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/grafana-icons/module-input.svg b/static/grafana-icons/module-input.svg new file mode 100644 index 0000000..1963c02 --- /dev/null +++ b/static/grafana-icons/module-input.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/grafana-icons/panel-nfs320.svg b/static/grafana-icons/panel-nfs320.svg new file mode 100644 index 0000000..101460d --- /dev/null +++ b/static/grafana-icons/panel-nfs320.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/grafana-icons/siren.svg b/static/grafana-icons/siren.svg new file mode 100644 index 0000000..8d22d13 --- /dev/null +++ b/static/grafana-icons/siren.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/js/vendor.js b/static/js/vendor.js new file mode 100644 index 0000000..0fc9eb3 --- /dev/null +++ b/static/js/vendor.js @@ -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 }; +})(); diff --git a/templates/index.html b/templates/index.html index a50b821..6747f47 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,155 +1,156 @@ - - + + V-Fire Monitor - + - - -
-
-
-

Executando varredura Modbus...

-
-
- -
-
-
-
Voltec
-

V-Fire Monitor

-
+ :root { + --bg: #0c0f12; + --panel: #12171d; + --panel-soft: #1a2129; + --border: #2a333d; + --text: #edf2f7; + --muted: #94a3b8; + --accent: #ff5a3d; + } + + body { + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(255, 90, 61, 0.15), transparent 30%), + linear-gradient(180deg, #091017 0%, var(--bg) 45%, #07090c 100%); + color: var(--text); + font-family: "Segoe UI", sans-serif; + } + + .app-header, + .panel, + .sensor-card, + .node-row { + background: rgba(18, 23, 29, 0.95); + border: 1px solid var(--border); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18); + } + + .app-header { + backdrop-filter: blur(8px); + } + + .panel { + border-radius: 18px; + padding: 1rem 1.25rem; + } + + .node-group { + margin-bottom: 1.5rem; + } + + .node-title { + margin: 0; + padding-left: 0.9rem; + border-left: 4px solid var(--accent); + font-size: 1.05rem; + font-weight: 700; + } + + .sensor-card { + height: 100%; + border-radius: 14px; + padding: 1rem; + position: relative; + transition: transform 0.16s ease, border-color 0.16s ease; + } + + .sensor-card:hover { + transform: translateY(-2px); + } + + .sensor-label { + padding-right: 2rem; + word-break: break-word; + } + + .edit-btn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + border: 0; + background: transparent; + color: var(--muted); + } + + .edit-btn:hover { + color: var(--text); + } + + .badge-status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 700; + } + + .node-row { + border-radius: 12px; + padding: 0.9rem; + margin-bottom: 0.75rem; + } + + .loading-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(7, 9, 12, 0.85); + z-index: 9999; + } + + + +
+
+
+

Executando varredura Modbus...

+
+
+ +
+
+
+
Voltec
+

V-Fire Monitor

+
+ Sair
-
-
- -
-
- - {% if not licenciado %} -
-
-
-

Sistema bloqueado

-

Ative a licenca deste equipamento para liberar o monitoramento.

-

Hardware ID

- {{ hwid }} - - -
-
-
- {% else %} +
+
+ +
+
+ + {% if not licenciado %} +
+
+
+

Sistema bloqueado

+

Ative a licenca deste equipamento para liberar o monitoramento.

+

Hardware ID

+ {{ hwid }} + + +
+
+
+ {% else %}
Licenca
@@ -160,77 +161,123 @@
+
+
+
Monitor
+
{{ "Em execucao" if monitor_status.running else "Parado" }}
+
+
+
Ultimo ciclo: {{ monitor_status.last_cycle_completed_at or "--" }}
+
Ultima licenca: {{ monitor_status.last_license_message or "--" }}
+
+
+
{% endif %}
+ + -