Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48752b7205 | |||
| 956f8aec56 | |||
| cec6f3694b | |||
| 3b20579d96 | |||
| a054aeb52c | |||
| b3e6f51a8f | |||
| f755dc0130 | |||
| 80441f0353 | |||
| 5dadaa5bd0 | |||
| facb948b33 | |||
| d73b6253fc |
@@ -11,3 +11,6 @@ __pycache__/
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Daily log files
|
||||
logs/
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# RPA Dashboard — Autodeploy con Woodpecker CI
|
||||
|
||||
## Panoramica
|
||||
|
||||
Il deploy avviene automaticamente tramite **Woodpecker CI** collegato a **Gitea**.
|
||||
Non serve nessun intervento manuale: basta taggare un commit con `test` o `prod`.
|
||||
|
||||
La pipeline è suddivisa in tre file indipendenti in `.woodpecker/`:
|
||||
|
||||
| File | Trigger | Cosa fa |
|
||||
|---|---|---|
|
||||
| `ci.yml` | ogni push / PR / tag | lint + syntax check |
|
||||
| `deploy-test.yml` | tag `test` | build + deploy su VM Linux (Docker + Traefik) |
|
||||
| `deploy-prod.yml` | tag `prod` | build + deploy su VM Windows (SSH, senza Docker) |
|
||||
|
||||
---
|
||||
|
||||
## Flusso
|
||||
|
||||
```
|
||||
push / PR → ci.yml: lint + syntax
|
||||
|
||||
tag test → ci.yml: lint + syntax
|
||||
deploy-test.yml: build → deploy Docker Linux
|
||||
└─ estrae i file su /ATG_RUN_TEST/Dashboard
|
||||
└─ riavvia il container Docker
|
||||
└─ dashboard live su https://dashboard.57.131.52.95.nip.io
|
||||
|
||||
tag prod → ci.yml: lint + syntax
|
||||
deploy-prod.yml: build → deploy Windows SSH
|
||||
└─ copia zip su C:/ATG_RUN/Dashboard (SCP)
|
||||
└─ riavvia il Windows Service "rpa-dashboard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pubblicare una nuova versione
|
||||
|
||||
### Deploy su Linux — tag `test`
|
||||
|
||||
```bash
|
||||
# 1. Commit delle modifiche
|
||||
git add .
|
||||
git commit -m "descrizione modifica"
|
||||
|
||||
# 2. Spostare il tag test sul commit corrente e pubblicare
|
||||
git tag -f test
|
||||
git push --force --tags
|
||||
```
|
||||
|
||||
### Deploy su Windows — tag `prod`
|
||||
|
||||
```bash
|
||||
# 1. Commit delle modifiche
|
||||
git add .
|
||||
git commit -m "descrizione modifica"
|
||||
|
||||
# 2. Spostare il tag prod sul commit corrente e pubblicare
|
||||
git tag -f prod
|
||||
git push --force --tags
|
||||
```
|
||||
|
||||
Woodpecker riceve il webhook da Gitea e avvia la pipeline in automatico.
|
||||
|
||||
### Manuale — dal pannello Woodpecker
|
||||
|
||||
Apri la pipeline su Woodpecker UI → pulsante **"trigger manually"**.
|
||||
Esegue build + deploy senza bisogno di taggare (utile per fix rapidi).
|
||||
|
||||
---
|
||||
|
||||
## Step della pipeline
|
||||
|
||||
### `ci.yml` — Lint + Syntax (ogni push/PR/tag)
|
||||
|
||||
| Step | Cosa fa |
|
||||
|---|---|
|
||||
| `lint` | flake8 — controlla stile e errori Python |
|
||||
| `syntax` | `py_compile` su tutti i `.py` |
|
||||
|
||||
### `deploy-test.yml` — Linux Docker (tag `test`)
|
||||
|
||||
| Step | Cosa fa |
|
||||
|---|---|
|
||||
| `build` | crea `rpa-tdi-dashboard-prod.zip` (esclude `.env`, `__pycache__`, `*.md`, ecc.) |
|
||||
| `deploy` | estrae lo zip in `/ATG_RUN_TEST/Dashboard`, riavvia il container Docker |
|
||||
|
||||
### `deploy-prod.yml` — Windows SSH (tag `prod`)
|
||||
|
||||
| Step | Cosa fa |
|
||||
|---|---|
|
||||
| `build` | crea `rpa-tdi-dashboard-prod.zip` (stesso del test) |
|
||||
| `deploy` | copia zip via SCP, estrae con PowerShell, riavvia il Service `rpa-dashboard` |
|
||||
|
||||
---
|
||||
|
||||
## Infrastruttura
|
||||
|
||||
### Linux (test)
|
||||
|
||||
| Componente | Dettaglio |
|
||||
|---|---|
|
||||
| VM | `57.131.52.95` |
|
||||
| URL dashboard | `https://dashboard.57.131.52.95.nip.io` |
|
||||
| Cartella deploy | `/ATG_RUN_TEST/Dashboard` |
|
||||
| Cartella DB | `/ATG_RUN_TEST/db` |
|
||||
| Container | `rpa-dashboard-test` |
|
||||
| Rete | `traefik_public` |
|
||||
|
||||
### Windows (prod)
|
||||
|
||||
| Componente | Dettaglio |
|
||||
|---|---|
|
||||
| VM | configurata via secret `win_host` |
|
||||
| Utente SSH | configurato via secret `win_user` |
|
||||
| Cartella deploy | `C:/ATG_RUN/Dashboard` |
|
||||
| Windows Service | `rpa-dashboard` (gestito con NSSM) |
|
||||
|
||||
### CI
|
||||
|
||||
| Componente | Dettaglio |
|
||||
|---|---|
|
||||
| Forge | Gitea (self-hosted) |
|
||||
| CI | Woodpecker CI — Ubuntu cloud + Docker |
|
||||
| Immagine Python | `python:3.14` |
|
||||
|
||||
---
|
||||
|
||||
## Secrets richiesti in Woodpecker
|
||||
|
||||
Configurare in **Woodpecker UI → Settings → Secrets**:
|
||||
|
||||
| Secret | Valore |
|
||||
|---|---|
|
||||
| `win_host` | IP o hostname della VM Windows |
|
||||
| `win_user` | utente SSH (es. `Administrator`) |
|
||||
| `win_ssh_key` | chiave privata SSH (contenuto del file `id_rsa`) |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisiti — VM Linux (setup iniziale, una tantum)
|
||||
|
||||
```bash
|
||||
# Creare le cartelle
|
||||
mkdir -p /ATG_RUN_TEST/Dashboard
|
||||
mkdir -p /ATG_RUN_TEST/db
|
||||
|
||||
# Creare il .env
|
||||
cat > /ATG_RUN_TEST/Dashboard/.env <<EOF
|
||||
LOGIN=true
|
||||
DASHBOARD_USER=TDI_admin
|
||||
DASHBOARD_PASSWORD=Dashboard_TDI_26!
|
||||
RPA_REPORT_PORT=8473
|
||||
EOF
|
||||
|
||||
# Copiare i file .db nella cartella db
|
||||
# es. cp rpa_FORMAZIONE.db /ATG_RUN_TEST/db/
|
||||
```
|
||||
|
||||
Abilitare i volume mounts nel Woodpecker runner:
|
||||
|
||||
```env
|
||||
WOODPECKER_BACKEND_DOCKER_VOLUMES=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prerequisiti — VM Windows (setup iniziale, una tantum)
|
||||
|
||||
```powershell
|
||||
# 1. Abilitare OpenSSH Server
|
||||
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
|
||||
Start-Service sshd
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
|
||||
# 2. Creare le cartelle
|
||||
New-Item -ItemType Directory -Force -Path C:/ATG_RUN/Dashboard
|
||||
New-Item -ItemType Directory -Force -Path C:/ATG_RUN/db
|
||||
|
||||
# 3. Creare il .env
|
||||
Set-Content C:/ATG_RUN/Dashboard/.env "LOGIN=true`nDASHBOARD_USER=TDI_admin`nDASHBOARD_PASSWORD=Dashboard_TDI_26!`nRPA_REPORT_PORT=8473"
|
||||
|
||||
# 4. Installare NSSM e creare il Windows Service
|
||||
# Scaricare NSSM da https://nssm.cc
|
||||
nssm install rpa-dashboard python C:/ATG_RUN/Dashboard/Dashboard.py
|
||||
nssm set rpa-dashboard AppDirectory C:/ATG_RUN/Dashboard
|
||||
nssm start rpa-dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifica deploy
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# Stato del container
|
||||
docker ps | grep rpa-dashboard-test
|
||||
|
||||
# Log in tempo reale
|
||||
docker logs -f rpa-dashboard-test
|
||||
|
||||
# Riavvio manuale
|
||||
docker restart rpa-dashboard-test
|
||||
```
|
||||
|
||||
Dashboard: `https://dashboard.57.131.52.95.nip.io`
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
# Stato del service
|
||||
Get-Service rpa-dashboard
|
||||
|
||||
# Riavvio manuale
|
||||
Restart-Service rpa-dashboard
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
# ci.yml — Lint + Syntax check
|
||||
# Eseguito su ogni push, PR, tag e manual
|
||||
|
||||
when:
|
||||
event: [push, pull_request, tag, manual]
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: python:3.14
|
||||
commands:
|
||||
- pip install --upgrade pip --quiet --root-user-action=ignore
|
||||
- pip install flake8 --quiet --root-user-action=ignore
|
||||
- flake8 . --max-line-length=120 --exclude=__pycache__
|
||||
|
||||
- name: syntax
|
||||
image: python:3.14
|
||||
depends_on: [lint]
|
||||
commands:
|
||||
- pip install --upgrade pip --quiet --root-user-action=ignore
|
||||
- pip install python-dotenv --quiet --root-user-action=ignore
|
||||
- python -m compileall -q .
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
# deploy-prod.yml — Build + Deploy su VM Windows remota (SSH, senza Docker)
|
||||
#
|
||||
# Trigger: git tag prod && git push --force --tags
|
||||
#
|
||||
# Prerequisiti sulla VM Windows:
|
||||
# - OpenSSH Server abilitato
|
||||
# - Windows Service "rpa-dashboard" creato con NSSM
|
||||
# - C:/ATG_RUN/Dashboard/.env → già presente
|
||||
#
|
||||
# Secrets richiesti in Woodpecker:
|
||||
# win_host → IP o hostname della VM Windows
|
||||
# win_user → utente SSH (es. Administrator)
|
||||
# win_ssh_key → chiave privata SSH (contenuto id_rsa)
|
||||
|
||||
when:
|
||||
- event: tag
|
||||
ref: refs/tags/prod
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: python:3.14
|
||||
commands:
|
||||
- apt-get update -qq && apt-get install -y zip --quiet
|
||||
- pip install --upgrade pip --quiet --root-user-action=ignore
|
||||
- pip install python-dotenv rcssmin rjsmin --quiet --root-user-action=ignore
|
||||
- |
|
||||
python - <<'EOF'
|
||||
import os, zipfile, fnmatch
|
||||
|
||||
EXCLUDE_FILES = ["tmp_*", "*.md"]
|
||||
EXCLUDE_DIRS = {".git", "__pycache__", "data", "tests", "docs", "secrets"}
|
||||
EXCLUDE_EXACTLY = {".env"}
|
||||
OUT = "rpa-tdi-dashboard-prod.zip"
|
||||
|
||||
def is_excluded(rel):
|
||||
parts = rel.replace(os.sep, "/").split("/")
|
||||
if parts[0] in EXCLUDE_DIRS:
|
||||
return True
|
||||
name = parts[-1]
|
||||
if name in EXCLUDE_EXACTLY:
|
||||
return True
|
||||
for pat in EXCLUDE_FILES:
|
||||
if fnmatch.fnmatch(name, pat):
|
||||
return True
|
||||
return False
|
||||
|
||||
with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk("."):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS and not d.startswith(".")]
|
||||
for f in files:
|
||||
path = os.path.join(root, f)
|
||||
rel = os.path.relpath(path, ".")
|
||||
if not is_excluded(rel):
|
||||
zf.write(path, rel)
|
||||
print(f" + {rel}")
|
||||
print(f"\n✓ {OUT}")
|
||||
EOF
|
||||
- ls -lh rpa-tdi-dashboard-prod.zip
|
||||
|
||||
- name: deploy
|
||||
image: alpine
|
||||
depends_on: [build]
|
||||
environment:
|
||||
WIN_HOST:
|
||||
from_secret: win_host
|
||||
WIN_USER:
|
||||
from_secret: win_user
|
||||
WIN_KEY:
|
||||
from_secret: win_ssh_key
|
||||
commands:
|
||||
- apk add --no-cache openssh-client --quiet
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$WIN_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- scp -o StrictHostKeyChecking=no rpa-tdi-dashboard-prod.zip $WIN_USER@$WIN_HOST:"C:/ATG_RUN/Dashboard/"
|
||||
- ssh -o StrictHostKeyChecking=no $WIN_USER@$WIN_HOST
|
||||
"powershell -Command Stop-Service rpa-dashboard -ErrorAction SilentlyContinue"
|
||||
- ssh $WIN_USER@$WIN_HOST
|
||||
"powershell -Command Expand-Archive -Force C:/ATG_RUN/Dashboard/rpa-tdi-dashboard-prod.zip C:/ATG_RUN/Dashboard/"
|
||||
- ssh $WIN_USER@$WIN_HOST
|
||||
"powershell -Command Start-Service rpa-dashboard"
|
||||
- echo "✓ Deploy completato su Windows ($WIN_HOST)"
|
||||
@@ -1,43 +1,25 @@
|
||||
---
|
||||
# .woodpecker.yml — RPA TDI Dashboard Service
|
||||
# Forge: Gitea — Runner: Ubuntu cloud + Docker
|
||||
# deploy-test.yml — Build + Deploy su VM Linux (Docker + Traefik)
|
||||
#
|
||||
# Flusso:
|
||||
# push / PR → lint + syntax
|
||||
# tag prod → lint + syntax + build + deploy (automatico)
|
||||
# Trigger: git tag test && git push --force --tags
|
||||
#
|
||||
# Per pubblicare:
|
||||
# git tag prod && git push --tags
|
||||
# Prerequisiti sulla VM Linux:
|
||||
# - /ATG_RUN_TEST/Dashboard/.env → già presente
|
||||
# - Woodpecker runner con volumes abilitati (WOODPECKER_BACKEND_DOCKER_VOLUMES=true)
|
||||
# - Traefik in esecuzione sulla rete "traefik_public"
|
||||
|
||||
when:
|
||||
event: [push, pull_request, tag, manual]
|
||||
- event: tag
|
||||
ref: refs/tags/test
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: lint
|
||||
image: python:3.12-slim
|
||||
when:
|
||||
event: [push, pull_request, tag, manual]
|
||||
commands:
|
||||
- pip install flake8 --quiet
|
||||
- flake8 . --max-line-length=120 --exclude=__pycache__
|
||||
|
||||
- name: syntax
|
||||
image: python:3.12-slim
|
||||
when:
|
||||
event: [push, pull_request, tag, manual]
|
||||
depends_on: [lint]
|
||||
commands:
|
||||
- pip install python-dotenv --quiet
|
||||
- python -m compileall -q .
|
||||
|
||||
- name: build
|
||||
image: python:3.12-slim
|
||||
when:
|
||||
event: [tag, manual]
|
||||
tag: prod
|
||||
image: python:3.14
|
||||
commands:
|
||||
- apt-get update -qq && apt-get install -y zip --quiet
|
||||
- pip install python-dotenv rcssmin rjsmin --quiet
|
||||
- pip install --upgrade pip --quiet --root-user-action=ignore
|
||||
- pip install python-dotenv rcssmin rjsmin --quiet --root-user-action=ignore
|
||||
- |
|
||||
python - <<'EOF'
|
||||
import os, zipfile, fnmatch
|
||||
@@ -72,14 +54,8 @@ steps:
|
||||
EOF
|
||||
- ls -lh rpa-tdi-dashboard-prod.zip
|
||||
|
||||
# Copia i file su /ATG_RUN_TEST/Dashboard e riavvia il container
|
||||
# Prerequisiti sulla VM:
|
||||
# - /ATG_RUN_TEST/Dashboard/.env → già presente con le variabili locali
|
||||
# - Woodpecker runner configurato con volumes abilitati (WOODPECKER_BACKEND_DOCKER_VOLUMES=true)
|
||||
- name: deploy
|
||||
image: docker:cli
|
||||
when:
|
||||
event: [tag, manual]
|
||||
depends_on: [build]
|
||||
volumes:
|
||||
- /ATG_RUN_TEST/Dashboard:/app
|
||||
@@ -88,16 +64,21 @@ steps:
|
||||
- apk add --no-cache unzip --quiet
|
||||
- unzip -o rpa-tdi-dashboard-prod.zip -d /app
|
||||
- echo "✓ File estratti in /ATG_RUN_TEST/Dashboard"
|
||||
- docker stop rpa-dashboard-test 2>/dev/null || true
|
||||
- docker rm rpa-dashboard-test 2>/dev/null || true
|
||||
- docker stop rpa-dashboard-test 2>/dev/null || true
|
||||
- docker rm rpa-dashboard-test 2>/dev/null || true
|
||||
- docker run -d
|
||||
--name rpa-dashboard-test
|
||||
--restart unless-stopped
|
||||
--network traefik_public
|
||||
-v /ATG_RUN_TEST/Dashboard:/app
|
||||
-v /ATG_RUN_TEST/db:/rpa-db
|
||||
-w /app
|
||||
-e RPA_DB_DIR=/rpa-db
|
||||
-p 8473:8473
|
||||
python:3.12-slim
|
||||
--label "traefik.enable=true"
|
||||
--label 'traefik.http.routers.rpa-dashboard.rule=Host(`dashboard.57.131.52.95.nip.io`)'
|
||||
--label "traefik.http.routers.rpa-dashboard.entrypoints=websecure"
|
||||
--label "traefik.http.routers.rpa-dashboard.tls.certresolver=letsencrypt"
|
||||
--label "traefik.http.services.rpa-dashboard.loadbalancer.server.port=8473"
|
||||
python:3.14
|
||||
sh -c "pip install -r requirements.txt -q && python Dashboard.py"
|
||||
- echo "✓ Container avviato su http://57.131.52.95:8473"
|
||||
- echo "✓ Container avviato su https://dashboard.57.131.52.95.nip.io"
|
||||
@@ -1,111 +0,0 @@
|
||||
# RPA Dashboard — Autodeploy con Woodpecker CI
|
||||
|
||||
## Panoramica
|
||||
|
||||
Il deploy avviene automaticamente tramite **Woodpecker CI** collegato a **Gitea**.
|
||||
Non serve nessun intervento manuale: basta taggare un commit con `prod`.
|
||||
|
||||
---
|
||||
|
||||
## Flusso
|
||||
|
||||
```
|
||||
push / PR → lint + syntax check (verifica automatica ad ogni commit)
|
||||
|
||||
tag prod → lint → syntax → build → deploy
|
||||
└─ estrae i file su /ATG_RUN_TEST/Dashboard
|
||||
└─ riavvia il container Docker
|
||||
└─ dashboard live su http://57.131.52.95:8473
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pubblicare una nuova versione
|
||||
|
||||
### Automatico — tag `prod` (raccomandato)
|
||||
|
||||
```bash
|
||||
# 1. Commit delle modifiche
|
||||
git add .
|
||||
git commit -m "descrizione modifica"
|
||||
|
||||
# 2. Spostare il tag prod sul commit corrente e pubblicare
|
||||
git tag -f prod
|
||||
git push --force --tags
|
||||
```
|
||||
|
||||
Woodpecker riceve il webhook da Gitea e avvia la pipeline in automatico.
|
||||
|
||||
### Manuale — dal pannello Woodpecker
|
||||
|
||||
Apri la pipeline su Woodpecker UI → pulsante **"trigger manually"**.
|
||||
Esegue build + deploy senza bisogno di taggare (utile per test o fix rapidi).
|
||||
|
||||
---
|
||||
|
||||
## Step della pipeline (`.woodpecker.yml`)
|
||||
|
||||
| Step | push / PR | tag `prod` | manuale | Cosa fa |
|
||||
|---|---|---|---|---|
|
||||
| `lint` | ✓ | ✓ | ✓ | flake8 — controlla stile e errori Python |
|
||||
| `syntax` | ✓ | ✓ | ✓ | `py_compile` su tutti i `.py` |
|
||||
| `build` | — | ✓ | ✓ | crea `rpa-tdi-dashboard-prod.zip` (esclude `.env`, `__pycache__`, `*.md`, ecc.) |
|
||||
| `deploy` | — | ✓ | ✓ | estrae lo zip in `/ATG_RUN_TEST/Dashboard`, riavvia il container |
|
||||
|
||||
---
|
||||
|
||||
## Infrastruttura
|
||||
|
||||
| Componente | Dettaglio |
|
||||
|---|---|
|
||||
| Forge | Gitea (self-hosted) |
|
||||
| CI | Woodpecker CI — Ubuntu cloud + Docker |
|
||||
| VM | `57.131.52.95` |
|
||||
| Porta dashboard | `8473` |
|
||||
| Cartella deploy | `/ATG_RUN_TEST/Dashboard` |
|
||||
| Cartella DB | `/ATG_RUN_TEST/db` |
|
||||
| Container | `rpa-dashboard-test` |
|
||||
|
||||
---
|
||||
|
||||
## Prerequisiti sulla VM (setup iniziale, una tantum)
|
||||
|
||||
```bash
|
||||
# Creare le cartelle
|
||||
mkdir -p /ATG_RUN_TEST/Dashboard
|
||||
mkdir -p /ATG_RUN_TEST/db
|
||||
|
||||
# Creare il .env con le variabili locali Linux
|
||||
cat > /ATG_RUN_TEST/Dashboard/.env <<EOF
|
||||
LOGIN=true
|
||||
DASHBOARD_USER=TDI_admin
|
||||
DASHBOARD_PASSWORD=Dashboard_TDI_26!
|
||||
RPA_REPORT_PORT=8473
|
||||
EOF
|
||||
|
||||
# Copiare i file .db nella cartella db
|
||||
# es. cp rpa_FORMAZIONE.db /ATG_RUN_TEST/db/
|
||||
```
|
||||
|
||||
Abilitare i volume mounts nel Woodpecker runner (nel file di config dell'agent):
|
||||
|
||||
```env
|
||||
WOODPECKER_BACKEND_DOCKER_VOLUMES=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifica deploy
|
||||
|
||||
```bash
|
||||
# Stato del container
|
||||
docker ps | grep rpa-dashboard-test
|
||||
|
||||
# Log in tempo reale
|
||||
docker logs -f rpa-dashboard-test
|
||||
|
||||
# Riavvio manuale (se necessario)
|
||||
docker restart rpa-dashboard-test
|
||||
```
|
||||
|
||||
Dashboard: `http://57.131.52.95:8473/login`
|
||||
@@ -22,6 +22,11 @@ class DbType(StrEnum):
|
||||
UNKNOWN = 'unknown'
|
||||
|
||||
|
||||
class ConnType(StrEnum):
|
||||
SQLITE = 'sqlite'
|
||||
POSTGRES = 'postgres'
|
||||
|
||||
|
||||
_db_dir_env = os.environ.get('RPA_DB_DIR', '')
|
||||
DB_DEFAULT_DIR = Path(_db_dir_env) if _db_dir_env else REPORTS_DIR.parent / "data_rpa" / "db"
|
||||
|
||||
@@ -45,3 +50,14 @@ _auth: dict = {
|
||||
'password': os.environ.get('DASHBOARD_PASSWORD', ''),
|
||||
}
|
||||
_sessions: set = set() # active session tokens
|
||||
|
||||
# Database connection — mutable so settings can be updated at runtime
|
||||
_conn_type_raw = os.environ.get('RPA_CONN_TYPE', 'sqlite').strip().lower()
|
||||
_conn: dict = {
|
||||
'type': ConnType.POSTGRES if _conn_type_raw == 'postgres' else ConnType.SQLITE,
|
||||
'pg_host': os.environ.get('PG_HOST', 'localhost'),
|
||||
'pg_port': os.environ.get('PG_PORT', '5432'),
|
||||
'pg_db': os.environ.get('PG_DB', ''),
|
||||
'pg_user': os.environ.get('PG_USER', ''),
|
||||
'pg_password': os.environ.get('PG_PASSWORD', ''),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ from .config import DB_DEFAULT_DIR
|
||||
|
||||
|
||||
def _pick_db_file() -> str:
|
||||
if not os.environ.get('DISPLAY') and os.name != 'nt':
|
||||
return ''
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
+30
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
import re
|
||||
|
||||
from .config import REPORTS_DIR, _auth
|
||||
from .config import REPORTS_DIR, _auth, _conn, ConnType
|
||||
|
||||
|
||||
def _set_env_key(text: str, key: str, value: str) -> str:
|
||||
@@ -31,3 +31,32 @@ def update_auth(enabled: bool, user: str | None = None, password: str | None = N
|
||||
_auth['user'] = user
|
||||
if password is not None:
|
||||
_auth['password'] = password
|
||||
|
||||
|
||||
def update_db_conn(
|
||||
conn_type: str,
|
||||
pg_host: str = '',
|
||||
pg_port: str = '5432',
|
||||
pg_db: str = '',
|
||||
pg_user: str = '',
|
||||
pg_password: str = '',
|
||||
) -> None:
|
||||
"""Write RPA_CONN_TYPE and PG_* vars to .env and update _conn in memory."""
|
||||
env_path = REPORTS_DIR / '.env'
|
||||
text = env_path.read_text(encoding='utf-8')
|
||||
|
||||
text = _set_env_key(text, 'RPA_CONN_TYPE', conn_type)
|
||||
text = _set_env_key(text, 'PG_HOST', pg_host or 'localhost')
|
||||
text = _set_env_key(text, 'PG_PORT', pg_port or '5432')
|
||||
text = _set_env_key(text, 'PG_DB', pg_db)
|
||||
text = _set_env_key(text, 'PG_USER', pg_user)
|
||||
text = _set_env_key(text, 'PG_PASSWORD', pg_password)
|
||||
|
||||
env_path.write_text(text, encoding='utf-8')
|
||||
|
||||
_conn['type'] = ConnType.POSTGRES if conn_type == 'postgres' else ConnType.SQLITE
|
||||
_conn['pg_host'] = pg_host or 'localhost'
|
||||
_conn['pg_port'] = pg_port or '5432'
|
||||
_conn['pg_db'] = pg_db
|
||||
_conn['pg_user'] = pg_user
|
||||
_conn['pg_password'] = pg_password
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def get_logger() -> logging.Logger:
|
||||
global _logger
|
||||
if _logger is None:
|
||||
_logger = logging.getLogger('rpa_dashboard')
|
||||
return _logger
|
||||
|
||||
|
||||
def setup_logging(log_dir: Path, backup_count: int = 30) -> logging.Logger:
|
||||
global _logger
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger('rpa_dashboard')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
fmt = logging.Formatter(
|
||||
'[%(asctime)s] %(levelname)-5s %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
)
|
||||
|
||||
# Daily rotation at midnight — keeps last `backup_count` days
|
||||
# Current day: logs/dashboard.log
|
||||
# Past days: logs/dashboard.log.YYYY-MM-DD
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
log_dir / 'dashboard.log',
|
||||
when='midnight',
|
||||
backupCount=backup_count,
|
||||
encoding='utf-8',
|
||||
)
|
||||
file_handler.setFormatter(fmt)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(fmt)
|
||||
logger.addHandler(console)
|
||||
|
||||
_logger = logger
|
||||
return logger
|
||||
+102
@@ -1,4 +1,7 @@
|
||||
import calendar as _cal_mod
|
||||
import json as _json
|
||||
from collections import defaultdict as _defaultdict
|
||||
from datetime import date as _date
|
||||
from .config import _state, _access_log, DbType, LOGS_PER_PAGE, STEPS_ROWS
|
||||
from .renderer import _base, _card, _page_css, _page_js, _tmpl, _css
|
||||
from .queries.db import e as _e
|
||||
@@ -12,6 +15,11 @@ from .queries import api_iscrizioni as q_api_iscrizioni
|
||||
from .queries import email as q_email
|
||||
from .queries import sharepoint as q_sharepoint
|
||||
|
||||
_IT_MONTHS = [
|
||||
'', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno',
|
||||
'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'
|
||||
]
|
||||
|
||||
|
||||
def _load_data(db_path: str) -> dict:
|
||||
db_type = _state.get('db_type', 'unknown')
|
||||
@@ -280,6 +288,100 @@ def _page_schema(db_path: str) -> str:
|
||||
return _base('Schema', content, 'nav_schema', db_path, _page_css('schema'), _page_js('schema'))
|
||||
|
||||
|
||||
def _page_calendar(db_path: str, year: int = None, month: int = None) -> str:
|
||||
today = _date.today()
|
||||
if year is None:
|
||||
year = today.year
|
||||
if month is None:
|
||||
month = today.month
|
||||
year = max(2000, min(2100, year))
|
||||
month = max(1, min(12, month))
|
||||
|
||||
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||
|
||||
runs = q_processes.get_runs_by_month(db_path, year, month)
|
||||
|
||||
runs_by_day = _defaultdict(list)
|
||||
for r in runs:
|
||||
start = r.get('start_run', '')
|
||||
if start and len(start) >= 10:
|
||||
try:
|
||||
runs_by_day[int(start[8:10])].append(r)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
_CAL_MAX_VISIBLE = 3
|
||||
|
||||
def _run_pill(r):
|
||||
run_id = r.get('id', '')
|
||||
proc = _e(r.get('process_name', 'processo'))
|
||||
ts = str(r.get('start_run', ''))
|
||||
time_str = ts[11:16] if len(ts) >= 16 else ''
|
||||
completed = r.get('completed') in (1, True, '1')
|
||||
cls = 'cal-run-ok' if completed else 'cal-run-err'
|
||||
icon = '✓' if completed else '✗'
|
||||
return (
|
||||
f'<a href="/logs?run={run_id}" class="cal-run {cls}"'
|
||||
f' title="{proc} {time_str}">'
|
||||
f'<span class="cal-run-icon">{icon}</span>'
|
||||
f'<span class="cal-run-name">{proc}</span>'
|
||||
f'</a>'
|
||||
)
|
||||
|
||||
IT_DOWS = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom']
|
||||
cells = ''.join(f'<div class="cal-dow">{d}</div>' for d in IT_DOWS)
|
||||
|
||||
for week in _cal_mod.monthcalendar(year, month):
|
||||
for day in week:
|
||||
if day == 0:
|
||||
cells += '<div class="cal-day cal-day-empty"></div>'
|
||||
continue
|
||||
is_today = (year == today.year and month == today.month and day == today.day)
|
||||
day_runs = runs_by_day.get(day, [])
|
||||
pills = [_run_pill(r) for r in day_runs]
|
||||
|
||||
if len(pills) <= _CAL_MAX_VISIBLE:
|
||||
runs_html = f'<div class="cal-runs">{"".join(pills)}</div>'
|
||||
else:
|
||||
visible = ''.join(pills[:_CAL_MAX_VISIBLE])
|
||||
extra = ''.join(pills[_CAL_MAX_VISIBLE:])
|
||||
extra_count = len(pills) - _CAL_MAX_VISIBLE
|
||||
more_label = f'⋯ +{extra_count}'
|
||||
runs_html = (
|
||||
f'<div class="cal-runs">{visible}</div>'
|
||||
f'<button class="cal-more" data-more-text="{more_label}"'
|
||||
f' onclick="calToggleMore(this)">{more_label}</button>'
|
||||
f'<div class="cal-runs cal-runs-extra">{extra}</div>'
|
||||
)
|
||||
|
||||
today_cls = ' cal-day-today' if is_today else ''
|
||||
cells += (
|
||||
f'<div class="cal-day{today_cls}">'
|
||||
f'<div class="cal-day-num">{day}</div>'
|
||||
f'{runs_html}'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
nav_html = (
|
||||
f'<div class="cal-nav">'
|
||||
f'<a href="/calendar?y={prev_year}&m={prev_month}" class="cal-nav-btn">'
|
||||
f'← {_IT_MONTHS[prev_month]}</a>'
|
||||
f'<h2 class="cal-month-title">{_IT_MONTHS[month]} {year}</h2>'
|
||||
f'<a href="/calendar?y={next_year}&m={next_month}" class="cal-nav-btn">'
|
||||
f'{_IT_MONTHS[next_month]} →</a>'
|
||||
f'</div>'
|
||||
)
|
||||
content = _tmpl('calendar.html').substitute(
|
||||
cal_nav=nav_html,
|
||||
calendar_html=f'<div class="cal-grid">{cells}</div>',
|
||||
)
|
||||
return _base(
|
||||
f'Calendario — {_IT_MONTHS[month]} {year}', content,
|
||||
'nav_calendar', db_path, _page_css('calendar'), _page_js('calendar')
|
||||
)
|
||||
|
||||
|
||||
def _page_login(error: bool = False) -> str:
|
||||
error_block = '<p class="login-error">✗ Credenziali non valide. Riprova.</p>' if error else ''
|
||||
return _tmpl('login.html').substitute(css=_css(), error_block=error_block)
|
||||
|
||||
+36
-4
@@ -1,4 +1,4 @@
|
||||
"""queries/db.py — helper condiviso per query SQLite"""
|
||||
"""queries/db.py — helper condiviso per query SQLite / PostgreSQL"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
@@ -8,7 +8,33 @@ if TYPE_CHECKING:
|
||||
from ..config import DbType
|
||||
|
||||
|
||||
def _pg_query(sql: str, params: tuple = ()) -> list:
|
||||
"""Execute a query against PostgreSQL using connection params from _conn."""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from ..config import _conn
|
||||
conn = psycopg2.connect(
|
||||
host=_conn['pg_host'],
|
||||
port=int(_conn['pg_port'] or 5432),
|
||||
dbname=_conn['pg_db'],
|
||||
user=_conn['pg_user'],
|
||||
password=_conn['pg_password'],
|
||||
connect_timeout=10,
|
||||
)
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
# SQLite uses ? placeholders; psycopg2 uses %s
|
||||
pg_sql = sql.replace('?', '%s')
|
||||
cur.execute(pg_sql, params)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def query(db_path: str, sql: str, params: tuple = ()) -> list:
|
||||
from ..config import _conn, ConnType
|
||||
if _conn['type'] == ConnType.POSTGRES:
|
||||
return _pg_query(sql, params)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
@@ -70,13 +96,19 @@ def badge(cls: str, text: str) -> str:
|
||||
|
||||
|
||||
def detect_db(db_path: str) -> 'DbType':
|
||||
"""Returns DB_TYPE_REG_LOMB, DB_TYPE_INTRAZ, or DB_TYPE_UNKNOWN based on DB schema.
|
||||
"""Returns REG_LOMB, INTRAZ, or UNKNOWN based on DB schema.
|
||||
|
||||
reg_Lomb (db_reg_lombardia) → has table 'sessione_documenti'
|
||||
Intraz (db_corsi_intraziendali) → has table 'rpa_intra_api_iscrizione'
|
||||
"""
|
||||
from ..config import DbType
|
||||
rows = query(db_path, "SELECT name FROM sqlite_master WHERE type='table'")
|
||||
from ..config import DbType, _conn, ConnType
|
||||
if _conn['type'] == ConnType.POSTGRES:
|
||||
rows = _pg_query(
|
||||
"SELECT table_name AS name FROM information_schema.tables "
|
||||
"WHERE table_schema='public'"
|
||||
)
|
||||
else:
|
||||
rows = query(db_path, "SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {r['name'] for r in rows}
|
||||
if 'sessione_documenti' in tables:
|
||||
return DbType.REG_LOMB
|
||||
|
||||
@@ -32,6 +32,17 @@ def get_stats(db_path: str, date_from: str = None, date_to: str = None) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_runs_by_month(db_path: str, year: int, month: int) -> list:
|
||||
return query(
|
||||
db_path,
|
||||
"SELECT id, process_name, completed, start_run, finish_run "
|
||||
"FROM rpa_process "
|
||||
"WHERE strftime('%Y', start_run) = ? AND strftime('%m', start_run) = ? "
|
||||
"ORDER BY start_run ASC",
|
||||
(str(year), f'{month:02d}')
|
||||
)
|
||||
|
||||
|
||||
def get_schema_stats(db_path: str) -> list:
|
||||
return query(db_path, """
|
||||
SELECT process_name,
|
||||
|
||||
+88
-3
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
from .config import TMPL_DIR, CSS_PATH, PAGES_CSS_DIR, JS_DIR, _state, DbType, _auth
|
||||
from .config import TMPL_DIR, CSS_PATH, PAGES_CSS_DIR, JS_DIR, _state, DbType, _auth, _conn, ConnType
|
||||
from .queries.db import e as _e
|
||||
|
||||
|
||||
@@ -57,6 +57,85 @@ def _card(label, value, color='#2563eb') -> str:
|
||||
)
|
||||
|
||||
|
||||
def _build_db_conn_modal() -> str:
|
||||
is_pg = _conn['type'] == ConnType.POSTGRES
|
||||
inp = (
|
||||
'style="width:100%;padding:.5rem .75rem;border:1.5px solid #cbd5e1;'
|
||||
'border-radius:6px;font-size:.9rem;box-sizing:border-box"'
|
||||
)
|
||||
lbl = 'style="font-size:.8rem;font-weight:600;color:#475569;display:block;margin-bottom:.25rem"'
|
||||
badge_sqlite = (
|
||||
'<span class="badge ok" style="font-size:.85rem;padding:4px 12px">📄 SQLite</span>'
|
||||
)
|
||||
badge_pg = (
|
||||
'<span class="badge" style="font-size:.85rem;padding:4px 12px;background:#7c3aed;color:#fff">'
|
||||
'📡 PostgreSQL</span>'
|
||||
)
|
||||
current_badge = badge_pg if is_pg else badge_sqlite
|
||||
|
||||
pg_host = _e(_conn.get('pg_host', 'localhost'))
|
||||
pg_port = _e(_conn.get('pg_port', '5432'))
|
||||
pg_db = _e(_conn.get('pg_db', ''))
|
||||
pg_user = _e(_conn.get('pg_user', ''))
|
||||
|
||||
body = (
|
||||
'<p class="modal-subtitle">Sorgente dati attuale:</p>'
|
||||
f'<p style="margin-bottom:1rem">{current_badge}</p>'
|
||||
'<form method="POST" action="/settings/db" style="display:flex;flex-direction:column;gap:.75rem">'
|
||||
'<div style="display:flex;gap:.5rem;margin-bottom:.25rem">'
|
||||
f'<label style="flex:1;display:flex;align-items:center;gap:.4rem;cursor:pointer;'
|
||||
f'padding:.5rem .75rem;border:1.5px solid {"#7c3aed" if not is_pg else "#cbd5e1"};'
|
||||
f'border-radius:6px;font-size:.9rem">'
|
||||
f'<input type="radio" name="conn_type" value="sqlite" {"checked" if not is_pg else ""}>'
|
||||
f' SQLite (file locale)</label>'
|
||||
f'<label style="flex:1;display:flex;align-items:center;gap:.4rem;cursor:pointer;'
|
||||
f'padding:.5rem .75rem;border:1.5px solid {"#7c3aed" if is_pg else "#cbd5e1"};'
|
||||
f'border-radius:6px;font-size:.9rem">'
|
||||
f'<input type="radio" name="conn_type" value="postgres" {"checked" if is_pg else ""}>'
|
||||
f' PostgreSQL (remoto)</label>'
|
||||
'</div>'
|
||||
'<fieldset id="pg-fields" style="border:1.5px solid #e2e8f0;border-radius:6px;padding:.75rem;'
|
||||
f'display:{"block" if is_pg else "none"}">'
|
||||
'<legend style="font-size:.8rem;font-weight:600;color:#7c3aed;padding:0 .4rem">Connessione PostgreSQL</legend>'
|
||||
f'<div style="display:grid;grid-template-columns:1fr auto;gap:.5rem;margin-bottom:.5rem">'
|
||||
f'<div><label {lbl}>Host</label><input type="text" name="pg_host" value="{pg_host}" {inp}></div>'
|
||||
f'<div style="width:80px"><label {lbl}>Porta</label>'
|
||||
f'<input type="text" name="pg_port" value="{pg_port}" {inp}></div>'
|
||||
'</div>'
|
||||
f'<div style="margin-bottom:.5rem"><label {lbl}>Database</label>'
|
||||
f'<input type="text" name="pg_db" value="{pg_db}" {inp}></div>'
|
||||
f'<div style="margin-bottom:.5rem"><label {lbl}>Utente</label>'
|
||||
f'<input type="text" name="pg_user" value="{pg_user}" {inp}></div>'
|
||||
f'<div><label {lbl}>Password</label>'
|
||||
f'<input type="password" name="pg_password" placeholder="(invariata se vuota)" {inp}></div>'
|
||||
'</fieldset>'
|
||||
'<button type="submit" style="width:100%;padding:.55rem;background:#2563eb;color:#fff;border:none;'
|
||||
'border-radius:6px;font-size:.9rem;font-weight:600;cursor:pointer">'
|
||||
'💾 Salva e Riconnetti'
|
||||
'</button>'
|
||||
'</form>'
|
||||
'<script>'
|
||||
'(function(){'
|
||||
'var radios=document.querySelectorAll(\'[name="conn_type"]\');'
|
||||
'var fields=document.getElementById(\'pg-fields\');'
|
||||
'radios.forEach(function(r){'
|
||||
'r.addEventListener(\'change\',function(){'
|
||||
'fields.style.display=this.value==="postgres"?"block":"none";'
|
||||
'});});})();'
|
||||
'</script>'
|
||||
)
|
||||
return (
|
||||
'<div id="modal-db-conn" class="modal-overlay">'
|
||||
'<div class="modal-box">'
|
||||
'<button class="modal-close"'
|
||||
' onclick="document.getElementById(\'modal-db-conn\').classList.remove(\'open\')"'
|
||||
' title="Chiudi">✕</button>'
|
||||
'<h3>📡 Sorgente Dati</h3>'
|
||||
+ body +
|
||||
'</div></div>'
|
||||
)
|
||||
|
||||
|
||||
def _build_login_modal() -> str:
|
||||
enabled = _auth['enabled']
|
||||
inp = (
|
||||
@@ -113,7 +192,7 @@ def _build_login_modal() -> str:
|
||||
|
||||
def _base(title: str, content: str, active: str, db_path: str, page_css: str = '', page_js: str = '') -> str:
|
||||
now = datetime.now().strftime('%d/%m/%Y %H:%M:%S')
|
||||
nav = {'nav_dashboard': '', 'nav_runs': '', 'nav_logs': '', 'nav_report': '', 'nav_schema': ''}
|
||||
nav = {'nav_dashboard': '', 'nav_runs': '', 'nav_logs': '', 'nav_report': '', 'nav_schema': '', 'nav_calendar': ''}
|
||||
if active in nav:
|
||||
nav[active] = 'active'
|
||||
|
||||
@@ -141,9 +220,14 @@ def _base(title: str, content: str, active: str, db_path: str, page_css: str = '
|
||||
nav_report_link = f'<a href="/report" class="{cls_rep}">Report</a>'
|
||||
nav_sharepoint_link = ''
|
||||
|
||||
if _conn['type'] == ConnType.POSTGRES:
|
||||
db_display = _e(f"{_conn['pg_host']}:{_conn['pg_port']}/{_conn['pg_db']}")
|
||||
else:
|
||||
db_display = _e(Path(db_path).name) if db_path else '-'
|
||||
|
||||
return _tmpl('_base.html').substitute(
|
||||
css=_css(), page_css=page_css, page_js=page_js, title=title, now=now,
|
||||
db_name=_e(Path(db_path).name),
|
||||
db_name=db_display,
|
||||
h1_title=h1_title,
|
||||
content=content,
|
||||
nav_step_link=nav_step_link,
|
||||
@@ -151,6 +235,7 @@ def _base(title: str, content: str, active: str, db_path: str, page_css: str = '
|
||||
nav_sharepoint_link=nav_sharepoint_link,
|
||||
nav_pec_link=nav_pec_link,
|
||||
modal_login=_build_login_modal(),
|
||||
modal_db_conn=_build_db_conn_modal(),
|
||||
logout_btn='<a href="/logout" class="logout-btn" title="Esci">🔒 Esci</a>' if _auth['enabled'] else '',
|
||||
**nav,
|
||||
)
|
||||
|
||||
+153
-30
@@ -6,13 +6,16 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse, parse_qs, unquote_plus
|
||||
|
||||
from .config import _state, _access_log, DbType, _auth, _sessions, LOGS_PER_PAGE, STEPS_ROWS
|
||||
from .env_manager import update_auth as _update_env_auth
|
||||
from .config import _conn, ConnType, REPORTS_DIR
|
||||
from .logger import get_logger
|
||||
from .env_manager import update_auth as _update_env_auth, update_db_conn as _update_db_conn
|
||||
from .db_finder import _find_db, _pick_db_file
|
||||
from .renderer import _base as _render_base
|
||||
from .pages import (
|
||||
_page_dashboard, _page_runs, _page_pec, _page_logs,
|
||||
_page_report, _page_documenti, _page_iscrizioni,
|
||||
_page_api_iscrizioni, _page_sharepoint, _page_email,
|
||||
_page_server_logs, _page_schema, _page_login,
|
||||
_page_server_logs, _page_schema, _page_login, _page_calendar,
|
||||
)
|
||||
from .queries.db import e as _e, query as _query, detect_db as _detect_db
|
||||
from .queries import documenti as q_documenti
|
||||
@@ -50,7 +53,7 @@ def make_handler(db_path: str):
|
||||
class ReportHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {fmt % args}")
|
||||
get_logger().info(fmt % args)
|
||||
|
||||
def _send(self, code: int, body: str, content_type: str = 'text/html; charset=utf-8'):
|
||||
encoded = body.encode('utf-8')
|
||||
@@ -128,6 +131,45 @@ def make_handler(db_path: str):
|
||||
'method': 'POST', 'path': '/settings/login',
|
||||
'code': 302, 'client': self.client_address[0],
|
||||
})
|
||||
elif parsed.path == '/settings/db':
|
||||
if not _is_authenticated(self.headers.get('Cookie', '')):
|
||||
self.send_response(302)
|
||||
self.send_header('Location', '/login')
|
||||
self.end_headers()
|
||||
return
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(length).decode('utf-8')
|
||||
fields = {}
|
||||
for part in body.split('&'):
|
||||
if '=' in part:
|
||||
k, v = part.split('=', 1)
|
||||
fields[unquote_plus(k)] = unquote_plus(v)
|
||||
conn_type = fields.get('conn_type', 'sqlite').strip().lower()
|
||||
pg_host = fields.get('pg_host', '').strip()
|
||||
pg_port = fields.get('pg_port', '5432').strip()
|
||||
pg_db = fields.get('pg_db', '').strip()
|
||||
pg_user = fields.get('pg_user', '').strip()
|
||||
pg_password = fields.get('pg_password', '').strip()
|
||||
# Keep existing password if field left blank
|
||||
if not pg_password and _conn['type'] == ConnType.POSTGRES:
|
||||
pg_password = _conn.get('pg_password', '')
|
||||
_update_db_conn(conn_type, pg_host, pg_port, pg_db, pg_user, pg_password)
|
||||
# Update db_path and db_type in _state
|
||||
if conn_type == 'postgres':
|
||||
new_path = f"postgresql://{pg_host}:{pg_port}/{pg_db}"
|
||||
_state['db_path'] = new_path
|
||||
try:
|
||||
_refresh_db_type(new_path)
|
||||
except Exception:
|
||||
_state['db_type'] = None
|
||||
self.send_response(302)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
_access_log.append({
|
||||
'ts': datetime.now().strftime('%d/%m/%Y %H:%M:%S'),
|
||||
'method': 'POST', 'path': '/settings/db',
|
||||
'code': 302, 'client': self.client_address[0],
|
||||
})
|
||||
else:
|
||||
self._send(405, '<h1>405 Method Not Allowed</h1>')
|
||||
|
||||
@@ -167,11 +209,34 @@ def make_handler(db_path: str):
|
||||
})
|
||||
return
|
||||
|
||||
if _state.get('db_error') or not _state.get('db_path'):
|
||||
err = _e(_state.get('db_error') or 'Database non trovato.')
|
||||
self._send(200, (
|
||||
'<html><head><meta charset="utf-8">'
|
||||
'<title>RPA Dashboard</title>'
|
||||
'<style>body{font-family:sans-serif;margin:0;padding:0;background:#f5f5f5;}'
|
||||
'.content{padding:60px 40px;}'
|
||||
'.banner{position:fixed;bottom:0;left:0;right:0;background:#c0392b;color:#fff;'
|
||||
'padding:16px 24px;font-size:15px;display:flex;align-items:center;gap:12px;}'
|
||||
'.banner svg{flex-shrink:0}'
|
||||
'</style></head><body>'
|
||||
'<div class="content"><h1>RPA Dashboard</h1>'
|
||||
'<p>Il server è avviato ma non è possibile accedere ai dati.</p></div>'
|
||||
'<div class="banner">'
|
||||
'<svg width="20" height="20" viewBox="0 0 20 20" fill="white">'
|
||||
'<path d="M10 2L1 17h18L10 2zm0 3l6.5 11H3.5L10 5zm-1 4v3h2V9H9zm0 4v2h2v-2H9z"/>'
|
||||
'</svg>'
|
||||
f'<span><strong>Errore DB:</strong> {err}</span>'
|
||||
'</div></body></html>'
|
||||
))
|
||||
return
|
||||
|
||||
if self.path == '/change-db':
|
||||
chosen = _pick_db_file()
|
||||
if chosen and os.path.exists(chosen):
|
||||
_state['db_path'] = chosen
|
||||
_refresh_db_type(chosen)
|
||||
if _conn['type'] != ConnType.POSTGRES:
|
||||
chosen = _pick_db_file()
|
||||
if chosen and os.path.exists(chosen):
|
||||
_state['db_path'] = chosen
|
||||
_refresh_db_type(chosen)
|
||||
self.send_response(302)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
@@ -280,8 +345,16 @@ def make_handler(db_path: str):
|
||||
page = int(raw_page) if raw_page and raw_page.isdigit() else 1
|
||||
return _page_logs(db_path, run_id=run_id, page=page)
|
||||
|
||||
def _calendar_page():
|
||||
raw_y = qs.get('y', [None])[0]
|
||||
raw_m = qs.get('m', [None])[0]
|
||||
cal_y = int(raw_y) if raw_y and raw_y.isdigit() else None
|
||||
cal_m = int(raw_m) if raw_m and raw_m.isdigit() else None
|
||||
return _page_calendar(db_path, year=cal_y, month=cal_m)
|
||||
|
||||
routes = {
|
||||
'/': lambda: _page_dashboard(db_path),
|
||||
'/calendar': _calendar_page,
|
||||
'/runs': lambda: _page_runs(db_path),
|
||||
'/logs': _logs_page,
|
||||
'/schema': lambda: _page_schema(db_path),
|
||||
@@ -305,7 +378,27 @@ def make_handler(db_path: str):
|
||||
try:
|
||||
self._send(200, fn())
|
||||
except Exception as exc:
|
||||
self._send(500, f"<pre>Errore: {_e(str(exc))}</pre>")
|
||||
err = _e(str(exc))
|
||||
banner = (
|
||||
'<div style="position:fixed;bottom:0;left:0;right:0;background:#c0392b;'
|
||||
'color:#fff;padding:16px 24px;font-size:14px;z-index:9999;'
|
||||
'display:flex;gap:10px;align-items:center;box-shadow:0 -2px 8px rgba(0,0,0,.2)">'
|
||||
'<svg width="18" height="18" viewBox="0 0 20 20" fill="white" style="flex-shrink:0">'
|
||||
'<path d="M10 2L1 17h18L10 2zm0 3l6.5 11H3.5L10 5zm-1 4v3h2V9H9zm0 4v2h2v-2H9z"/>'
|
||||
'</svg>'
|
||||
f'<strong>Errore:</strong> <span>{err}</span>'
|
||||
'</div>'
|
||||
)
|
||||
content = (
|
||||
'<section style="padding:2rem 2rem 5rem">'
|
||||
'<p style="color:#64748b">Impossibile caricare i dati della pagina.</p>'
|
||||
'</section>'
|
||||
)
|
||||
try:
|
||||
page = _render_base('Errore', content, '', db_path or '', '', '')
|
||||
self._send(500, page.replace('</body>', banner + '</body>'))
|
||||
except Exception:
|
||||
self._send(500, f'<pre>Errore: {err}</pre>')
|
||||
elif path == '/ping':
|
||||
self._send(200, 'ok', 'text/plain; charset=utf-8')
|
||||
else:
|
||||
@@ -314,43 +407,73 @@ def make_handler(db_path: str):
|
||||
return ReportHandler
|
||||
|
||||
|
||||
def run_server(db_path: str, host: str = '0.0.0.0', port: int = 8473):
|
||||
def run_server(db_path: str | None, host: str = '0.0.0.0', port: int = 8473, db_error: str | None = None):
|
||||
_state['db_path'] = db_path
|
||||
db_type = _refresh_db_type(db_path)
|
||||
_state['db_error'] = db_error
|
||||
if db_path:
|
||||
db_type = _refresh_db_type(db_path)
|
||||
else:
|
||||
db_type = None
|
||||
_state['db_type'] = None
|
||||
handler = make_handler(db_path)
|
||||
server = HTTPServer((host, port), handler)
|
||||
log = get_logger()
|
||||
display_host = 'localhost' if host in ('0.0.0.0', '') else host
|
||||
base = f"http://{display_host}:{port}"
|
||||
print(f"RPA Report server avviato [db: {db_type}]")
|
||||
print(f" Dashboard : {base}/")
|
||||
print(f" Processi : {base}/runs")
|
||||
if db_type == DbType.INTRAZ:
|
||||
print(f" RPA Steps : {base}/steps")
|
||||
print(f" Iscr. API : {base}/iscrizioni-api")
|
||||
print(f" SharePoint : {base}/sharepoint")
|
||||
print(f" Email : {base}/email")
|
||||
if db_error:
|
||||
log.warning(f"ATTENZIONE: {db_error}")
|
||||
log.info(f"RPA Report server avviato senza DB — pagina di errore su {base}/")
|
||||
else:
|
||||
print(f" PEC : {base}/pec")
|
||||
print(f" Documenti : {base}/documenti")
|
||||
print(f" Report : {base}/report")
|
||||
print(f" Schema : {base}/schema")
|
||||
print(f" Log DB : {base}/logs")
|
||||
print(f" Server log : {base}/server-logs")
|
||||
print(f" DB : {db_path}")
|
||||
print("Ctrl+C per fermare.\n")
|
||||
log.info(f"RPA Report server avviato [db: {db_type}]")
|
||||
log.info(f" Dashboard : {base}/")
|
||||
log.info(f" Processi : {base}/runs")
|
||||
if db_type == DbType.INTRAZ:
|
||||
log.info(f" RPA Steps : {base}/steps")
|
||||
log.info(f" Iscr. API : {base}/iscrizioni-api")
|
||||
log.info(f" SharePoint : {base}/sharepoint")
|
||||
log.info(f" Email : {base}/email")
|
||||
else:
|
||||
log.info(f" PEC : {base}/pec")
|
||||
log.info(f" Documenti : {base}/documenti")
|
||||
log.info(f" Report : {base}/report")
|
||||
log.info(f" Schema : {base}/schema")
|
||||
log.info(f" Log DB : {base}/logs")
|
||||
log.info(f" Server log : {base}/server-logs")
|
||||
log.info(f" DB : {db_path}")
|
||||
log.info("Ctrl+C per fermare.")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer fermato.")
|
||||
log.info("Server fermato.")
|
||||
server.server_close()
|
||||
|
||||
|
||||
def main():
|
||||
from dotenv import load_dotenv
|
||||
from pathlib import Path
|
||||
from .logger import setup_logging
|
||||
load_dotenv()
|
||||
|
||||
db_file = os.environ.get('RPA_DB_FILE', 'rpa_FORMAZIONE.db')
|
||||
db_path = _find_db(db_file)
|
||||
log_dir_env = os.environ.get('RPA_LOG_DIR', '')
|
||||
log_dir = Path(log_dir_env) if log_dir_env else REPORTS_DIR / 'logs'
|
||||
setup_logging(log_dir)
|
||||
|
||||
db_error = None
|
||||
if _conn['type'] == ConnType.POSTGRES:
|
||||
db_path = f"postgresql://{_conn['pg_host']}:{_conn['pg_port']}/{_conn['pg_db']}"
|
||||
try:
|
||||
from .queries.db import _pg_query
|
||||
_pg_query("SELECT 1")
|
||||
except Exception as exc:
|
||||
db_path = None
|
||||
db_error = f"Connessione PostgreSQL fallita: {exc}"
|
||||
else:
|
||||
db_file = os.environ.get('RPA_DB_FILE', 'rpa_FORMAZIONE.db')
|
||||
try:
|
||||
db_path = _find_db(db_file)
|
||||
except FileNotFoundError as exc:
|
||||
db_path = None
|
||||
db_error = str(exc)
|
||||
|
||||
port = int(os.environ.get('RPA_REPORT_PORT', 8473))
|
||||
run_server(db_path, port=port)
|
||||
run_server(db_path, port=port, db_error=db_error)
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
python-dotenv>=1.0.0
|
||||
psycopg2-binary>=2.9 # optional — only needed for PostgreSQL mode
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Install (or remove) RpaDashboard as a Windows service via NSSM.
|
||||
|
||||
.PARAMETER PythonExe
|
||||
Full path to python.exe. Leave blank to auto-detect from PATH
|
||||
(or the active virtual environment if the script is run inside one).
|
||||
|
||||
.PARAMETER Uninstall
|
||||
Stop and remove the service instead of installing it.
|
||||
|
||||
.EXAMPLE
|
||||
.\install_service.ps1
|
||||
.\install_service.ps1 -PythonExe "C:\Python314\python.exe"
|
||||
.\install_service.ps1 -Uninstall
|
||||
#>
|
||||
param(
|
||||
[string]$PythonExe = '',
|
||||
[switch]$Uninstall
|
||||
)
|
||||
|
||||
$ServiceName = 'RpaDashboard'
|
||||
$RootDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
|
||||
# --- Uninstall path ---
|
||||
if ($Uninstall) {
|
||||
Write-Host "Stopping and removing service '$ServiceName'..."
|
||||
nssm stop $ServiceName 2>$null
|
||||
nssm remove $ServiceName confirm
|
||||
Write-Host "Done."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- Resolve Python executable ---
|
||||
if (-not $PythonExe) {
|
||||
$venvPython = Join-Path $RootDir '.venv\Scripts\python.exe'
|
||||
if (Test-Path $venvPython) {
|
||||
$PythonExe = $venvPython
|
||||
} else {
|
||||
$found = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($found) { $PythonExe = $found.Source }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $PythonExe -or -not (Test-Path $PythonExe)) {
|
||||
Write-Error "Python not found. Pass -PythonExe 'C:\path\to\python.exe'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "NSSM not found in PATH. Download from https://nssm.cc and add it to PATH."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$Script = Join-Path $RootDir 'Dashboard.py'
|
||||
$LogDir = Join-Path $RootDir 'logs'
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||
|
||||
Write-Host "Installing service '$ServiceName'..."
|
||||
Write-Host " Python : $PythonExe"
|
||||
Write-Host " Script : $Script"
|
||||
Write-Host " Logs : $LogDir"
|
||||
Write-Host ""
|
||||
|
||||
# Install service
|
||||
nssm install $ServiceName $PythonExe "-u" $Script
|
||||
|
||||
# Working directory (important for .env loading)
|
||||
nssm set $ServiceName AppDirectory $RootDir
|
||||
|
||||
# Display name and description visible in services.msc
|
||||
nssm set $ServiceName DisplayName 'RPA Dashboard'
|
||||
nssm set $ServiceName Description 'Dashboard per il monitoraggio dei processi RPA — autodeployed by viXEvo'
|
||||
|
||||
# Start automatically with Windows
|
||||
nssm set $ServiceName Start SERVICE_AUTO_START
|
||||
|
||||
# Redirect stdout/stderr (catches crashes before logging initialises)
|
||||
nssm set $ServiceName AppStdout (Join-Path $LogDir 'service_stdout.log')
|
||||
nssm set $ServiceName AppStderr (Join-Path $LogDir 'service_stderr.log')
|
||||
|
||||
# Rotate NSSM's own stdout/stderr files daily
|
||||
nssm set $ServiceName AppRotateFiles 1
|
||||
nssm set $ServiceName AppRotateSeconds 86400
|
||||
|
||||
# Restart automatically 5 s after a crash
|
||||
nssm set $ServiceName AppExit Default Restart
|
||||
nssm set $ServiceName AppRestartDelay 5000
|
||||
|
||||
# Start the service now
|
||||
nssm start $ServiceName
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Service '$ServiceName' installed and started."
|
||||
Write-Host ""
|
||||
Write-Host "Useful commands:"
|
||||
Write-Host " Status : Get-Service $ServiceName"
|
||||
Write-Host " Stop : Stop-Service $ServiceName"
|
||||
Write-Host " Restart : Restart-Service $ServiceName"
|
||||
Write-Host " Logs : Get-Content '$LogDir\dashboard.log' -Wait"
|
||||
Write-Host " Uninstall : .\install_service.ps1 -Uninstall"
|
||||
@@ -0,0 +1,165 @@
|
||||
/* calendar.css */
|
||||
|
||||
.cal-section {
|
||||
padding: 1.5rem 2rem 3rem;
|
||||
}
|
||||
|
||||
.cal-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cal-month-title {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.cal-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
padding: .45rem 1.1rem;
|
||||
border-radius: 7px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: .9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: background .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cal-nav-btn:hover { background: #1d4ed8; }
|
||||
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cal-dow {
|
||||
text-align: center;
|
||||
font-size: .75rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
padding: .35rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.cal-day {
|
||||
min-height: 82px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: .4rem .5rem .5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.cal-day:hover { box-shadow: 0 2px 10px rgba(0,0,0,.09); }
|
||||
|
||||
.cal-day-empty {
|
||||
background: #f8fafc;
|
||||
border-color: #f1f5f9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cal-day-today {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.cal-day-num {
|
||||
font-size: .76rem;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
line-height: 1;
|
||||
}
|
||||
.cal-day-today .cal-day-num {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.cal-runs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.cal-run {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
padding: .15rem .45rem .15rem .3rem;
|
||||
border-radius: 999px;
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
line-height: 1.3;
|
||||
transition: transform .12s, box-shadow .12s;
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.cal-run:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.18);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cal-run-icon {
|
||||
font-size: .78rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cal-run-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.cal-run-ok {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
border: 1.5px solid #86efac;
|
||||
}
|
||||
.cal-run-ok:hover { background: #bbf7d0; }
|
||||
|
||||
.cal-run-err {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: 1.5px solid #fca5a5;
|
||||
}
|
||||
.cal-run-err:hover { background: #fecaca; }
|
||||
|
||||
.cal-runs-extra {
|
||||
display: none;
|
||||
}
|
||||
.cal-runs-extra.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cal-more {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: .15rem .5rem;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.cal-more:hover {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// page_calendar.js
|
||||
|
||||
function calToggleMore(btn) {
|
||||
var extra = btn.nextElementSibling;
|
||||
var open = extra.classList.toggle('open');
|
||||
btn.innerHTML = open ? '▲ meno' : btn.dataset.moreText;
|
||||
}
|
||||
|
||||
// Keyboard month navigation (← →)
|
||||
(function () {
|
||||
function _navHref(selector) {
|
||||
var el = document.querySelector(selector);
|
||||
return el ? el.getAttribute('href') : null;
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA')) return;
|
||||
if (e.key === 'ArrowLeft') {
|
||||
var href = _navHref('.cal-nav .cal-nav-btn:first-child');
|
||||
if (href) location.href = href;
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
var href = _navHref('.cal-nav .cal-nav-btn:last-child');
|
||||
if (href) location.href = href;
|
||||
}
|
||||
});
|
||||
})();
|
||||
+14
-4
@@ -11,10 +11,11 @@
|
||||
<div id="page-loader"><div class="page-loader-ring"></div></div>
|
||||
<header>
|
||||
<div class="hdr-left">
|
||||
<a href="/" class="hdr-nav ${nav_dashboard}">Dashboard</a>
|
||||
<a href="/schema" class="hdr-nav ${nav_schema}">Schema</a>
|
||||
<a href="/runs" class="hdr-nav ${nav_runs}">Processi RPA</a>
|
||||
<a href="/logs" class="hdr-nav ${nav_logs}">Log DB</a>
|
||||
<a href="/" class="hdr-nav ${nav_dashboard}">Dashboard</a>
|
||||
<a href="/calendar" class="hdr-nav ${nav_calendar}">Calendario</a>
|
||||
<a href="/schema" class="hdr-nav ${nav_schema}">Schema</a>
|
||||
<a href="/runs" class="hdr-nav ${nav_runs}">Processi RPA</a>
|
||||
<a href="/logs" class="hdr-nav ${nav_logs}">Log DB</a>
|
||||
</div>
|
||||
<h1>${h1_title}</h1>
|
||||
<div class="hdr-right">
|
||||
@@ -25,6 +26,7 @@
|
||||
<div class="wrench-drop">
|
||||
<a href="/change-db">📂 Cambia DB…</a>
|
||||
<button onclick="document.getElementById('modal-db-types').classList.add('open');this.closest('.wrench-drop').classList.remove('open')">📋 Tipi di Dashboard…</button>
|
||||
<button onclick="document.getElementById('modal-db-conn').classList.add('open');this.closest('.wrench-drop').classList.remove('open')">📡 Sorgente Dati…</button>
|
||||
<button onclick="document.getElementById('modal-login-settings').classList.add('open');this.closest('.wrench-drop').classList.remove('open')">🔒 Impostazioni Login…</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,6 +56,7 @@ ${content}
|
||||
</div>
|
||||
</div>
|
||||
${modal_login}
|
||||
${modal_db_conn}
|
||||
<script>${page_js}</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded',function(){
|
||||
@@ -88,6 +91,13 @@ if (_modalLogin) {
|
||||
});
|
||||
}
|
||||
|
||||
var _modalDbConn = document.getElementById('modal-db-conn');
|
||||
if (_modalDbConn) {
|
||||
_modalDbConn.addEventListener('click', function(e) {
|
||||
if (e.target === this) this.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('th').forEach(function(th, i) {
|
||||
th.style.cursor = 'pointer';
|
||||
th.title = 'Clicca per ordinare';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<section class="cal-section">
|
||||
${cal_nav}
|
||||
${calendar_html}
|
||||
</section>
|
||||
@@ -119,7 +119,14 @@ body {
|
||||
}
|
||||
.login-field { display: flex; flex-direction: column; gap: .3rem; }
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
color: rgba(255,255,255,.55) !important;
|
||||
text-align: center;
|
||||
font-size: .78rem;
|
||||
padding: 10px 0;
|
||||
}
|
||||
footer span { color: #86efac !important; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user