Add production installer and observability tooling
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,20 +6,28 @@ Aplicacao Flask para monitoramento de centrais Notifier via Modbus/TCP, com pain
|
|||||||
|
|
||||||
- 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.
|
||||||
@@ -32,6 +40,7 @@ Aplicacao Flask para monitoramento de centrais Notifier via Modbus/TCP, com pain
|
|||||||
- 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:
|
||||||
|
|
||||||
@@ -50,8 +59,19 @@ As principais:
|
|||||||
- `VFM_LICENSE_MASTER_KEY`: chave mestre do licenciamento. Em producao, use esta variavel e remova a dependencia da chave legada.
|
- `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_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
|
||||||
@@ -60,6 +80,67 @@ 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.
|
||||||
|
|
||||||
|
Healthcheck:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
Status operacional autenticado:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/api/system/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Instalador Debian 13
|
||||||
|
|
||||||
|
O repositorio inclui um instalador para Debian 13 que provisiona:
|
||||||
|
|
||||||
|
- `V-Fire Monitor` como servico `systemd`
|
||||||
|
- `PostgreSQL`
|
||||||
|
- `Zabbix Server + frontend Nginx`
|
||||||
|
- `Grafana`
|
||||||
|
- importacao automatica do template `Notifier NFS320`
|
||||||
|
- criacao automatica do host monitorado no Zabbix
|
||||||
|
- datasource do Grafana apontando para o Zabbix
|
||||||
|
- dashboard inicial de planta baixa e icones SVG locais
|
||||||
|
|
||||||
|
Arquivos:
|
||||||
|
|
||||||
|
- [installer/install_debian13.sh](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/installer/install_debian13.sh)
|
||||||
|
- [installer/vfire-stack.env.example](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/installer/vfire-stack.env.example)
|
||||||
|
- [serve.py](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/serve.py): entrypoint de producao do app com Waitress
|
||||||
|
- [docs/INSTALACAO_DEBIAN13.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/INSTALACAO_DEBIAN13.md)
|
||||||
|
- [docs/LICENCIAMENTO.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/LICENCIAMENTO.md)
|
||||||
|
- [docs/GRAFANA_PLANTA_BAIXA.md](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/docs/GRAFANA_PLANTA_BAIXA.md)
|
||||||
|
- [floorplan/floorplan-map.example.json](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/floorplan/floorplan-map.example.json)
|
||||||
|
- [tools/build_floorplan_bundle.py](/mnt/c/Users/Syllas/Documents/V-Fire-Monitor/tools/build_floorplan_bundle.py)
|
||||||
|
|
||||||
|
Fluxo recomendado:
|
||||||
|
|
||||||
|
1. Copie `installer/vfire-stack.env.example` para `installer/vfire-stack.env`.
|
||||||
|
2. Ajuste senhas, hostname e `VFM_LICENSE_MASTER_KEY`.
|
||||||
|
3. Opcionalmente valide em dry-run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./installer/install_debian13.sh --dry-run ./installer/vfire-stack.env
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Execute como `root` no Debian 13:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x installer/install_debian13.sh
|
||||||
|
./installer/install_debian13.sh ./installer/vfire-stack.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Portas padrao do instalador:
|
||||||
|
|
||||||
|
- `V-Fire Monitor`: `8088`
|
||||||
|
- `Zabbix frontend`: `8080`
|
||||||
|
- `Grafana`: `3000`
|
||||||
|
|
||||||
|
Ao final, o instalador grava um resumo em `/root/vfire-stack-summary.txt`.
|
||||||
|
|
||||||
## Geracao de licenca
|
## Geracao de licenca
|
||||||
|
|
||||||
Modo interativo:
|
Modo interativo:
|
||||||
@@ -74,24 +155,43 @@ Modo por argumentos:
|
|||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
## 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": ""
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 "$@"
|
||||||
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 };
|
||||||
|
})();
|
||||||
+90
-10
@@ -4,7 +4,7 @@
|
|||||||
<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;
|
||||||
@@ -127,6 +127,7 @@
|
|||||||
<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>
|
||||||
@@ -160,6 +161,17 @@
|
|||||||
</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>
|
||||||
@@ -195,9 +207,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<div class="modal fade" id="modalAccount" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content bg-dark text-light border-secondary">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h2 class="modal-title fs-5">Conta</h2>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="account-username" class="form-label">Usuario</label>
|
||||||
|
<input type="text" id="account-username" class="form-control bg-black text-light border-secondary" value="{{ config.web_user }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="account-current-password" class="form-label">Senha atual</label>
|
||||||
|
<input type="password" id="account-current-password" class="form-control bg-black text-light border-secondary">
|
||||||
|
</div>
|
||||||
|
<div class="mb-0">
|
||||||
|
<label for="account-new-password" class="form-label">Nova senha</label>
|
||||||
|
<input type="password" id="account-new-password" class="form-control bg-black text-light border-secondary">
|
||||||
|
<div class="form-text text-secondary">Use ao menos 8 caracteres.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-secondary">
|
||||||
|
<button class="btn btn-danger w-100" onclick="saveAccount()">Salvar credenciais</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/vendor.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
const modalNodes = new bootstrap.Modal(document.getElementById("modalNodes"));
|
const modalNodes = new bootstrap.Modal(document.getElementById("modalNodes"));
|
||||||
|
const modalAccount = new bootstrap.Modal(document.getElementById("modalAccount"));
|
||||||
|
const csrfToken = {{ csrf_token | tojson }};
|
||||||
let nodesAtuais = {{ config.nodes | tojson }};
|
let nodesAtuais = {{ config.nodes | tojson }};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -217,12 +260,16 @@
|
|||||||
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.");
|
||||||
@@ -230,7 +277,7 @@
|
|||||||
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,7 +294,11 @@
|
|||||||
<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>
|
||||||
@@ -269,14 +320,21 @@
|
|||||||
document.getElementById("nodes-list").appendChild(renderNodeRow());
|
document.getElementById("nodes-list").appendChild(renderNodeRow());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAccountModal() {
|
||||||
|
document.getElementById("account-current-password").value = "";
|
||||||
|
document.getElementById("account-new-password").value = "";
|
||||||
|
modalAccount.show();
|
||||||
|
}
|
||||||
|
|
||||||
function collectNodes() {
|
function collectNodes() {
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
document.querySelectorAll(".node-row").forEach((row) => {
|
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;
|
||||||
@@ -285,11 +343,12 @@
|
|||||||
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);
|
||||||
@@ -319,7 +378,8 @@
|
|||||||
const node = {
|
const node = {
|
||||||
nome: row.querySelector(".n-nome").value.trim(),
|
nome: row.querySelector(".n-nome").value.trim(),
|
||||||
ip: row.querySelector(".n-ip").value.trim(),
|
ip: row.querySelector(".n-ip").value.trim(),
|
||||||
unit: Number(row.querySelector(".n-unit").value)
|
unit: Number(row.querySelector(".n-unit").value),
|
||||||
|
port: Number(row.querySelector(".n-port").value)
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById("loader").style.display = "flex";
|
document.getElementById("loader").style.display = "flex";
|
||||||
@@ -357,6 +417,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveAccount() {
|
||||||
|
const payload = {
|
||||||
|
username: document.getElementById("account-username").value.trim(),
|
||||||
|
current_password: document.getElementById("account-current-password").value,
|
||||||
|
new_password: document.getElementById("account-new-password").value
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/account", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
showAlert("Credenciais atualizadas. Entre novamente com a nova conta.", "success");
|
||||||
|
setTimeout(() => location.assign("/login"), 800);
|
||||||
|
} catch (error) {
|
||||||
|
showAlert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderNodes(data) {
|
function renderNodes(data) {
|
||||||
const main = document.getElementById("main-container");
|
const main = document.getElementById("main-container");
|
||||||
if (!main) {
|
if (!main) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<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;
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
{% 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>
|
||||||
@@ -51,5 +52,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
+88
-4
@@ -11,13 +11,14 @@ from vfire_monitor.core import (
|
|||||||
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():
|
||||||
@@ -25,6 +26,16 @@ def test_validate_node_payload_rejects_invalid_unit():
|
|||||||
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(
|
||||||
{
|
{
|
||||||
@@ -60,12 +71,28 @@ def test_license_service_validates_serial(tmp_path, monkeypatch):
|
|||||||
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")
|
||||||
@@ -85,8 +112,18 @@ 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,8 +131,13 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -106,8 +148,9 @@ def test_api_nodes_updates_configuration(client, app):
|
|||||||
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.status_code == 200
|
||||||
@@ -117,3 +160,44 @@ def test_api_nodes_updates_configuration(client, app):
|
|||||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
assert config["hostname_zabbix"] == "HOST-01"
|
assert config["hostname_zabbix"] == "HOST-01"
|
||||||
assert config["nodes"][0]["nome"] == "Central A"
|
assert config["nodes"][0]["nome"] == "Central A"
|
||||||
|
assert config["nodes"][0]["port"] == 1502
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_nodes_rejects_missing_csrf(client):
|
||||||
|
login(client)
|
||||||
|
response = client.post("/api/nodes", json={"zabbix_server": "127.0.0.1", "hostname_zabbix": "HOST-01", "nodes": []})
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz_is_public(client):
|
||||||
|
response = client.get("/healthz")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_account_updates_credentials(client, app):
|
||||||
|
login(client)
|
||||||
|
response = client.post(
|
||||||
|
"/api/account",
|
||||||
|
json={
|
||||||
|
"username": "operador",
|
||||||
|
"current_password": "admin",
|
||||||
|
"new_password": "senha-forte-123",
|
||||||
|
},
|
||||||
|
headers={"X-CSRF-Token": csrf_token(client, "/")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json()["status"] == "success"
|
||||||
|
|
||||||
|
runtime_state = app.extensions["runtime_state"]
|
||||||
|
assert runtime_state.authenticate("operador", "senha-forte-123") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_system_status_returns_monitor_snapshot(client):
|
||||||
|
login(client)
|
||||||
|
response = client.get("/api/system/status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_json()
|
||||||
|
assert body["status"] == "success"
|
||||||
|
assert "monitor" in body
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
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 (
|
from .core import (
|
||||||
LicenseService,
|
LicenseService,
|
||||||
@@ -28,18 +30,44 @@ def is_production_mode() -> bool:
|
|||||||
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(
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
SESSION_COOKIE_SECURE=env_flag("VFM_SESSION_COOKIE_SECURE", is_production_mode()),
|
||||||
|
REMEMBER_COOKIE_HTTPONLY=True,
|
||||||
|
REMEMBER_COOKIE_SAMESITE="Lax",
|
||||||
|
MAX_CONTENT_LENGTH=int(os.getenv("VFM_MAX_CONTENT_LENGTH", str(1024 * 1024))),
|
||||||
|
)
|
||||||
|
|
||||||
|
if env_flag("VFM_TRUST_PROXY"):
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
|
|
||||||
runtime_state = RuntimeState(resolved_base_dir, default_password)
|
runtime_state = RuntimeState(resolved_base_dir, default_password)
|
||||||
license_service = LicenseService(
|
license_service = LicenseService(
|
||||||
@@ -60,9 +88,18 @@ def create_app(base_dir: str | Path | None = None, start_monitor: bool = True) -
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
+151
-11
@@ -5,6 +5,7 @@ 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
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ DEFAULT_CONFIG_TEMPLATE = {
|
|||||||
"web_user": "admin",
|
"web_user": "admin",
|
||||||
"nodes": [],
|
"nodes": [],
|
||||||
}
|
}
|
||||||
|
DEFAULT_MODBUS_PORT = 502
|
||||||
LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
|
LEGACY_LICENSE_KEY = b"vS-C5Z_R1ST-Gf_K8_L9_Xo2-M1A3B5C7D9E1F2G3H4="
|
||||||
MAX_DISCOVERY_ADDR = 600
|
MAX_DISCOVERY_ADDR = 600
|
||||||
MODBUS_BLOCK_SIZE = 120
|
MODBUS_BLOCK_SIZE = 120
|
||||||
@@ -80,6 +82,13 @@ class JsonStore:
|
|||||||
temp_path.replace(self.path)
|
temp_path.replace(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_private_text(path: Path, content: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
if os.name != "nt":
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
def build_default_config(default_password: str) -> dict:
|
def build_default_config(default_password: str) -> dict:
|
||||||
config = dict(DEFAULT_CONFIG_TEMPLATE)
|
config = dict(DEFAULT_CONFIG_TEMPLATE)
|
||||||
config["web_password_hash"] = generate_password_hash(default_password)
|
config["web_password_hash"] = generate_password_hash(default_password)
|
||||||
@@ -90,7 +99,7 @@ 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
|
||||||
|
|
||||||
@@ -99,10 +108,14 @@ 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[
|
||||||
|
"web_user"
|
||||||
|
]
|
||||||
|
|
||||||
normalized_nodes = []
|
normalized_nodes = []
|
||||||
for raw_node in config.get("nodes", []):
|
for raw_node in config.get("nodes", []):
|
||||||
@@ -172,15 +185,40 @@ def validate_node_payload(data: dict) -> dict:
|
|||||||
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 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]]:
|
def contiguous_ranges(addresses: List[int]) -> List[Tuple[int, int]]:
|
||||||
@@ -205,7 +243,7 @@ def contiguous_ranges(addresses: List[int]) -> List[Tuple[int, int]]:
|
|||||||
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,
|
||||||
@@ -276,11 +314,39 @@ class LicenseService:
|
|||||||
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:
|
||||||
@@ -294,6 +360,14 @@ class RuntimeState:
|
|||||||
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]:
|
||||||
@@ -305,6 +379,10 @@ class RuntimeState:
|
|||||||
dict(self.status_nodes_web),
|
dict(self.status_nodes_web),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_monitor_status(self) -> MonitorStatus:
|
||||||
|
with self.lock:
|
||||||
|
return MonitorStatus(**self.monitor_status.__dict__)
|
||||||
|
|
||||||
def save_config(self) -> None:
|
def save_config(self) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.config_store.save(self.config)
|
self.config_store.save(self.config)
|
||||||
@@ -312,6 +390,10 @@ class RuntimeState:
|
|||||||
def trigger_restart(self) -> None:
|
def trigger_restart(self) -> None:
|
||||||
self.restart_event.set()
|
self.restart_event.set()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.stop_event.set()
|
||||||
|
self.restart_event.set()
|
||||||
|
|
||||||
def consume_restart(self) -> bool:
|
def consume_restart(self) -> bool:
|
||||||
if self.restart_event.is_set():
|
if self.restart_event.is_set():
|
||||||
self.restart_event.clear()
|
self.restart_event.clear()
|
||||||
@@ -324,6 +406,13 @@ class RuntimeState:
|
|||||||
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_credentials(self, username: str, password: str) -> None:
|
||||||
|
normalized_username, normalized_password = validate_login_payload(username, password)
|
||||||
|
with self.lock:
|
||||||
|
self.config["web_user"] = normalized_username
|
||||||
|
self.config["web_password_hash"] = generate_password_hash(normalized_password)
|
||||||
|
self.config_store.save(self.config)
|
||||||
|
|
||||||
def update_config(self, zabbix_server: str, hostname_zabbix: str, nodes: List[dict]) -> None:
|
def update_config(self, zabbix_server: str, hostname_zabbix: str, nodes: List[dict]) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.config["zabbix_server"] = zabbix_server.strip()
|
self.config["zabbix_server"] = zabbix_server.strip()
|
||||||
@@ -360,23 +449,68 @@ class RuntimeState:
|
|||||||
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,7 +524,11 @@ 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"),
|
||||||
|
)
|
||||||
|
self.runtime_state.stop_event.wait(POLL_INTERVAL_SECONDS)
|
||||||
|
self.runtime_state.update_monitor_status(running=False)
|
||||||
|
|
||||||
def _build_sender(self, server: str):
|
def _build_sender(self, server: str):
|
||||||
if not server:
|
if not server:
|
||||||
@@ -438,11 +576,12 @@ class MonitorEngine:
|
|||||||
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,
|
||||||
@@ -488,6 +627,7 @@ class MonitorEngine:
|
|||||||
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()
|
||||||
|
|
||||||
|
|||||||
+98
-5
@@ -1,7 +1,17 @@
|
|||||||
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,
|
||||||
|
User,
|
||||||
|
discover_node,
|
||||||
|
get_status_info,
|
||||||
|
validate_login_payload,
|
||||||
|
validate_node_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_runtime_state() -> RuntimeState:
|
def get_runtime_state() -> RuntimeState:
|
||||||
@@ -16,7 +26,34 @@ 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,6 +83,8 @@ 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"])
|
||||||
@@ -88,18 +130,19 @@ def register_routes(app):
|
|||||||
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
|
@login_required
|
||||||
@@ -143,8 +186,58 @@ def register_routes(app):
|
|||||||
|
|
||||||
return jsonify({"sensores": sensors, "nodes_status": node_statuses})
|
return jsonify({"sensores": sensors, "nodes_status": node_statuses})
|
||||||
|
|
||||||
|
@app.route("/api/system/status")
|
||||||
|
@login_required
|
||||||
|
def api_system_status():
|
||||||
|
config, device_map, realtime_data, node_statuses = get_runtime_state().snapshot()
|
||||||
|
license_status = get_license_service().verify()
|
||||||
|
monitor_status = get_runtime_state().get_monitor_status()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"license": {
|
||||||
|
"valid": license_status.valid,
|
||||||
|
"message": license_status.message,
|
||||||
|
"hardware_id": license_status.hardware_id,
|
||||||
|
"days_remaining": license_status.days_remaining,
|
||||||
|
},
|
||||||
|
"monitor": monitor_status.__dict__,
|
||||||
|
"counts": {
|
||||||
|
"nodes": len(config.get("nodes", [])),
|
||||||
|
"devices": len(device_map),
|
||||||
|
"realtime_points": len(realtime_data),
|
||||||
|
},
|
||||||
|
"nodes_status": node_statuses,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/account", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_account():
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return json_error("JSON invalido.")
|
||||||
|
|
||||||
|
username = str(payload.get("username", ""))
|
||||||
|
current_password = str(payload.get("current_password", ""))
|
||||||
|
new_password = str(payload.get("new_password", ""))
|
||||||
|
|
||||||
|
if not get_runtime_state().authenticate(current_user.get_id(), current_password):
|
||||||
|
return json_error("Senha atual invalida.", 403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validated_username, validated_password = validate_login_payload(username, new_password)
|
||||||
|
except ValueError as exc:
|
||||||
|
return json_error(str(exc))
|
||||||
|
|
||||||
|
get_runtime_state().update_credentials(validated_username, validated_password)
|
||||||
|
logout_user()
|
||||||
|
session.clear()
|
||||||
|
return jsonify({"status": "success"})
|
||||||
|
|
||||||
@app.route("/logout")
|
@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"))
|
||||||
|
|||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user