Compare commits
16
Commits
c0c7152b47
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f4b43b6f | ||
|
|
6eed88ac99 | ||
|
|
8a8198a51a | ||
|
|
6f79554cce | ||
|
|
c26584e4ae | ||
|
|
a19705ffda | ||
|
|
555ddc3556 | ||
|
|
182d520675 | ||
|
|
8cd18b14b2 | ||
|
|
5f7cf9f837 | ||
|
|
4cfa628036 | ||
|
|
96b9885fb3 | ||
|
|
4265b77996 | ||
|
|
9e36f03a5c | ||
|
|
544989542f | ||
|
|
feba5d6b93 |
+12
@@ -1 +1,13 @@
|
||||
repo.md
|
||||
|
||||
# Rust build artifacts
|
||||
agent/target/
|
||||
|
||||
# Go build artifacts
|
||||
server/tmp/
|
||||
server/*.test
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
config.toml
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"type":"server-started","port":55731,"host":"0.0.0.0","url_host":"10.0.0.50","url":"http://10.0.0.50:55731","screen_dir":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content","state_dir":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/state"}
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1779431805976}
|
||||
@@ -24,3 +24,4 @@
|
||||
{"type":"screen-added","file":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content/layout-v7.html"}
|
||||
{"type":"screen-added","file":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content/layout-v8.html"}
|
||||
{"type":"screen-added","file":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content/layout-v9.html"}
|
||||
{"type":"server-stopped","reason":"idle timeout"}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Nanometrics
|
||||
|
||||
Système client-serveur de surveillance matérielle (CPU, RAM, disque) conçu pour une empreinte quasi nulle sur les machines Debian.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ UDP / MQTT ┌──────────────────────────┐
|
||||
│ Agent Rust │ ─────────────────────▶ │ Serveur Go │
|
||||
│ ~1 Mo binaire │ │ SQLite · Prometheus │
|
||||
│ systemd │ │ REST API · WebSocket │
|
||||
└─────────────────┘ └──────────┬───────────────┘
|
||||
│ HTTP
|
||||
┌──────────▼───────────────┐
|
||||
│ Dashboard Nginx │
|
||||
│ HTML/CSS/JS · temps réel │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Métriques collectées** : CPU, RAM (libre/utilisée), disque (total/libre/utilisé), uptime, réseau, température, SMART (si `smartctl` disponible)
|
||||
- **Double protocole** : UDP (fire-and-forget) et/ou MQTT (bidirectionnel, compatible Home Assistant auto-discovery)
|
||||
- **Temps réel** : WebSocket — mise à jour des tuiles sans rechargement
|
||||
- **Historique** : SQLite avec rétention configurable (7 à 365 jours)
|
||||
- **Dashboard interactif** : grille responsive, popup détail avec courbes, configuration par agent depuis l'UI
|
||||
- **Empreinte agent minimale** : pas de runtime async (pas de Tokio), boucle mono-thread, `sysinfo` sans threads de fond
|
||||
- **Sécurité systemd** : `DynamicUser=yes`, `ProtectSystem=strict`, `ProtectHome=read-only`
|
||||
|
||||
---
|
||||
|
||||
## Installation de l'agent Rust
|
||||
|
||||
### Prérequis
|
||||
|
||||
```bash
|
||||
# Rust (si non installé)
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
### Compilation
|
||||
|
||||
```bash
|
||||
cargo build --release --manifest-path agent/Cargo.toml
|
||||
# Binaire produit : agent/target/release/nanometrics-agent (~1 Mo)
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Créer `/etc/nanometrics/config.toml` :
|
||||
|
||||
```toml
|
||||
[server]
|
||||
ip = "10.0.0.50" # IP du serveur Go
|
||||
port = 9999 # Port UDP du serveur
|
||||
|
||||
[mqtt]
|
||||
enabled = false
|
||||
host = "10.0.0.3"
|
||||
port = 1883
|
||||
topic_base = "nanometrics/agents"
|
||||
auto_discovery = true
|
||||
birth_message = true
|
||||
last_will = true
|
||||
|
||||
[metrics.cpu]
|
||||
enabled = true
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.memory]
|
||||
enabled = true
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.disk]
|
||||
enabled = true
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.smart]
|
||||
enabled = true # nécessite smartctl installé
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.uptime]
|
||||
enabled = true
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.network]
|
||||
enabled = true
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.temperature]
|
||||
enabled = true
|
||||
udp = true
|
||||
mqtt = false
|
||||
```
|
||||
|
||||
### Déploiement systemd
|
||||
|
||||
```bash
|
||||
# Copier le binaire
|
||||
sudo cp agent/target/release/nanometrics-agent /usr/local/bin/
|
||||
|
||||
# Créer le répertoire de config
|
||||
sudo mkdir -p /etc/nanometrics
|
||||
sudo cp agent/config.toml /etc/nanometrics/config.toml
|
||||
|
||||
# Installer le service
|
||||
sudo cp deploy/nanometrics-agent.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now nanometrics-agent
|
||||
|
||||
# Vérifier
|
||||
sudo systemctl status nanometrics-agent
|
||||
journalctl -u nanometrics-agent -f
|
||||
```
|
||||
|
||||
Pour SMART, installer `smartmontools` sur la machine cible :
|
||||
|
||||
```bash
|
||||
sudo apt install smartmontools
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation du serveur Go + dashboard
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Docker et Docker Compose
|
||||
|
||||
### Lancement
|
||||
|
||||
```bash
|
||||
cd server
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Le dashboard est disponible sur **http://<ip-serveur>** (port 80).
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
|----------|--------|-------------|
|
||||
| `UDP_ADDR` | `0.0.0.0:9999` | Adresse d'écoute UDP |
|
||||
| `HTTP_ADDR` | `0.0.0.0:8080` | Adresse du serveur HTTP |
|
||||
| `DB_PATH` | `/data/nanometrics.db` | Chemin de la base SQLite |
|
||||
| `MQTT_BROKER` | `tcp://10.0.0.3:1883` | Adresse du broker MQTT |
|
||||
| `MQTT_TOPIC_BASE` | `nanometrics/agents` | Topic MQTT de base |
|
||||
|
||||
### Endpoints exposés
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/agents` | Liste de tous les agents |
|
||||
| `GET /api/agents/{id}/history` | Historique des métriques |
|
||||
| `GET /api/agents/{id}/config` | Configuration d'un agent |
|
||||
| `PUT /api/agents/{id}/config` | Modifier la config d'un agent |
|
||||
| `POST /api/agents/{id}/icon` | Upload d'icône (JPG/PNG/WEBP) |
|
||||
| `GET /api/config` | Configuration serveur |
|
||||
| `PUT /api/config` | Modifier la configuration serveur |
|
||||
| `GET /metrics` | Métriques Prometheus |
|
||||
| `WS /ws` | WebSocket temps réel |
|
||||
|
||||
---
|
||||
|
||||
## Commandes de développement
|
||||
|
||||
```bash
|
||||
# Agent Rust — build, tests, lint
|
||||
cargo build --release --manifest-path agent/Cargo.toml
|
||||
cargo test --manifest-path agent/Cargo.toml
|
||||
cargo clippy --manifest-path agent/Cargo.toml
|
||||
|
||||
# Serveur Go — tests, vet
|
||||
go test ./server/...
|
||||
go vet ./server/...
|
||||
|
||||
# Serveur Go — lancement local (sans Docker)
|
||||
cd server
|
||||
DB_PATH=/tmp/nm.db HTTP_ADDR=0.0.0.0:8080 UDP_ADDR=0.0.0.0:9999 go run .
|
||||
|
||||
# Simuler un agent avec netcat
|
||||
echo '{"hostname":"test-01","ip":"127.0.0.1","status":"online","cpu_percent":42.5,"memory_used":3000000000,"memory_total":8000000000,"hdd_used":60000000000,"hdd_total":200000000000,"uptime":86400}' \
|
||||
| nc -u 127.0.0.1 9999
|
||||
```
|
||||
@@ -0,0 +1,262 @@
|
||||
/* Variables globales (indépendantes du thème) */
|
||||
:root {
|
||||
--font-ui:'Inter',system-ui,sans-serif;
|
||||
--font-mono:'JetBrains Mono',monospace;
|
||||
--font-terminal:'Share Tech Mono',monospace;
|
||||
}
|
||||
|
||||
/* Polices locales */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('../fonts/inter.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('../fonts/jetbrains-mono.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Share Tech Mono';
|
||||
src: url('../fonts/share-tech-mono.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Tokens design system */
|
||||
:root[data-theme="dark"] {
|
||||
--accent:#fe8019;--accent-soft:#d65d0e;--accent-glow:rgba(254,128,25,.28);--accent-tint:rgba(254,128,25,.1);
|
||||
--bg-0:#1d1813;--bg-1:#2a231d;--bg-2:#32291f;--bg-3:#3c332a;--bg-4:#4a3f33;--bg-5:#57493c;
|
||||
--ink-1:#f2e5c7;--ink-2:#d5c4a1;--ink-3:#a89984;--ink-4:#7c6f64;
|
||||
--ok:#4dbb26;--warn:#fabd2f;--err:#fb4934;--blue:#3db0d1;--purple:#c882c8;
|
||||
--border-1:rgba(255,255,255,.06);--border-2:rgba(255,255,255,.12);--border-3:rgba(255,255,255,.26);
|
||||
--tile-3d:0 1px 0 rgba(255,255,255,.08) inset,0 -1px 0 rgba(0,0,0,.3) inset,0 6px 20px rgba(0,0,0,.5);
|
||||
--tile-press:inset 0 2px 8px rgba(0,0,0,.5),inset 0 1px 3px rgba(0,0,0,.4);
|
||||
--hover-glow:0 0 0 1px var(--accent-soft),0 0 24px var(--accent-glow),0 6px 20px rgba(0,0,0,.5);
|
||||
--warn-glow:rgba(250,189,47,.22);--err-glow:rgba(251,73,52,.25);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--accent:#af3a03;--accent-soft:#d65d0e;--accent-glow:rgba(175,58,3,.18);--accent-tint:rgba(175,58,3,.08);
|
||||
--bg-0:#d5c4a1;--bg-1:#ebdbb2;--bg-2:#d5c4a1;--bg-3:#bdae93;--bg-4:#a89984;--bg-5:#928374;
|
||||
--ink-1:#3c3836;--ink-2:#504945;--ink-3:#665c54;--ink-4:#7c6f64;
|
||||
--ok:#3c911c;--warn:#b57614;--err:#9d0006;--blue:#2d82a3;--purple:#8c468c;
|
||||
--border-1:rgba(0,0,0,.08);--border-2:rgba(0,0,0,.15);--border-3:rgba(0,0,0,.3);
|
||||
--tile-3d:0 1px 0 rgba(255,255,255,.55) inset,0 -1px 0 rgba(0,0,0,.08) inset,0 4px 14px rgba(0,0,0,.13);
|
||||
--tile-press:inset 0 2px 6px rgba(0,0,0,.2);
|
||||
--hover-glow:0 0 0 1px var(--accent-soft),0 0 18px var(--accent-glow),0 4px 14px rgba(0,0,0,.13);
|
||||
--warn-glow:rgba(181,118,20,.22);--err-glow:rgba(157,0,6,.25);
|
||||
}
|
||||
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-size:13px;
|
||||
height:100vh;display:flex;flex-direction:column;overflow:hidden;transition:background .2s,color .2s}
|
||||
|
||||
/* TOOLTIP global position:fixed */
|
||||
#tooltip{position:fixed;z-index:9999;pointer-events:none;background:var(--bg-0);color:var(--ink-1);
|
||||
border:1px solid var(--border-3);border-radius:5px;padding:4px 9px;font-size:11px;
|
||||
font-family:var(--font-ui);white-space:nowrap;opacity:0;transition:opacity .12s;
|
||||
box-shadow:0 4px 12px rgba(0,0,0,.4)}
|
||||
#tooltip.show{opacity:1}
|
||||
|
||||
/* HEADER */
|
||||
.header{background:var(--bg-2);border-bottom:1px solid var(--border-2);padding:0 20px;
|
||||
height:48px;display:flex;align-items:center;gap:12px;flex-shrink:0}
|
||||
.logo{display:flex;align-items:center;gap:8px}
|
||||
.logo-led{width:9px;height:9px;border-radius:50%;background:var(--accent);
|
||||
box-shadow:0 0 8px var(--accent-glow);animation:blink 2s infinite}
|
||||
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.logo-name{font-weight:700;font-size:14px;letter-spacing:.05em;font-family:var(--font-terminal)}
|
||||
.logo-ver{font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)}
|
||||
.h-sep{width:1px;height:24px;background:var(--border-2)}
|
||||
.h-spacer{flex:1}
|
||||
.h-stats{display:flex;gap:14px}
|
||||
.h-stat{display:flex;align-items:center;gap:5px}
|
||||
.h-stat .lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
|
||||
.h-stat .val{font-family:var(--font-mono);font-weight:700;font-size:13px}
|
||||
.c-ok{color:var(--ok)}.c-warn{color:var(--warn)}.c-err{color:var(--err)}.c-n{color:var(--ink-2)}
|
||||
.hbtn{width:34px;height:34px;border-radius:8px;border:1px solid var(--border-2);background:var(--bg-3);
|
||||
color:var(--ink-2);font-size:14px;display:flex;align-items:center;justify-content:center;
|
||||
cursor:pointer;user-select:none;transition:background .12s,color .12s,transform .08s,box-shadow .08s}
|
||||
.hbtn:hover{background:var(--bg-4);color:var(--accent)}
|
||||
.hbtn:active{transform:translateY(1px) scale(.96);box-shadow:var(--tile-press)}
|
||||
.hbtn.active-btn{background:var(--accent);color:var(--bg-0);border-color:var(--accent-soft)}
|
||||
|
||||
/* GRILLE */
|
||||
.main{flex:1;padding:14px 16px;overflow-y:auto}
|
||||
.agents-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--tile-min,220px),1fr));gap:10px}
|
||||
.tile{background:var(--bg-3);border-radius:10px;padding:12px 14px;border:1px solid var(--border-1);
|
||||
box-shadow:var(--tile-3d);cursor:pointer;display:flex;flex-direction:column;gap:9px;
|
||||
transition:box-shadow .15s,transform .08s,border-color .15s}
|
||||
.tile:hover{box-shadow:var(--hover-glow);border-color:var(--accent-soft)}
|
||||
.tile:active{transform:translateY(2px) scale(.99);box-shadow:var(--tile-press)}
|
||||
.tile.t-warn{border-color:rgba(250,189,47,.3)}
|
||||
.tile.t-warn:hover{border-color:var(--warn);box-shadow:0 0 0 1px var(--warn),0 0 22px var(--warn-glow),0 6px 20px rgba(0,0,0,.5)}
|
||||
.tile.t-err{border-color:rgba(251,73,52,.35)}
|
||||
.tile.t-err:hover{border-color:var(--err);box-shadow:0 0 0 1px var(--err),0 0 22px var(--err-glow),0 6px 20px rgba(0,0,0,.5)}
|
||||
.tile.t-off{opacity:.5;cursor:default}.tile.t-off:hover,.tile.t-off:active{box-shadow:var(--tile-3d);border-color:var(--border-1);transform:none}
|
||||
.tile-head{display:flex;align-items:center;gap:8px;user-select:none}
|
||||
.t-icon{width:28px;height:28px;border-radius:7px;background:var(--bg-4);display:flex;align-items:center;
|
||||
justify-content:center;color:var(--accent);font-size:13px;flex-shrink:0;overflow:hidden}
|
||||
.t-icon img{width:100%;height:100%;object-fit:cover}
|
||||
.t-names{flex:1;min-width:0}
|
||||
.t-host{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.t-ip{font-family:var(--font-mono);font-size:10px;color:var(--ink-4)}
|
||||
.t-led{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||
.s-ok{background:var(--ok);box-shadow:0 0 6px var(--ok)}
|
||||
.s-warn{background:var(--warn);box-shadow:0 0 6px var(--warn);animation:blink 1.5s infinite}
|
||||
.s-err{background:var(--err);box-shadow:0 0 8px var(--err);animation:blink 1s infinite}
|
||||
.s-off{background:var(--ink-4)}
|
||||
.tile-gauges{display:flex;flex-direction:column;gap:5px}
|
||||
.g-row{display:flex;align-items:center;gap:7px}
|
||||
.g-ico{width:18px;height:18px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:11px;color:var(--ink-3);flex-shrink:0;cursor:help}
|
||||
.g-bar{flex:1;height:5px;border-radius:3px;background:var(--bg-1);overflow:hidden}
|
||||
.g-fill{height:100%;border-radius:3px;background:var(--ok);transition:width .3s}
|
||||
.g-fill.w{background:var(--warn)}.g-fill.e{background:var(--err)}
|
||||
.g-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);width:34px;text-align:right}
|
||||
.tile-foot{font-family:var(--font-terminal);font-size:10px;color:var(--ink-4);
|
||||
display:flex;align-items:center;gap:5px;user-select:none}
|
||||
|
||||
/* FOOTER */
|
||||
.footer{background:var(--bg-0);border-top:1px solid var(--border-2);height:26px;
|
||||
display:flex;align-items:center;font-family:var(--font-terminal);font-size:11px;
|
||||
color:var(--ink-4);flex-shrink:0}
|
||||
.f-mode{background:var(--accent);color:var(--bg-0);padding:0 12px;height:100%;
|
||||
display:flex;align-items:center;font-weight:700;letter-spacing:.04em}
|
||||
.f-cell{padding:0 12px;border-right:1px solid var(--border-1);display:flex;align-items:center;gap:5px;height:100%}
|
||||
.f-val{font-family:var(--font-mono);color:var(--ink-2)}.f-val.w{color:var(--warn)}
|
||||
.f-minibar{width:36px;height:4px;border-radius:2px;background:var(--bg-3);overflow:hidden}
|
||||
.f-minifill{height:100%;border-radius:2px;background:var(--ok)}.f-minifill.w{background:var(--warn)}
|
||||
.f-spacer{flex:1}.f-right{padding:0 12px;display:flex;align-items:center;gap:6px;color:var(--ink-3)}
|
||||
.f-time{font-family:var(--font-mono);color:var(--ink-2)}
|
||||
|
||||
/* OVERLAY + POPUP */
|
||||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:100;display:flex;
|
||||
align-items:center;justify-content:center;backdrop-filter:blur(2px)}
|
||||
.popup{background:var(--bg-2);border:1px solid var(--border-3);border-radius:12px;
|
||||
box-shadow:0 24px 64px rgba(0,0,0,.7);display:flex;flex-direction:column;overflow:hidden}
|
||||
.pop-close{width:28px;height:28px;border-radius:6px;background:var(--bg-5);color:var(--ink-3);
|
||||
display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:12px;
|
||||
border:1px solid var(--border-1);user-select:none;transition:background .12s,color .12s,transform .08s}
|
||||
.pop-close:hover{background:var(--err);color:#fff}
|
||||
.pop-close:active{transform:translateY(1px) scale(.93)}
|
||||
.btn{padding:6px 14px;border-radius:8px;border:1px solid var(--border-2);background:var(--bg-4);
|
||||
color:var(--ink-2);font-size:12px;font-family:var(--font-ui);cursor:pointer;
|
||||
display:flex;align-items:center;gap:6px;user-select:none;transition:background .1s,transform .08s}
|
||||
.btn:hover{background:var(--bg-5)}.btn:active{transform:translateY(1px) scale(.97)}
|
||||
.btn.primary{background:var(--accent);color:var(--bg-0);border-color:var(--accent-soft);font-weight:600}
|
||||
.btn.primary:hover{background:var(--accent-soft)}
|
||||
|
||||
/* Popup détail agent */
|
||||
#popup-detail{width:560px;max-width:96vw;max-height:92vh;resize:both;overflow:hidden;
|
||||
min-width:400px;min-height:320px}
|
||||
#popup-detail .pop-body{overflow-y:auto;flex:1}
|
||||
.pop-head{background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);
|
||||
display:flex;align-items:center;gap:12px;flex-shrink:0}
|
||||
.agent-icon-wrap{position:relative;width:44px;height:44px;border-radius:10px;flex-shrink:0;
|
||||
cursor:pointer;overflow:hidden;background:var(--bg-4);display:flex;
|
||||
align-items:center;justify-content:center;color:var(--accent);font-size:18px;
|
||||
border:2px solid var(--border-2);transition:border-color .15s}
|
||||
.agent-icon-wrap:hover{border-color:var(--accent)}
|
||||
.agent-icon-overlay{position:absolute;inset:0;background:rgba(0,0,0,.6);display:flex;
|
||||
flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:2px;opacity:0;transition:opacity .15s;font-size:10px;color:#fff}
|
||||
.agent-icon-wrap:hover .agent-icon-overlay{opacity:1}
|
||||
.pop-host{font-weight:700;font-size:15px}.pop-ip{font-family:var(--font-mono);font-size:11px;color:var(--ink-4)}
|
||||
.pop-led{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok);flex-shrink:0}
|
||||
.pop-body{padding:16px 18px;display:flex;flex-direction:column;gap:14px}
|
||||
.sec-title{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;margin-bottom:8px}
|
||||
.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}
|
||||
.kpi{background:var(--bg-3);border-radius:8px;padding:10px 12px;border:1px solid var(--border-1);box-shadow:var(--tile-3d)}
|
||||
.kpi-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
|
||||
.kpi-val{font-family:var(--font-mono);font-size:20px;font-weight:700;line-height:1.1;margin-top:2px}
|
||||
.kpi-val .u{font-size:10px;color:var(--ink-3);font-weight:400}
|
||||
.kpi-sub{font-size:10px;color:var(--ink-4);font-family:var(--font-mono);margin-top:2px}
|
||||
.charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.chart-card{background:var(--bg-3);border-radius:8px;padding:10px 12px;border:1px solid var(--border-1)}
|
||||
.chart-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
|
||||
.chart-label{display:flex;align-items:center;gap:6px;font-size:10px;font-family:var(--font-terminal);color:var(--ink-3)}
|
||||
.chart-cur{font-family:var(--font-mono);font-size:16px;font-weight:700}
|
||||
.chart-svg{width:100%;height:52px;display:block}
|
||||
.chart-axis{display:flex;justify-content:space-between;margin-top:2px;font-family:var(--font-terminal);font-size:9px;color:var(--ink-4)}
|
||||
.smart-btn{display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:8px;
|
||||
border:1px solid var(--border-2);background:var(--bg-3);cursor:pointer;
|
||||
transition:background .12s,border-color .12s,transform .08s;font-family:var(--font-terminal);font-size:11px}
|
||||
.smart-btn:hover{background:var(--bg-4)}.smart-btn:active{transform:translateY(1px)}
|
||||
.smart-btn.ok{border-color:rgba(77,187,38,.3);color:var(--ok)}
|
||||
.smart-dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 5px var(--ok)}
|
||||
.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.meta{background:var(--bg-3);border-radius:6px;padding:8px 10px;border:1px solid var(--border-1)}
|
||||
.meta-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
|
||||
.meta-val{font-family:var(--font-mono);font-size:12px;color:var(--ink-2);margin-top:2px}
|
||||
.proto-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:999px;
|
||||
font-size:10px;font-family:var(--font-terminal);font-weight:600}
|
||||
.proto-badge.udp{background:rgba(61,176,209,.15);color:var(--blue);border:1px solid rgba(61,176,209,.3)}
|
||||
.proto-badge.mqtt{background:rgba(200,130,200,.15);color:var(--purple);border:1px solid rgba(200,130,200,.3)}
|
||||
.pop-foot{padding:10px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);
|
||||
display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.pop-uptime{font-family:var(--font-terminal);font-size:11px;color:var(--ink-4);flex:1}
|
||||
.btn-agent-cfg{width:34px;height:34px;border-radius:8px;border:1px solid var(--border-2);
|
||||
background:var(--bg-4);color:var(--ink-3);font-size:15px;display:flex;
|
||||
align-items:center;justify-content:center;cursor:pointer;user-select:none;
|
||||
transition:background .12s,color .12s,transform .08s}
|
||||
.btn-agent-cfg:hover{background:var(--bg-5);color:var(--accent)}
|
||||
|
||||
/* Métriques tableau 3 colonnes */
|
||||
.metrics-table{display:flex;flex-direction:column;border:1px solid var(--border-2);border-radius:8px;overflow:hidden}
|
||||
.metrics-header{display:grid;grid-template-columns:1fr 56px 56px;background:var(--bg-4);
|
||||
padding:8px 12px;gap:4px;align-items:center}
|
||||
.mh-label{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
|
||||
.mh-proto{font-size:9px;font-family:var(--font-terminal);font-weight:700;text-align:center;
|
||||
display:flex;flex-direction:column;align-items:center;gap:2px}
|
||||
.mh-proto.udp{color:var(--blue)}.mh-proto.mqtt{color:var(--purple)}
|
||||
.metric-row{display:grid;grid-template-columns:1fr 56px 56px;padding:8px 12px;gap:4px;
|
||||
align-items:center;background:var(--bg-3);border-top:1px solid var(--border-1);transition:background .1s}
|
||||
.metric-row:hover{background:var(--bg-4)}
|
||||
.metric-cell{display:flex;align-items:center;gap:8px}
|
||||
.metric-ico{font-size:13px;color:var(--ink-3);width:16px;text-align:center}
|
||||
.metric-name{font-size:12px;color:var(--ink-2);font-family:var(--font-terminal)}
|
||||
.metric-chk{display:flex;justify-content:center}
|
||||
.cbox{width:20px;height:20px;border-radius:5px;border:2px solid var(--border-2);background:var(--bg-1);
|
||||
display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:11px;
|
||||
color:transparent;transition:background .12s,border-color .12s,color .12s,transform .08s;
|
||||
user-select:none;flex-shrink:0}
|
||||
.cbox:hover{border-color:var(--border-3);transform:scale(1.08)}
|
||||
.cbox.udp-on{background:rgba(61,176,209,.18);border-color:var(--blue);color:var(--blue)}
|
||||
.cbox.mqtt-on{background:rgba(200,130,200,.18);border-color:var(--purple);color:var(--purple)}
|
||||
|
||||
/* Config serveur */
|
||||
.scfg-body{padding:18px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;flex:1;min-height:0}
|
||||
.scfg-sec-title{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);
|
||||
letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)}
|
||||
.scfg-row{display:flex;align-items:center;gap:10px}
|
||||
.scfg-row>label{font-size:12px;color:var(--ink-3);width:110px;flex-shrink:0;font-family:var(--font-terminal)}
|
||||
.scfg-slider{flex:1;accent-color:var(--accent)}
|
||||
.scfg-val{font-family:var(--font-mono);font-size:12px;color:var(--ink-2);width:48px;text-align:right}
|
||||
.scfg-select{flex:1;background:var(--bg-3);border:1px solid var(--border-2);border-radius:6px;
|
||||
color:var(--ink-1);padding:6px 10px;font-size:12px}
|
||||
.scfg-toggle-row{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:8px 10px;background:var(--bg-3);border-radius:7px;border:1px solid var(--border-1)}
|
||||
.toggle{position:relative;width:34px;height:18px;flex-shrink:0}
|
||||
.toggle input{opacity:0;width:0;height:0}
|
||||
.toggle-slider{position:absolute;inset:0;border-radius:9px;background:var(--bg-4);
|
||||
border:1px solid var(--border-2);cursor:pointer;transition:background .2s}
|
||||
.toggle-slider::before{content:'';position:absolute;width:12px;height:12px;border-radius:50%;
|
||||
background:var(--ink-4);top:2px;left:2px;transition:transform .2s,background .2s}
|
||||
.toggle input:checked+.toggle-slider{background:rgba(254,128,25,.3);border-color:var(--accent)}
|
||||
.toggle input:checked+.toggle-slider::before{transform:translateX(16px);background:var(--accent)}
|
||||
|
||||
/* SMART */
|
||||
.smart-verdict{display:flex;align-items:center;gap:14px;background:rgba(77,187,38,.1);
|
||||
border:1px solid rgba(77,187,38,.3);border-radius:10px;padding:14px 18px}
|
||||
.si-val{font-family:var(--font-mono);font-size:18px;font-weight:700}
|
||||
.si-val .u{font-size:11px;color:var(--ink-3);font-weight:400}
|
||||
.si-desc{font-size:11px;color:var(--ink-3);margin-top:4px;line-height:1.4}
|
||||
.attr-row{display:flex;align-items:center;gap:10px;padding:6px 10px;
|
||||
border-radius:6px;background:var(--bg-3);border:1px solid var(--border-1)}
|
||||
.attr-ok{color:var(--ok)}
|
||||
|
||||
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg-1)}
|
||||
::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px}
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjOVmNeaAh.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/sharetechmono/v15/J7aHnp1uDWRBEqV98dVQztYldFc7pAsEIc3Xew.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-theme="dark" lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nanometrics</title>
|
||||
<link rel="stylesheet" href="vendor/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="tooltip"></div>
|
||||
|
||||
<!-- HEADER -->
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<div class="logo-led"></div>
|
||||
<span class="logo-name">NANOMETRICS</span>
|
||||
<span class="logo-ver">v1.0</span>
|
||||
</div>
|
||||
<div class="h-sep"></div>
|
||||
<div class="h-stats" id="h-stats">
|
||||
<div class="h-stat"><span class="lbl">AGENTS</span><span class="val c-n" id="stat-total">0</span></div>
|
||||
<div class="h-stat"><span class="lbl">OK</span><span class="val c-ok" id="stat-ok">0</span></div>
|
||||
<div class="h-stat"><span class="lbl">WARN</span><span class="val c-warn" id="stat-warn">0</span></div>
|
||||
<div class="h-stat"><span class="lbl">ERR</span><span class="val c-err" id="stat-err">0</span></div>
|
||||
</div>
|
||||
<div class="h-spacer"></div>
|
||||
<div class="hbtn" id="btn-theme" onclick="App.toggleTheme()" data-tip="Thème clair / sombre">
|
||||
<i class="fa-solid fa-moon" id="theme-icon"></i>
|
||||
</div>
|
||||
<div class="hbtn" id="btn-srvcfg" onclick="Popups.showSrvCfg()" data-tip="Configuration serveur / interface">
|
||||
<i class="fa-solid fa-sliders"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GRILLE -->
|
||||
<div class="main"><div class="agents-grid" id="agents-grid"></div></div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div class="footer">
|
||||
<div class="f-mode">LIVE</div>
|
||||
<div class="f-cell"><i class="fa-solid fa-server" style="font-size:10px"></i><span>SERVEUR</span></div>
|
||||
<div class="f-cell">
|
||||
<i class="fa-solid fa-microchip" style="font-size:10px"></i>
|
||||
<span class="f-val" id="srv-cpu">—</span>
|
||||
<div class="f-minibar"><div class="f-minifill" id="srv-cpu-bar"></div></div>
|
||||
</div>
|
||||
<div class="f-cell">
|
||||
<i class="fa-solid fa-memory" style="font-size:10px"></i>
|
||||
<span class="f-val" id="srv-mem">—</span>
|
||||
<div class="f-minibar"><div class="f-minifill" id="srv-mem-bar"></div></div>
|
||||
</div>
|
||||
<div class="f-spacer"></div>
|
||||
<div class="f-right">
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
<span>Actualisation : <span class="f-time" id="f-time">—</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POPUP DÉTAIL AGENT -->
|
||||
<div class="overlay" id="overlay-detail" style="display:none" onclick="if(event.target===this)Popups.hideDetail()">
|
||||
<div class="popup" id="popup-detail" onclick="event.stopPropagation()">
|
||||
<div class="pop-head">
|
||||
<div class="agent-icon-wrap" id="pop-icon-wrap" data-tip="Changer l'icône">
|
||||
<span id="pop-icon-fa"><i class="fa-solid fa-server"></i></span>
|
||||
<img id="pop-icon-img" src="" alt="" style="display:none">
|
||||
<div class="agent-icon-overlay"><i class="fa-solid fa-camera"></i><span>Changer</span></div>
|
||||
</div>
|
||||
<input type="file" id="icon-upload" accept=".svg,.jpg,.jpeg,.png,.webp" style="display:none">
|
||||
<div style="flex:1">
|
||||
<div class="pop-host" id="pop-host">—</div>
|
||||
<div class="pop-ip" id="pop-ip">—</div>
|
||||
<div style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal);margin-top:2px">
|
||||
Cliquer sur l'icône pour personnaliser · SVG JPG PNG WEBP · max 128×128 px
|
||||
</div>
|
||||
</div>
|
||||
<div class="pop-led" id="pop-led"></div>
|
||||
<div class="pop-close" onclick="Popups.hideDetail()" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
|
||||
</div>
|
||||
<div class="pop-body" id="pop-body"></div>
|
||||
<div class="pop-foot">
|
||||
<span class="pop-uptime" id="pop-uptime"></span>
|
||||
<span style="font-family:var(--font-terminal);font-size:9px;color:var(--ink-4);display:flex;align-items:center;gap:4px"
|
||||
data-tip="Taille sauvegardée sur le serveur">
|
||||
<i class="fa-solid fa-up-right-and-down-left-from-center"></i>Redimensionnable
|
||||
</span>
|
||||
<div class="btn-agent-cfg" onclick="Popups.showAgentCfg()" data-tip="Configurer l'agent">
|
||||
<i class="fa-solid fa-gears"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POPUP CONFIG AGENT -->
|
||||
<div class="overlay" id="overlay-agentcfg" style="display:none;z-index:200" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div class="popup" id="popup-agentcfg" style="width:520px;max-width:96vw;max-height:90vh" onclick="event.stopPropagation()">
|
||||
<div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:var(--bg-4);display:flex;align-items:center;justify-content:center;color:var(--accent);font-size:15px"><i class="fa-solid fa-gears"></i></div>
|
||||
<div style="flex:1"><div style="font-weight:700;font-size:14px">Configuration de l'agent</div>
|
||||
<div style="font-size:11px;color:var(--ink-4);font-family:var(--font-terminal)" id="agentcfg-sub">—</div></div>
|
||||
<div class="pop-close" onclick="this.closest('.overlay').style.display='none'" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
|
||||
</div>
|
||||
<div style="padding:18px;display:flex;flex-direction:column;gap:16px;overflow-y:auto;max-height:62vh" id="agentcfg-body"></div>
|
||||
<div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;align-items:center;gap:8px">
|
||||
<div style="flex:1;display:flex;align-items:center;gap:6px;font-family:var(--font-terminal);font-size:11px;color:var(--ink-4)">
|
||||
<div style="width:6px;height:6px;border-radius:50%;background:var(--ok)"></div>
|
||||
<span>Config synchronisée avec l'agent</span>
|
||||
</div>
|
||||
<button class="btn" onclick="this.closest('.overlay').style.display='none'">Annuler</button>
|
||||
<button class="btn primary" onclick="Popups.sendAgentConfig()"><i class="fa-solid fa-paper-plane"></i> Envoyer à l'agent</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POPUP CONFIG SERVEUR -->
|
||||
<div class="overlay" id="overlay-srvcfg" style="display:none" onclick="if(event.target===this)Popups.hideSrvCfg()">
|
||||
<div class="popup" id="popup-srvcfg" style="width:400px;max-width:96vw;max-height:88vh" onclick="event.stopPropagation()">
|
||||
<div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:var(--accent);display:flex;align-items:center;justify-content:center;color:var(--bg-0);font-size:15px"><i class="fa-solid fa-sliders"></i></div>
|
||||
<span style="flex:1;font-weight:700;font-size:14px">Configuration interface</span>
|
||||
<div class="pop-close" onclick="Popups.hideSrvCfg()" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
|
||||
</div>
|
||||
<div class="scfg-body" id="srvcfg-body"></div>
|
||||
<div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn" onclick="Popups.hideSrvCfg()">Annuler</button>
|
||||
<button class="btn primary" onclick="Popups.saveSrvCfg()"><i class="fa-solid fa-floppy-disk"></i> Sauvegarder</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POPUP SMART -->
|
||||
<div class="overlay" id="overlay-smart" style="display:none;z-index:300" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div class="popup" id="popup-smart" style="width:500px;max-width:96vw;max-height:88vh" onclick="event.stopPropagation()">
|
||||
<div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:var(--bg-4);display:flex;align-items:center;justify-content:center;color:var(--ok);font-size:15px"><i class="fa-solid fa-shield-heart"></i></div>
|
||||
<div style="flex:1"><div style="font-weight:700;font-size:14px">Santé du disque dur</div>
|
||||
<div style="font-size:11px;color:var(--ink-4);font-family:var(--font-terminal)" id="smart-sub">—</div></div>
|
||||
<div class="pop-close" onclick="this.closest('.overlay').style.display='none'" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
|
||||
</div>
|
||||
<div style="padding:18px;display:flex;flex-direction:column;gap:16px;overflow-y:auto;max-height:70vh" id="smart-body"></div>
|
||||
<div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;align-items:center;gap:8px">
|
||||
<span style="flex:1;font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)">
|
||||
<i class="fa-solid fa-circle-info" style="margin-right:4px"></i>Données via smartctl
|
||||
</span>
|
||||
<button class="btn primary" onclick="this.closest('.overlay').style.display='none'"><i class="fa-solid fa-xmark"></i> Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/charts.js"></script>
|
||||
<script src="js/grid.js"></script>
|
||||
<script src="js/popups.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
// Échappe les valeurs serveur avant injection dans innerHTML
|
||||
function esc(s) {
|
||||
if (s == null) return '—';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const API = (() => {
|
||||
const BASE = ''; // même origine, proxy Nginx vers le serveur Go
|
||||
|
||||
async function get(path) {
|
||||
const r = await fetch(BASE + path);
|
||||
if (!r.ok) throw new Error(`GET ${path}: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function put(path, body) {
|
||||
const r = await fetch(BASE + path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error(`PUT ${path}: ${r.status}`);
|
||||
}
|
||||
|
||||
async function postForm(path, formData) {
|
||||
const r = await fetch(BASE + path, { method: 'POST', body: formData });
|
||||
if (!r.ok) throw new Error(`POST ${path}: ${r.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
getAgents: () => get('/api/agents'),
|
||||
getAgentHistory: (id, from, to) => get(`/api/agents/${id}/history?from=${from}&to=${to}`),
|
||||
getAgentConfig: (id) => get(`/api/agents/${id}/config`),
|
||||
putAgentConfig: (id, cfg) => put(`/api/agents/${id}/config`, cfg),
|
||||
getServerConfig: () => get('/api/config'),
|
||||
putServerConfig: (cfg) => put('/api/config', cfg),
|
||||
uploadIcon: (id, file) => {
|
||||
const fd = new FormData();
|
||||
fd.append('icon', file);
|
||||
return postForm(`/api/agents/${id}/icon`, fd);
|
||||
},
|
||||
iconUrl: (id) => `/api/agents/${id}/icon`,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,134 @@
|
||||
const App = (() => {
|
||||
let _ws = null;
|
||||
let _reconnectDelay = 1000;
|
||||
let _reconnectTimer = null;
|
||||
let _serverConfig = null;
|
||||
|
||||
// Tooltip global position:fixed
|
||||
const tip = document.getElementById('tooltip');
|
||||
let _tt;
|
||||
document.addEventListener('mouseover', e => {
|
||||
const el = e.target.closest('[data-tip]');
|
||||
if (!el) return;
|
||||
clearTimeout(_tt);
|
||||
_tt = setTimeout(() => {
|
||||
tip.textContent = el.dataset.tip;
|
||||
tip.classList.add('show');
|
||||
}, 120);
|
||||
});
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (!tip.classList.contains('show')) return;
|
||||
const w = tip.offsetWidth, h = tip.offsetHeight;
|
||||
let x = e.clientX - w / 2, y = e.clientY - h - 10;
|
||||
x = Math.max(6, Math.min(x, window.innerWidth - w - 6));
|
||||
if (y < 6) y = e.clientY + 18;
|
||||
tip.style.left = x + 'px';
|
||||
tip.style.top = y + 'px';
|
||||
});
|
||||
document.addEventListener('mouseout', e => {
|
||||
if (!e.target.closest('[data-tip]')) return;
|
||||
clearTimeout(_tt);
|
||||
tip.classList.remove('show');
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
const h = document.documentElement;
|
||||
h.dataset.theme = h.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.getElementById('theme-icon').className =
|
||||
h.dataset.theme === 'dark' ? 'fa-solid fa-moon' : 'fa-solid fa-sun';
|
||||
}
|
||||
|
||||
function updateClock() {
|
||||
document.getElementById('f-time').textContent =
|
||||
new Date().toLocaleTimeString('fr-FR');
|
||||
}
|
||||
|
||||
function updateServerStats(stats) {
|
||||
const cpu = stats.cpu_percent ?? 0;
|
||||
const memPct = stats.mem_total > 0 ? (stats.mem_used / stats.mem_total * 100) : 0;
|
||||
const cpuEl = document.getElementById('srv-cpu');
|
||||
const memEl = document.getElementById('srv-mem');
|
||||
const cpuBar = document.getElementById('srv-cpu-bar');
|
||||
const memBar = document.getElementById('srv-mem-bar');
|
||||
if (cpuEl) {
|
||||
cpuEl.textContent = cpu.toFixed(0) + '%';
|
||||
cpuEl.className = 'f-val' + (cpu >= 70 ? ' w' : '');
|
||||
}
|
||||
if (cpuBar) {
|
||||
cpuBar.style.width = cpu.toFixed(0) + '%';
|
||||
cpuBar.className = 'f-minifill' + (cpu >= 70 ? ' w' : '');
|
||||
}
|
||||
if (memEl) {
|
||||
memEl.textContent = memPct.toFixed(0) + '%';
|
||||
memEl.className = 'f-val' + (memPct >= 70 ? ' w' : '');
|
||||
}
|
||||
if (memBar) {
|
||||
memBar.style.width = memPct.toFixed(0) + '%';
|
||||
memBar.className = 'f-minifill' + (memPct >= 70 ? ' w' : '');
|
||||
}
|
||||
}
|
||||
|
||||
function connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
_ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
|
||||
_ws.onopen = () => {
|
||||
_reconnectDelay = 1000;
|
||||
document.querySelector('.logo-led').style.animation = 'blink 2s infinite';
|
||||
};
|
||||
|
||||
_ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'metrics_update') {
|
||||
Grid.update(msg.agent_id, msg.data);
|
||||
updateClock();
|
||||
} else if (msg.type === 'server_stats') {
|
||||
updateServerStats(msg.data);
|
||||
} else if (msg.type === 'status_update') {
|
||||
Grid.updateStatus(msg.agent_id, msg.data.status);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
_ws.onclose = () => {
|
||||
clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = setTimeout(connectWS, _reconnectDelay);
|
||||
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
|
||||
};
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
_serverConfig = await API.getServerConfig();
|
||||
if (_serverConfig.tile_min_width) {
|
||||
document.documentElement.style.setProperty('--tile-min', _serverConfig.tile_min_width + 'px');
|
||||
}
|
||||
if (_serverConfig.font_size) {
|
||||
document.body.style.fontSize = _serverConfig.font_size + 'px';
|
||||
}
|
||||
if (_serverConfig.popup_detail_w && _serverConfig.popup_detail_h) {
|
||||
const pd = document.getElementById('popup-detail');
|
||||
pd.style.width = _serverConfig.popup_detail_w + 'px';
|
||||
pd.style.height = _serverConfig.popup_detail_h + 'px';
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const agents = await API.getAgents();
|
||||
Grid.refresh(agents);
|
||||
} catch {}
|
||||
|
||||
connectWS();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
return {
|
||||
toggleTheme,
|
||||
get serverConfig() { return _serverConfig; },
|
||||
set serverConfig(v) { _serverConfig = v; },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,46 @@
|
||||
const Charts = (() => {
|
||||
function makeCurve(pts, stroke, fill, w, h) {
|
||||
if (!pts || pts.length < 2) return '';
|
||||
const xs = pts.map((_, i) => (i / (pts.length - 1)) * w);
|
||||
const ys = pts.map(v => h - (v / 100) * (h - 6) - 3);
|
||||
const wy = h - (70 / 100) * (h - 6) - 3;
|
||||
let d = `M${xs[0]} ${ys[0]}`;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const cx = (xs[i - 1] + xs[i]) / 2;
|
||||
d += ` C${cx} ${ys[i - 1]},${cx} ${ys[i]},${xs[i]} ${ys[i]}`;
|
||||
}
|
||||
const uid = Math.random().toString(36).slice(2);
|
||||
return `<defs>
|
||||
<linearGradient id="g${uid}" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="${fill}" stop-opacity=".4"/>
|
||||
<stop offset="100%" stop-color="${fill}" stop-opacity=".02"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1="0" y1="${wy}" x2="${w}" y2="${wy}"
|
||||
stroke="var(--warn)" stroke-width=".8" stroke-dasharray="3,3" opacity=".5"/>
|
||||
<path d="${d} L${xs.at(-1)} ${h} L${xs[0]} ${h}Z" fill="url(#g${uid})"/>
|
||||
<path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.6"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="${xs.at(-1)}" cy="${ys.at(-1)}" r="2.5" fill="${stroke}"/>`;
|
||||
}
|
||||
|
||||
function historyToCpuPts(history) {
|
||||
return history.map(h => h.cpu_percent ?? 0);
|
||||
}
|
||||
|
||||
function historyToMemPts(history) {
|
||||
return history.map(h => {
|
||||
if (!h.memory_total || h.memory_total === 0) return 0;
|
||||
return (h.memory_used / h.memory_total) * 100;
|
||||
});
|
||||
}
|
||||
|
||||
function renderChart(svgEl, pts, color) {
|
||||
if (!svgEl) return;
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const c = cs.getPropertyValue(color).trim() || color;
|
||||
svgEl.innerHTML = makeCurve(pts, c, c, 200, 52);
|
||||
}
|
||||
|
||||
return { makeCurve, historyToCpuPts, historyToMemPts, renderChart };
|
||||
})();
|
||||
@@ -0,0 +1,154 @@
|
||||
const Grid = (() => {
|
||||
const _agents = new Map();
|
||||
|
||||
function statusClass(agent) {
|
||||
if (agent.status === 'offline') return 't-off';
|
||||
const m = _agents.get(agent.id)?.metrics;
|
||||
if (!m) return '';
|
||||
const cfg = App.serverConfig;
|
||||
const errThreshold = cfg?.err_cpu ?? 85;
|
||||
const warnThreshold = cfg?.warn_cpu ?? 70;
|
||||
if ((m.cpu_percent ?? 0) >= errThreshold) return 't-err';
|
||||
if ((m.cpu_percent ?? 0) >= warnThreshold || (m.hdd_used && m.hdd_total && (m.hdd_used / m.hdd_total * 100) >= (cfg?.warn_disk ?? 75))) return 't-warn';
|
||||
return '';
|
||||
}
|
||||
|
||||
function ledClass(status) {
|
||||
return { online: 's-ok', warn: 's-warn', err: 's-err', offline: 's-off' }[status] ?? 's-off';
|
||||
}
|
||||
|
||||
function fmt(bytes) {
|
||||
if (!bytes) return '—';
|
||||
if (bytes < 1024) return bytes + 'o';
|
||||
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + 'Ko';
|
||||
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + 'Mo';
|
||||
return (bytes / 1024 ** 3).toFixed(1) + 'Go';
|
||||
}
|
||||
|
||||
function fmtPct(val) {
|
||||
return val != null ? val.toFixed(0) + '%' : '—';
|
||||
}
|
||||
|
||||
function gFill(pct) {
|
||||
const cfg = App.serverConfig;
|
||||
if (pct >= (cfg?.err_cpu ?? 85)) return 'e';
|
||||
if (pct >= (cfg?.warn_cpu ?? 70)) return 'w';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderTile(agent, metrics) {
|
||||
const id = agent.id;
|
||||
const sc = statusClass(agent);
|
||||
const offline = agent.status === 'offline';
|
||||
|
||||
const cpu = metrics?.cpu_percent ?? null;
|
||||
const memPct = (metrics?.memory_used && metrics?.memory_total)
|
||||
? metrics.memory_used / metrics.memory_total * 100 : null;
|
||||
const diskPct = (metrics?.hdd_used && metrics?.hdd_total)
|
||||
? metrics.hdd_used / metrics.hdd_total * 100 : null;
|
||||
|
||||
const uptimeSec = metrics?.uptime;
|
||||
let uptimeStr = '';
|
||||
if (uptimeSec) {
|
||||
const d = Math.floor(uptimeSec / 86400);
|
||||
const h = Math.floor((uptimeSec % 86400) / 3600);
|
||||
uptimeStr = d > 0 ? `${d}j ${h}h` : `${h}h`;
|
||||
}
|
||||
|
||||
const iconContent = `<img src="${API.iconUrl(id)}" alt=""
|
||||
style="width:100%;height:100%;object-fit:cover;border-radius:7px"
|
||||
onerror="this.style.display='none';this.nextSibling.style.display='flex'">
|
||||
<span style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--accent)">
|
||||
<i class="fa-solid fa-server"></i></span>`;
|
||||
|
||||
return `<div class="tile ${sc}" id="tile-${id}" onclick="Popups.showDetail('${esc(id)}')">
|
||||
<div class="tile-head">
|
||||
<div class="t-icon">${iconContent}</div>
|
||||
<div class="t-names">
|
||||
<div class="t-host">${esc(agent.hostname)}</div>
|
||||
<div class="t-ip">${esc(agent.ip) || '—'}</div>
|
||||
</div>
|
||||
<div class="t-led ${ledClass(agent.status)}"></div>
|
||||
</div>
|
||||
<div class="tile-gauges">
|
||||
<div class="g-row">
|
||||
<div class="g-ico" data-tip="CPU"><i class="fa-solid fa-microchip"></i></div>
|
||||
<div class="g-bar"><div class="g-fill ${offline ? '' : gFill(cpu ?? 0)}"
|
||||
style="width:${offline ? 0 : (cpu ?? 0).toFixed(0)}%"></div></div>
|
||||
<span class="g-val">${offline ? '—' : fmtPct(cpu)}</span>
|
||||
</div>
|
||||
<div class="g-row">
|
||||
<div class="g-ico" data-tip="RAM"><i class="fa-solid fa-memory"></i></div>
|
||||
<div class="g-bar"><div class="g-fill ${offline ? '' : gFill(memPct ?? 0)}"
|
||||
style="width:${offline ? 0 : (memPct ?? 0).toFixed(0)}%"></div></div>
|
||||
<span class="g-val">${offline ? '—' : fmtPct(memPct)}</span>
|
||||
</div>
|
||||
<div class="g-row">
|
||||
<div class="g-ico" data-tip="Disque"><i class="fa-solid fa-hard-drive"></i></div>
|
||||
<div class="g-bar"><div class="g-fill ${offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : '')}"
|
||||
style="width:${offline ? 0 : (diskPct ?? 0).toFixed(0)}%"></div></div>
|
||||
<span class="g-val">${offline ? '—' : fmtPct(diskPct)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-foot">
|
||||
${offline
|
||||
? '<i class="fa-solid fa-circle-xmark" style="color:var(--err)"></i><span style="color:var(--err)">Hors ligne</span>'
|
||||
: `<i class="fa-solid fa-clock"></i><span>${uptimeStr}</span>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function update(agentId, metrics) {
|
||||
const entry = _agents.get(agentId);
|
||||
if (!entry) return;
|
||||
entry.metrics = metrics;
|
||||
const el = document.getElementById('tile-' + agentId);
|
||||
if (el) {
|
||||
el.outerHTML = renderTile(entry.agent, metrics);
|
||||
}
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function refresh(agents) {
|
||||
agents.forEach(a => {
|
||||
if (!_agents.has(a.id)) {
|
||||
_agents.set(a.id, { agent: a, metrics: null });
|
||||
} else {
|
||||
_agents.get(a.id).agent = a;
|
||||
}
|
||||
});
|
||||
const grid = document.getElementById('agents-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = agents.map(a => renderTile(a, _agents.get(a.id)?.metrics)).join('');
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
let total = 0, ok = 0, warn = 0, err = 0;
|
||||
_agents.forEach(({ agent }) => {
|
||||
total++;
|
||||
const sc = statusClass(agent);
|
||||
if (agent.status === 'offline') {}
|
||||
else if (sc === 't-err') err++;
|
||||
else if (sc === 't-warn') warn++;
|
||||
else ok++;
|
||||
});
|
||||
document.getElementById('stat-total').textContent = total;
|
||||
document.getElementById('stat-ok').textContent = ok;
|
||||
document.getElementById('stat-warn').textContent = warn;
|
||||
document.getElementById('stat-err').textContent = err;
|
||||
}
|
||||
|
||||
function getAgent(id) { return _agents.get(id); }
|
||||
|
||||
function updateStatus(agentId, status) {
|
||||
const entry = _agents.get(agentId);
|
||||
if (!entry) return;
|
||||
entry.agent.status = status;
|
||||
const el = document.getElementById('tile-' + agentId);
|
||||
if (el) el.outerHTML = renderTile(entry.agent, entry.metrics);
|
||||
updateStats();
|
||||
}
|
||||
|
||||
return { refresh, update, updateStatus, getAgent, fmt, fmtPct };
|
||||
})();
|
||||
@@ -0,0 +1,400 @@
|
||||
const Popups = (() => {
|
||||
let _currentAgentId = null;
|
||||
let _agentCfgData = null;
|
||||
let _resizeObs = null;
|
||||
|
||||
// ══ POPUP DÉTAIL ══
|
||||
async function showDetail(agentId) {
|
||||
_currentAgentId = agentId;
|
||||
const entry = Grid.getAgent(agentId);
|
||||
if (!entry) return;
|
||||
const { agent, metrics } = entry;
|
||||
|
||||
document.getElementById('pop-host').textContent = agent.hostname;
|
||||
document.getElementById('pop-ip').textContent = agent.ip || '—';
|
||||
const led = document.getElementById('pop-led');
|
||||
led.className = 'pop-led';
|
||||
led.style.background = agent.status === 'online' ? 'var(--ok)' : 'var(--err)';
|
||||
led.style.boxShadow = `0 0 8px ${agent.status === 'online' ? 'var(--ok)' : 'var(--err)'}`;
|
||||
|
||||
// Icône
|
||||
const img = document.getElementById('pop-icon-img');
|
||||
const fa = document.getElementById('pop-icon-fa');
|
||||
img.src = API.iconUrl(agentId) + '?t=' + Date.now();
|
||||
img.style.display = 'block';
|
||||
img.onerror = () => { img.style.display = 'none'; fa.style.display = 'flex'; };
|
||||
fa.style.display = 'none';
|
||||
|
||||
// Upload icône
|
||||
document.getElementById('pop-icon-wrap').onclick = () => document.getElementById('icon-upload').click();
|
||||
document.getElementById('icon-upload').onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
await API.uploadIcon(agentId, file);
|
||||
img.src = API.iconUrl(agentId) + '?t=' + Date.now();
|
||||
};
|
||||
|
||||
// Uptime
|
||||
const up = metrics?.uptime;
|
||||
if (up) {
|
||||
const d = Math.floor(up / 86400), h = Math.floor((up % 86400) / 3600);
|
||||
document.getElementById('pop-uptime').innerHTML =
|
||||
`<i class="fa-solid fa-clock" style="margin-right:4px"></i>En ligne depuis ${d}j ${h}h`;
|
||||
}
|
||||
|
||||
// Corps du popup
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let history = [];
|
||||
try { history = await API.getAgentHistory(agentId, now - 1800, now); } catch {}
|
||||
|
||||
const cpuPts = Charts.historyToCpuPts(history);
|
||||
const memPts = Charts.historyToMemPts(history);
|
||||
|
||||
const smartBtn = metrics?.smart
|
||||
? `<div class="smart-btn ok" onclick="Popups.showSmart('${esc(agentId)}')" data-tip="Voir la santé complète du disque">
|
||||
<div class="smart-dot"></div>
|
||||
<span style="font-weight:600">SMART</span>
|
||||
<span>·</span>
|
||||
<span>${metrics.smart.passed ? 'PASSED' : 'FAILED'}</span>
|
||||
${metrics.smart.temperature ? `<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)"><i class="fa-solid fa-temperature-half"></i> ${metrics.smart.temperature}°C</span>` : ''}
|
||||
<i class="fa-solid fa-chevron-right" style="font-size:10px;color:var(--ink-4);margin-left:auto"></i>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const protos = [
|
||||
metrics?.cpu_percent != null ? `<span class="proto-badge udp"><i class="fa-solid fa-arrow-up"></i>UDP</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
|
||||
document.getElementById('pop-body').innerHTML = `
|
||||
<div>
|
||||
<div class="sec-title">MÉTRIQUES ACTUELLES</div>
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi"><div class="kpi-lbl">CPU</div>
|
||||
<div class="kpi-val c-ok">${(metrics?.cpu_percent ?? 0).toFixed(0)}<span class="u">%</span></div></div>
|
||||
<div class="kpi"><div class="kpi-lbl">MÉMOIRE</div>
|
||||
<div class="kpi-val">${Grid.fmt(metrics?.memory_used)}</div>
|
||||
<div class="kpi-sub">/ ${Grid.fmt(metrics?.memory_total)}</div></div>
|
||||
<div class="kpi"><div class="kpi-lbl">DISQUE</div>
|
||||
<div class="kpi-val">${Grid.fmt(metrics?.hdd_used)}</div>
|
||||
<div class="kpi-sub">/ ${Grid.fmt(metrics?.hdd_total)}</div></div>
|
||||
<div class="kpi"><div class="kpi-lbl">UPTIME</div>
|
||||
<div class="kpi-val" style="font-size:15px">${document.getElementById('pop-uptime').textContent.replace(/.*depuis /,'')}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sec-title">HISTORIQUE — 30 MIN</div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-label" style="color:var(--accent)"><i class="fa-solid fa-microchip"></i>CPU</div>
|
||||
<span class="chart-cur c-ok">${(metrics?.cpu_percent ?? 0).toFixed(0)}%</span>
|
||||
</div>
|
||||
<svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-cpu-chart"></svg>
|
||||
<div class="chart-axis"><span>−30min</span><span>−15min</span><span>now</span></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-label" style="color:var(--blue)"><i class="fa-solid fa-memory"></i>RAM</div>
|
||||
<span class="chart-cur" style="color:var(--blue)">${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}</span>
|
||||
</div>
|
||||
<svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-mem-chart"></svg>
|
||||
<div class="chart-axis"><span>−30min</span><span>−15min</span><span>now</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sec-title">STOCKAGE</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div style="width:22px;text-align:center;font-size:13px;cursor:help" data-tip="Utilisé"><i class="fa-solid fa-hard-drive"></i></div>
|
||||
<div style="flex:1;height:7px;border-radius:4px;background:var(--bg-1);overflow:hidden">
|
||||
<div style="height:100%;border-radius:4px;background:var(--ok);width:${metrics?.hdd_total ? (metrics.hdd_used/metrics.hdd_total*100).toFixed(0) : 0}%"></div></div>
|
||||
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ink-2);width:90px;text-align:right">${Grid.fmt(metrics?.hdd_used)} / ${Grid.fmt(metrics?.hdd_total)}</span>
|
||||
</div>
|
||||
${smartBtn}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sec-title">INFORMATIONS</div>
|
||||
<div class="meta-grid">
|
||||
<div class="meta"><div class="meta-lbl">HOSTNAME</div><div class="meta-val">${esc(agent.hostname)}</div></div>
|
||||
<div class="meta"><div class="meta-lbl">ADRESSE IP</div><div class="meta-val">${esc(agent.ip) || '—'}</div></div>
|
||||
<div class="meta"><div class="meta-lbl">PROTOCOLES ACTIFS</div><div style="display:flex;gap:5px;margin-top:4px">${protos || '—'}</div></div>
|
||||
<div class="meta"><div class="meta-lbl">DERNIER CONTACT</div><div class="meta-val">${new Date(agent.last_seen * 1000).toLocaleTimeString('fr-FR')}</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
Charts.renderChart(document.getElementById('det-cpu-chart'), cpuPts, '--accent');
|
||||
Charts.renderChart(document.getElementById('det-mem-chart'), memPts, '--blue');
|
||||
});
|
||||
|
||||
// Resize → sauvegarder sur serveur
|
||||
if (_resizeObs) _resizeObs.disconnect();
|
||||
const pd = document.getElementById('popup-detail');
|
||||
_resizeObs = new ResizeObserver(() => {
|
||||
API.putServerConfig({
|
||||
...App.serverConfig,
|
||||
popup_detail_w: pd.offsetWidth,
|
||||
popup_detail_h: pd.offsetHeight,
|
||||
}).catch(() => {});
|
||||
});
|
||||
_resizeObs.observe(pd);
|
||||
|
||||
document.getElementById('overlay-detail').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideDetail() {
|
||||
document.getElementById('overlay-detail').style.display = 'none';
|
||||
}
|
||||
|
||||
// ══ CONFIG AGENT ══
|
||||
async function showAgentCfg() {
|
||||
if (!_currentAgentId) return;
|
||||
let cfg = {};
|
||||
try { cfg = await API.getAgentConfig(_currentAgentId); } catch {}
|
||||
_agentCfgData = cfg;
|
||||
|
||||
document.getElementById('agentcfg-sub').textContent =
|
||||
`${_currentAgentId} · config récupérée`;
|
||||
|
||||
const metrics = ['cpu','memory','disk','smart','uptime','network','temperature'];
|
||||
const icons = {
|
||||
cpu:'fa-microchip',memory:'fa-memory',disk:'fa-hard-drive',
|
||||
smart:'fa-shield-heart',uptime:'fa-clock',network:'fa-network-wired',
|
||||
temperature:'fa-thermometer-half'
|
||||
};
|
||||
const mqttCfg = cfg.protocols?.mqtt ?? {};
|
||||
|
||||
document.getElementById('agentcfg-body').innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">MÉTRIQUES PAR PROTOCOLE</div>
|
||||
<div class="metrics-table">
|
||||
<div class="metrics-header">
|
||||
<span class="mh-label">MÉTRIQUE</span>
|
||||
<span class="mh-proto udp"><i class="fa-solid fa-arrow-up"></i> UDP</span>
|
||||
<span class="mh-proto mqtt"><i class="fa-solid fa-tower-broadcast" style="font-size:8px"></i> MQTT</span>
|
||||
</div>
|
||||
${metrics.map(m => {
|
||||
const udpOn = cfg.metrics?.[m]?.udp ? 'udp-on' : '';
|
||||
const mqttOn = cfg.metrics?.[m]?.mqtt ? 'mqtt-on' : '';
|
||||
return `<div class="metric-row">
|
||||
<div class="metric-cell">
|
||||
<div class="metric-ico"><i class="fa-solid ${icons[m]}"></i></div>
|
||||
<span class="metric-name">${m}</span>
|
||||
</div>
|
||||
<div class="metric-chk"><div class="cbox ${udpOn}" id="cbox-${m}-udp" onclick="Popups.toggleCbox(this,'${m}','udp')" data-tip="${m} via UDP"><i class="fa-solid fa-check"></i></div></div>
|
||||
<div class="metric-chk"><div class="cbox ${mqttOn}" id="cbox-${m}-mqtt" onclick="Popups.toggleCbox(this,'${m}','mqtt')" data-tip="${m} via MQTT"><i class="fa-solid fa-check"></i></div></div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">PARAMÈTRES MQTT</div>
|
||||
<div style="background:var(--bg-3);border-radius:8px;border:1px solid rgba(200,130,200,.2);padding:12px 14px;display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Broker</label>
|
||||
<input id="mqtt-host" style="flex:1;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.host ?? '10.0.0.3'}"></div>
|
||||
<div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Port</label>
|
||||
<input id="mqtt-port" type="number" style="width:90px;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.port ?? 1883}"></div>
|
||||
<div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Topic base</label>
|
||||
<input id="mqtt-topic" style="flex:1;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.topic_base ?? 'nanometrics/agents'}"></div>
|
||||
<div style="border-top:1px solid var(--border-1);padding-top:8px;display:flex;flex-direction:column;gap:5px">
|
||||
${['auto_discovery:Auto-discovery (Home Assistant):fa-satellite-dish',
|
||||
'birth_message:Birth message:fa-arrow-right-to-bracket',
|
||||
'last_will:Last Will message:fa-skull'].map(s => {
|
||||
const [key, label, icon] = s.split(':');
|
||||
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0">
|
||||
<label style="font-size:12px;color:var(--ink-2);display:flex;align-items:center;gap:7px;cursor:pointer">
|
||||
<i class="fa-solid ${icon}" style="color:var(--purple);font-size:11px"></i>${label}
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="mqtt-${key}" ${mqttCfg[key] !== false ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">
|
||||
COMMANDES DISTANTES <span style="color:var(--ink-4);font-size:8px;margin-left:6px">— BIENTÔT</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
|
||||
${[['fa-rotate-right','reboot'],['fa-power-off','shutdown'],['fa-display','screen off'],
|
||||
['fa-arrow-up-from-bracket','update'],['fa-arrow-up-right-dots','upgrade'],['fa-terminal','shell cmd']].map(
|
||||
([icon, label]) => `<div style="display:flex;flex-direction:column;align-items:center;gap:4px;padding:10px 8px;border-radius:8px;background:var(--bg-3);border:1px solid var(--border-1);cursor:not-allowed;opacity:.4">
|
||||
<i class="fa-solid ${icon}" style="font-size:16px;color:var(--ink-3)"></i>
|
||||
<span style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)">${label}</span>
|
||||
<span style="font-size:8px;color:var(--ink-4);font-family:var(--font-terminal)">bientôt</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('overlay-agentcfg').style.display = 'flex';
|
||||
}
|
||||
|
||||
function toggleCbox(el, metric, proto) {
|
||||
const isOn = el.classList.contains(proto + '-on');
|
||||
el.classList.toggle(proto + '-on', !isOn);
|
||||
el.style.color = isOn ? 'transparent' : '';
|
||||
if (!_agentCfgData.metrics) _agentCfgData.metrics = {};
|
||||
if (!_agentCfgData.metrics[metric]) _agentCfgData.metrics[metric] = {};
|
||||
_agentCfgData.metrics[metric][proto] = !isOn;
|
||||
}
|
||||
|
||||
async function sendAgentConfig() {
|
||||
if (!_currentAgentId || !_agentCfgData) return;
|
||||
if (!_agentCfgData.protocols) _agentCfgData.protocols = {};
|
||||
_agentCfgData.protocols.mqtt = {
|
||||
..._agentCfgData.protocols.mqtt,
|
||||
host: document.getElementById('mqtt-host')?.value ?? '10.0.0.3',
|
||||
port: parseInt(document.getElementById('mqtt-port')?.value ?? '1883'),
|
||||
topic_base: document.getElementById('mqtt-topic')?.value ?? 'nanometrics/agents',
|
||||
auto_discovery: document.getElementById('mqtt-auto_discovery')?.checked ?? true,
|
||||
birth_message: document.getElementById('mqtt-birth_message')?.checked ?? true,
|
||||
last_will: document.getElementById('mqtt-last_will')?.checked ?? true,
|
||||
};
|
||||
try {
|
||||
await API.putAgentConfig(_currentAgentId, _agentCfgData);
|
||||
document.getElementById('overlay-agentcfg').style.display = 'none';
|
||||
} catch (e) {
|
||||
alert('Erreur lors de l\'envoi : ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ══ CONFIG SERVEUR ══
|
||||
async function showSrvCfg() {
|
||||
const cfg = App.serverConfig ?? {};
|
||||
document.getElementById('btn-srvcfg').classList.add('active-btn');
|
||||
document.getElementById('srvcfg-body').innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div class="scfg-sec-title">AFFICHAGE DES TUILES</div>
|
||||
<div class="scfg-row"><label>Largeur min.</label>
|
||||
<input type="range" class="scfg-slider" min="160" max="420" value="${cfg.tile_min_width ?? 220}"
|
||||
oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-tile-w">
|
||||
<span class="scfg-val">${cfg.tile_min_width ?? 220}px</span></div>
|
||||
<div class="scfg-row"><label>Taille du texte</label>
|
||||
<input type="range" class="scfg-slider" min="10" max="18" value="${cfg.font_size ?? 13}"
|
||||
oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-font">
|
||||
<span class="scfg-val">${cfg.font_size ?? 13}px</span></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div class="scfg-sec-title">SEUILS D'ALERTE</div>
|
||||
<div class="scfg-row"><label>Warning CPU/RAM</label>
|
||||
<input type="range" class="scfg-slider" min="50" max="95" value="${cfg.warn_cpu ?? 70}"
|
||||
oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-warn-cpu">
|
||||
<span class="scfg-val">${cfg.warn_cpu ?? 70}%</span></div>
|
||||
<div class="scfg-row"><label>Erreur CPU/RAM</label>
|
||||
<input type="range" class="scfg-slider" min="60" max="100" value="${cfg.err_cpu ?? 85}"
|
||||
oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-err-cpu">
|
||||
<span class="scfg-val">${cfg.err_cpu ?? 85}%</span></div>
|
||||
<div class="scfg-row"><label>Warning Disque</label>
|
||||
<input type="range" class="scfg-slider" min="50" max="95" value="${cfg.warn_disk ?? 75}"
|
||||
oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-warn-disk">
|
||||
<span class="scfg-val">${cfg.warn_disk ?? 75}%</span></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div class="scfg-sec-title">DONNÉES & RÉTENTION</div>
|
||||
<div class="scfg-row"><label>Historique</label>
|
||||
<select class="scfg-select" id="s-retention">
|
||||
${[7,30,90,365].map(d => `<option value="${d}" ${(cfg.retention_days??30)==d?'selected':''}>${d} jours</option>`).join('')}
|
||||
</select></div>
|
||||
<div class="scfg-row"><label>Courbes (durée)</label>
|
||||
<select class="scfg-select" id="s-chart-dur">
|
||||
${[[15,'15 min'],[30,'30 min'],[60,'1 heure'],[360,'6 heures']].map(([v,l]) =>
|
||||
`<option value="${v}" ${(cfg.chart_duration_min??30)==v?'selected':''}>${l}</option>`).join('')}
|
||||
</select></div>
|
||||
</div>`;
|
||||
document.getElementById('overlay-srvcfg').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideSrvCfg() {
|
||||
document.getElementById('overlay-srvcfg').style.display = 'none';
|
||||
document.getElementById('btn-srvcfg').classList.remove('active-btn');
|
||||
}
|
||||
|
||||
async function saveSrvCfg() {
|
||||
const cfg = {
|
||||
...App.serverConfig,
|
||||
tile_min_width: parseInt(document.getElementById('s-tile-w')?.value ?? 220),
|
||||
font_size: parseInt(document.getElementById('s-font')?.value ?? 13),
|
||||
warn_cpu: parseInt(document.getElementById('s-warn-cpu')?.value ?? 70),
|
||||
err_cpu: parseInt(document.getElementById('s-err-cpu')?.value ?? 85),
|
||||
warn_disk: parseInt(document.getElementById('s-warn-disk')?.value ?? 75),
|
||||
retention_days: parseInt(document.getElementById('s-retention')?.value ?? 30),
|
||||
chart_duration_min: parseInt(document.getElementById('s-chart-dur')?.value ?? 30),
|
||||
};
|
||||
await API.putServerConfig(cfg);
|
||||
App.serverConfig = cfg;
|
||||
document.documentElement.style.setProperty('--tile-min', cfg.tile_min_width + 'px');
|
||||
document.body.style.fontSize = cfg.font_size + 'px';
|
||||
hideSrvCfg();
|
||||
}
|
||||
|
||||
// ══ POPUP SMART ══
|
||||
function showSmart(agentId) {
|
||||
const m = Grid.getAgent(agentId)?.metrics?.smart;
|
||||
if (!m) return;
|
||||
document.getElementById('smart-sub').textContent = agentId;
|
||||
const passColor = m.passed ? 'var(--ok)' : 'var(--err)';
|
||||
const passText = m.passed ? 'Disque en bonne santé' : 'Disque en mauvais état';
|
||||
const passSub = m.passed
|
||||
? 'Aucun problème détecté. Le disque fonctionne normalement.'
|
||||
: 'Des problèmes ont été détectés. Envisagez un remplacement.';
|
||||
|
||||
document.getElementById('smart-body').innerHTML = `
|
||||
<div class="smart-verdict" style="${m.passed ? '' : 'background:rgba(251,73,52,.1);border-color:rgba(251,73,52,.3)'}">
|
||||
<div style="font-size:28px;color:${passColor}"><i class="fa-solid ${m.passed ? 'fa-circle-check' : 'fa-circle-xmark'}"></i></div>
|
||||
<div><div style="font-size:16px;font-weight:700;color:${passColor}">${passText}</div>
|
||||
<div style="font-size:12px;color:var(--ink-3);margin-top:3px">${passSub}</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sec-title">POINTS DE CONTRÔLE</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
${m.temperature != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="color:var(--warn);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-temperature-half"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Température</span>
|
||||
<span style="font-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(77,187,38,.15);color:var(--ok)">Normale</span>
|
||||
</div>
|
||||
<div class="si-val">${m.temperature}<span class="u">°C</span></div>
|
||||
<div class="si-desc">Idéal : 20–50°C. Au-delà de 60°C le disque risque de s'abîmer.</div>
|
||||
</div>` : ''}
|
||||
${m.reallocated_sectors != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="color:${m.reallocated_sectors > 0 ? 'var(--err)' : 'var(--ok)'};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-circle-check"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Secteurs défectueux</span>
|
||||
</div>
|
||||
<div class="si-val">${m.reallocated_sectors}<span class="u"> sect.</span></div>
|
||||
<div class="si-desc">S'ils apparaissent en grand nombre, une panne est imminente.</div>
|
||||
</div>` : ''}
|
||||
${m.power_on_hours != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="color:var(--blue);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-clock-rotate-left"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Heures de fonctionnement</span>
|
||||
</div>
|
||||
<div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u">h</span></div>
|
||||
<div class="si-desc">≈${Math.floor(m.power_on_hours/24)} jours. Un disque dure en moyenne 3 à 5 ans.</div>
|
||||
</div>` : ''}
|
||||
${m.wear_level != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="color:var(--ok);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-battery-full"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Durée de vie SSD</span>
|
||||
</div>
|
||||
<div class="si-val">${m.wear_level}<span class="u">%</span></div>
|
||||
<div class="si-desc">100% = neuf · 0% = fin de vie recommandée.</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('overlay-smart').style.display = 'flex';
|
||||
}
|
||||
|
||||
return {
|
||||
showDetail, hideDetail,
|
||||
showAgentCfg, sendAgentConfig, toggleCbox,
|
||||
showSrvCfg, hideSrvCfg, saveSrvCfg,
|
||||
showSmart,
|
||||
};
|
||||
})();
|
||||
+8003
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+1573
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+6369
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+19
@@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:root, :host {
|
||||
--fa-style-family-classic: 'Font Awesome 6 Free';
|
||||
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Free'; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); }
|
||||
|
||||
.far,
|
||||
.fa-regular {
|
||||
font-weight: 400; }
|
||||
@@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:root, :host {
|
||||
--fa-style-family-classic: 'Font Awesome 6 Free';
|
||||
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Free'; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
|
||||
|
||||
.fas,
|
||||
.fa-solid {
|
||||
font-weight: 900; }
|
||||
@@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}
|
||||
+640
@@ -0,0 +1,640 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:root, :host {
|
||||
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Solid';
|
||||
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Regular';
|
||||
--fa-font-light: normal 300 1em/1 'Font Awesome 6 Light';
|
||||
--fa-font-thin: normal 100 1em/1 'Font Awesome 6 Thin';
|
||||
--fa-font-duotone: normal 900 1em/1 'Font Awesome 6 Duotone';
|
||||
--fa-font-sharp-solid: normal 900 1em/1 'Font Awesome 6 Sharp';
|
||||
--fa-font-sharp-regular: normal 400 1em/1 'Font Awesome 6 Sharp';
|
||||
--fa-font-sharp-light: normal 300 1em/1 'Font Awesome 6 Sharp';
|
||||
--fa-font-sharp-thin: normal 100 1em/1 'Font Awesome 6 Sharp';
|
||||
--fa-font-brands: normal 400 1em/1 'Font Awesome 6 Brands'; }
|
||||
|
||||
svg:not(:root).svg-inline--fa, svg:not(:host).svg-inline--fa {
|
||||
overflow: visible;
|
||||
box-sizing: content-box; }
|
||||
|
||||
.svg-inline--fa {
|
||||
display: var(--fa-display, inline-block);
|
||||
height: 1em;
|
||||
overflow: visible;
|
||||
vertical-align: -.125em; }
|
||||
.svg-inline--fa.fa-2xs {
|
||||
vertical-align: 0.1em; }
|
||||
.svg-inline--fa.fa-xs {
|
||||
vertical-align: 0em; }
|
||||
.svg-inline--fa.fa-sm {
|
||||
vertical-align: -0.07143em; }
|
||||
.svg-inline--fa.fa-lg {
|
||||
vertical-align: -0.2em; }
|
||||
.svg-inline--fa.fa-xl {
|
||||
vertical-align: -0.25em; }
|
||||
.svg-inline--fa.fa-2xl {
|
||||
vertical-align: -0.3125em; }
|
||||
.svg-inline--fa.fa-pull-left {
|
||||
margin-right: var(--fa-pull-margin, 0.3em);
|
||||
width: auto; }
|
||||
.svg-inline--fa.fa-pull-right {
|
||||
margin-left: var(--fa-pull-margin, 0.3em);
|
||||
width: auto; }
|
||||
.svg-inline--fa.fa-li {
|
||||
width: var(--fa-li-width, 2em);
|
||||
top: 0.25em; }
|
||||
.svg-inline--fa.fa-fw {
|
||||
width: var(--fa-fw-width, 1.25em); }
|
||||
|
||||
.fa-layers svg.svg-inline--fa {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0; }
|
||||
|
||||
.fa-layers-text, .fa-layers-counter {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
text-align: center; }
|
||||
|
||||
.fa-layers {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
vertical-align: -.125em;
|
||||
width: 1em; }
|
||||
.fa-layers svg.svg-inline--fa {
|
||||
-webkit-transform-origin: center center;
|
||||
transform-origin: center center; }
|
||||
|
||||
.fa-layers-text {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
-webkit-transform-origin: center center;
|
||||
transform-origin: center center; }
|
||||
|
||||
.fa-layers-counter {
|
||||
background-color: var(--fa-counter-background-color, #ff253a);
|
||||
border-radius: var(--fa-counter-border-radius, 1em);
|
||||
box-sizing: border-box;
|
||||
color: var(--fa-inverse, #fff);
|
||||
line-height: var(--fa-counter-line-height, 1);
|
||||
max-width: var(--fa-counter-max-width, 5em);
|
||||
min-width: var(--fa-counter-min-width, 1.5em);
|
||||
overflow: hidden;
|
||||
padding: var(--fa-counter-padding, 0.25em 0.5em);
|
||||
right: var(--fa-right, 0);
|
||||
text-overflow: ellipsis;
|
||||
top: var(--fa-top, 0);
|
||||
-webkit-transform: scale(var(--fa-counter-scale, 0.25));
|
||||
transform: scale(var(--fa-counter-scale, 0.25));
|
||||
-webkit-transform-origin: top right;
|
||||
transform-origin: top right; }
|
||||
|
||||
.fa-layers-bottom-right {
|
||||
bottom: var(--fa-bottom, 0);
|
||||
right: var(--fa-right, 0);
|
||||
top: auto;
|
||||
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||
transform: scale(var(--fa-layers-scale, 0.25));
|
||||
-webkit-transform-origin: bottom right;
|
||||
transform-origin: bottom right; }
|
||||
|
||||
.fa-layers-bottom-left {
|
||||
bottom: var(--fa-bottom, 0);
|
||||
left: var(--fa-left, 0);
|
||||
right: auto;
|
||||
top: auto;
|
||||
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||
transform: scale(var(--fa-layers-scale, 0.25));
|
||||
-webkit-transform-origin: bottom left;
|
||||
transform-origin: bottom left; }
|
||||
|
||||
.fa-layers-top-right {
|
||||
top: var(--fa-top, 0);
|
||||
right: var(--fa-right, 0);
|
||||
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||
transform: scale(var(--fa-layers-scale, 0.25));
|
||||
-webkit-transform-origin: top right;
|
||||
transform-origin: top right; }
|
||||
|
||||
.fa-layers-top-left {
|
||||
left: var(--fa-left, 0);
|
||||
right: auto;
|
||||
top: var(--fa-top, 0);
|
||||
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
|
||||
transform: scale(var(--fa-layers-scale, 0.25));
|
||||
-webkit-transform-origin: top left;
|
||||
transform-origin: top left; }
|
||||
|
||||
.fa-1x {
|
||||
font-size: 1em; }
|
||||
|
||||
.fa-2x {
|
||||
font-size: 2em; }
|
||||
|
||||
.fa-3x {
|
||||
font-size: 3em; }
|
||||
|
||||
.fa-4x {
|
||||
font-size: 4em; }
|
||||
|
||||
.fa-5x {
|
||||
font-size: 5em; }
|
||||
|
||||
.fa-6x {
|
||||
font-size: 6em; }
|
||||
|
||||
.fa-7x {
|
||||
font-size: 7em; }
|
||||
|
||||
.fa-8x {
|
||||
font-size: 8em; }
|
||||
|
||||
.fa-9x {
|
||||
font-size: 9em; }
|
||||
|
||||
.fa-10x {
|
||||
font-size: 10em; }
|
||||
|
||||
.fa-2xs {
|
||||
font-size: 0.625em;
|
||||
line-height: 0.1em;
|
||||
vertical-align: 0.225em; }
|
||||
|
||||
.fa-xs {
|
||||
font-size: 0.75em;
|
||||
line-height: 0.08333em;
|
||||
vertical-align: 0.125em; }
|
||||
|
||||
.fa-sm {
|
||||
font-size: 0.875em;
|
||||
line-height: 0.07143em;
|
||||
vertical-align: 0.05357em; }
|
||||
|
||||
.fa-lg {
|
||||
font-size: 1.25em;
|
||||
line-height: 0.05em;
|
||||
vertical-align: -0.075em; }
|
||||
|
||||
.fa-xl {
|
||||
font-size: 1.5em;
|
||||
line-height: 0.04167em;
|
||||
vertical-align: -0.125em; }
|
||||
|
||||
.fa-2xl {
|
||||
font-size: 2em;
|
||||
line-height: 0.03125em;
|
||||
vertical-align: -0.1875em; }
|
||||
|
||||
.fa-fw {
|
||||
text-align: center;
|
||||
width: 1.25em; }
|
||||
|
||||
.fa-ul {
|
||||
list-style-type: none;
|
||||
margin-left: var(--fa-li-margin, 2.5em);
|
||||
padding-left: 0; }
|
||||
.fa-ul > li {
|
||||
position: relative; }
|
||||
|
||||
.fa-li {
|
||||
left: calc(var(--fa-li-width, 2em) * -1);
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
width: var(--fa-li-width, 2em);
|
||||
line-height: inherit; }
|
||||
|
||||
.fa-border {
|
||||
border-color: var(--fa-border-color, #eee);
|
||||
border-radius: var(--fa-border-radius, 0.1em);
|
||||
border-style: var(--fa-border-style, solid);
|
||||
border-width: var(--fa-border-width, 0.08em);
|
||||
padding: var(--fa-border-padding, 0.2em 0.25em 0.15em); }
|
||||
|
||||
.fa-pull-left {
|
||||
float: left;
|
||||
margin-right: var(--fa-pull-margin, 0.3em); }
|
||||
|
||||
.fa-pull-right {
|
||||
float: right;
|
||||
margin-left: var(--fa-pull-margin, 0.3em); }
|
||||
|
||||
.fa-beat {
|
||||
-webkit-animation-name: fa-beat;
|
||||
animation-name: fa-beat;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, ease-in-out);
|
||||
animation-timing-function: var(--fa-animation-timing, ease-in-out); }
|
||||
|
||||
.fa-bounce {
|
||||
-webkit-animation-name: fa-bounce;
|
||||
animation-name: fa-bounce;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1));
|
||||
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); }
|
||||
|
||||
.fa-fade {
|
||||
-webkit-animation-name: fa-fade;
|
||||
animation-name: fa-fade;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
|
||||
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); }
|
||||
|
||||
.fa-beat-fade {
|
||||
-webkit-animation-name: fa-beat-fade;
|
||||
animation-name: fa-beat-fade;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
|
||||
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); }
|
||||
|
||||
.fa-flip {
|
||||
-webkit-animation-name: fa-flip;
|
||||
animation-name: fa-flip;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, ease-in-out);
|
||||
animation-timing-function: var(--fa-animation-timing, ease-in-out); }
|
||||
|
||||
.fa-shake {
|
||||
-webkit-animation-name: fa-shake;
|
||||
animation-name: fa-shake;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, linear);
|
||||
animation-timing-function: var(--fa-animation-timing, linear); }
|
||||
|
||||
.fa-spin {
|
||||
-webkit-animation-name: fa-spin;
|
||||
animation-name: fa-spin;
|
||||
-webkit-animation-delay: var(--fa-animation-delay, 0s);
|
||||
animation-delay: var(--fa-animation-delay, 0s);
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 2s);
|
||||
animation-duration: var(--fa-animation-duration, 2s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, linear);
|
||||
animation-timing-function: var(--fa-animation-timing, linear); }
|
||||
|
||||
.fa-spin-reverse {
|
||||
--fa-animation-direction: reverse; }
|
||||
|
||||
.fa-pulse,
|
||||
.fa-spin-pulse {
|
||||
-webkit-animation-name: fa-spin;
|
||||
animation-name: fa-spin;
|
||||
-webkit-animation-direction: var(--fa-animation-direction, normal);
|
||||
animation-direction: var(--fa-animation-direction, normal);
|
||||
-webkit-animation-duration: var(--fa-animation-duration, 1s);
|
||||
animation-duration: var(--fa-animation-duration, 1s);
|
||||
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
|
||||
-webkit-animation-timing-function: var(--fa-animation-timing, steps(8));
|
||||
animation-timing-function: var(--fa-animation-timing, steps(8)); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fa-beat,
|
||||
.fa-bounce,
|
||||
.fa-fade,
|
||||
.fa-beat-fade,
|
||||
.fa-flip,
|
||||
.fa-pulse,
|
||||
.fa-shake,
|
||||
.fa-spin,
|
||||
.fa-spin-pulse {
|
||||
-webkit-animation-delay: -1ms;
|
||||
animation-delay: -1ms;
|
||||
-webkit-animation-duration: 1ms;
|
||||
animation-duration: 1ms;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
animation-iteration-count: 1;
|
||||
-webkit-transition-delay: 0s;
|
||||
transition-delay: 0s;
|
||||
-webkit-transition-duration: 0s;
|
||||
transition-duration: 0s; } }
|
||||
|
||||
@-webkit-keyframes fa-beat {
|
||||
0%, 90% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1); }
|
||||
45% {
|
||||
-webkit-transform: scale(var(--fa-beat-scale, 1.25));
|
||||
transform: scale(var(--fa-beat-scale, 1.25)); } }
|
||||
|
||||
@keyframes fa-beat {
|
||||
0%, 90% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1); }
|
||||
45% {
|
||||
-webkit-transform: scale(var(--fa-beat-scale, 1.25));
|
||||
transform: scale(var(--fa-beat-scale, 1.25)); } }
|
||||
|
||||
@-webkit-keyframes fa-bounce {
|
||||
0% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); }
|
||||
10% {
|
||||
-webkit-transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
|
||||
transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); }
|
||||
30% {
|
||||
-webkit-transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
|
||||
transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); }
|
||||
50% {
|
||||
-webkit-transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
|
||||
transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); }
|
||||
57% {
|
||||
-webkit-transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
|
||||
transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); }
|
||||
64% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); }
|
||||
100% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); } }
|
||||
|
||||
@keyframes fa-bounce {
|
||||
0% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); }
|
||||
10% {
|
||||
-webkit-transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
|
||||
transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); }
|
||||
30% {
|
||||
-webkit-transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
|
||||
transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); }
|
||||
50% {
|
||||
-webkit-transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
|
||||
transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); }
|
||||
57% {
|
||||
-webkit-transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
|
||||
transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); }
|
||||
64% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); }
|
||||
100% {
|
||||
-webkit-transform: scale(1, 1) translateY(0);
|
||||
transform: scale(1, 1) translateY(0); } }
|
||||
|
||||
@-webkit-keyframes fa-fade {
|
||||
50% {
|
||||
opacity: var(--fa-fade-opacity, 0.4); } }
|
||||
|
||||
@keyframes fa-fade {
|
||||
50% {
|
||||
opacity: var(--fa-fade-opacity, 0.4); } }
|
||||
|
||||
@-webkit-keyframes fa-beat-fade {
|
||||
0%, 100% {
|
||||
opacity: var(--fa-beat-fade-opacity, 0.4);
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1); }
|
||||
50% {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(var(--fa-beat-fade-scale, 1.125));
|
||||
transform: scale(var(--fa-beat-fade-scale, 1.125)); } }
|
||||
|
||||
@keyframes fa-beat-fade {
|
||||
0%, 100% {
|
||||
opacity: var(--fa-beat-fade-opacity, 0.4);
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1); }
|
||||
50% {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(var(--fa-beat-fade-scale, 1.125));
|
||||
transform: scale(var(--fa-beat-fade-scale, 1.125)); } }
|
||||
|
||||
@-webkit-keyframes fa-flip {
|
||||
50% {
|
||||
-webkit-transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
|
||||
transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); } }
|
||||
|
||||
@keyframes fa-flip {
|
||||
50% {
|
||||
-webkit-transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
|
||||
transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); } }
|
||||
|
||||
@-webkit-keyframes fa-shake {
|
||||
0% {
|
||||
-webkit-transform: rotate(-15deg);
|
||||
transform: rotate(-15deg); }
|
||||
4% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
transform: rotate(15deg); }
|
||||
8%, 24% {
|
||||
-webkit-transform: rotate(-18deg);
|
||||
transform: rotate(-18deg); }
|
||||
12%, 28% {
|
||||
-webkit-transform: rotate(18deg);
|
||||
transform: rotate(18deg); }
|
||||
16% {
|
||||
-webkit-transform: rotate(-22deg);
|
||||
transform: rotate(-22deg); }
|
||||
20% {
|
||||
-webkit-transform: rotate(22deg);
|
||||
transform: rotate(22deg); }
|
||||
32% {
|
||||
-webkit-transform: rotate(-12deg);
|
||||
transform: rotate(-12deg); }
|
||||
36% {
|
||||
-webkit-transform: rotate(12deg);
|
||||
transform: rotate(12deg); }
|
||||
40%, 100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); } }
|
||||
|
||||
@keyframes fa-shake {
|
||||
0% {
|
||||
-webkit-transform: rotate(-15deg);
|
||||
transform: rotate(-15deg); }
|
||||
4% {
|
||||
-webkit-transform: rotate(15deg);
|
||||
transform: rotate(15deg); }
|
||||
8%, 24% {
|
||||
-webkit-transform: rotate(-18deg);
|
||||
transform: rotate(-18deg); }
|
||||
12%, 28% {
|
||||
-webkit-transform: rotate(18deg);
|
||||
transform: rotate(18deg); }
|
||||
16% {
|
||||
-webkit-transform: rotate(-22deg);
|
||||
transform: rotate(-22deg); }
|
||||
20% {
|
||||
-webkit-transform: rotate(22deg);
|
||||
transform: rotate(22deg); }
|
||||
32% {
|
||||
-webkit-transform: rotate(-12deg);
|
||||
transform: rotate(-12deg); }
|
||||
36% {
|
||||
-webkit-transform: rotate(12deg);
|
||||
transform: rotate(12deg); }
|
||||
40%, 100% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); } }
|
||||
|
||||
@-webkit-keyframes fa-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); }
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg); } }
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg); }
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg); } }
|
||||
|
||||
.fa-rotate-90 {
|
||||
-webkit-transform: rotate(90deg);
|
||||
transform: rotate(90deg); }
|
||||
|
||||
.fa-rotate-180 {
|
||||
-webkit-transform: rotate(180deg);
|
||||
transform: rotate(180deg); }
|
||||
|
||||
.fa-rotate-270 {
|
||||
-webkit-transform: rotate(270deg);
|
||||
transform: rotate(270deg); }
|
||||
|
||||
.fa-flip-horizontal {
|
||||
-webkit-transform: scale(-1, 1);
|
||||
transform: scale(-1, 1); }
|
||||
|
||||
.fa-flip-vertical {
|
||||
-webkit-transform: scale(1, -1);
|
||||
transform: scale(1, -1); }
|
||||
|
||||
.fa-flip-both,
|
||||
.fa-flip-horizontal.fa-flip-vertical {
|
||||
-webkit-transform: scale(-1, -1);
|
||||
transform: scale(-1, -1); }
|
||||
|
||||
.fa-rotate-by {
|
||||
-webkit-transform: rotate(var(--fa-rotate-angle, none));
|
||||
transform: rotate(var(--fa-rotate-angle, none)); }
|
||||
|
||||
.fa-stack {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
width: 2.5em; }
|
||||
|
||||
.fa-stack-1x,
|
||||
.fa-stack-2x {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: var(--fa-stack-z-index, auto); }
|
||||
|
||||
.svg-inline--fa.fa-stack-1x {
|
||||
height: 1em;
|
||||
width: 1.25em; }
|
||||
|
||||
.svg-inline--fa.fa-stack-2x {
|
||||
height: 2em;
|
||||
width: 2.5em; }
|
||||
|
||||
.fa-inverse {
|
||||
color: var(--fa-inverse, #fff); }
|
||||
|
||||
.sr-only,
|
||||
.fa-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0; }
|
||||
|
||||
.sr-only-focusable:not(:focus),
|
||||
.fa-sr-only-focusable:not(:focus) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0; }
|
||||
|
||||
.svg-inline--fa .fa-primary {
|
||||
fill: var(--fa-primary-color, currentColor);
|
||||
opacity: var(--fa-primary-opacity, 1); }
|
||||
|
||||
.svg-inline--fa .fa-secondary {
|
||||
fill: var(--fa-secondary-color, currentColor);
|
||||
opacity: var(--fa-secondary-opacity, 0.4); }
|
||||
|
||||
.svg-inline--fa.fa-swap-opacity .fa-primary {
|
||||
opacity: var(--fa-secondary-opacity, 0.4); }
|
||||
|
||||
.svg-inline--fa.fa-swap-opacity .fa-secondary {
|
||||
opacity: var(--fa-primary-opacity, 1); }
|
||||
|
||||
.svg-inline--fa mask .fa-primary,
|
||||
.svg-inline--fa mask .fa-secondary {
|
||||
fill: black; }
|
||||
|
||||
.fad.fa-inverse,
|
||||
.fa-duotone.fa-inverse {
|
||||
color: var(--fa-inverse, #fff); }
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'FontAwesome';
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
|
||||
|
||||
@font-face {
|
||||
font-family: 'FontAwesome';
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); }
|
||||
|
||||
@font-face {
|
||||
font-family: 'FontAwesome';
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype");
|
||||
unicode-range: U+F003,U+F006,U+F014,U+F016-F017,U+F01A-F01B,U+F01D,U+F022,U+F03E,U+F044,U+F046,U+F05C-F05D,U+F06E,U+F070,U+F087-F088,U+F08A,U+F094,U+F096-F097,U+F09D,U+F0A0,U+F0A2,U+F0A4-F0A7,U+F0C5,U+F0C7,U+F0E5-F0E6,U+F0EB,U+F0F6-F0F8,U+F10C,U+F114-F115,U+F118-F11A,U+F11C-F11D,U+F133,U+F147,U+F14E,U+F150-F152,U+F185-F186,U+F18E,U+F190-F192,U+F196,U+F1C1-F1C9,U+F1D9,U+F1DB,U+F1E3,U+F1EA,U+F1F7,U+F1F9,U+F20A,U+F247-F248,U+F24A,U+F24D,U+F255-F25B,U+F25D,U+F271-F274,U+F278,U+F27B,U+F28C,U+F28E,U+F29C,U+F2B5,U+F2B7,U+F2BA,U+F2BC,U+F2BE,U+F2C0-F2C1,U+F2C3,U+F2D0,U+F2D2,U+F2D4,U+F2DC; }
|
||||
|
||||
@font-face {
|
||||
font-family: 'FontAwesome';
|
||||
font-display: block;
|
||||
src: url("../webfonts/fa-v4compatibility.woff2") format("woff2"), url("../webfonts/fa-v4compatibility.ttf") format("truetype");
|
||||
unicode-range: U+F041,U+F047,U+F065-F066,U+F07D-F07E,U+F080,U+F08B,U+F08E,U+F090,U+F09A,U+F0AC,U+F0AE,U+F0B2,U+F0D0,U+F0D6,U+F0E4,U+F0EC,U+F10A-F10B,U+F123,U+F13E,U+F148-F149,U+F14C,U+F156,U+F15E,U+F160-F161,U+F163,U+F175-F178,U+F195,U+F1F8,U+F219,U+F27A; }
|
||||
@@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a}
|
||||
+2194
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 5 Brands';
|
||||
font-display: block;
|
||||
font-weight: 400;
|
||||
src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-display: block;
|
||||
font-weight: 900;
|
||||
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
|
||||
|
||||
@font-face {
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
font-display: block;
|
||||
font-weight: 400;
|
||||
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); }
|
||||
@@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BINARY="$(dirname "$0")/../agent/target/release/nanometrics-agent"
|
||||
SERVICE="$(dirname "$0")/nanometrics-agent.service"
|
||||
CONFIG="$(dirname "$0")/../agent/config.toml"
|
||||
|
||||
# Vérifications
|
||||
if [ ! -f "$BINARY" ]; then
|
||||
echo "ERREUR : binaire introuvable : $BINARY"
|
||||
echo "Compilez d'abord : cargo build --release --manifest-path agent/Cargo.toml"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
echo "ERREUR : config.toml introuvable : $CONFIG"
|
||||
echo "Copiez agent/config.toml.example vers agent/config.toml et ajustez l'IP du serveur."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[1/5] Copie du binaire vers /usr/local/bin/"
|
||||
cp "$BINARY" /usr/local/bin/nanometrics-agent
|
||||
chmod 755 /usr/local/bin/nanometrics-agent
|
||||
|
||||
echo "[2/5] Création du répertoire de configuration /etc/nanometrics/"
|
||||
mkdir -p /etc/nanometrics
|
||||
chmod 755 /etc/nanometrics
|
||||
|
||||
echo "[3/5] Copie de config.toml vers /etc/nanometrics/"
|
||||
cp "$CONFIG" /etc/nanometrics/config.toml
|
||||
chmod 644 /etc/nanometrics/config.toml
|
||||
|
||||
echo "[4/5] Installation du service systemd"
|
||||
cp "$SERVICE" /etc/systemd/system/nanometrics-agent.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable nanometrics-agent
|
||||
|
||||
echo "[5/5] Démarrage du service"
|
||||
systemctl restart nanometrics-agent
|
||||
|
||||
sleep 2
|
||||
echo ""
|
||||
echo "=== Statut ==="
|
||||
systemctl status nanometrics-agent --no-pager
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installe l'agent Nanometrics depuis la dernière release Gitea.
|
||||
# Usage :
|
||||
# curl -fsSL https://git.maison43gil.com/gilles/nano_metrics/raw/branch/main/deploy/install.sh | bash
|
||||
# SERVER_IP=10.0.0.50 SERVER_PORT=9999 curl -fsSL ... | bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_API="https://git.maison43gil.com/api/v1/repos/gilles/nano_metrics"
|
||||
SERVICE_URL="https://git.maison43gil.com/gilles/nano_metrics/raw/branch/main/deploy/nanometrics-agent.service"
|
||||
INSTALL_BIN="/usr/local/bin/nanometrics-agent"
|
||||
CONFIG_DIR="/etc/nanometrics"
|
||||
CONFIG_FILE="$CONFIG_DIR/config.toml"
|
||||
SERVICE_FILE="/etc/systemd/system/nanometrics-agent.service"
|
||||
|
||||
# ── Couleurs ───────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
ok() { echo -e "${GREEN}✓${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}!${NC} $*"; }
|
||||
err() { echo -e "${RED}✗${NC} $*" >&2; }
|
||||
|
||||
# ── Root check ─────────────────────────────────────────────────────────────────
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
err "Ce script doit être lancé en root (sudo bash ou sudo curl | bash)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "======================================"
|
||||
echo " Nanometrics Agent — Installation"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# ── 1. Détection de l'architecture ────────────────────────────────────────────
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) LABEL="linux-amd64" ;;
|
||||
aarch64) LABEL="linux-arm64" ;;
|
||||
*)
|
||||
err "Architecture non supportée : $ARCH"
|
||||
err "Seules x86_64 et aarch64 sont supportées."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
ok "Architecture détectée : $ARCH → $LABEL"
|
||||
|
||||
# ── 2. Récupérer l'URL du binaire depuis la dernière release ──────────────────
|
||||
echo "→ Récupération de la dernière release..."
|
||||
|
||||
ASSETS_JSON=$(curl -sf "$REPO_API/releases?limit=1&page=1")
|
||||
ASSET_URL=$(echo "$ASSETS_JSON" | python3 -c "
|
||||
import sys, json
|
||||
releases = json.load(sys.stdin)
|
||||
if not releases:
|
||||
raise SystemExit('Aucune release trouvée sur le dépôt.')
|
||||
assets = releases[0].get('assets', [])
|
||||
name = 'nanometrics-agent-$LABEL'
|
||||
for a in assets:
|
||||
if a['name'] == name:
|
||||
print(a['browser_download_url'])
|
||||
break
|
||||
else:
|
||||
raise SystemExit(f'Asset {name!r} introuvable dans la release.')
|
||||
")
|
||||
|
||||
TAG=$(echo "$ASSETS_JSON" | python3 -c "
|
||||
import sys, json
|
||||
releases = json.load(sys.stdin)
|
||||
print(releases[0]['tag_name'])
|
||||
")
|
||||
|
||||
ok "Release : $TAG — URL : $ASSET_URL"
|
||||
|
||||
# ── 3. Télécharger le binaire ─────────────────────────────────────────────────
|
||||
TMP_BIN="$(mktemp)"
|
||||
trap 'rm -f "$TMP_BIN"' EXIT
|
||||
|
||||
echo "→ Téléchargement du binaire..."
|
||||
curl -fsSL -o "$TMP_BIN" "$ASSET_URL"
|
||||
chmod 755 "$TMP_BIN"
|
||||
ok "Binaire téléchargé ($(du -sh "$TMP_BIN" | cut -f1))"
|
||||
|
||||
# ── 4. Paramètres de configuration ────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "--- Configuration du serveur ---"
|
||||
|
||||
if [ -z "${SERVER_IP:-}" ]; then
|
||||
read -rp "Adresse IP du serveur Nanometrics : " SERVER_IP
|
||||
fi
|
||||
if [ -z "${SERVER_IP:-}" ]; then
|
||||
err "SERVER_IP est requis."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_PORT="${SERVER_PORT:-9999}"
|
||||
MQTT_ENABLED="${MQTT_ENABLED:-false}"
|
||||
|
||||
ok "Serveur : $SERVER_IP:$SERVER_PORT"
|
||||
|
||||
# ── 5. Installer le binaire ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[1/5] Installation du binaire dans /usr/local/bin/"
|
||||
cp "$TMP_BIN" "$INSTALL_BIN"
|
||||
chmod 755 "$INSTALL_BIN"
|
||||
ok "Binaire installé"
|
||||
|
||||
# ── 6. Créer le répertoire de configuration ───────────────────────────────────
|
||||
echo "[2/5] Création de $CONFIG_DIR"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chmod 755 "$CONFIG_DIR"
|
||||
ok "Répertoire créé"
|
||||
|
||||
# ── 7. Écrire config.toml ─────────────────────────────────────────────────────
|
||||
echo "[3/5] Écriture de $CONFIG_FILE"
|
||||
|
||||
# Ne pas écraser une config existante (upgrade)
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
warn "config.toml déjà présent — conservé tel quel"
|
||||
else
|
||||
cat > "$CONFIG_FILE" << TOML
|
||||
[server]
|
||||
ip = "$SERVER_IP"
|
||||
port = $SERVER_PORT
|
||||
|
||||
[protocols.udp]
|
||||
enabled = true
|
||||
|
||||
[protocols.mqtt]
|
||||
enabled = $MQTT_ENABLED
|
||||
host = "10.0.0.3"
|
||||
port = 1883
|
||||
topic_base = "nanometrics/agents"
|
||||
auto_discovery = true
|
||||
birth_message = true
|
||||
last_will = true
|
||||
|
||||
[metrics.cpu]
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.memory]
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.disk]
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.network]
|
||||
udp = false
|
||||
mqtt = false
|
||||
|
||||
[metrics.uptime]
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.temperature]
|
||||
udp = true
|
||||
mqtt = false
|
||||
|
||||
[metrics.smart]
|
||||
udp = true
|
||||
mqtt = false
|
||||
TOML
|
||||
chmod 640 "$CONFIG_FILE"
|
||||
ok "config.toml créé"
|
||||
fi
|
||||
|
||||
# ── 8. Installer le fichier service ──────────────────────────────────────────
|
||||
echo "[4/5] Installation du service systemd"
|
||||
curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL"
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
systemctl enable nanometrics-agent
|
||||
ok "Service installé et activé"
|
||||
|
||||
# ── 9. Démarrer le service ────────────────────────────────────────────────────
|
||||
echo "[5/5] Démarrage du service"
|
||||
systemctl restart nanometrics-agent
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
echo "=== Statut ==="
|
||||
systemctl status nanometrics-agent --no-pager || true
|
||||
|
||||
echo ""
|
||||
ok "Installation terminée — agent $TAG opérationnel"
|
||||
echo " Config : $CONFIG_FILE"
|
||||
echo " Logs : journalctl -u nanometrics-agent -f"
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compile le binaire release et publie une release Gitea avec le binaire en asset.
|
||||
# Usage : ./deploy/release.sh v0.1.0
|
||||
# ./deploy/release.sh v0.1.1 "Description du changement"
|
||||
set -euo pipefail
|
||||
|
||||
REPO_API="https://git.maison43gil.com/api/v1/repos/gilles/nano_metrics"
|
||||
TOKEN_FILE="$(dirname "$0")/../repo.md"
|
||||
|
||||
# Lire le token depuis repo.md
|
||||
TOKEN=$(grep -oP '(?<=\*\*Token\*\* : ).*' "$TOKEN_FILE" | tr -d '[:space:]')
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "ERREUR : token Gitea introuvable dans repo.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="${1:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "Usage : $0 <tag> [description]"
|
||||
echo "Exemple : $0 v0.1.0"
|
||||
exit 1
|
||||
fi
|
||||
DESCRIPTION="${2:-Release $TAG}"
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CARGO_TOML="$ROOT/agent/Cargo.toml"
|
||||
|
||||
# ── 1. Compiler pour toutes les cibles supportées ──────────────────────────
|
||||
echo "=== Compilation de l'agent ==="
|
||||
TARGETS=("x86_64-unknown-linux-musl" "aarch64-unknown-linux-musl")
|
||||
LABELS=("linux-amd64" "linux-arm64")
|
||||
|
||||
mkdir -p "$ROOT/dist"
|
||||
|
||||
for i in "${!TARGETS[@]}"; do
|
||||
TARGET="${TARGETS[$i]}"
|
||||
LABEL="${LABELS[$i]}"
|
||||
echo "→ $TARGET ($LABEL)..."
|
||||
|
||||
# Installer la cible si absente
|
||||
rustup target add "$TARGET" 2>/dev/null || true
|
||||
|
||||
# Compiler
|
||||
cargo build --release \
|
||||
--manifest-path "$CARGO_TOML" \
|
||||
--target "$TARGET" \
|
||||
2>&1 | tail -3
|
||||
|
||||
SRC="$ROOT/agent/target/$TARGET/release/nanometrics-agent"
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo " AVERTISSEMENT : binaire introuvable pour $TARGET, ignoré"
|
||||
continue
|
||||
fi
|
||||
|
||||
DEST="$ROOT/dist/nanometrics-agent-$LABEL"
|
||||
cp "$SRC" "$DEST"
|
||||
strip "$DEST" 2>/dev/null || true
|
||||
echo " OK : $(du -sh "$DEST" | cut -f1)"
|
||||
done
|
||||
|
||||
# ── 2. Créer la release Gitea ──────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Création de la release Gitea $TAG ==="
|
||||
|
||||
RELEASE_JSON=$(curl -sf \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$REPO_API/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"$TAG\",
|
||||
\"body\": \"$DESCRIPTION\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}")
|
||||
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
echo "Release ID : $RELEASE_ID"
|
||||
|
||||
# ── 3. Uploader les binaires comme assets ─────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Upload des binaires ==="
|
||||
|
||||
for LABEL in "${LABELS[@]}"; do
|
||||
ASSET="$ROOT/dist/nanometrics-agent-$LABEL"
|
||||
[ -f "$ASSET" ] || continue
|
||||
|
||||
ASSET_NAME="nanometrics-agent-$LABEL"
|
||||
echo "→ Upload $ASSET_NAME..."
|
||||
|
||||
curl -sf \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$ASSET;filename=$ASSET_NAME" \
|
||||
"$REPO_API/releases/$RELEASE_ID/assets" \
|
||||
> /dev/null
|
||||
|
||||
echo " OK"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✓ Release $TAG publiée : https://git.maison43gil.com/gilles/nano_metrics/releases/tag/$TAG"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Dockerfile de dev : utilise le binaire pré-compilé localement (pas de pull Docker Hub)
|
||||
FROM nginx:alpine
|
||||
COPY nanometrics-server /app/nanometrics-server
|
||||
WORKDIR /app
|
||||
VOLUME /data
|
||||
EXPOSE 8080 9999/udp
|
||||
CMD ["./nanometrics-server"]
|
||||
@@ -8,6 +8,7 @@ type Config struct {
|
||||
HTTPAddr string
|
||||
MQTTBroker string
|
||||
MQTTTopicBase string
|
||||
DashboardDir string // optionnel : sert le dashboard en dev sans Nginx
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -17,6 +18,7 @@ func Load() Config {
|
||||
HTTPAddr: getEnv("HTTP_ADDR", "0.0.0.0:8080"),
|
||||
MQTTBroker: getEnv("MQTT_BROKER", "tcp://10.0.0.3:1883"),
|
||||
MQTTTopicBase: getEnv("MQTT_TOPIC_BASE", "nanometrics/agents"),
|
||||
DashboardDir: getEnv("DASHBOARD_DIR", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-4
@@ -117,6 +117,9 @@ func (d *DB) GetAgents() ([]models.Agent, error) {
|
||||
}
|
||||
agents = append(agents, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
@@ -143,6 +146,9 @@ func (d *DB) GetMetricsHistory(agentID string, from, to int64) ([]map[string]int
|
||||
"hdd_used": hddUsed, "hdd_total": hddTotal,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -228,13 +234,35 @@ func (d *DB) PruneOldMetrics(retentionDays int) error {
|
||||
}
|
||||
|
||||
func (d *DB) MarkOffline(timeoutSec int64) error {
|
||||
cutoff := time.Now().Unix() - timeoutSec
|
||||
_, err := d.conn.Exec(
|
||||
`UPDATE agents SET status='offline' WHERE last_seen < ? AND status != 'offline'`,
|
||||
cutoff)
|
||||
_, err := d.MarkOfflineAndGetIDs(timeoutSec)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkOfflineAndGetIDs marque les agents inactifs et retourne leurs IDs.
|
||||
func (d *DB) MarkOfflineAndGetIDs(timeoutSec int64) ([]string, error) {
|
||||
cutoff := time.Now().Unix() - timeoutSec
|
||||
rows, err := d.conn.Query(
|
||||
`SELECT id FROM agents WHERE last_seen < ? AND status != 'offline'`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
_ = rows.Scan(&id)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
if len(ids) > 0 {
|
||||
_, err = d.conn.Exec(
|
||||
`UPDATE agents SET status='offline' WHERE last_seen < ? AND status != 'offline'`, cutoff)
|
||||
}
|
||||
return ids, err
|
||||
}
|
||||
|
||||
func init() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
server:
|
||||
image: nanometrics-server:dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
UDP_ADDR: "0.0.0.0:9999"
|
||||
DB_PATH: "/data/nanometrics.db"
|
||||
HTTP_ADDR: "0.0.0.0:8080"
|
||||
MQTT_BROKER: "tcp://10.0.0.3:1883"
|
||||
MQTT_TOPIC_BASE: "nanometrics/agents"
|
||||
volumes:
|
||||
- nanometrics_data:/data
|
||||
ports:
|
||||
- "9999:9999/udp"
|
||||
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ../dashboard:/usr/share/nginx/html:ro
|
||||
ports:
|
||||
- "8888:80"
|
||||
depends_on:
|
||||
- server
|
||||
|
||||
volumes:
|
||||
nanometrics_data:
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -40,18 +41,15 @@ func IconUploadHandler(database *db.DB) http.HandlerFunc {
|
||||
mime = "image/png"
|
||||
}
|
||||
|
||||
// SVG refusé (risque XSS)
|
||||
if strings.Contains(mime, "svg") {
|
||||
var buf bytes.Buffer
|
||||
buf.ReadFrom(file)
|
||||
if err := database.SaveIcon(agentID, buf.Bytes(), "image/svg+xml"); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
http.Error(w, "SVG non supporté — utilisez PNG, JPG ou WEBP", 400)
|
||||
return
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(file)
|
||||
// Limite de taille
|
||||
limited := io.LimitReader(file, 2<<20)
|
||||
img, _, err := image.Decode(limited)
|
||||
if err != nil {
|
||||
http.Error(w, "image invalide", 400)
|
||||
return
|
||||
|
||||
+125
-2
@@ -1,8 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
@@ -59,11 +64,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Maintenance : nettoyage + détection offline avec notification WS
|
||||
go func() {
|
||||
for range time.Tick(time.Minute) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
srvCfg, _ := database.GetServerConfig()
|
||||
_ = database.PruneOldMetrics(srvCfg.RetentionDays)
|
||||
_ = database.MarkOffline(30)
|
||||
ids, _ := database.MarkOfflineAndGetIDs(30)
|
||||
for _, id := range ids {
|
||||
hub.Broadcast(models.WSMessage{
|
||||
Type: "status_update",
|
||||
AgentID: id,
|
||||
Data: map[string]string{"status": "offline"},
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Métriques du serveur lui-même → footer du dashboard
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
var prevIdle, prevTotal uint64
|
||||
for range ticker.C {
|
||||
stats, err := collectServerStats(&prevIdle, &prevTotal)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hub.Broadcast(models.WSMessage{Type: "server_stats", Data: stats})
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -87,6 +116,11 @@ func main() {
|
||||
})
|
||||
mux.HandleFunc("/api/config", handlers.ServerConfigHandler(database))
|
||||
|
||||
if cfg.DashboardDir != "" {
|
||||
log.Printf("[http] dashboard servi depuis %s", cfg.DashboardDir)
|
||||
mux.Handle("/", http.FileServer(http.Dir(cfg.DashboardDir)))
|
||||
}
|
||||
|
||||
log.Printf("[http] écoute sur %s", cfg.HTTPAddr)
|
||||
log.Fatal(http.ListenAndServe(cfg.HTTPAddr, mux))
|
||||
}
|
||||
@@ -94,3 +128,92 @@ func main() {
|
||||
func endsWith(path, suffix string) bool {
|
||||
return len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix
|
||||
}
|
||||
|
||||
func collectServerStats(prevIdle, prevTotal *uint64) (*models.ServerStats, error) {
|
||||
idle, total, err := readCPUStat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cpuPct float64
|
||||
if *prevTotal > 0 && total > *prevTotal {
|
||||
deltaIdle := float64(idle - *prevIdle)
|
||||
deltaTotal := float64(total - *prevTotal)
|
||||
cpuPct = 100.0 * (1.0 - deltaIdle/deltaTotal)
|
||||
if cpuPct < 0 {
|
||||
cpuPct = 0
|
||||
}
|
||||
}
|
||||
*prevIdle = idle
|
||||
*prevTotal = total
|
||||
|
||||
memTotal, memAvail, err := readMemInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.ServerStats{
|
||||
CPUPercent: cpuPct,
|
||||
MemUsed: memTotal - memAvail,
|
||||
MemTotal: memTotal,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readCPUStat() (idle, total uint64, err error) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)[1:]
|
||||
var vals [10]uint64
|
||||
for i, v := range fields {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
vals[i], _ = strconv.ParseUint(v, 10, 64)
|
||||
}
|
||||
// idle = idle + iowait
|
||||
idle = vals[3] + vals[4]
|
||||
for _, v := range vals {
|
||||
total += v
|
||||
}
|
||||
return idle, total, nil
|
||||
}
|
||||
return 0, 0, fmt.Errorf("cpu line not found in /proc/stat")
|
||||
}
|
||||
|
||||
func readMemInfo() (totalBytes, availBytes int64, err error) {
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
scanner := bufio.NewScanner(f)
|
||||
var total, avail int64
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
val, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
total = val * 1024
|
||||
case "MemAvailable:":
|
||||
avail = val * 1024
|
||||
}
|
||||
if total > 0 && avail > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, 0, fmt.Errorf("MemTotal not found in /proc/meminfo")
|
||||
}
|
||||
return total, avail, nil
|
||||
}
|
||||
|
||||
@@ -103,3 +103,9 @@ type WSMessage struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ServerStats struct {
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemUsed int64 `json:"mem_used"`
|
||||
MemTotal int64 `json:"mem_total"`
|
||||
}
|
||||
|
||||
+30
-7
@@ -8,24 +8,35 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*websocket.Conn]struct{}
|
||||
clients map[*websocket.Conn]*client
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{clients: make(map[*websocket.Conn]struct{})}
|
||||
return &Hub{clients: make(map[*websocket.Conn]*client)}
|
||||
}
|
||||
|
||||
func (h *Hub) Register(conn *websocket.Conn) {
|
||||
c := &client{conn: conn, send: make(chan []byte, 64)}
|
||||
h.mu.Lock()
|
||||
h.clients[conn] = struct{}{}
|
||||
h.clients[conn] = c
|
||||
h.mu.Unlock()
|
||||
go c.writePump()
|
||||
}
|
||||
|
||||
func (h *Hub) Unregister(conn *websocket.Conn) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, conn)
|
||||
if c, ok := h.clients[conn]; ok {
|
||||
close(c.send)
|
||||
delete(h.clients, conn)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -37,9 +48,11 @@ func (h *Hub) Broadcast(msg interface{}) {
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for conn := range h.clients {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
log.Printf("[ws] write: %v", err)
|
||||
for _, c := range h.clients {
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
// canal plein, client lent — on skip
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,3 +62,13 @@ func (h *Hub) Count() int {
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
func (c *client) writePump() {
|
||||
defer c.conn.Close()
|
||||
for data := range c.send {
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
log.Printf("[ws] write: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user