Initial commit from Martins Github
This commit is contained in:
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ ./app/
|
||||
|
||||
EXPOSE 8095
|
||||
|
||||
# When running standalone, override these to point at your Prometheus/Alertmanager
|
||||
ENV MARVIS_PROMETHEUS_URL=http://127.0.0.1:9090
|
||||
ENV MARVIS_PROMETHEUS_PREFIX=/prometheus
|
||||
ENV MARVIS_ALERTMANAGER_URL=http://127.0.0.1:9093
|
||||
ENV MARVIS_AI_MODE=rule
|
||||
# MARVIS_AI_MODE=openai → set MARVIS_OPENAI_API_KEY
|
||||
# MARVIS_AI_MODE=ollama → set MARVIS_OLLAMA_URL + MARVIS_OLLAMA_MODEL
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8095"]
|
||||
21
Dockerfile.ueransim
Normal file
21
Dockerfile.ueransim
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM ubuntu:22.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git \
|
||||
libsctp-dev lksctp-tools \
|
||||
iproute2 iputils-ping \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN git config --global http.sslVerify false \
|
||||
&& git clone --depth 1 https://github.com/aligungr/UERANSIM /ueransim \
|
||||
&& cmake -S /ueransim -B /ueransim/build -DCMAKE_BUILD_TYPE=Release \
|
||||
&& cmake --build /ueransim/build --parallel $(nproc) \
|
||||
&& cp /ueransim/build/nr-gnb /usr/local/bin/ \
|
||||
&& cp /ueransim/build/nr-ue /usr/local/bin/ \
|
||||
&& rm -rf /ueransim
|
||||
|
||||
COPY config/run-test.sh /run-test.sh
|
||||
RUN chmod +x /run-test.sh
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/run-test.sh"]
|
||||
325
README.md
325
README.md
@@ -1,2 +1,325 @@
|
||||
# p5g-marvis
|
||||
# P5G Marvis — Full Setup Guide
|
||||
|
||||
This document describes the complete architecture and deployment procedure for the
|
||||
**P5G Marvis** sidebar extension that injects custom pages into the Athonet NCM UI.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Browser → Traefik (HTTPS :443)
|
||||
├── /core/marvis/* → strip prefix → http://127.0.0.1:8100 (p5g-marvis FastAPI)
|
||||
└── /core/radio/* → strip prefix → http://127.0.0.1:4000 (rm-ui, if present)
|
||||
|
||||
NCM UI (React SPA)
|
||||
└── index-Cw8Irsq8.js (patched by patch-ncm.py)
|
||||
├── Sidebar: P5G Marvis (Insights, Actions, Minis, AI) + P5G Radio
|
||||
├── Router: /marvis/* and /radio/* → <iframe> loading the FastAPI pages
|
||||
└── Perms: BW registry entries for /marvis and /radio (Tt = always allowed)
|
||||
|
||||
p5g-marvis FastAPI (port 8100)
|
||||
├── GET /overview → app/ui/overview.html
|
||||
├── GET /minis → app/ui/tasks.html
|
||||
├── GET /actions → app/ui/actions.html
|
||||
├── GET / → app/ui/index.html (catch-all SPA)
|
||||
├── GET /api/network/nf-status → Prometheus metrics
|
||||
├── GET /api/alerts → Alertmanager
|
||||
└── GET /api/actions → log_analyzer service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Files
|
||||
|
||||
All source files live at: `~/p5g-marvis/`
|
||||
|
||||
```
|
||||
~/p5g-marvis/
|
||||
├── patch-ncm.py # Injects sidebar/routes/perms into the NCM JS bundle
|
||||
├── requirements.txt # fastapi==0.115.0, uvicorn[standard]==0.30.6, httpx==0.27.2
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI app — route definitions and UI file serving
|
||||
│ ├── config.py # Reads env vars (prometheus URL, alertmanager URL, AI config)
|
||||
│ ├── ui/
|
||||
│ │ ├── overview.html # P5G Marvis Insights page
|
||||
│ │ ├── tasks.html # P5G Marvis Minis page (action tiles)
|
||||
│ │ ├── actions.html # P5G Marvis Actions page
|
||||
│ │ └── index.html # SPA catch-all
|
||||
│ ├── routers/
|
||||
│ │ ├── actions.py # /api/actions — log analysis
|
||||
│ │ ├── alerts.py # /api/alerts — Alertmanager
|
||||
│ │ ├── network.py # /api/network/nf-status — Prometheus
|
||||
│ │ └── query.py # /api/query — PromQL passthrough
|
||||
│ └── services/
|
||||
│ ├── prometheus.py
|
||||
│ ├── alertmanager.py
|
||||
│ ├── log_analyzer.py
|
||||
│ └── ai.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployed Hosts
|
||||
|
||||
| Host | IP | Status | P5G Radio |
|
||||
|---|---|---|---|
|
||||
| Primary | 172.27.0.159 | ✅ Deployed | ✅ (rm-ui on :4000) |
|
||||
| Secondary | 192.168.86.150 | ✅ Deployed | ⚠️ No rm-ui container |
|
||||
|
||||
---
|
||||
|
||||
## Deploy / Re-deploy to a Host
|
||||
|
||||
### Prerequisites
|
||||
- SSH key: `~/.ssh/5G-SSH-Key.pem`
|
||||
- Target host must have Python 3.x and the Athonet NCM stack running
|
||||
|
||||
### Step 1 — Copy application files
|
||||
|
||||
```bash
|
||||
TARGET=<IP> # e.g. 172.27.0.159 or 192.168.86.150
|
||||
|
||||
ssh -i ~/.ssh/5G-SSH-Key.pem root@$TARGET 'mkdir -p /opt/p5g-marvis/app/ui /opt/p5g-marvis/app/routers /opt/p5g-marvis/app/services /etc/athonet/traefik/ssl'
|
||||
|
||||
scp -i ~/.ssh/5G-SSH-Key.pem \
|
||||
~/p5g-marvis/requirements.txt ~/p5g-marvis/patch-ncm.py \
|
||||
root@$TARGET:/opt/p5g-marvis/
|
||||
|
||||
scp -i ~/.ssh/5G-SSH-Key.pem \
|
||||
~/p5g-marvis/app/__init__.py ~/p5g-marvis/app/main.py ~/p5g-marvis/app/config.py \
|
||||
root@$TARGET:/opt/p5g-marvis/app/
|
||||
|
||||
scp -i ~/.ssh/5G-SSH-Key.pem ~/p5g-marvis/app/ui/*.html \
|
||||
root@$TARGET:/opt/p5g-marvis/app/ui/
|
||||
|
||||
scp -i ~/.ssh/5G-SSH-Key.pem ~/p5g-marvis/app/routers/*.py \
|
||||
root@$TARGET:/opt/p5g-marvis/app/routers/
|
||||
|
||||
scp -i ~/.ssh/5G-SSH-Key.pem ~/p5g-marvis/app/services/*.py \
|
||||
root@$TARGET:/opt/p5g-marvis/app/services/
|
||||
```
|
||||
|
||||
### Step 2 — Install Python dependencies
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/5G-SSH-Key.pem root@$TARGET '
|
||||
python3 -m ensurepip --upgrade 2>/dev/null
|
||||
python3 -m pip install -r /opt/p5g-marvis/requirements.txt --break-system-packages -q
|
||||
'
|
||||
```
|
||||
|
||||
### Step 3 — Create systemd service
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/5G-SSH-Key.pem root@$TARGET 'cat > /etc/systemd/system/p5g-marvis.service << EOF
|
||||
[Unit]
|
||||
Description=P5G Marvis AI Network Assistant
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/p5g-marvis
|
||||
Environment=MARVIS_PROMETHEUS_URL=http://127.0.0.1:9090
|
||||
Environment=MARVIS_PROMETHEUS_PREFIX=/prometheus
|
||||
Environment=MARVIS_ALERTMANAGER_URL=http://127.0.0.1:9093
|
||||
Environment=MARVIS_AI_MODE=openai
|
||||
Environment=MARVIS_OPENAI_BASE_URL=https://172.27.0.135:8001
|
||||
Environment=MARVIS_OPENAI_MODEL=gemma-4-26B-A4B-it-UD-Q4_K_S.gguf
|
||||
ExecStart=/usr/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8100
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable p5g-marvis
|
||||
systemctl start p5g-marvis'
|
||||
```
|
||||
|
||||
### Step 4 — Create Traefik routing config
|
||||
|
||||
Create `/etc/athonet/traefik/ssl/marvis.yml`:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/5G-SSH-Key.pem root@$TARGET 'cat > /etc/athonet/traefik/ssl/marvis.yml << EOF
|
||||
http:
|
||||
routers:
|
||||
router-marvis-0:
|
||||
rule: "PathPrefix(\`/core/marvis\`)"
|
||||
service: service-marvis
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
priority: 25
|
||||
middlewares:
|
||||
- cors@http
|
||||
- strip-path-marvis-0
|
||||
|
||||
# Add router-radio-0 here if rm-ui is present (port 4000)
|
||||
|
||||
middlewares:
|
||||
strip-path-marvis-0:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/core/marvis"
|
||||
|
||||
services:
|
||||
service-marvis:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8100"
|
||||
passHostHeader: false
|
||||
EOF'
|
||||
```
|
||||
|
||||
### Step 5 — Add file provider to Traefik static config
|
||||
|
||||
Append to `/etc/athonet/traefik/traefik.yml` under the `providers:` section:
|
||||
|
||||
```yaml
|
||||
file:
|
||||
filename: "/etc/traefik/ssl/marvis.yml"
|
||||
watch: true
|
||||
```
|
||||
|
||||
> **Note**: The path inside the container is `/etc/traefik/ssl/marvis.yml` because the `ssl/` directory is bind-mounted. The host path is `/etc/athonet/traefik/ssl/marvis.yml`.
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/5G-SSH-Key.pem root@$TARGET '
|
||||
grep -q "file:" /etc/athonet/traefik/traefik.yml || (
|
||||
echo " file:" >> /etc/athonet/traefik/traefik.yml
|
||||
echo " filename: \"/etc/traefik/ssl/marvis.yml\"" >> /etc/athonet/traefik/traefik.yml
|
||||
echo " watch: true" >> /etc/athonet/traefik/traefik.yml
|
||||
)
|
||||
docker restart traefik'
|
||||
```
|
||||
|
||||
### Step 6 — Patch the NCM JS bundle
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/5G-SSH-Key.pem root@$TARGET '
|
||||
JS="/etc/athonet/ems-frontend/advanced/assets/index-Cw8Irsq8.js"
|
||||
# Only create .bak if it does not already exist (preserve the clean original)
|
||||
[ -f "$JS.bak" ] || cp "$JS" "$JS.bak"
|
||||
cd /opt/p5g-marvis && python3 patch-ncm.py'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Applied: sidebar entry
|
||||
Applied: marvis + radio routes with Or wrapper
|
||||
Applied: /marvis permissions entry
|
||||
Done — P5G Marvis: 10 occurrences, P5G Radio: 3 occurrences
|
||||
```
|
||||
|
||||
If you see `Skipped:` instead of `Applied:`, the patch was already applied from a previous run — this is safe.
|
||||
|
||||
### Step 7 — Verify
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/5G-SSH-Key.pem root@$TARGET '
|
||||
systemctl status p5g-marvis --no-pager | head -5
|
||||
curl -sk http://127.0.0.1:8100/health'
|
||||
```
|
||||
|
||||
Expected: `{"status":"ok"}`
|
||||
|
||||
---
|
||||
|
||||
## Update a UI Page
|
||||
|
||||
To update any UI page (e.g. after editing `tasks.html` locally):
|
||||
|
||||
```bash
|
||||
/usr/bin/scp -i ~/.ssh/5G-SSH-Key.pem \
|
||||
~/p5g-marvis/app/ui/tasks.html \
|
||||
root@<IP>:/opt/p5g-marvis/app/ui/tasks.html
|
||||
```
|
||||
|
||||
No service restart needed — FastAPI reads the file on each request.
|
||||
|
||||
---
|
||||
|
||||
## Re-run the JS Patch (e.g. after an NCM upgrade)
|
||||
|
||||
If NCM is upgraded and the JS bundle is replaced:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/5G-SSH-Key.pem root@<IP> '
|
||||
JS="/etc/athonet/ems-frontend/advanced/assets/index-Cw8Irsq8.js"
|
||||
# The new build will have a different filename — update patch-ncm.py JS= and BAK= lines
|
||||
# Then create a fresh .bak and re-run
|
||||
cp "$JS" "$JS.bak"
|
||||
cd /opt/p5g-marvis && python3 patch-ncm.py'
|
||||
```
|
||||
|
||||
> **Warning**: Check if the bundle filename changed after the upgrade. The filename is `index-<hash>.js`. Update the `JS` variable at the top of `patch-ncm.py` if it has changed.
|
||||
|
||||
---
|
||||
|
||||
## What the Patch Does (patch-ncm.py)
|
||||
|
||||
The script modifies the minified NCM React JS bundle in 3 places — it always reads from the `.bak` (clean original) ensuring it is safe to re-run:
|
||||
|
||||
1. **Sidebar nav entry** — appends P5G Marvis (with 4 sub-items) and P5G Radio items to the existing top-level navigation after the UPF entry.
|
||||
|
||||
2. **React Router routes** — inserts `/marvis` and `/radio` route objects between the existing UPF and Platform routes. Each renders an iframe pointing to `/core/marvis/<page>` or `/core/radio/`.
|
||||
|
||||
3. **Permissions registry** — registers all `/marvis/*` and `/radio` paths in the BW permissions map with `Tt` (always-allowed), preventing "Permissions for route not managed" errors.
|
||||
|
||||
---
|
||||
|
||||
## P5G Marvis Sidebar Structure
|
||||
|
||||
```
|
||||
P5G Marvis
|
||||
├── P5G Marvis Insights → /marvis/overview → iframes /core/marvis/overview
|
||||
├── P5G Marvis Actions → /marvis/actions → iframes /core/marvis/actions
|
||||
├── P5G Marvis Minis → /marvis/minis → iframes /core/marvis/minis (tasks.html)
|
||||
└── P5G Marvis AI → /marvis/ai → iframes /core/marvis/ (index.html)
|
||||
|
||||
P5G Radio → /radio → iframes /core/radio/ (rm-ui :4000)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Minis Page — Action Tiles (tasks.html)
|
||||
|
||||
### Diagnostics & Health
|
||||
| Tile | Description |
|
||||
|---|---|
|
||||
| Ping All NFs | ICMP probes to all NFs via Prometheus |
|
||||
| Refresh Alerts | Pull latest alerts from Alertmanager |
|
||||
| Full NF Status Report | Query all 12 NF health metrics |
|
||||
| Trace UE Data Path | Trace AMF→SMF→UPF path for a sample SUPI |
|
||||
|
||||
### Network Operations
|
||||
| Tile | Description |
|
||||
|---|---|
|
||||
| Perform Emulated Data Session | Full attach + data session end-to-end test (non-disruptive) |
|
||||
| Check Connected Devices | Query AMF/UPF state and report registration status |
|
||||
| Generate Capacity Report | Device counts, bandwidth utilisation, peak hour trends |
|
||||
| Clear All UE Sessions | Force-release all active sessions (**requires confirmation**) |
|
||||
|
||||
### Maintenance
|
||||
| Tile | Description |
|
||||
|---|---|
|
||||
| Backup Configuration | Export configs for all NFs to timestamped archive |
|
||||
| Reload Configuration | Reload from disk without restarting services |
|
||||
| Purge Old Logs | Delete log files older than 7 days |
|
||||
| Export Debug Bundle | Collect and compress NF logs, configs, metrics |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Check |
|
||||
|---|---|
|
||||
| Sidebar items not visible | Browser hard-refresh (Cmd+Shift+R). Confirm patch ran successfully. |
|
||||
| Clicking sidebar shows blank page | Traefik routing — check `docker logs traefik`. `marvis.yml` must be present and file provider added to `traefik.yml`. |
|
||||
| Service won't start | `journalctl -u p5g-marvis -n 50`. Usually a missing Python package. |
|
||||
| Metrics not loading | Confirm Prometheus is on `127.0.0.1:9090` — check `MARVIS_PROMETHEUS_URL` env var in the service file. |
|
||||
| patch-ncm.py: ERROR anchor not found | The NCM JS bundle was upgraded and the filename/content changed. Find new anchors in the `.bak` file and update `patch-ncm.py`. |
|
||||
| After NCM upgrade patch not applied | Re-run Step 6 — but first check if the bundle filename changed. |
|
||||
|
||||
348
ai-slides.html
Normal file
348
ai-slides.html
Normal file
@@ -0,0 +1,348 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>P5G Marvis AI — Architecture</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0c0f1a;
|
||||
--surface: #131825;
|
||||
--card: #1a2035;
|
||||
--border: #252e48;
|
||||
--text: #e2e8f0;
|
||||
--muted: #64748b;
|
||||
--purple: #7c3aed;
|
||||
--blue: #3b82f6;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: #07090f; font-family: var(--font); }
|
||||
|
||||
/* ── Deck navigation ──────────────────────────────────────────── */
|
||||
.deck { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; }
|
||||
.slide { display: none; width: 1100px; aspect-ratio: 16/9; background: var(--bg); border: 1px solid var(--border); border-radius: 18px; overflow: hidden; box-shadow: 0 30px 80px rgba(0,0,0,.7); position: relative; }
|
||||
.slide.active { display: flex; flex-direction: column; }
|
||||
.nav { display: flex; gap: 12px; align-items: center; }
|
||||
.nav button { background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 8px 22px; border-radius: 8px; font-size: 13px; cursor: pointer; font-family: var(--font); transition: all .15s; }
|
||||
.nav button:hover { border-color: var(--blue); color: #fff; }
|
||||
.nav button:disabled { opacity: .3; cursor: default; border-color: var(--border); }
|
||||
.slide-indicator { color: var(--muted); font-size: 12px; min-width: 60px; text-align: center; }
|
||||
|
||||
/* ── Shared layout ──────────────────────────────────────────── */
|
||||
.slide-header { padding: 28px 40px 0; display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
||||
.slide-pretitle { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .14em; color: var(--purple); }
|
||||
.slide-title { font-size: 26px; font-weight: 800; letter-spacing: -.02em; color: var(--text); margin-top: 4px; }
|
||||
.slide-subtitle { font-size: 13px; color: var(--muted); margin-top: 4px; }
|
||||
.slide-body { flex: 1; padding: 22px 40px 28px; display: flex; gap: 20px; overflow: hidden; }
|
||||
|
||||
/* ── Slide 1 layout ──────────────────────────────────────────── */
|
||||
#s1 .slide-body { flex-direction: column; gap: 16px; }
|
||||
.flow { display: flex; align-items: stretch; gap: 0; flex: 1; }
|
||||
.flow-col { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
/* Nodes */
|
||||
.node {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 10px 14px; position: relative; display: flex; flex-direction: column;
|
||||
justify-content: center; font-size: 12px;
|
||||
}
|
||||
.node-label { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-bottom: 4px; }
|
||||
.node-name { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.node-sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
||||
.node.purple { border-color: rgba(124,58,237,.5); background: rgba(124,58,237,.08); }
|
||||
.node.blue { border-color: rgba(59,130,246,.5); background: rgba(59,130,246,.08); }
|
||||
.node.green { border-color: rgba(16,185,129,.5); background: rgba(16,185,129,.08); }
|
||||
.node.yellow { border-color: rgba(245,158,11,.5); background: rgba(245,158,11,.08); }
|
||||
.node.red { border-color: rgba(239,68,68,.5); background: rgba(239,68,68,.08); }
|
||||
|
||||
/* Arrows */
|
||||
.arrow { display: flex; align-items: center; justify-content: center; padding: 0 6px; color: var(--muted); font-size: 15px; flex-shrink: 0; position: relative; }
|
||||
.arrow-label { position: absolute; top: -11px; font-size: 8.5px; font-weight: 600; color: var(--blue); white-space: nowrap; letter-spacing: .03em; }
|
||||
.arrow-label.below { top: auto; bottom: -11px; }
|
||||
.arrow svg { width: 20px; height: 20px; }
|
||||
|
||||
/* Main flow row */
|
||||
.flow-row { display: flex; align-items: center; gap: 0; }
|
||||
|
||||
/* Context box */
|
||||
.ctx-box {
|
||||
border: 1px dashed var(--border); border-radius: 10px; padding: 10px 14px;
|
||||
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||
background: rgba(255,255,255,.02);
|
||||
}
|
||||
.ctx-tag {
|
||||
font-size: 10px; padding: 3px 9px; border-radius: 20px; font-weight: 600;
|
||||
background: var(--card); border: 1px solid var(--border); color: var(--muted);
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.ctx-tag b { color: var(--text); }
|
||||
.ctx-label { font-size: 9px; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); font-weight: 700; margin-bottom: 5px; }
|
||||
|
||||
/* ── Slide 2 layout ──────────────────────────────────────────── */
|
||||
#s2 .slide-body { display: grid; grid-template-columns: 1fr 1px 1fr; gap: 0; }
|
||||
.divider { background: var(--border); }
|
||||
.s2-col { padding: 0 28px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.s2-col:first-child { padding-left: 0; }
|
||||
.s2-col:last-child { padding-right: 0; }
|
||||
|
||||
.s2-section { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .12em; color: var(--purple); margin-bottom: 4px; }
|
||||
|
||||
.list-item { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.list-icon { font-size: 16px; flex-shrink: 0; width: 22px; text-align: center; margin-top: 1px; }
|
||||
.list-text { flex: 1; }
|
||||
.list-title { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.list-desc { font-size: 11px; color: var(--muted); margin-top: 2px; line-height: 1.5; }
|
||||
|
||||
.code-block {
|
||||
background: #0a0d16; border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 14px; font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 10.5px; line-height: 1.7; color: #94a3b8; overflow: hidden;
|
||||
}
|
||||
.code-block .k { color: #7c3aed; }
|
||||
.code-block .s { color: #10b981; }
|
||||
.code-block .c { color: #4b5563; font-style: italic; }
|
||||
.code-block .v { color: #f59e0b; }
|
||||
|
||||
.kv-row { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; border-bottom: 1px solid var(--border); }
|
||||
.kv-row:last-child { border-bottom: none; }
|
||||
.kv-key { font-size: 11px; color: var(--muted); }
|
||||
.kv-val { font-size: 11px; font-weight: 700; color: var(--text); }
|
||||
.kv-val.green { color: var(--green); }
|
||||
.kv-val.yellow { color: var(--yellow); }
|
||||
.kv-val.blue { color: var(--blue); }
|
||||
.kv-val.purple { color: var(--purple); }
|
||||
|
||||
/* Slide number watermark */
|
||||
.slide-num { position: absolute; bottom: 16px; right: 22px; font-size: 10px; color: var(--border); font-weight: 600; }
|
||||
|
||||
/* Gradient accent line at top */
|
||||
.slide::before { content:''; position:absolute; top:0; left:0; right:0; height:3px; background: linear-gradient(90deg, var(--purple), var(--blue), var(--green)); border-radius: 18px 18px 0 0; }
|
||||
|
||||
.badge { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-weight: 700; padding: 3px 10px; border-radius: 20px; }
|
||||
.badge.purple { background: rgba(124,58,237,.15); color: var(--purple); border: 1px solid rgba(124,58,237,.4); }
|
||||
.badge.green { background: rgba(16,185,129,.12); color: var(--green); border: 1px solid rgba(16,185,129,.4); }
|
||||
.badge.blue { background: rgba(59,130,246,.12); color: var(--blue); border: 1px solid rgba(59,130,246,.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="deck">
|
||||
|
||||
<!-- ═══════════════════ SLIDE 1 — Architecture ═════════════════════ -->
|
||||
<div class="slide active" id="s1">
|
||||
<div class="slide-header">
|
||||
<div>
|
||||
<div class="slide-pretitle">P5G Marvis · AI Integration</div>
|
||||
<div class="slide-title">End-to-End Request Flow</div>
|
||||
<div class="slide-subtitle">How a natural-language query becomes a network-aware AI response</div>
|
||||
</div>
|
||||
<div style="margin-left:auto;display:flex;gap:8px">
|
||||
<span class="badge purple">On-Prem LLM</span>
|
||||
<span class="badge green">No data leaves the network</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide-body">
|
||||
|
||||
<!-- Main flow -->
|
||||
<div class="flow-row" style="gap:0;align-items:stretch">
|
||||
|
||||
<!-- NCM Browser -->
|
||||
<div class="node purple" style="width:155px;flex-shrink:0">
|
||||
<div class="node-label">1 · Operator</div>
|
||||
<div class="node-name">NCM Browser</div>
|
||||
<div class="node-sub">React SPA (HPE NCM)</div>
|
||||
<div class="node-sub" style="margin-top:6px;font-size:9px;color:#7c3aed">P5G Marvis AI pane<br>loaded in iframe</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow">
|
||||
<span class="arrow-label">HTTPS query</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Traefik -->
|
||||
<div class="node blue" style="width:140px;flex-shrink:0">
|
||||
<div class="node-label">2 · Proxy</div>
|
||||
<div class="node-name">Traefik</div>
|
||||
<div class="node-sub">172.27.0.159</div>
|
||||
<div class="node-sub" style="margin-top:6px;font-size:9px;color:#3b82f6">/core/marvis/*<br>→ :8100 strip prefix</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow">
|
||||
<span class="arrow-label">POST /api/query</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- FastAPI -->
|
||||
<div class="node blue" style="width:170px;flex-shrink:0">
|
||||
<div class="node-label">3 · Backend</div>
|
||||
<div class="node-name">p5g-marvis</div>
|
||||
<div class="node-sub">FastAPI · :8100</div>
|
||||
<div class="node-sub" style="margin-top:6px;font-size:9px;color:#3b82f6">Fetches live NF status<br>+ active alerts<br>→ builds system prompt</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow">
|
||||
<span class="arrow-label">OpenAI-compat API</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- LLM -->
|
||||
<div class="node green" style="flex:1">
|
||||
<div class="node-label">4 · Inference</div>
|
||||
<div class="node-name">llama.cpp server</div>
|
||||
<div class="node-sub">172.27.0.135:8001 · HTTPS · self-signed TLS</div>
|
||||
<div style="margin-top:7px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
<span style="font-size:9px;background:rgba(16,185,129,.12);border:1px solid rgba(16,185,129,.35);color:#10b981;padding:2px 7px;border-radius:10px">Gemma 4 · 26B</span>
|
||||
<span style="font-size:9px;background:rgba(16,185,129,.12);border:1px solid rgba(16,185,129,.35);color:#10b981;padding:2px 7px;border-radius:10px">Q4_K_S quant</span>
|
||||
<span style="font-size:9px;background:rgba(16,185,129,.12);border:1px solid rgba(16,185,129,.35);color:#10b981;padding:2px 7px;border-radius:10px">Reasoning model</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Context enrichment box -->
|
||||
<div>
|
||||
<div class="ctx-label">Context injected into system prompt by p5g-marvis before every LLM call</div>
|
||||
<div class="ctx-box">
|
||||
<div class="ctx-tag"><span>📡</span> <b>12 NF states</b> (UDR, AMF, SMF, UPF…)</div>
|
||||
<div class="ctx-tag"><span>🔴</span> <b>Active alerts</b> (name, severity, summary)</div>
|
||||
<div class="ctx-tag"><span>🕐</span> <b>Timestamp</b></div>
|
||||
<div class="ctx-tag"><span>📝</span> <b>User query</b> (natural language)</div>
|
||||
<div style="flex:1"></div>
|
||||
<div style="font-size:9px;color:var(--muted);text-align:right;line-height:1.6">
|
||||
No training data leaves the site.<br>
|
||||
Context window: ~1 024 tokens out · 120 s timeout.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Return path note -->
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:10px;color:var(--muted)">
|
||||
<span style="color:var(--green);font-weight:700">↩ Response path:</span>
|
||||
LLM generates markdown analysis (via <code style="font-size:9.5px;background:#0a0d16;padding:1px 5px;border-radius:3px;color:#7c3aed">content</code> or <code style="font-size:9.5px;background:#0a0d16;padding:1px 5px;border-radius:3px;color:#7c3aed">reasoning_content</code> field) → FastAPI returns JSON → iframe renders markdown → operator reads actionable insight
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="slide-num">1 / 2</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════ SLIDE 2 — Components ══════════════════════ -->
|
||||
<div class="slide" id="s2">
|
||||
<div class="slide-header">
|
||||
<div>
|
||||
<div class="slide-pretitle">P5G Marvis · AI Integration</div>
|
||||
<div class="slide-title">Configuration & Design Choices</div>
|
||||
<div class="slide-subtitle">How the integration is wired, and why</div>
|
||||
</div>
|
||||
<div style="margin-left:auto;display:flex;gap:8px">
|
||||
<span class="badge blue">172.27.0.159 only</span>
|
||||
<span class="badge purple">Rule-based fallback</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slide-body">
|
||||
|
||||
<!-- LEFT: Design choices -->
|
||||
<div class="s2-col">
|
||||
<div class="s2-section">Design Choices</div>
|
||||
|
||||
<div class="list-item">
|
||||
<div class="list-icon">🔒</div>
|
||||
<div class="list-text">
|
||||
<div class="list-title">Fully air-gapped inference</div>
|
||||
<div class="list-desc">LLM at 172.27.0.135 stays inside the private 5G network. No cloud API keys, no data egress. Self-signed TLS with verify=False for local trust boundary.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-item">
|
||||
<div class="list-icon">🧠</div>
|
||||
<div class="list-text">
|
||||
<div class="list-title">Context-enriched prompt engineering</div>
|
||||
<div class="list-desc">Every request carries live NF state and alert data. The model never sees a bare question — it always gets the full network picture, so answers are grounded in real telemetry.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-item">
|
||||
<div class="list-icon">⚡</div>
|
||||
<div class="list-text">
|
||||
<div class="list-title">Reasoning model handling</div>
|
||||
<div class="list-desc">Gemma 4 returns a <code style="font-size:10px;background:#0a0d16;padding:1px 5px;border-radius:3px;color:#7c3aed">reasoning_content</code> field when <code style="font-size:10px;background:#0a0d16;padding:1px 5px;border-radius:3px;color:#7c3aed">content</code> is empty. The backend falls back gracefully so the thinking trace is surfaced rather than dropped.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-item">
|
||||
<div class="list-icon">🛡️</div>
|
||||
<div class="list-text">
|
||||
<div class="list-title">Rule-based fallback</div>
|
||||
<div class="list-desc">If the LLM is unreachable or times out, the backend falls through to a deterministic rule engine that still returns a formatted, accurate network health summary.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- RIGHT: Config + key params -->
|
||||
<div class="s2-col">
|
||||
<div class="s2-section">Runtime Configuration (systemd env)</div>
|
||||
|
||||
<div class="code-block">
|
||||
<span class="c"># /etc/systemd/system/p5g-marvis.service</span>
|
||||
<span class="k">Environment</span>=<span class="v">MARVIS_AI_MODE</span>=<span class="s">openai</span>
|
||||
<span class="k">Environment</span>=<span class="v">MARVIS_OPENAI_BASE_URL</span>=<span class="s">https://172.27.0.135:8001</span>
|
||||
<span class="k">Environment</span>=<span class="v">MARVIS_OPENAI_MODEL</span>=<span class="s">gemma-4-26B-A4B-it-UD-Q4_K_S.gguf</span>
|
||||
</div>
|
||||
|
||||
<div class="s2-section" style="margin-top:8px">Key Parameters</div>
|
||||
<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 14px;">
|
||||
<div class="kv-row"><span class="kv-key">LLM endpoint</span><span class="kv-val blue">172.27.0.135:8001</span></div>
|
||||
<div class="kv-row"><span class="kv-key">API format</span><span class="kv-val">/v1/chat/completions</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Auth header</span><span class="kv-val green">None (skipped if key empty)</span></div>
|
||||
<div class="kv-row"><span class="kv-key">TLS verify</span><span class="kv-val yellow">Disabled (self-signed)</span></div>
|
||||
<div class="kv-row"><span class="kv-key">max_tokens</span><span class="kv-val">1 024</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Timeout</span><span class="kv-val">120 s</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Hosts with LLM mode</span><span class="kv-val purple">172.27.0.159 only</span></div>
|
||||
<div class="kv-row"><span class="kv-key">192.168.86.173 mode</span><span class="kv-val green">rule (deterministic)</span></div>
|
||||
</div>
|
||||
|
||||
<div class="s2-section" style="margin-top:8px">Routing (Traefik)</div>
|
||||
<div style="background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 14px;">
|
||||
<div class="kv-row"><span class="kv-key">NCM sidebar inject</span><span class="kv-val blue">patch-ncm.py → JS bundle</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Marvis iframe path</span><span class="kv-val">/core/marvis/ → :8100</span></div>
|
||||
<div class="kv-row"><span class="kv-key">AI sub-page</span><span class="kv-val">/core/marvis/ai</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="slide-num">2 / 2</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="nav">
|
||||
<button id="prev" onclick="go(-1)" disabled>← Prev</button>
|
||||
<span class="slide-indicator" id="indicator">1 / 2</span>
|
||||
<button id="next" onclick="go(1)">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const slides = document.querySelectorAll('.slide');
|
||||
let cur = 0;
|
||||
function go(d) {
|
||||
slides[cur].classList.remove('active');
|
||||
cur = Math.max(0, Math.min(slides.length - 1, cur + d));
|
||||
slides[cur].classList.add('active');
|
||||
document.getElementById('indicator').textContent = `${cur+1} / ${slides.length}`;
|
||||
document.getElementById('prev').disabled = cur === 0;
|
||||
document.getElementById('next').disabled = cur === slides.length - 1;
|
||||
}
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowRight') go(1);
|
||||
if (e.key === 'ArrowLeft') go(-1);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
26
app/config.py
Normal file
26
app/config.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
|
||||
# Prometheus — the HPE P5G stack uses /prometheus as base path
|
||||
PROMETHEUS_URL = os.getenv("MARVIS_PROMETHEUS_URL", "http://127.0.0.1:9090")
|
||||
PROMETHEUS_PREFIX = os.getenv("MARVIS_PROMETHEUS_PREFIX", "/prometheus")
|
||||
ALERTMANAGER_URL = os.getenv("MARVIS_ALERTMANAGER_URL", "http://127.0.0.1:9093")
|
||||
|
||||
# AI backend: "rule" | "openai" | "ollama"
|
||||
AI_MODE = os.getenv("MARVIS_AI_MODE", "rule")
|
||||
OPENAI_API_KEY = os.getenv("MARVIS_OPENAI_API_KEY", "")
|
||||
OPENAI_MODEL = os.getenv("MARVIS_OPENAI_MODEL", "gpt-4o-mini")
|
||||
# Override to use any OpenAI-compatible local LLM (llama.cpp, vLLM, LM Studio, etc.)
|
||||
OPENAI_BASE_URL = os.getenv("MARVIS_OPENAI_BASE_URL", "https://api.openai.com")
|
||||
OLLAMA_URL = os.getenv("MARVIS_OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL = os.getenv("MARVIS_OLLAMA_MODEL", "llama3")
|
||||
|
||||
# Maps Prometheus target_type label → display name
|
||||
TARGET_TYPE_MAP = {
|
||||
"amf": "AMF", "smf": "SMF", "upf": "UPF",
|
||||
"udm": "UDM", "udr": "UDR", "nrf": "NRF",
|
||||
"ausf": "AUSF", "pcf": "PCF", "mme": "MME",
|
||||
"sgwc": "SGWC", "dra": "DRA", "dsm": "DSM",
|
||||
"ncm": "NCM", "pls": "PLS",
|
||||
}
|
||||
|
||||
ALL_NFS = ["AMF", "SMF", "UPF", "UDM", "UDR", "NRF", "AUSF", "PCF", "MME", "SGWC", "DRA", "DSM"]
|
||||
57
app/main.py
Normal file
57
app/main.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pathlib import Path
|
||||
|
||||
from app.routers import network, alerts, query as query_router, actions as actions_router, emulated_session as emulated_session_router
|
||||
|
||||
app = FastAPI(title="P5G Marvis", version="1.0.0", docs_url="/api/docs")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(network.router, prefix="/api")
|
||||
app.include_router(alerts.router, prefix="/api")
|
||||
app.include_router(query_router.router, prefix="/api")
|
||||
app.include_router(actions_router.router, prefix="/api")
|
||||
app.include_router(emulated_session_router.router, prefix="/api")
|
||||
|
||||
UI = Path(__file__).parent / "ui" / "index.html"
|
||||
OVERVIEW_UI = Path(__file__).parent / "ui" / "overview.html"
|
||||
TASKS_UI = Path(__file__).parent / "ui" / "tasks.html"
|
||||
ACTIONS_UI = Path(__file__).parent / "ui" / "actions.html"
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/overview")
|
||||
async def overview_page():
|
||||
return FileResponse(str(OVERVIEW_UI))
|
||||
|
||||
|
||||
@app.get("/minis")
|
||||
async def minis_page():
|
||||
return FileResponse(str(TASKS_UI))
|
||||
|
||||
|
||||
@app.get("/tasks")
|
||||
async def tasks_page():
|
||||
return FileResponse(str(TASKS_UI))
|
||||
|
||||
|
||||
@app.get("/actions")
|
||||
async def actions_page():
|
||||
return FileResponse(str(ACTIONS_UI))
|
||||
|
||||
|
||||
# Catch-all: serve the SPA for any unmatched path (supports deep-linking)
|
||||
@app.get("/{full_path:path}")
|
||||
async def spa(full_path: str):
|
||||
return FileResponse(str(UI))
|
||||
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
16
app/routers/actions.py
Normal file
16
app/routers/actions.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter
|
||||
from app.services import log_analyzer
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/actions")
|
||||
async def get_actions():
|
||||
"""Return current action analysis: categorised issues from logs + Prometheus + Alertmanager."""
|
||||
return await log_analyzer.analyze_logs()
|
||||
|
||||
|
||||
@router.get("/actions/history")
|
||||
async def get_actions_history():
|
||||
"""Return the in-memory history ring-buffer for the time-series chart."""
|
||||
return {"history": log_analyzer.get_history()}
|
||||
11
app/routers/alerts.py
Normal file
11
app/routers/alerts.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
from app.services import alertmanager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/alerts")
|
||||
async def get_alerts():
|
||||
alerts = await alertmanager.get_alerts()
|
||||
critical = sum(1 for a in alerts if a.get("severity") == "critical")
|
||||
return {"alerts": alerts, "total": len(alerts), "critical": critical}
|
||||
20
app/routers/emulated_session.py
Normal file
20
app/routers/emulated_session.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import asyncio
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.services import ueransim
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/emulated-session/start")
|
||||
async def start_emulated_session():
|
||||
task_id = ueransim.create_task()
|
||||
asyncio.create_task(ueransim.run_test(task_id))
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@router.get("/emulated-session/status/{task_id}")
|
||||
async def get_emulated_session_status(task_id: str):
|
||||
task = ueransim.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
12
app/routers/network.py
Normal file
12
app/routers/network.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
from app.services import prometheus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/network/status")
|
||||
async def network_status():
|
||||
nfs = await prometheus.get_nf_status()
|
||||
up = sum(1 for n in nfs if n["state"] == "up")
|
||||
down = sum(1 for n in nfs if n["state"] == "down")
|
||||
return {"nfs": nfs, "summary": {"up": up, "down": down, "total": len(nfs)}}
|
||||
24
app/routers/query.py
Normal file
24
app/routers/query.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from app.services import prometheus, alertmanager, ai
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
@router.post("/query")
|
||||
async def query(req: QueryRequest):
|
||||
network_state, alerts = await _gather(req.query)
|
||||
response = await ai.answer(req.query, network_state, alerts)
|
||||
return {"response": response, "network_state": network_state, "alerts": alerts}
|
||||
|
||||
|
||||
async def _gather(query_text: str):
|
||||
import asyncio
|
||||
nfs_task = asyncio.create_task(prometheus.get_nf_status())
|
||||
alerts_task = asyncio.create_task(alertmanager.get_alerts())
|
||||
nfs, alerts = await asyncio.gather(nfs_task, alerts_task)
|
||||
return {"nfs": nfs}, alerts
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
207
app/services/ai.py
Normal file
207
app/services/ai.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
AI engine for P5G Marvis.
|
||||
|
||||
Phase 1: rule-based with real network data.
|
||||
Phase 2: swap MARVIS_AI_MODE=openai or MARVIS_AI_MODE=ollama to route through LLM.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from app.config import AI_MODE, OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL, OLLAMA_URL, OLLAMA_MODEL
|
||||
|
||||
|
||||
async def answer(query: str, network_state: dict, alerts: list) -> str:
|
||||
if AI_MODE == "openai":
|
||||
return await _call_openai(query, network_state, alerts)
|
||||
if AI_MODE == "ollama":
|
||||
return await _call_ollama(query, network_state, alerts)
|
||||
return _rule_based(query, network_state, alerts)
|
||||
|
||||
|
||||
# ── Rule-based engine ──────────────────────────────────────────────────────
|
||||
|
||||
def _rule_based(query: str, network_state: dict, alerts: list) -> str:
|
||||
q = query.lower()
|
||||
nfs = network_state.get("nfs", [])
|
||||
up = [n for n in nfs if n["state"] == "up"]
|
||||
down = [n for n in nfs if n["state"] == "down"]
|
||||
|
||||
if any(w in q for w in ["hello", "hi ", "hey", "howdy"]):
|
||||
return ("Hello! I'm **P5G Marvis**, your AI network assistant for HPE Private 5G.\n"
|
||||
"Ask me about network health, specific functions, alerts, or performance.")
|
||||
|
||||
if any(w in q for w in ["help", "what can", "capabilities", "commands", "features"]):
|
||||
return (
|
||||
"Here's what I can help with:\n\n"
|
||||
"• **Network health** — overall P5G status overview\n"
|
||||
"• **Network functions** — ask about AMF, SMF, UPF, UDM, NRF, etc.\n"
|
||||
"• **Alerts** — active alarms and their severity\n"
|
||||
"• **Subscribers** — UE registration and session analysis\n"
|
||||
"• **Sessions** — PDU session and data plane health\n\n"
|
||||
"_Tip: Connect an LLM by setting `MARVIS_AI_MODE=openai` or `=ollama`._"
|
||||
)
|
||||
|
||||
# Specific NF query
|
||||
from app.config import ALL_NFS
|
||||
for nf_name in ALL_NFS:
|
||||
if nf_name.lower() in q:
|
||||
return _nf_detail(nf_name, nfs, alerts)
|
||||
|
||||
if any(w in q for w in ["alert", "alarm", "warning", "critical", "incident", "problem", "issue"]):
|
||||
return _alerts_summary(alerts)
|
||||
|
||||
if any(w in q for w in ["subscriber", "ue ", "device", "phone", "handset", "registration", "attach"]):
|
||||
return _subscriber_analysis(nfs, alerts)
|
||||
|
||||
if any(w in q for w in ["session", "pdu", "bearer", "user plane", "traffic", "throughput"]):
|
||||
return _session_analysis(nfs, alerts)
|
||||
|
||||
# Default → health summary
|
||||
return _health_summary(up, down, alerts)
|
||||
|
||||
|
||||
def _health_summary(up: list, down: list, alerts: list) -> str:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
crit = [a for a in alerts if a.get("severity") == "critical"]
|
||||
warn = [a for a in alerts if a.get("severity") != "critical"]
|
||||
lines = [f"**P5G Network Health — {ts}**\n"]
|
||||
|
||||
if up:
|
||||
lines.append(f"✅ **{len(up)} UP**: {', '.join(n['name'] for n in up)}")
|
||||
if down:
|
||||
lines.append(f"🔴 **{len(down)} DOWN**: {', '.join(n['name'] for n in down)}")
|
||||
lines.append(f" ⚡ Action: check `podman logs <nf>` on the VM")
|
||||
|
||||
if alerts:
|
||||
lines.append(f"\n⚠️ **{len(alerts)} alert(s)** — {len(crit)} critical, {len(warn)} warning")
|
||||
for a in alerts[:4]:
|
||||
icon = "🔴" if a.get("severity") == "critical" else "🟡"
|
||||
lines.append(f" {icon} {a['name']}: {a.get('summary', a.get('instance', ''))}")
|
||||
else:
|
||||
lines.append("\n✅ **No active alerts**")
|
||||
|
||||
if not down and not alerts:
|
||||
lines.append("\n🟢 All systems nominal.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _nf_detail(nf_name: str, nfs: list, alerts: list) -> str:
|
||||
nf = next((n for n in nfs if n["name"] == nf_name), None)
|
||||
nf_alerts = [a for a in alerts
|
||||
if nf_name in a.get("name", "") or nf_name.lower() in a.get("instance", "").lower()]
|
||||
|
||||
if not nf or nf["state"] == "unknown":
|
||||
return (f"ℹ️ No Prometheus data found for **{nf_name}**.\n"
|
||||
f"Check: `podman ps | grep {nf_name.lower()}`")
|
||||
|
||||
icon = "✅" if nf["state"] == "up" else "🔴"
|
||||
lines = [f"{icon} **{nf_name}** is **{nf['state'].upper()}**",
|
||||
f"Instance: `{nf.get('instance', 'n/a')}`"]
|
||||
if nf_alerts:
|
||||
lines.append(f"\n⚠️ {len(nf_alerts)} alert(s) for {nf_name}:")
|
||||
for a in nf_alerts:
|
||||
lines.append(f" → {a['name']}: {a.get('summary', '')}")
|
||||
else:
|
||||
lines.append("No active alerts for this function.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _alerts_summary(alerts: list) -> str:
|
||||
if not alerts:
|
||||
return "✅ **No active alerts.** Network is running cleanly."
|
||||
crit = [a for a in alerts if a.get("severity") == "critical"]
|
||||
warn = [a for a in alerts if a.get("severity") != "critical"]
|
||||
lines = [f"⚠️ **{len(alerts)} active alert(s)** — {len(crit)} critical, {len(warn)} warning\n"]
|
||||
for a in alerts:
|
||||
icon = "🔴" if a.get("severity") == "critical" else "🟡"
|
||||
lines.append(f"{icon} **{a['name']}**")
|
||||
if a.get("summary"):
|
||||
lines.append(f" {a['summary']}")
|
||||
if a.get("instance"):
|
||||
lines.append(f" `{a['instance']}`")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _subscriber_analysis(nfs: list, alerts: list) -> str:
|
||||
amf = next((n for n in nfs if n["name"] == "AMF"), None)
|
||||
smf = next((n for n in nfs if n["name"] == "SMF"), None)
|
||||
lines = ["**Subscriber & Registration Analysis**\n"]
|
||||
lines.append(f"AMF (registration/mobility): {'✅ UP' if amf and amf['state'] == 'up' else '🔴 DOWN — subscribers cannot register'}")
|
||||
lines.append(f"SMF (session management): {'✅ UP' if smf and smf['state'] == 'up' else '🔴 DOWN — no new data sessions'}")
|
||||
sub_alerts = [a for a in alerts if any(k in a.get("name", "").lower()
|
||||
for k in ["ue", "subscriber", "session", "attach", "registration"])]
|
||||
if sub_alerts:
|
||||
lines.append(f"\n⚠️ {len(sub_alerts)} subscriber-related alert(s) active.")
|
||||
else:
|
||||
lines.append("\nNo subscriber-related alerts detected.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _session_analysis(nfs: list, alerts: list) -> str:
|
||||
smf = next((n for n in nfs if n["name"] == "SMF"), None)
|
||||
upf = next((n for n in nfs if n["name"] == "UPF"), None)
|
||||
lines = ["**PDU Session & Data Plane Analysis**\n"]
|
||||
lines.append(f"SMF: {'✅ UP' if smf and smf['state'] == 'up' else '🔴 DOWN'}")
|
||||
lines.append(f"UPF: {'✅ UP' if upf and upf['state'] == 'up' else '🔴 DOWN'}")
|
||||
if (not smf or smf["state"] != "up") or (not upf or upf["state"] != "up"):
|
||||
lines.append("\n⚡ **Impact**: PDU sessions will fail until both SMF and UPF are operational.")
|
||||
else:
|
||||
lines.append("\nBoth SMF and UPF operational — sessions should be establishing normally.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── LLM backends ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_context(network_state: dict, alerts: list) -> str:
|
||||
nfs = network_state.get("nfs", [])
|
||||
up = [n["name"] for n in nfs if n["state"] == "up"]
|
||||
down = [n["name"] for n in nfs if n["state"] == "down"]
|
||||
return (
|
||||
f"NFs UP: {', '.join(up) or 'none'}\n"
|
||||
f"NFs DOWN: {', '.join(down) or 'none'}\n"
|
||||
f"Active alerts: {', '.join(a.get('name','') for a in alerts[:5]) or 'none'}"
|
||||
)
|
||||
|
||||
|
||||
async def _call_openai(query: str, network_state: dict, alerts: list) -> str:
|
||||
try:
|
||||
import httpx
|
||||
ctx = _build_context(network_state, alerts)
|
||||
messages = [
|
||||
{"role": "system", "content":
|
||||
f"You are P5G Marvis, an AI network assistant for HPE Private 5G.\n"
|
||||
f"Current network state:\n{ctx}\n\nRespond concisely, use markdown."},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
base = OPENAI_BASE_URL.rstrip("/")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if OPENAI_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {OPENAI_API_KEY}"
|
||||
# disable cert verification for self-signed local LLM servers
|
||||
verify = base.startswith("https://api.openai.com")
|
||||
async with httpx.AsyncClient(timeout=120, verify=verify) as client:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/chat/completions",
|
||||
headers=headers,
|
||||
json={"model": OPENAI_MODEL, "messages": messages, "max_tokens": 1024},
|
||||
)
|
||||
msg = resp.json()["choices"][0]["message"]
|
||||
# some reasoning models put the answer in content, others in reasoning_content
|
||||
return msg.get("content") or msg.get("reasoning_content") or "(empty response)"
|
||||
except Exception as e:
|
||||
return f"LLM error: {e}\n\n" + _rule_based(query, network_state, alerts)
|
||||
|
||||
|
||||
async def _call_ollama(query: str, network_state: dict, alerts: list) -> str:
|
||||
try:
|
||||
import httpx
|
||||
ctx = _build_context(network_state, alerts)
|
||||
prompt = (f"You are P5G Marvis, an AI network assistant.\n"
|
||||
f"Network state:\n{ctx}\n\nUser: {query}\nAssistant:")
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
f"{OLLAMA_URL}/api/generate",
|
||||
json={"model": OLLAMA_MODEL, "prompt": prompt, "stream": False},
|
||||
)
|
||||
return resp.json().get("response", "No response.")
|
||||
except Exception as e:
|
||||
return f"Ollama error: {e}\n\n" + _rule_based(query, network_state, alerts)
|
||||
29
app/services/alertmanager.py
Normal file
29
app/services/alertmanager.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Alertmanager client."""
|
||||
|
||||
import httpx
|
||||
from app.config import ALERTMANAGER_URL
|
||||
|
||||
_BASE = ALERTMANAGER_URL.rstrip("/")
|
||||
|
||||
|
||||
async def get_alerts() -> list:
|
||||
"""Return normalised list of active alerts from Alertmanager."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
r = await client.get(f"{_BASE}/api/v2/alerts", params={"active": "true", "silenced": "false"})
|
||||
r.raise_for_status()
|
||||
raw = r.json()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
alerts = []
|
||||
for a in raw:
|
||||
labels = a.get("labels", {})
|
||||
annotations = a.get("annotations", {})
|
||||
alerts.append({
|
||||
"name": labels.get("alertname", "Unknown"),
|
||||
"severity": labels.get("severity", "warning"),
|
||||
"instance": labels.get("instance", ""),
|
||||
"summary": annotations.get("summary", annotations.get("description", "")),
|
||||
})
|
||||
return alerts
|
||||
338
app/services/log_analyzer.py
Normal file
338
app/services/log_analyzer.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
log_analyzer.py — Reads P5G NF container logs and active Prometheus/Alertmanager
|
||||
data to produce a structured list of recommended remediation actions, grouped
|
||||
by category. This is the data backend powering the /api/actions endpoint.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
|
||||
# ── In-memory history (up to 96 snapshots ≈ 48 min at 30 s refresh) ────────
|
||||
_history: deque = deque(maxlen=96)
|
||||
|
||||
# ── Category colour palette ──────────────────────────────────────────────────
|
||||
CATEGORY_COLORS: dict[str, str] = {
|
||||
"Registration": "#3b82f6",
|
||||
"Sessions": "#7c3aed",
|
||||
"Authentication": "#f59e0b",
|
||||
"Connectivity": "#06b6d4",
|
||||
"Policy": "#10b981",
|
||||
"Security": "#ef4444",
|
||||
}
|
||||
|
||||
# All categories in canonical display order (left side, right side)
|
||||
ALL_CATEGORIES = ["Registration", "Authentication", "Security",
|
||||
"Sessions", "Connectivity", "Policy"]
|
||||
|
||||
# ── Log-pattern definitions ──────────────────────────────────────────────────
|
||||
# Each entry: (regex, affected_nf, severity, short_description, remediation)
|
||||
CATEGORY_PATTERNS: dict[str, list[tuple]] = {
|
||||
"Registration": [
|
||||
(r"RegistrationFailure|UeRegistrationFailed|N1.*[Rr]egistration.*[Ff]ail",
|
||||
"AMF", "critical",
|
||||
"UE registration failure",
|
||||
"Check AMF logs for NGAP errors; verify UE credentials and NRF registration."),
|
||||
(r"N2SetupFail|NgapSetupFail|N2.*[Tt]imeout|NgapProcedure.*failed",
|
||||
"AMF", "critical",
|
||||
"N2 interface setup failure",
|
||||
"Verify gNB connectivity to AMF; check SCTP transport and NGAP PLMN config."),
|
||||
(r"InitialContextSetupFail|UeContextRelease.*[Aa]bnormal",
|
||||
"AMF", "warning",
|
||||
"UE context setup failure",
|
||||
"Review AMF-SMF N11 interface; check subscriber profile in UDM/UDR."),
|
||||
(r"PagingFail|UeUnreachable|UeNotFound",
|
||||
"AMF", "warning",
|
||||
"UE paging failure",
|
||||
"Verify UE is registered; check AMF tracking area configuration."),
|
||||
],
|
||||
"Sessions": [
|
||||
(r"PduSessionEstablishmentReject|PduSession.*[Ff]ail|CreateSessionResponse.*[Ff]ail",
|
||||
"SMF", "critical",
|
||||
"PDU session establishment failure",
|
||||
"Check SMF-UPF N4 path; verify DNN/APN config and UPF N3/N9 interfaces."),
|
||||
(r"N4Session.*[Ff]ail|PfcpSession.*[Ee]rror|N4.*[Tt]imeout|PfcpAssociation.*[Ff]ail",
|
||||
"UPF", "critical",
|
||||
"N4/PFCP session error",
|
||||
"Restart PFCP association between SMF and UPF; check N4 IP reachability."),
|
||||
(r"IpAllocationFail|AddressPoolExhausted|NoIpAvailable",
|
||||
"SMF", "critical",
|
||||
"IP address pool exhausted",
|
||||
"Expand UE IP address pool in SMF config; review active session count."),
|
||||
(r"SessionModification.*[Ff]ail|BearerModification.*[Ee]rror",
|
||||
"SMF", "warning",
|
||||
"Session modification failure",
|
||||
"Check PCF policy consistency; verify QoS parameters match UPF capabilities."),
|
||||
],
|
||||
"Authentication": [
|
||||
(r"AuthenticationFailure|AuthReject|EapFailure|5g-aka.*[Ff]ail|EapAkaFailure",
|
||||
"AUSF", "critical",
|
||||
"UE authentication failure",
|
||||
"Verify USIM credentials match UDM subscriber data; check AUSF-UDM N12 link."),
|
||||
(r"UdmAuthReq.*[Ee]rror|SuciDeconceal.*[Ff]ail|UdmUeAuth.*[Ee]rror",
|
||||
"UDM", "critical",
|
||||
"UDM authentication error",
|
||||
"Check UDM-UDR N35 connectivity; verify Home Network Public Key configuration."),
|
||||
(r"AuthVectorFetch.*[Ff]ail|AusfUeAuth.*[Rr]eject|HssAuth.*[Ff]ail",
|
||||
"AUSF", "warning",
|
||||
"Auth vector fetch failure",
|
||||
"Review UDR data integrity for affected SUPI; check AUSF-UDM TLS certificates."),
|
||||
],
|
||||
"Connectivity": [
|
||||
(r"NfDiscovery.*[Ff]ail|NrfRegistration.*[Ff]ail|NfDeregistration.*unexpect",
|
||||
"NRF", "warning",
|
||||
"NF service discovery failure",
|
||||
"Verify NRF is reachable from all NFs; check NRF registration TTL and heartbeat."),
|
||||
(r"ServiceUnavailable.*NF|HTTP.*503.*NF|NfProfile.*expired",
|
||||
"NRF", "warning",
|
||||
"NF service unavailable",
|
||||
"Check NF pod health and SBI listen port; review NRF subscription notifications."),
|
||||
(r"SbiRequest.*[Tt]imeout|SbiConn.*[Ff]ail|Http2.*[Ee]rror",
|
||||
"NRF", "warning",
|
||||
"SBI interface timeout",
|
||||
"Inspect inter-NF network MTU and TLS handshake; check load balancer config."),
|
||||
],
|
||||
"Policy": [
|
||||
(r"PcfSmPolicy.*[Ee]rror|PolicyDecision.*[Ff]ail|SmPolicy.*[Rr]eject",
|
||||
"PCF", "warning",
|
||||
"Policy decision failure",
|
||||
"Review PCF policy rules and subscriber group config; check PCF-UDR N36 link."),
|
||||
(r"QosEnforce.*[Ff]ail|ChargingRule.*[Ee]rror|PccRule.*[Rr]eject",
|
||||
"PCF", "warning",
|
||||
"QoS policy enforcement failure",
|
||||
"Verify QoS profiles match UPF capabilities; check PCF-CHF N40 charging path."),
|
||||
],
|
||||
"Security": [
|
||||
(r"SecurityMode.*[Ff]ail|IntegrityCheck.*[Ff]ail|NasIntegrity.*[Ee]rror",
|
||||
"AMF", "critical",
|
||||
"NAS security mode failure",
|
||||
"Check AMF cipher/integrity algorithm priority list matches UE capabilities."),
|
||||
(r"TlsHandshake.*[Ff]ail|Certificate.*[Ee]xpir|x509.*[Ee]rror|CertVerify.*[Ff]ail",
|
||||
"AMF", "critical",
|
||||
"TLS/certificate error",
|
||||
"Renew expired certificates; verify trust chain between NFs; check SBI TLS config."),
|
||||
(r"SuciProtection.*[Ff]ail|PrivacyProtection.*[Ee]rror|HomeNetworkKey.*[Ee]rror",
|
||||
"UDM", "warning",
|
||||
"SUCI privacy protection error",
|
||||
"Verify Home Network Public Key provisioning on UDM; check SUPI revealing config."),
|
||||
],
|
||||
}
|
||||
|
||||
# ── NF → possible container name fragments (tried in order) ─────────────────
|
||||
NF_CONTAINER_HINTS: dict[str, list[str]] = {
|
||||
"AMF": ["amf"],
|
||||
"SMF": ["smf"],
|
||||
"UPF": ["upf"],
|
||||
"NRF": ["nrf"],
|
||||
"UDM": ["udm"],
|
||||
"AUSF": ["ausf"],
|
||||
"PCF": ["pcf"],
|
||||
}
|
||||
|
||||
# ── Container discovery cache ────────────────────────────────────────────────
|
||||
_container_cache: dict[str, str] = {}
|
||||
_container_cache_ts: float = 0.0
|
||||
|
||||
|
||||
async def _discover_containers() -> dict[str, str]:
|
||||
"""Run `podman ps` and map NF names to actual container names."""
|
||||
global _container_cache, _container_cache_ts
|
||||
now = time.monotonic()
|
||||
if _container_cache and now - _container_cache_ts < 60:
|
||||
return _container_cache
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"podman", "ps", "--format", "{{.Names}}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
||||
names = [n.strip() for n in stdout.decode().splitlines() if n.strip()]
|
||||
except Exception:
|
||||
names = []
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
for nf, hints in NF_CONTAINER_HINTS.items():
|
||||
for hint in hints:
|
||||
match = next((n for n in names if hint in n.lower()), None)
|
||||
if match:
|
||||
mapping[nf] = match
|
||||
break
|
||||
|
||||
_container_cache = mapping
|
||||
_container_cache_ts = now
|
||||
return mapping
|
||||
|
||||
|
||||
async def _read_logs(container: str, tail: int = 400) -> str:
|
||||
"""Read recent logs from a podman container (stdout + stderr)."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"podman", "logs", "--tail", str(tail), container,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=8)
|
||||
return (stdout.decode("utf-8", errors="replace") +
|
||||
stderr.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _match_count(text: str, pattern: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
try:
|
||||
return len(re.findall(pattern, text, re.IGNORECASE | re.MULTILINE))
|
||||
except re.error:
|
||||
return 0
|
||||
|
||||
|
||||
# ── Category/NF mapping for Alertmanager alerts ──────────────────────────────
|
||||
|
||||
def _alert_category(alert: dict) -> str:
|
||||
name = (alert.get("name", "") + " " + alert.get("summary", "")).lower()
|
||||
if any(k in name for k in ["register", "attach", "ngap", "n2"]):
|
||||
return "Registration"
|
||||
if any(k in name for k in ["session", "pdu", "bearer", "smf_", "upf_", "n4", "pfcp"]):
|
||||
return "Sessions"
|
||||
if any(k in name for k in ["auth", "ausf", "udm_", "supi", "aka", "eap"]):
|
||||
return "Authentication"
|
||||
if any(k in name for k in ["nrf", "discovery", "unavailable", "sbi", "connect"]):
|
||||
return "Connectivity"
|
||||
if any(k in name for k in ["pcf", "policy", "qos", "pcc", "charge"]):
|
||||
return "Policy"
|
||||
if any(k in name for k in ["tls", "cert", "security", "cipher", "integ", "suci"]):
|
||||
return "Security"
|
||||
return "Connectivity"
|
||||
|
||||
|
||||
def _alert_nf(alert: dict) -> str:
|
||||
from app.config import ALL_NFS
|
||||
text = (alert.get("name", "") + alert.get("instance", "")).lower()
|
||||
for nf in ALL_NFS:
|
||||
if nf.lower() in text:
|
||||
return nf
|
||||
return "System"
|
||||
|
||||
|
||||
# ── Main analysis entry point ────────────────────────────────────────────────
|
||||
|
||||
async def analyze_logs() -> dict:
|
||||
"""
|
||||
Gather log-pattern issues + Prometheus NF status + Alertmanager alerts.
|
||||
Returns a fully structured dict ready for JSON serialisation.
|
||||
"""
|
||||
from app.services import alertmanager, prometheus
|
||||
|
||||
# Kick off all I/O in parallel
|
||||
containers_f = asyncio.create_task(_discover_containers())
|
||||
alerts_f = asyncio.create_task(alertmanager.get_alerts())
|
||||
nf_status_f = asyncio.create_task(prometheus.get_nf_status())
|
||||
|
||||
containers = await containers_f
|
||||
alerts, nf_statuses = await asyncio.gather(alerts_f, nf_status_f,
|
||||
return_exceptions=True)
|
||||
if isinstance(alerts, Exception):
|
||||
alerts = []
|
||||
if isinstance(nf_statuses, Exception):
|
||||
nf_statuses = []
|
||||
|
||||
# Read all container logs concurrently
|
||||
log_tasks = {nf: asyncio.create_task(_read_logs(cname))
|
||||
for nf, cname in containers.items()}
|
||||
log_texts: dict[str, str] = {}
|
||||
if log_tasks:
|
||||
log_results = await asyncio.gather(*log_tasks.values(), return_exceptions=True)
|
||||
for nf, result in zip(log_tasks.keys(), log_results):
|
||||
log_texts[nf] = result if isinstance(result, str) else ""
|
||||
|
||||
issues: list[dict] = []
|
||||
|
||||
# 1. Log-pattern analysis
|
||||
for category, patterns in CATEGORY_PATTERNS.items():
|
||||
for (pat_re, nf, severity, description, remediation) in patterns:
|
||||
count = _match_count(log_texts.get(nf, ""), pat_re)
|
||||
if count:
|
||||
issues.append({
|
||||
"id": f"log-{nf}-{len(issues)}",
|
||||
"category": category,
|
||||
"nf": nf,
|
||||
"severity": severity,
|
||||
"count": count,
|
||||
"description": description,
|
||||
"remediation": remediation,
|
||||
"source": "log",
|
||||
})
|
||||
|
||||
# 2. NF-down events from Prometheus
|
||||
for nf_st in nf_statuses:
|
||||
if isinstance(nf_st, dict) and nf_st.get("state") == "down":
|
||||
issues.append({
|
||||
"id": f"nf-down-{nf_st['name']}",
|
||||
"category": "Connectivity",
|
||||
"nf": nf_st["name"],
|
||||
"severity": "critical",
|
||||
"count": 1,
|
||||
"description": f"{nf_st['name']} is unreachable",
|
||||
"remediation": (f"Run `podman ps` on the VM and check if {nf_st['name']} "
|
||||
f"container is running; inspect its logs."),
|
||||
"source": "prometheus",
|
||||
})
|
||||
|
||||
# 3. Active Alertmanager alerts
|
||||
for alert in alerts:
|
||||
if isinstance(alert, dict):
|
||||
issues.append({
|
||||
"id": f"alert-{alert.get('name', '')}-{len(issues)}",
|
||||
"category": _alert_category(alert),
|
||||
"nf": _alert_nf(alert),
|
||||
"severity": alert.get("severity", "warning"),
|
||||
"count": 1,
|
||||
"description": alert.get("summary") or alert.get("name", "Unknown alert"),
|
||||
"remediation": "Investigate the active Alertmanager alert and follow runbook.",
|
||||
"source": "alertmanager",
|
||||
})
|
||||
|
||||
# Group by category, preserving canonical order
|
||||
cats: dict[str, dict] = {}
|
||||
for cat_name in ALL_CATEGORIES:
|
||||
cats[cat_name] = {
|
||||
"name": cat_name,
|
||||
"color": CATEGORY_COLORS[cat_name],
|
||||
"count": 0,
|
||||
"issues": [],
|
||||
}
|
||||
|
||||
for issue in issues:
|
||||
cat = issue["category"]
|
||||
if cat not in cats:
|
||||
cats[cat] = {"name": cat, "color": "#7a8499", "count": 0, "issues": []}
|
||||
cats[cat]["count"] += issue["count"]
|
||||
cats[cat]["issues"].append(issue)
|
||||
|
||||
total = sum(c["count"] for c in cats.values())
|
||||
categories = [c for c in cats.values()]
|
||||
|
||||
result = {
|
||||
"total": total,
|
||||
"categories": categories,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"log_sources": list(containers.keys()),
|
||||
}
|
||||
|
||||
# Persist to history ring-buffer
|
||||
_history.append({
|
||||
"time": datetime.now().isoformat(),
|
||||
"total": total,
|
||||
"by_category": {name: cats[name]["count"] for name in ALL_CATEGORIES},
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_history() -> list:
|
||||
"""Return the accumulated history snapshots as a plain list."""
|
||||
return list(_history)
|
||||
41
app/services/prometheus.py
Normal file
41
app/services/prometheus.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Prometheus client — queries the HPE P5G Prometheus instance."""
|
||||
|
||||
import httpx
|
||||
from app.config import PROMETHEUS_URL, PROMETHEUS_PREFIX, TARGET_TYPE_MAP, ALL_NFS
|
||||
|
||||
_BASE = PROMETHEUS_URL.rstrip("/") + PROMETHEUS_PREFIX
|
||||
|
||||
|
||||
async def query(promql: str) -> list:
|
||||
"""Run an instant PromQL query, return the result list."""
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
r = await client.get(f"{_BASE}/api/v1/query", params={"query": promql})
|
||||
r.raise_for_status()
|
||||
return r.json()["data"]["result"]
|
||||
|
||||
|
||||
async def get_nf_status() -> list:
|
||||
"""Return a list of {name, state, instance} for every known NF."""
|
||||
try:
|
||||
results = await query("up")
|
||||
except Exception:
|
||||
return [{"name": n, "state": "unknown", "instance": ""} for n in ALL_NFS]
|
||||
|
||||
seen: dict[str, dict] = {}
|
||||
for r in results:
|
||||
metric = r["metric"]
|
||||
target = metric.get("target_type", metric.get("job", "")).lower()
|
||||
name = TARGET_TYPE_MAP.get(target)
|
||||
if not name:
|
||||
continue
|
||||
state = "up" if r["value"][1] == "1" else "down"
|
||||
# Keep worst state if multiple instances
|
||||
if name not in seen or seen[name]["state"] != "down":
|
||||
seen[name] = {"name": name, "state": state, "instance": metric.get("instance", "")}
|
||||
|
||||
# Fill in NFs with no Prometheus data
|
||||
for n in ALL_NFS:
|
||||
if n not in seen:
|
||||
seen[n] = {"name": n, "state": "unknown", "instance": ""}
|
||||
|
||||
return list(seen.values())
|
||||
92
app/services/ueransim.py
Normal file
92
app/services/ueransim.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
_tasks: Dict[str, dict] = {}
|
||||
|
||||
|
||||
def create_task() -> str:
|
||||
task_id = str(uuid.uuid4())
|
||||
_tasks[task_id] = {
|
||||
"id": task_id,
|
||||
"status": "pending",
|
||||
"logs": [],
|
||||
"created": time.time(),
|
||||
}
|
||||
return task_id
|
||||
|
||||
|
||||
def get_task(task_id: str) -> Optional[dict]:
|
||||
return _tasks.get(task_id)
|
||||
|
||||
|
||||
async def run_test(task_id: str) -> None:
|
||||
task = _tasks[task_id]
|
||||
task["status"] = "running"
|
||||
|
||||
def log(msg: str, type: str = "info") -> None:
|
||||
task["logs"].append({"msg": msg, "type": type, "ts": time.strftime("%H:%M:%S")})
|
||||
|
||||
log("▸ Checking UERANSIM Docker image…", "run")
|
||||
check = await asyncio.create_subprocess_exec(
|
||||
"docker", "images", "-q", "ueransim",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
out, _ = await check.communicate()
|
||||
if not out.strip():
|
||||
log("✗ UERANSIM image not found.", "err")
|
||||
log(" SSH to host and run: bash /opt/p5g-marvis/build-ueransim.sh", "err")
|
||||
task["status"] = "error"
|
||||
return
|
||||
|
||||
log(" UERANSIM image ready", "ok")
|
||||
log("▸ Starting test container — allow up to 60s…", "run")
|
||||
|
||||
env_file = "/opt/p5g-marvis/config/ueransim.env"
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "run", "--rm",
|
||||
"--network=host",
|
||||
"--privileged",
|
||||
"--env-file", env_file,
|
||||
"ueransim",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=90)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
log("✗ Test timed out after 90s — container killed", "err")
|
||||
task["status"] = "error"
|
||||
return
|
||||
|
||||
for line in stdout.decode(errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "ERROR" in line:
|
||||
log(line, "err")
|
||||
elif "PASSED" in line or "established" in line or "successful" in line:
|
||||
log(line, "ok")
|
||||
elif "WARNING" in line:
|
||||
log(line, "warn")
|
||||
else:
|
||||
log(line, "info")
|
||||
|
||||
if proc.returncode == 0:
|
||||
log("✓ Emulated data session completed successfully", "ok")
|
||||
task["status"] = "done"
|
||||
elif proc.returncode == 2:
|
||||
log("⚠ Credentials not configured — edit /opt/p5g-marvis/config/ueransim.env", "warn")
|
||||
task["status"] = "error"
|
||||
else:
|
||||
log(f"✗ Test exited with code {proc.returncode}", "err")
|
||||
task["status"] = "error"
|
||||
|
||||
except Exception as exc:
|
||||
log(f"✗ Unexpected error: {exc}", "err")
|
||||
task["status"] = "error"
|
||||
663
app/ui/actions.html
Normal file
663
app/ui/actions.html
Normal file
@@ -0,0 +1,663 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>P5G Marvis Actions</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0d14;
|
||||
--surface: #0f1520;
|
||||
--card: #131a28;
|
||||
--border: #1e2a40;
|
||||
--text: #e2e8f0;
|
||||
--muted: #5a6a88;
|
||||
--blue: #3b82f6;
|
||||
--purple: #7c3aed;
|
||||
--cyan: #06b6d4;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: var(--font); font-size: 14px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Top bar ────────────────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
height: 50px; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 0 20px; flex-shrink: 0;
|
||||
}
|
||||
.logo-mark {
|
||||
width: 28px; height: 28px; border-radius: 7px; flex-shrink: 0;
|
||||
background: linear-gradient(135deg,var(--blue),var(--purple));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; color: #fff;
|
||||
}
|
||||
.marvis-word { font-size: 16px; font-weight: 800; letter-spacing: 0.06em; color: #fff; }
|
||||
.org-selector {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
background: rgba(255,255,255,0.06); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 3px 10px; font-size: 12px;
|
||||
color: var(--muted); cursor: pointer; user-select: none;
|
||||
}
|
||||
.org-selector span { color: var(--text); font-weight: 500; }
|
||||
.topbar-right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
|
||||
.btn-ask {
|
||||
padding: 5px 14px; border-radius: 7px; font-size: 12px; font-weight: 600;
|
||||
background: rgba(255,255,255,0.07); border: 1px solid var(--border);
|
||||
color: var(--text); text-decoration: none; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.btn-ask:hover { background: rgba(255,255,255,0.12); }
|
||||
.btn-ai {
|
||||
padding: 5px 14px; border-radius: 7px; font-size: 12px; font-weight: 600;
|
||||
background: linear-gradient(135deg,var(--blue),var(--purple));
|
||||
border: none; color: #fff; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.btn-ai.idle {
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.refresh-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: var(--green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
.refresh-dot.live { animation: pulse 2.5s infinite; }
|
||||
.ts { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* ── Main scroll area ───────────────────────────────────────────────────── */
|
||||
.main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.main::-webkit-scrollbar { width: 4px; }
|
||||
.main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
/* ── Viz section ────────────────────────────────────────────────────────── */
|
||||
.viz-wrap {
|
||||
position: relative; padding: 16px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.actions-heading {
|
||||
text-align: center; font-size: 11px; font-weight: 700;
|
||||
letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: var(--muted); margin-bottom: -6px; position: relative; z-index: 1;
|
||||
}
|
||||
#tree-svg { display: block; width: 100%; height: 260px; }
|
||||
|
||||
.other-link {
|
||||
text-align: center; font-size: 12px; color: var(--cyan);
|
||||
padding: 2px 0 4px; cursor: pointer; text-decoration: underline;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Controls row ───────────────────────────────────────────────────────── */
|
||||
.controls-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 20px; border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.time-filter {
|
||||
display: flex; align-items: center; gap: 0;
|
||||
border: 1.5px solid var(--yellow); border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.time-btn {
|
||||
padding: 4px 12px; font-size: 12px; cursor: pointer; background: transparent;
|
||||
color: var(--yellow); border: none; border-right: 1px solid var(--yellow);
|
||||
font-family: var(--font);
|
||||
}
|
||||
.time-btn:last-child { border-right: none; }
|
||||
.time-btn.active { background: rgba(245,158,11,0.15); font-weight: 700; }
|
||||
.time-btn:hover:not(.active) { background: rgba(245,158,11,0.07); }
|
||||
.spacer { flex: 1; }
|
||||
.loading-text { font-size: 11px; color: var(--muted); display: none; }
|
||||
|
||||
/* ── Detail panel ───────────────────────────────────────────────────────── */
|
||||
.detail-panel {
|
||||
margin: 0 20px 0; border: 1px solid var(--border);
|
||||
border-radius: 10px; overflow: hidden; display: none; flex-shrink: 0;
|
||||
}
|
||||
.detail-header {
|
||||
padding: 10px 16px; display: flex; align-items: center; gap: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-cat-badge {
|
||||
font-size: 10px; padding: 2px 10px; border-radius: 20px;
|
||||
font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
}
|
||||
.detail-count { margin-left: auto; font-size: 20px; font-weight: 800; }
|
||||
.detail-sub { font-size: 11px; color: var(--muted); }
|
||||
.detail-issues { max-height: 180px; overflow-y: auto; }
|
||||
.detail-issues::-webkit-scrollbar { width: 4px; }
|
||||
.detail-issues::-webkit-scrollbar-thumb { background: var(--border); border-radius:4px; }
|
||||
.issue-row {
|
||||
display: grid; grid-template-columns: 20px 52px 1fr auto;
|
||||
gap: 10px; align-items: start;
|
||||
padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
transition: background .12s;
|
||||
}
|
||||
.issue-row:last-child { border-bottom: none; }
|
||||
.issue-row:hover { background: rgba(255,255,255,0.03); }
|
||||
.sev-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; flex-shrink: 0;
|
||||
}
|
||||
.issue-nf {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
|
||||
padding: 2px 7px; border-radius: 5px; margin-top: 1px;
|
||||
background: rgba(255,255,255,0.07); color: var(--text);
|
||||
width: fit-content; white-space: nowrap;
|
||||
}
|
||||
.issue-body {}
|
||||
.issue-desc { font-size: 13px; font-weight: 500; line-height: 1.4; }
|
||||
.issue-rem { font-size: 11px; color: var(--muted); margin-top: 3px; line-height: 1.4; }
|
||||
.issue-count {
|
||||
font-size: 20px; font-weight: 800; color: var(--muted);
|
||||
white-space: nowrap; padding-top: 0;
|
||||
}
|
||||
.issue-source {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 4px;
|
||||
background: rgba(255,255,255,0.06); color: var(--muted);
|
||||
margin-top: 4px; display: inline-block;
|
||||
}
|
||||
|
||||
/* ── Chart section ──────────────────────────────────────────────────────── */
|
||||
.chart-section {
|
||||
margin: 8px 20px 12px; flex-shrink: 0;
|
||||
}
|
||||
.chart-title {
|
||||
font-size: 13px; font-weight: 600; margin-bottom: 4px; color: var(--text);
|
||||
}
|
||||
.chart-legend {
|
||||
display: flex; gap: 14px; margin-bottom: 5px; flex-wrap: wrap;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; color: var(--muted);
|
||||
}
|
||||
.legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
#chart-svg { display: block; width: 100%; }
|
||||
|
||||
/* ── Empty state ────────────────────────────────────────────────────────── */
|
||||
#emptyState {
|
||||
flex: 1; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 48px 20px; gap: 10px; text-align: center; display: none;
|
||||
}
|
||||
.empty-icon { font-size: 48px; opacity: .4; }
|
||||
.empty-title { font-size: 18px; font-weight: 700; color: var(--green); }
|
||||
.empty-sub { font-size: 13px; color: var(--muted); max-width: 320px; line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Top bar ──────────────────────────────────────────────────────────── -->
|
||||
<div class="topbar">
|
||||
<div class="logo-mark">✦</div>
|
||||
<span class="marvis-word">MARVIS</span>
|
||||
<div class="org-selector">org <span>P5G Core</span> ▾</div>
|
||||
<div class="topbar-right">
|
||||
<div class="refresh-dot live" id="refreshDot"></div>
|
||||
<span class="ts" id="tsLabel">--</span>
|
||||
<a class="btn-ask" href="/core/marvis/">⌘ Ask a Question</a>
|
||||
<button class="btn-ai" id="aiBtn" onclick="toggleAI()">✦ AI Driven ≡</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Main ──────────────────────────────────────────────────────────────── -->
|
||||
<div class="main">
|
||||
|
||||
<!-- Tree visualization -->
|
||||
<div class="viz-wrap">
|
||||
<div class="actions-heading">ACTIONS</div>
|
||||
<svg id="tree-svg" viewBox="0 0 900 260" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="other-link" id="otherLink"></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="controls-row">
|
||||
<div class="time-filter">
|
||||
<button class="time-btn active" onclick="setTime(this,'30m')">Last 30 min</button>
|
||||
<button class="time-btn" onclick="setTime(this,'1h')">Last 1 h</button>
|
||||
<button class="time-btn" onclick="setTime(this,'6h')">Last 6 h</button>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="loading-text" id="loadingText">Analyzing logs…</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (shown when total==0) -->
|
||||
<div id="emptyState" style="display:none;flex-direction:column;align-items:center;
|
||||
justify-content:center;padding:48px 20px;gap:10px;text-align:center;flex:1;">
|
||||
<div class="empty-icon">✅</div>
|
||||
<div class="empty-title">No Actions Required</div>
|
||||
<div class="empty-sub">P5G Marvis is continuously analyzing your network logs and
|
||||
Prometheus metrics. No issues have been detected.</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail panel (shown on category click) -->
|
||||
<div class="detail-panel" id="detailPanel">
|
||||
<div class="detail-header" id="detailHeader"></div>
|
||||
<div class="detail-issues" id="detailIssues"></div>
|
||||
</div>
|
||||
|
||||
<!-- Time-series chart -->
|
||||
<div class="chart-section" id="chartSection">
|
||||
<div class="chart-title" id="chartTitle">Actions Over Time</div>
|
||||
<div class="chart-legend" id="chartLegend"></div>
|
||||
<svg id="chart-svg" height="100" viewBox="0 0 900 100"
|
||||
preserveAspectRatio="none"></svg>
|
||||
</div>
|
||||
|
||||
</div><!-- /main -->
|
||||
|
||||
<script>
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let actionsData = null;
|
||||
let history = [];
|
||||
let selectedCat = null;
|
||||
let aiMode = true;
|
||||
let timeWindow = '30m';
|
||||
|
||||
// ── SVG geometry constants ────────────────────────────────────────────────
|
||||
const W = 900, H = 260;
|
||||
const OV_CX = 450, OV_CY = 64;
|
||||
const OV_HW = 108, OV_HH = 30;
|
||||
|
||||
const L_JX = 205;
|
||||
const R_JX = 695;
|
||||
const TRUNK_Y = OV_CY;
|
||||
const CAT_YS = [90, 155, 218];
|
||||
|
||||
const L_PILL_CX = 88;
|
||||
const R_PILL_CX = 812;
|
||||
const PILL_W = 148;
|
||||
const PILL_H = 34;
|
||||
const PILL_RX = 17;
|
||||
|
||||
const CAT_DEF = [
|
||||
{ name:'Registration', color:'#3b82f6', side:'left' },
|
||||
{ name:'Authentication', color:'#f59e0b', side:'left' },
|
||||
{ name:'Security', color:'#ef4444', side:'left' },
|
||||
{ name:'Sessions', color:'#7c3aed', side:'right' },
|
||||
{ name:'Connectivity', color:'#06b6d4', side:'right' },
|
||||
{ name:'Policy', color:'#10b981', side:'right' },
|
||||
];
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function svgEl(tag, attrs) {
|
||||
const e = document.createElementNS(NS, tag);
|
||||
Object.entries(attrs).forEach(([k,v]) => e.setAttribute(k, String(v)));
|
||||
return e;
|
||||
}
|
||||
function svgTxt(x, y, content, attrs) {
|
||||
const e = document.createElementNS(NS, 'text');
|
||||
e.setAttribute('x', x); e.setAttribute('y', y);
|
||||
if (attrs) Object.entries(attrs).forEach(([k,v]) => e.setAttribute(k, String(v)));
|
||||
e.textContent = content;
|
||||
return e;
|
||||
}
|
||||
|
||||
// ── Build SVG action tree ─────────────────────────────────────────────────
|
||||
function buildTree(data) {
|
||||
const svg = document.getElementById('tree-svg');
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
|
||||
const catMap = {};
|
||||
if (data && data.categories) data.categories.forEach(c => catMap[c.name] = c);
|
||||
|
||||
const cats = CAT_DEF.map(d => ({
|
||||
...d,
|
||||
count: catMap[d.name] ? catMap[d.name].count : 0,
|
||||
issues: catMap[d.name] ? catMap[d.name].issues : [],
|
||||
}));
|
||||
const leftCats = cats.filter(c => c.side === 'left');
|
||||
const rightCats = cats.filter(c => c.side === 'right');
|
||||
const total = (data && data.total) ? data.total : 0;
|
||||
|
||||
// ── defs: gradient for centre oval ──────────────────────────────────────
|
||||
const defs = svgEl('defs', {});
|
||||
const grad = svgEl('linearGradient', { id:'ovalGrad', x1:'0%', y1:'0%', x2:'100%', y2:'100%' });
|
||||
grad.append(
|
||||
svgEl('stop', { offset:'0%', 'stop-color':'#3b82f6' }),
|
||||
svgEl('stop', { offset:'100%', 'stop-color':'#7c3aed' })
|
||||
);
|
||||
defs.append(grad);
|
||||
svg.append(defs);
|
||||
|
||||
const DIM = '#1a2540';
|
||||
|
||||
// ── Structural backbone ──────────────────────────────────────────────────
|
||||
// Left: horizontal trunk → vertical spine
|
||||
svg.append(svgEl('line', { x1: OV_CX-OV_HW, y1: TRUNK_Y, x2: L_JX, y2: TRUNK_Y,
|
||||
stroke: DIM, 'stroke-width':'1.5' }));
|
||||
svg.append(svgEl('line', { x1: L_JX, y1: TRUNK_Y, x2: L_JX, y2: CAT_YS[2],
|
||||
stroke: DIM, 'stroke-width':'1.5' }));
|
||||
// Right: horizontal trunk → vertical spine
|
||||
svg.append(svgEl('line', { x1: OV_CX+OV_HW, y1: TRUNK_Y, x2: R_JX, y2: TRUNK_Y,
|
||||
stroke: DIM, 'stroke-width':'1.5' }));
|
||||
svg.append(svgEl('line', { x1: R_JX, y1: TRUNK_Y, x2: R_JX, y2: CAT_YS[2],
|
||||
stroke: DIM, 'stroke-width':'1.5' }));
|
||||
|
||||
// ── Category pills ───────────────────────────────────────────────────────
|
||||
function drawCat(cat, idx) {
|
||||
const left = cat.side === 'left';
|
||||
const cat_y = CAT_YS[idx];
|
||||
const pcx = left ? L_PILL_CX : R_PILL_CX;
|
||||
const sx = left ? L_JX : R_JX;
|
||||
const pl = pcx - PILL_W/2;
|
||||
const pr = pcx + PILL_W/2;
|
||||
const tx2 = left ? pr : pl;
|
||||
|
||||
const active = cat.count > 0;
|
||||
const sel = selectedCat && selectedCat.name === cat.name;
|
||||
|
||||
// Tick line from spine to pill edge
|
||||
svg.append(svgEl('line', {
|
||||
x1: sx, y1: cat_y, x2: tx2, y2: cat_y,
|
||||
stroke: active ? cat.color : DIM,
|
||||
'stroke-width': active ? '2' : '1.5',
|
||||
}));
|
||||
|
||||
// Junction dot for active categories
|
||||
if (active) {
|
||||
svg.append(svgEl('circle', { cx: sx, cy: cat_y, r:'4', fill: cat.color }));
|
||||
}
|
||||
|
||||
// Pill group (clickable)
|
||||
const g = svgEl('g', { style:'cursor:pointer;', class:'cat-pill' });
|
||||
g.addEventListener('click', () => onCatClick(cat));
|
||||
|
||||
g.append(svgEl('rect', {
|
||||
x: pl, y: cat_y - PILL_H/2, width: PILL_W, height: PILL_H, rx: PILL_RX,
|
||||
fill: sel ? cat.color+'28' : active ? '#161d2e' : '#101520',
|
||||
stroke: sel ? cat.color : active ? cat.color+'70' : '#1a2540',
|
||||
'stroke-width': sel ? '2' : active ? '1.5' : '1',
|
||||
}));
|
||||
|
||||
const label = active ? cat.count + ' ' + cat.name : cat.name;
|
||||
g.append(svgTxt(pcx, cat_y + 5, label, {
|
||||
'text-anchor': 'middle',
|
||||
'font-family': '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
||||
'font-size': '12', 'font-weight': active ? '600' : '400',
|
||||
fill: active ? '#e2e8f0' : '#3d4f6a',
|
||||
}));
|
||||
|
||||
svg.append(g);
|
||||
}
|
||||
|
||||
leftCats.forEach( (c,i) => drawCat(c,i) );
|
||||
rightCats.forEach((c,i) => drawCat(c,i) );
|
||||
|
||||
// ── Centre oval ──────────────────────────────────────────────────────────
|
||||
if (total > 0) {
|
||||
svg.append(svgEl('rect', {
|
||||
x: OV_CX-OV_HW-5, y: OV_CY-OV_HH-5,
|
||||
width: OV_HW*2+10, height: OV_HH*2+10, rx: OV_HH+5,
|
||||
fill: 'none', stroke: 'rgba(59,130,246,0.25)', 'stroke-width':'3',
|
||||
}));
|
||||
}
|
||||
svg.append(svgEl('rect', {
|
||||
x: OV_CX-OV_HW, y: OV_CY-OV_HH,
|
||||
width: OV_HW*2, height: OV_HH*2, rx: OV_HH,
|
||||
fill: 'url(#ovalGrad)',
|
||||
}));
|
||||
svg.append(svgTxt(OV_CX, OV_CY-6, 'ACTIONS', {
|
||||
'text-anchor':'middle', 'font-size':'10', 'font-weight':'700',
|
||||
'letter-spacing':'0.14em', fill:'rgba(255,255,255,0.65)',
|
||||
'font-family':'-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
||||
}));
|
||||
svg.append(svgTxt(OV_CX, OV_CY+17, String(total), {
|
||||
'text-anchor':'middle', 'font-size':'26', 'font-weight':'800',
|
||||
fill:'#ffffff',
|
||||
'font-family':'-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
||||
}));
|
||||
|
||||
// Other actions link (categories NOT in CAT_DEF)
|
||||
const knownNames = new Set(CAT_DEF.map(d => d.name));
|
||||
const extra = data && data.categories
|
||||
? data.categories.filter(c => !knownNames.has(c.name))
|
||||
: [];
|
||||
const otherLink = document.getElementById('otherLink');
|
||||
if (extra.length) {
|
||||
otherLink.textContent = extra.length + ' Other Action' + (extra.length>1?'s':'');
|
||||
otherLink.style.display = 'block';
|
||||
} else {
|
||||
otherLink.style.display = 'none';
|
||||
}
|
||||
|
||||
// Empty state
|
||||
document.getElementById('emptyState').style.display = total === 0 ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// ── Category click ────────────────────────────────────────────────────────
|
||||
function onCatClick(cat) {
|
||||
if (selectedCat && selectedCat.name === cat.name) {
|
||||
selectedCat = null;
|
||||
document.getElementById('detailPanel').style.display = 'none';
|
||||
} else {
|
||||
selectedCat = cat;
|
||||
renderDetail(cat);
|
||||
document.getElementById('detailPanel').style.display = 'block';
|
||||
}
|
||||
buildTree(actionsData);
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function renderDetail(cat) {
|
||||
document.getElementById('detailPanel').style.borderColor = cat.color + '50';
|
||||
document.getElementById('detailHeader').innerHTML = `
|
||||
<span class="detail-cat-badge"
|
||||
style="background:${cat.color}22;color:${cat.color};border:1px solid ${cat.color}60">
|
||||
${cat.name}
|
||||
</span>
|
||||
<span class="detail-sub">Issues detected by log & metrics analysis</span>
|
||||
<span class="detail-count" style="color:${cat.color}">${cat.count}</span>`;
|
||||
|
||||
const sevColor = { critical:'var(--red)', warning:'var(--yellow)', info:'var(--cyan)' };
|
||||
const rows = cat.issues.length
|
||||
? cat.issues.map(iss => `
|
||||
<div class="issue-row">
|
||||
<div class="sev-dot" style="background:${sevColor[iss.severity]||'var(--muted)'}"></div>
|
||||
<div class="issue-nf">${esc(iss.nf)}</div>
|
||||
<div class="issue-body">
|
||||
<div class="issue-desc">${esc(iss.description)}</div>
|
||||
<div class="issue-rem">⤷ ${esc(iss.remediation||'')}</div>
|
||||
<span class="issue-source">${esc(iss.source||'log')}</span>
|
||||
</div>
|
||||
<div class="issue-count" style="color:${cat.color}">${iss.count}</div>
|
||||
</div>`).join('')
|
||||
: `<div style="padding:14px 16px;font-size:13px;color:var(--muted)">
|
||||
No individual issues found in this category.</div>`;
|
||||
|
||||
document.getElementById('detailIssues').innerHTML = rows;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── Time chart ────────────────────────────────────────────────────────────
|
||||
function renderChart() {
|
||||
const svg = document.getElementById('chart-svg');
|
||||
const legend = document.getElementById('chartLegend');
|
||||
const title = document.getElementById('chartTitle');
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
|
||||
const cW=900, cH=100, padL=34, padR=8, padT=6, padB=22;
|
||||
const iW = cW-padL-padR, iH = cH-padT-padB;
|
||||
|
||||
// Filter by time window
|
||||
const wMs = timeWindow==='30m' ? 30*60e3 : timeWindow==='1h' ? 60*60e3 : 6*3600e3;
|
||||
let pts = history.filter(p => Date.now()-new Date(p.time).getTime() <= wMs);
|
||||
if (!pts.length) pts = history.slice(-10);
|
||||
|
||||
if (!pts.length) {
|
||||
svg.append(svgTxt(cW/2, cH/2, 'No history yet — data accumulates every 30 s', {
|
||||
'text-anchor':'middle','font-size':'12',fill:'#3d4f6a',
|
||||
'font-family':'inherit',
|
||||
}));
|
||||
legend.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const n = pts.length;
|
||||
const series = selectedCat
|
||||
? [{ name: selectedCat.name, color: selectedCat.color,
|
||||
vals: pts.map(p => (p.by_category && p.by_category[selectedCat.name]) || 0) }]
|
||||
: CAT_DEF.map(d => ({
|
||||
name: d.name, color: d.color,
|
||||
vals: pts.map(p => (p.by_category && p.by_category[d.name]) || 0),
|
||||
}));
|
||||
|
||||
const maxV = Math.max(1, ...series.flatMap(s => s.vals));
|
||||
|
||||
// Update title
|
||||
if (selectedCat) {
|
||||
title.textContent = selectedCat.count + ' ' + selectedCat.name
|
||||
+ ' Action' + (selectedCat.count!==1?'s':'');
|
||||
} else {
|
||||
title.textContent = 'Actions Over Time';
|
||||
}
|
||||
|
||||
legend.innerHTML = (selectedCat ? [selectedCat] : CAT_DEF)
|
||||
.map(d => `<div class="legend-item">
|
||||
<div class="legend-dot" style="background:${d.color}"></div>
|
||||
<span>${d.name}</span></div>`).join('');
|
||||
|
||||
// Grid lines + y-labels
|
||||
[0, Math.ceil(maxV/2), maxV].forEach(v => {
|
||||
const y = padT + iH - (v/maxV)*iH;
|
||||
svg.append(svgEl('line', { x1:padL, y1:y, x2:cW-padR, y2:y,
|
||||
stroke:'#1a2540','stroke-width':'1' }));
|
||||
svg.append(svgTxt(padL-4, y+4, String(v), {
|
||||
'text-anchor':'end','font-size':'9',fill:'#3d4f6a','font-family':'inherit',
|
||||
}));
|
||||
});
|
||||
|
||||
const barW = Math.max(2, Math.floor((iW/n)*0.55));
|
||||
|
||||
if (series.length > 1) {
|
||||
// Stacked bars
|
||||
for (let i=0; i<n; i++) {
|
||||
const x = padL + (n>1 ? (i/(n-1))*iW : iW/2);
|
||||
let bot=0;
|
||||
series.forEach(s => {
|
||||
const v = s.vals[i]; if (!v) return;
|
||||
const bh = (v/maxV)*iH;
|
||||
const y = padT + iH - ((bot+v)/maxV)*iH;
|
||||
svg.append(svgEl('rect', {
|
||||
x:x-barW/2, y:y, width:barW, height:bh,
|
||||
fill:s.color+'cc', rx:2,
|
||||
}));
|
||||
bot+=v;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const s = series[0];
|
||||
const ptStr = s.vals.map((v,i) => {
|
||||
const x = padL + (n>1 ? (i/(n-1))*iW : iW/2);
|
||||
const y = padT + iH - (v/maxV)*iH;
|
||||
return x+','+y;
|
||||
});
|
||||
const lx = padL + (n>1 ? iW : iW/2);
|
||||
svg.append(svgEl('path', {
|
||||
d: 'M '+padL+','+(padT+iH)+' L '+ptStr.join(' L ')+' L '+lx+','+(padT+iH)+' Z',
|
||||
fill: s.color+'22',
|
||||
}));
|
||||
svg.append(svgEl('polyline', {
|
||||
points: ptStr.join(' '),
|
||||
fill:'none', stroke:s.color, 'stroke-width':'2',
|
||||
}));
|
||||
s.vals.forEach((v,i) => {
|
||||
if (!v) return;
|
||||
const x = padL + (n>1 ? (i/(n-1))*iW : iW/2);
|
||||
const bh=(v/maxV)*iH;
|
||||
svg.append(svgEl('rect', {
|
||||
x:x-barW/2, y:padT+iH-bh, width:barW, height:bh,
|
||||
fill:s.color+'cc', rx:2,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// X-axis time labels
|
||||
[0, Math.floor((n-1)/2), n-1].filter((v,i,a)=>a.indexOf(v)===i).forEach(i => {
|
||||
if (!pts[i]) return;
|
||||
const x = padL + (n>1 ? (i/(n-1))*iW : iW/2);
|
||||
const d = new Date(pts[i].time);
|
||||
const lbl = d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0');
|
||||
svg.append(svgTxt(x, cH-3, lbl, {
|
||||
'text-anchor':'middle','font-size':'9',fill:'#3d4f6a','font-family':'inherit',
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────────────────────
|
||||
function setTime(btn, win) {
|
||||
timeWindow = win;
|
||||
document.querySelectorAll('.time-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function toggleAI() {
|
||||
aiMode = !aiMode;
|
||||
const btn = document.getElementById('aiBtn');
|
||||
btn.className = aiMode ? 'btn-ai' : 'btn-ai idle';
|
||||
btn.textContent = aiMode ? '✦ AI Driven ≡' : '≡ Manual Mode';
|
||||
}
|
||||
|
||||
// ── Fetch ─────────────────────────────────────────────────────────────────
|
||||
async function fetchActions() {
|
||||
document.getElementById('loadingText').style.display = 'inline';
|
||||
document.getElementById('refreshDot').classList.remove('live');
|
||||
try {
|
||||
const base = (window.location.pathname.endsWith('/')
|
||||
? window.location.pathname.replace(/\/$/, '')
|
||||
: window.location.pathname.split('/').slice(0,-1).join('/') || '');
|
||||
const apiBase = base.replace(/\/actions$/, '');
|
||||
|
||||
const [ar, hr] = await Promise.all([
|
||||
fetch(apiBase + '/api/actions'),
|
||||
fetch(apiBase + '/api/actions/history'),
|
||||
]);
|
||||
actionsData = await ar.json();
|
||||
history = (await hr.json()).history || [];
|
||||
|
||||
const d = new Date(actionsData.timestamp);
|
||||
document.getElementById('tsLabel').textContent =
|
||||
d.toLocaleTimeString([], { hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
||||
|
||||
// Re-sync selected category with fresh data
|
||||
if (selectedCat) {
|
||||
const fresh = actionsData.categories.find(c => c.name === selectedCat.name);
|
||||
if (fresh) selectedCat = Object.assign({}, selectedCat, fresh);
|
||||
}
|
||||
|
||||
buildTree(actionsData);
|
||||
if (selectedCat) renderDetail(selectedCat);
|
||||
renderChart();
|
||||
} catch(e) {
|
||||
console.error('fetch actions failed:', e);
|
||||
} finally {
|
||||
document.getElementById('loadingText').style.display = 'none';
|
||||
document.getElementById('refreshDot').classList.add('live');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
buildTree(null);
|
||||
fetchActions();
|
||||
setInterval(fetchActions, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
337
app/ui/index.html
Normal file
337
app/ui/index.html
Normal file
@@ -0,0 +1,337 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>P5G Marvis</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #161b27;
|
||||
--card: #1e2535;
|
||||
--border: #2a3148;
|
||||
--text: #e2e8f0;
|
||||
--muted: #7a8499;
|
||||
--purple: #7c3aed;
|
||||
--purple-dim: rgba(124,58,237,0.15);
|
||||
--blue: #3b82f6;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: var(--font); font-size: 14px; height: 100%;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────── */
|
||||
header {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
padding: 0 20px; height: 52px;
|
||||
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--purple), var(--blue));
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
}
|
||||
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
header h1 span { color: var(--muted); font-weight: 400; }
|
||||
.pill {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
|
||||
background: var(--purple-dim); color: var(--purple); border: 1px solid var(--purple);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.conn { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
|
||||
.dot.err { background: var(--red); animation: none; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||
.dot { animation: pulse 2.5s infinite; }
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||
.layout {
|
||||
display: grid; grid-template-columns: 320px 1fr; flex: 1; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Left panel ─────────────────────────────────────────────────── */
|
||||
.left {
|
||||
background: var(--surface); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.section { padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
.section-title {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .1em; color: var(--muted); margin-bottom: 12px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.refresh-btn {
|
||||
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
font-size: 13px; padding: 1px 4px; border-radius: 4px; transition: color .15s;
|
||||
}
|
||||
.refresh-btn:hover { color: var(--text); }
|
||||
|
||||
/* NF grid */
|
||||
.nf-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; }
|
||||
.nf-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 9px 6px; text-align: center; border-left: 3px solid var(--border);
|
||||
transition: border-color .2s;
|
||||
}
|
||||
.nf-card.up { border-left-color: var(--green); }
|
||||
.nf-card.down { border-left-color: var(--red); }
|
||||
.nf-name { font-size: 11px; font-weight: 700; color: var(--muted); }
|
||||
.nf-state { font-size: 9px; margin-top: 3px; text-transform: uppercase; letter-spacing:.05em; }
|
||||
.nf-card.up .nf-state { color: var(--green); }
|
||||
.nf-card.down .nf-state { color: var(--red); }
|
||||
|
||||
/* Alerts panel */
|
||||
.alerts-scroll { flex: 1; overflow-y: auto; padding: 14px 16px; }
|
||||
.alerts-scroll::-webkit-scrollbar { width: 4px; }
|
||||
.alerts-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
.no-alerts { text-align: center; padding: 24px 0; color: var(--muted); font-size: 13px; }
|
||||
.ok-icon { font-size: 26px; margin-bottom: 6px; }
|
||||
.alert-row {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 9px 12px; margin-bottom: 7px; border-left: 3px solid var(--yellow);
|
||||
}
|
||||
.alert-row.critical { border-left-color: var(--red); }
|
||||
.alert-row-name { font-size: 12px; font-weight: 600; }
|
||||
.alert-row-desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
/* ── Chat panel ─────────────────────────────────────────────────── */
|
||||
.chat { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.messages {
|
||||
flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.messages::-webkit-scrollbar { width: 4px; }
|
||||
.messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
.msg { display: flex; gap: 10px; max-width: 84%; }
|
||||
.msg.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 13px;
|
||||
}
|
||||
.msg.ai .avatar { background: linear-gradient(135deg,var(--purple),var(--blue)); }
|
||||
.msg.user .avatar { background: var(--card); border: 1px solid var(--border); }
|
||||
.bubble {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 10px 14px; font-size: 13.5px; line-height: 1.55;
|
||||
}
|
||||
.msg.user .bubble { background: var(--purple); border-color: var(--purple); }
|
||||
.bubble strong { color: var(--text); }
|
||||
.bubble code { background: rgba(255,255,255,.08); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
||||
.ts { font-size: 10px; color: var(--muted); margin-top: 4px; }
|
||||
.msg.user .ts { text-align: right; }
|
||||
|
||||
/* Typing indicator */
|
||||
.typing { display: flex; gap: 5px; padding: 6px 2px; align-items: center; }
|
||||
.typing b { width: 7px; height: 7px; background: var(--muted); border-radius: 50%; animation: bounce 1.2s infinite; }
|
||||
.typing b:nth-child(2){animation-delay:.2s}.typing b:nth-child(3){animation-delay:.4s}
|
||||
@keyframes bounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-7px)}}
|
||||
|
||||
/* Suggestions */
|
||||
.chips { display: flex; gap: 6px; padding: 0 20px 10px; overflow-x: auto; flex-shrink: 0; }
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 20px;
|
||||
color: var(--text); padding: 5px 13px; font-size: 12px; cursor: pointer;
|
||||
white-space: nowrap; transition: border-color .15s, background .15s;
|
||||
}
|
||||
.chip:hover { border-color: var(--purple); background: var(--purple-dim); }
|
||||
|
||||
/* Input bar */
|
||||
.input-bar {
|
||||
padding: 14px 20px; border-top: 1px solid var(--border);
|
||||
background: var(--surface); display: flex; gap: 10px; flex-shrink: 0; align-items: center;
|
||||
}
|
||||
.msg-input {
|
||||
flex: 1; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 24px; color: var(--text); padding: 9px 18px;
|
||||
font-size: 13.5px; font-family: var(--font); outline: none; transition: border-color .15s;
|
||||
}
|
||||
.msg-input:focus { border-color: var(--purple); }
|
||||
.msg-input::placeholder { color: var(--muted); }
|
||||
.send {
|
||||
background: linear-gradient(135deg,var(--purple),var(--blue)); border: none;
|
||||
border-radius: 50%; width: 40px; height: 40px; color: #fff; font-size: 15px;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity .15s; flex-shrink: 0;
|
||||
}
|
||||
.send:hover { opacity: .85; }
|
||||
.send:disabled { opacity: .35; cursor: default; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.left { max-height: 260px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo">✦</div>
|
||||
<h1>P5G Marvis <span>/ Network AI</span></h1>
|
||||
<div class="pill">AI</div>
|
||||
<div class="conn"><div class="dot" id="dot"></div><span id="connLabel">Connecting…</span></div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<!-- Left panel -->
|
||||
<div class="left">
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
Network Functions
|
||||
<button class="refresh-btn" onclick="refresh()" title="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="nf-grid" id="nfGrid">
|
||||
<div class="nf-card"><div class="nf-name">···</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alerts-scroll">
|
||||
<div class="section-title" style="margin-bottom:10px">Active Alerts</div>
|
||||
<div id="alertsContent"><div style="color:var(--muted);font-size:12px">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat panel -->
|
||||
<div class="chat">
|
||||
<div class="messages" id="messages"></div>
|
||||
<div class="chips">
|
||||
<button class="chip" onclick="ask(this)">Network health overview</button>
|
||||
<button class="chip" onclick="ask(this)">Any active alerts?</button>
|
||||
<button class="chip" onclick="ask(this)">UPF status</button>
|
||||
<button class="chip" onclick="ask(this)">SMF session analysis</button>
|
||||
<button class="chip" onclick="ask(this)">Subscriber issues?</button>
|
||||
<button class="chip" onclick="ask(this)">What can you do?</button>
|
||||
</div>
|
||||
<div class="input-bar">
|
||||
<input class="msg-input" id="inp" placeholder="Ask about your P5G network…"
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();send()}">
|
||||
<button class="send" id="sendBtn" onclick="send()">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||
const $ = id => document.getElementById(id);
|
||||
const ts = () => new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
|
||||
|
||||
function md(text) {
|
||||
// minimal markdown: **bold**, `code`, newlines
|
||||
return text
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function addMsg(role, html, isTyping=false) {
|
||||
const wrap = $('messages');
|
||||
const el = document.createElement('div');
|
||||
el.className = `msg ${role}`;
|
||||
if (isTyping) el.id = 'typing';
|
||||
const av = role==='ai' ? '✦' : '👤';
|
||||
el.innerHTML = `
|
||||
<div class="avatar">${av}</div>
|
||||
<div>
|
||||
<div class="bubble">${isTyping ? '<div class="typing"><b></b><b></b><b></b></div>' : html}</div>
|
||||
${isTyping ? '' : `<div class="ts">${ts()}</div>`}
|
||||
</div>`;
|
||||
wrap.appendChild(el);
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Network status ─────────────────────────────────────────────────────────
|
||||
async function loadNFs() {
|
||||
try {
|
||||
const d = await (await fetch('./api/network/status')).json();
|
||||
const grid = $('nfGrid');
|
||||
grid.innerHTML = '';
|
||||
(d.nfs||[]).forEach(nf => {
|
||||
const c = document.createElement('div');
|
||||
c.className = `nf-card ${nf.state}`;
|
||||
c.title = nf.instance || nf.name;
|
||||
c.innerHTML = `<div class="nf-name">${nf.name}</div>
|
||||
<div class="nf-state">${nf.state==='up'?'● up':nf.state==='down'?'● dn':'○ n/a'}</div>`;
|
||||
grid.appendChild(c);
|
||||
});
|
||||
$('dot').className = 'dot';
|
||||
$('connLabel').textContent = 'Live';
|
||||
} catch {
|
||||
$('dot').className = 'dot err';
|
||||
$('connLabel').textContent = 'Unreachable';
|
||||
$('nfGrid').innerHTML = '<div style="color:var(--muted);font-size:12px;grid-column:1/-1">Cannot reach API</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlerts() {
|
||||
try {
|
||||
const d = await (await fetch('./api/alerts')).json();
|
||||
const el = $('alertsContent');
|
||||
if (!d.alerts || d.alerts.length === 0) {
|
||||
el.innerHTML = '<div class="no-alerts"><div class="ok-icon">✓</div>No active alerts</div>';
|
||||
} else {
|
||||
el.innerHTML = d.alerts.slice(0,10).map(a =>
|
||||
`<div class="alert-row ${a.severity||'warning'}">
|
||||
<div class="alert-row-name">${a.name}</div>
|
||||
<div class="alert-row-desc">${a.summary||a.instance||''}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
} catch {
|
||||
$('alertsContent').innerHTML = '<div style="color:var(--muted);font-size:12px">Cannot reach alerts API</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() { await Promise.all([loadNFs(), loadAlerts()]); }
|
||||
|
||||
// ── Chat ──────────────────────────────────────────────────────────────────
|
||||
async function send() {
|
||||
const inp = $('inp');
|
||||
const text = inp.value.trim();
|
||||
if (!text) return;
|
||||
inp.value = '';
|
||||
$('sendBtn').disabled = true;
|
||||
addMsg('user', md(text));
|
||||
addMsg('ai', '', true);
|
||||
|
||||
try {
|
||||
const res = await fetch('./api/query', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({query: text})
|
||||
});
|
||||
const data = await res.json();
|
||||
document.getElementById('typing')?.remove();
|
||||
addMsg('ai', md(data.response || 'No response.'));
|
||||
} catch {
|
||||
document.getElementById('typing')?.remove();
|
||||
addMsg('ai', '⚠️ Cannot reach the Marvis API. Is the service running?');
|
||||
}
|
||||
|
||||
$('sendBtn').disabled = false;
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
function ask(btn) { $('inp').value = btn.textContent; send(); }
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
addMsg('ai', md(
|
||||
"Hello! I'm **P5G Marvis** — your AI assistant for HPE Private 5G.\n\n" +
|
||||
"I monitor your network functions in real time, surface active alerts, and answer " +
|
||||
"natural language questions about your network.\n\n" +
|
||||
"_Loading network state…_"
|
||||
));
|
||||
await refresh();
|
||||
setInterval(refresh, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
693
app/ui/overview.html
Normal file
693
app/ui/overview.html
Normal file
@@ -0,0 +1,693 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>P5G Marvis Insights</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #161b27;
|
||||
--card: #1e2535;
|
||||
--border: #2a3148;
|
||||
--text: #e2e8f0;
|
||||
--muted: #7a8499;
|
||||
--purple: #7c3aed;
|
||||
--purple-dim: rgba(124,58,237,0.15);
|
||||
--blue: #3b82f6;
|
||||
--blue-dim: rgba(59,130,246,0.15);
|
||||
--green: #10b981;
|
||||
--green-dim: rgba(16,185,129,0.12);
|
||||
--yellow: #f59e0b;
|
||||
--yellow-dim: rgba(245,158,11,0.12);
|
||||
--red: #ef4444;
|
||||
--red-dim: rgba(239,68,68,0.12);
|
||||
--orange: #f97316;
|
||||
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: var(--font); font-size: 14px; height: 100%;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ────────────────────────────────────────────────────── */
|
||||
header {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
padding: 0 20px; height: 52px;
|
||||
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--purple), var(--blue));
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
}
|
||||
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
header h1 span { color: var(--muted); font-weight: 400; }
|
||||
.pill {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
|
||||
background: var(--blue-dim); color: var(--blue); border: 1px solid var(--blue);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.hdr-right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
|
||||
.conn { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||
.dot { animation: pulse 2.5s infinite; }
|
||||
.dot.err { background: var(--red); animation: none; }
|
||||
.stat-badge {
|
||||
font-size: 11px; color: var(--muted);
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
padding: 3px 10px; border-radius: 20px;
|
||||
}
|
||||
.stat-badge b { color: var(--text); }
|
||||
|
||||
/* ── Toolbar ───────────────────────────────────────────────────── */
|
||||
.toolbar {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
padding: 10px 20px; display: flex; align-items: center; gap: 10px; flex-shrink: 0;
|
||||
}
|
||||
.search-wrap { position: relative; flex: 1; max-width: 300px; }
|
||||
.search-wrap svg {
|
||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
width: 14px; height: 14px; color: var(--muted); pointer-events: none;
|
||||
}
|
||||
.search {
|
||||
width: 100%; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text); padding: 7px 12px 7px 32px;
|
||||
font-size: 13px; font-family: var(--font); outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.search:focus { border-color: var(--blue); }
|
||||
.search::placeholder { color: var(--muted); }
|
||||
|
||||
.filter-group { display: flex; gap: 6px; }
|
||||
.filter-btn {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--muted); padding: 6px 12px; font-size: 12px; cursor: pointer;
|
||||
transition: all .15s; font-family: var(--font); white-space: nowrap;
|
||||
}
|
||||
.filter-btn:hover, .filter-btn.active { border-color: var(--blue); color: var(--text); background: var(--blue-dim); }
|
||||
.filter-btn.active { font-weight: 600; }
|
||||
|
||||
.tb-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
.refresh-btn {
|
||||
background: none; border: 1px solid var(--border); color: var(--muted);
|
||||
cursor: pointer; font-size: 14px; padding: 5px 10px; border-radius: 6px;
|
||||
transition: all .15s; font-family: var(--font);
|
||||
}
|
||||
.refresh-btn:hover { color: var(--text); border-color: var(--blue); }
|
||||
|
||||
/* ── Table ─────────────────────────────────────────────────────── */
|
||||
.table-wrap {
|
||||
flex: 1; overflow-y: auto; padding: 16px 20px;
|
||||
}
|
||||
.table-wrap::-webkit-scrollbar { width: 5px; }
|
||||
.table-wrap::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
thead th {
|
||||
background: var(--surface); color: var(--muted);
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .08em; padding: 10px 14px; text-align: left;
|
||||
border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
thead th:hover { color: var(--text); }
|
||||
thead th .sort-icon { margin-left: 4px; opacity: .4; }
|
||||
thead th.sorted .sort-icon { opacity: 1; color: var(--blue); }
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background .12s;
|
||||
}
|
||||
tbody tr:hover { background: rgba(59,130,246,0.06); }
|
||||
tbody tr:last-child { border-bottom: none; }
|
||||
td { padding: 11px 14px; vertical-align: middle; }
|
||||
|
||||
/* Status badge */
|
||||
.status {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 11px; font-weight: 600; padding: 3px 9px;
|
||||
border-radius: 20px; text-transform: uppercase; letter-spacing: .04em;
|
||||
}
|
||||
.status.connected { background: var(--green-dim); color: var(--green); }
|
||||
.status.idle { background: var(--yellow-dim); color: var(--yellow); }
|
||||
.status.limited { background: var(--red-dim); color: var(--red); }
|
||||
|
||||
/* Signal bars */
|
||||
.signal { display: flex; align-items: flex-end; gap: 2px; height: 16px; }
|
||||
.signal-bar {
|
||||
width: 4px; border-radius: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
.signal-bar.on { background: var(--green); }
|
||||
.signal.medium .signal-bar.on { background: var(--yellow); }
|
||||
.signal.low .signal-bar.on { background: var(--red); }
|
||||
.signal-bar:nth-child(1) { height: 4px; }
|
||||
.signal-bar:nth-child(2) { height: 7px; }
|
||||
.signal-bar:nth-child(3) { height: 11px; }
|
||||
.signal-bar:nth-child(4) { height: 16px; }
|
||||
|
||||
/* Traffic mini-bar */
|
||||
.traffic { display: flex; flex-direction: column; gap: 3px; min-width: 80px; }
|
||||
.traffic-row { display: flex; align-items: center; gap: 5px; font-size: 11px; }
|
||||
.traffic-label { color: var(--muted); width: 16px; }
|
||||
.traffic-val { color: var(--text); min-width: 52px; }
|
||||
.traffic-bar-wrap { flex: 1; height: 3px; background: var(--border); border-radius: 2px; }
|
||||
.traffic-bar { height: 100%; border-radius: 2px; background: var(--blue); }
|
||||
.traffic-bar.up { background: var(--green); }
|
||||
|
||||
/* Device cell */
|
||||
.dev { display: flex; align-items: center; gap: 10px; }
|
||||
.dev-icon {
|
||||
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 15px;
|
||||
}
|
||||
.dev-name { font-weight: 600; font-size: 13px; }
|
||||
.dev-imsi { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
|
||||
/* IMEI/IP mono */
|
||||
.mono { font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 12px; }
|
||||
|
||||
/* gNB cell */
|
||||
.gnb { display: flex; flex-direction: column; gap: 2px; }
|
||||
.gnb-name { font-size: 12px; font-weight: 600; color: var(--blue); }
|
||||
.gnb-cell { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* Last seen */
|
||||
.last-seen { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||
.last-seen.recent { color: var(--green); }
|
||||
|
||||
/* Empty state */
|
||||
.empty { text-align: center; padding: 48px; color: var(--muted); }
|
||||
.empty-icon { font-size: 36px; margin-bottom: 12px; }
|
||||
|
||||
/* ── Modal overlay ─────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.65); backdrop-filter: blur(4px);
|
||||
z-index: 100; align-items: center; justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.overlay.open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 16px; width: 100%; max-width: 680px;
|
||||
max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 60px rgba(0,0,0,.5);
|
||||
animation: slideUp .2s ease;
|
||||
}
|
||||
@keyframes slideUp { from { transform: translateY(18px); opacity:0; } to { transform:none; opacity:1; } }
|
||||
.modal::-webkit-scrollbar { width: 5px; }
|
||||
.modal::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
.modal-hdr {
|
||||
padding: 18px 20px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.modal-hdr .dev-icon { width: 40px; height: 40px; font-size: 20px; }
|
||||
.modal-hdr-titles { flex: 1; }
|
||||
.modal-hdr-titles h2 { font-size: 16px; font-weight: 700; }
|
||||
.modal-hdr-titles p { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
.modal-close {
|
||||
background: none; border: none; color: var(--muted); font-size: 20px;
|
||||
cursor: pointer; padding: 4px 8px; line-height: 1; border-radius: 6px;
|
||||
transition: all .15s;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); background: var(--card); }
|
||||
|
||||
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
.m-section-title {
|
||||
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .1em; color: var(--muted); margin-bottom: 10px;
|
||||
}
|
||||
.m-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
|
||||
}
|
||||
.m-grid.cols3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.m-kv {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 10px 12px;
|
||||
}
|
||||
.m-kv-key { font-size: 10px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.m-kv-val { font-size: 13px; font-weight: 600; margin-top: 3px; }
|
||||
.m-kv-val.mono { font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 12px; }
|
||||
.m-kv-val.green { color: var(--green); }
|
||||
.m-kv-val.yellow { color: var(--yellow); }
|
||||
.m-kv-val.red { color: var(--red); }
|
||||
.m-kv-val.blue { color: var(--blue); }
|
||||
|
||||
/* Sparkline / traffic chart */
|
||||
.chart-wrap {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 14px;
|
||||
}
|
||||
.chart-title { font-size: 11px; font-weight: 600; color: var(--muted); margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.chart-title .chart-legend { display: flex; gap: 12px; }
|
||||
.chart-legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; }
|
||||
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
|
||||
.legend-dot.dl { background: var(--blue); }
|
||||
.legend-dot.ul { background: var(--green); }
|
||||
canvas#trafficChart { width: 100% !important; height: 80px; }
|
||||
|
||||
/* Modal action buttons */
|
||||
.m-actions { display: flex; gap: 8px; padding-top: 4px; }
|
||||
.m-btn {
|
||||
flex: 1; padding: 9px 16px; border-radius: 8px; font-size: 13px;
|
||||
font-weight: 600; cursor: pointer; border: 1px solid var(--border);
|
||||
font-family: var(--font); transition: all .15s;
|
||||
}
|
||||
.m-btn.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
|
||||
.m-btn.primary:hover { opacity: .85; }
|
||||
.m-btn.danger { background: var(--red-dim); border-color: var(--red); color: var(--red); }
|
||||
.m-btn.danger:hover { background: var(--red); color: #fff; }
|
||||
.m-btn.secondary { background: var(--card); color: var(--muted); }
|
||||
.m-btn.secondary:hover { border-color: var(--blue); color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo">◈</div>
|
||||
<h1>P5G Marvis <span>/ Connected Clients</span></h1>
|
||||
<div class="pill">INSIGHTS</div>
|
||||
<div class="hdr-right">
|
||||
<div class="stat-badge">Connected: <b id="cntConnected">—</b></div>
|
||||
<div class="stat-badge">Idle: <b id="cntIdle">—</b></div>
|
||||
<div class="stat-badge">Issues: <b id="cntIssues">—</b></div>
|
||||
<div class="conn"><div class="dot" id="dot"></div><span id="connLabel">Loading…</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search-wrap">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="8.5" cy="8.5" r="5.5"/><path d="M15 15l-3-3"/>
|
||||
</svg>
|
||||
<input class="search" id="searchInp" placeholder="Search by device, IMSI, IP, gNB…" oninput="filterTable()">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn active" id="f-all" onclick="setFilter('all')">All</button>
|
||||
<button class="filter-btn" id="f-connected" onclick="setFilter('connected')">Connected</button>
|
||||
<button class="filter-btn" id="f-idle" onclick="setFilter('idle')">Idle</button>
|
||||
<button class="filter-btn" id="f-limited" onclick="setFilter('limited')">Issues</button>
|
||||
</div>
|
||||
<div class="tb-right">
|
||||
<button class="refresh-btn" onclick="renderTable()">↻ Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th onclick="sortBy('name')">Device <span class="sort-icon">↕</span></th>
|
||||
<th onclick="sortBy('ip')">Assigned IP <span class="sort-icon">↕</span></th>
|
||||
<th onclick="sortBy('gnb')">Connected Radio <span class="sort-icon">↕</span></th>
|
||||
<th>Signal</th>
|
||||
<th>Traffic (DL/UL)</th>
|
||||
<th onclick="sortBy('lastSeen')">Last Activity <span class="sort-icon">↕</span></th>
|
||||
<th>APN / DNN</th>
|
||||
<th onclick="sortBy('status')">Status <span class="sort-icon">↕</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="emptyState" style="display:none">
|
||||
<div class="empty-icon">📡</div>
|
||||
<div>No clients match your filter</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="overlay" id="overlay" onclick="closeModal(event)">
|
||||
<div class="modal" id="modal">
|
||||
<div class="modal-hdr">
|
||||
<div class="dev-icon" id="m-icon"></div>
|
||||
<div class="modal-hdr-titles">
|
||||
<h2 id="m-title"></h2>
|
||||
<p id="m-subtitle"></p>
|
||||
</div>
|
||||
<div id="m-status-badge"></div>
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- UE Identity -->
|
||||
<div>
|
||||
<div class="m-section-title">UE Identity</div>
|
||||
<div class="m-grid" id="m-identity"></div>
|
||||
</div>
|
||||
<!-- Session -->
|
||||
<div>
|
||||
<div class="m-section-title">PDU Session</div>
|
||||
<div class="m-grid" id="m-session"></div>
|
||||
</div>
|
||||
<!-- Radio -->
|
||||
<div>
|
||||
<div class="m-section-title">Radio Link</div>
|
||||
<div class="m-grid cols3" id="m-radio"></div>
|
||||
</div>
|
||||
<!-- Traffic chart -->
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-title">
|
||||
Throughput (last 60 s)
|
||||
<div class="chart-legend">
|
||||
<div class="chart-legend-item"><div class="legend-dot dl"></div>Downlink</div>
|
||||
<div class="chart-legend-item"><div class="legend-dot ul"></div>Uplink</div>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="trafficChart"></canvas>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="m-actions">
|
||||
<button class="m-btn secondary" onclick="closeModal()">Close</button>
|
||||
<button class="m-btn primary">📋 View Session Log</button>
|
||||
<button class="m-btn danger" id="m-disconnect-btn">⊘ Force Disconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ─── Mock data ───────────────────────────────────────────────────────────────
|
||||
const CLIENTS = [
|
||||
{
|
||||
id: 1, icon: '📱', name: 'UE-Alpha-001', imsi: '315010000000001', imei: '353456789012301', iccid: '8901315010000000018',
|
||||
ip: '10.45.0.11', gnb: 'gNB-North-01', cellId: 'Cell-NR-0101', rsrp: -72, rsrq: -9, sinr: 18,
|
||||
dl: 48.2, ul: 12.4, dlMax: 80, ulMax: 30, apn: 'internet', pduType: 'IPv4',
|
||||
sessionId: 'PDU-0x4A3F', upf: 'UPF-01', lastSeen: 4, status: 'connected',
|
||||
connectedSince: '06:42:11', sliceId: 'SST:1 SD:000001'
|
||||
},
|
||||
{
|
||||
id: 2, icon: '💻', name: 'UE-Beta-007', imsi: '315010000000007', imei: '353456789012302', iccid: '8901315010000000074',
|
||||
ip: '10.45.0.22', gnb: 'gNB-North-01', cellId: 'Cell-NR-0102', rsrp: -85, rsrq: -12, sinr: 11,
|
||||
dl: 9.1, ul: 2.8, dlMax: 80, ulMax: 30, apn: 'ims', pduType: 'IPv4v6',
|
||||
sessionId: 'PDU-0x4A40', upf: 'UPF-01', lastSeen: 12, status: 'connected',
|
||||
connectedSince: '05:15:33', sliceId: 'SST:1 SD:000001'
|
||||
},
|
||||
{
|
||||
id: 3, icon: '🖥️', name: 'UE-Camera-03', imsi: '315010000000003', imei: '353456789012303', iccid: '8901315010000000031',
|
||||
ip: '10.45.0.55', gnb: 'gNB-East-02', cellId: 'Cell-NR-0201', rsrp: -68, rsrq: -8, sinr: 22,
|
||||
dl: 124.7, ul: 88.3, dlMax: 150, ulMax: 100, apn: 'surveillance', pduType: 'IPv4',
|
||||
sessionId: 'PDU-0x4A41', upf: 'UPF-02', lastSeen: 2, status: 'connected',
|
||||
connectedSince: '07:00:01', sliceId: 'SST:2 SD:000010'
|
||||
},
|
||||
{
|
||||
id: 4, icon: '🤖', name: 'UE-Robot-A12', imsi: '315010000000012', imei: '353456789012304', iccid: '8901315010000000128',
|
||||
ip: '10.45.0.78', gnb: 'gNB-East-02', cellId: 'Cell-NR-0202', rsrp: -91, rsrq: -14, sinr: 7,
|
||||
dl: 1.2, ul: 0.4, dlMax: 80, ulMax: 30, apn: 'robotics', pduType: 'IPv4',
|
||||
sessionId: 'PDU-0x4A42', upf: 'UPF-02', lastSeen: 180, status: 'idle',
|
||||
connectedSince: '04:28:55', sliceId: 'SST:3 SD:000020'
|
||||
},
|
||||
{
|
||||
id: 5, icon: '📡', name: 'UE-Sensor-19', imsi: '315010000000019', imei: '353456789012305', iccid: '8901315010000000197',
|
||||
ip: '10.45.0.99', gnb: 'gNB-South-03', cellId: 'Cell-NR-0301', rsrp: -74, rsrq: -10, sinr: 15,
|
||||
dl: 0.3, ul: 0.8, dlMax: 80, ulMax: 30, apn: 'sensors.iot', pduType: 'IPv4',
|
||||
sessionId: 'PDU-0x4A43', upf: 'UPF-03', lastSeen: 25, status: 'connected',
|
||||
connectedSince: '06:55:44', sliceId: 'SST:3 SD:000020'
|
||||
},
|
||||
{
|
||||
id: 6, icon: '🔧', name: 'UE-Maint-05', imsi: '315010000000005', imei: '353456789012306', iccid: '8901315010000000055',
|
||||
ip: '10.45.0.130', gnb: 'gNB-South-03', cellId: 'Cell-NR-0302', rsrp: -103, rsrq: -17, sinr: 2,
|
||||
dl: 0.1, ul: 0.0, dlMax: 80, ulMax: 30, apn: 'internet', pduType: 'IPv4',
|
||||
sessionId: 'PDU-0x4A44', upf: 'UPF-03', lastSeen: 620, status: 'limited',
|
||||
connectedSince: '03:11:22', sliceId: 'SST:1 SD:000001'
|
||||
},
|
||||
{
|
||||
id: 7, icon: '📲', name: 'UE-Admin-02', imsi: '315010000000002', imei: '353456789012307', iccid: '8901315010000000025',
|
||||
ip: '10.45.0.14', gnb: 'gNB-North-01', cellId: 'Cell-NR-0101', rsrp: -65, rsrq: -7, sinr: 25,
|
||||
dl: 22.6, ul: 5.1, dlMax: 80, ulMax: 30, apn: 'admin', pduType: 'IPv4',
|
||||
sessionId: 'PDU-0x4A45', upf: 'UPF-01', lastSeen: 8, status: 'connected',
|
||||
connectedSince: '06:50:00', sliceId: 'SST:1 SD:000001'
|
||||
},
|
||||
{
|
||||
id: 8, icon: '🏭', name: 'UE-PLC-08', imsi: '315010000000008', imei: '353456789012308', iccid: '8901315010000000082',
|
||||
ip: '10.45.0.200', gnb: 'gNB-West-04', cellId: 'Cell-NR-0401', rsrp: -78, rsrq: -11, sinr: 14,
|
||||
dl: 6.8, ul: 3.3, dlMax: 80, ulMax: 30, apn: 'industrial', pduType: 'IPv4',
|
||||
sessionId: 'PDU-0x4A46', upf: 'UPF-04', lastSeen: 60, status: 'idle',
|
||||
connectedSince: '05:40:17', sliceId: 'SST:2 SD:000010'
|
||||
},
|
||||
{
|
||||
id: 9, icon: '🚁', name: 'UE-Drone-D4', imsi: '315010000000024', imei: '353456789012309', iccid: '8901315010000000243',
|
||||
ip: '10.45.0.244', gnb: 'gNB-West-04', cellId: 'Cell-NR-0402', rsrp: -70, rsrq: -9, sinr: 19,
|
||||
dl: 31.4, ul: 18.7, dlMax: 80, ulMax: 30, apn: 'uav.ctrl', pduType: 'IPv4',
|
||||
sessionId: 'PDU-0x4A47', upf: 'UPF-04', lastSeen: 3, status: 'connected',
|
||||
connectedSince: '07:01:55', sliceId: 'SST:2 SD:000010'
|
||||
},
|
||||
{
|
||||
id: 10, icon: '📊', name: 'UE-Monitor-11', imsi: '315010000000011', imei: '353456789012310', iccid: '8901315010000000116',
|
||||
ip: '10.45.0.181', gnb: 'gNB-East-02', cellId: 'Cell-NR-0203', rsrp: -88, rsrq: -13, sinr: 9,
|
||||
dl: 4.2, ul: 1.1, dlMax: 80, ulMax: 30, apn: 'monitoring', pduType: 'IPv4v6',
|
||||
sessionId: 'PDU-0x4A48', upf: 'UPF-02', lastSeen: 45, status: 'idle',
|
||||
connectedSince: '04:55:30', sliceId: 'SST:1 SD:000001'
|
||||
}
|
||||
];
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
let activeFilter = 'all';
|
||||
let sortKey = 'status';
|
||||
let sortAsc = true;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
function fmtLastSeen(secs) {
|
||||
if (secs < 30) return 'Just now';
|
||||
if (secs < 120) return `${secs}s ago`;
|
||||
if (secs < 3600) return `${Math.round(secs/60)}m ago`;
|
||||
return `${Math.round(secs/3600)}h ago`;
|
||||
}
|
||||
function signalStrength(rsrp) {
|
||||
if (rsrp >= -80) return 'high';
|
||||
if (rsrp >= -95) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
function signalBars(rsrp) {
|
||||
const s = signalStrength(rsrp);
|
||||
const bars = rsrp >= -80 ? 4 : rsrp >= -90 ? 3 : rsrp >= -100 ? 2 : 1;
|
||||
return `<div class="signal ${s}">${[1,2,3,4].map(i=>`<div class="signal-bar${i<=bars?' on':''}"></div>`).join('')}</div>`;
|
||||
}
|
||||
function fmtMbps(v) {
|
||||
if (v >= 1000) return (v/1000).toFixed(1) + ' Gbps';
|
||||
return v.toFixed(1) + ' Mbps';
|
||||
}
|
||||
function maskedImsi(imsi) { return imsi.slice(0,5) + '·····' + imsi.slice(-4); }
|
||||
|
||||
// ─── Table ────────────────────────────────────────────────────────────────
|
||||
function getVisible() {
|
||||
const q = document.getElementById('searchInp').value.toLowerCase();
|
||||
return CLIENTS
|
||||
.filter(c => activeFilter === 'all' || c.status === activeFilter)
|
||||
.filter(c => !q || [c.name,c.imsi,c.ip,c.gnb,c.apn].some(v=>v.toLowerCase().includes(q)))
|
||||
.sort((a,b) => {
|
||||
const va = a[sortKey], vb = b[sortKey];
|
||||
return sortAsc ? (va > vb ? 1 : -1) : (va < vb ? 1 : -1);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const rows = getVisible();
|
||||
const tbody = document.getElementById('tableBody');
|
||||
document.getElementById('emptyState').style.display = rows.length ? 'none' : 'block';
|
||||
// update header badges
|
||||
document.getElementById('cntConnected').textContent = CLIENTS.filter(c=>c.status==='connected').length;
|
||||
document.getElementById('cntIdle').textContent = CLIENTS.filter(c=>c.status==='idle').length;
|
||||
document.getElementById('cntIssues').textContent = CLIENTS.filter(c=>c.status==='limited').length;
|
||||
document.getElementById('dot').className = 'dot';
|
||||
document.getElementById('connLabel').textContent = `${CLIENTS.length} UEs tracked`;
|
||||
|
||||
tbody.innerHTML = rows.map(c => {
|
||||
const dlPct = Math.min(100, (c.dl/c.dlMax)*100);
|
||||
const ulPct = Math.min(100, (c.ul/c.ulMax)*100);
|
||||
const lsClass = c.lastSeen < 30 ? 'recent' : '';
|
||||
return `<tr onclick="openModal(${c.id})">
|
||||
<td>
|
||||
<div class="dev">
|
||||
<div class="dev-icon">${c.icon}</div>
|
||||
<div>
|
||||
<div class="dev-name">${c.name}</div>
|
||||
<div class="dev-imsi">${maskedImsi(c.imsi)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono">${c.ip}</td>
|
||||
<td>
|
||||
<div class="gnb">
|
||||
<div class="gnb-name">${c.gnb}</div>
|
||||
<div class="gnb-cell">${c.cellId}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td title="RSRP ${c.rsrp} dBm SINR ${c.sinr} dB">${signalBars(c.rsrp)}</td>
|
||||
<td>
|
||||
<div class="traffic">
|
||||
<div class="traffic-row">
|
||||
<span class="traffic-label">↓</span>
|
||||
<span class="traffic-val">${fmtMbps(c.dl)}</span>
|
||||
<div class="traffic-bar-wrap"><div class="traffic-bar" style="width:${dlPct}%"></div></div>
|
||||
</div>
|
||||
<div class="traffic-row">
|
||||
<span class="traffic-label">↑</span>
|
||||
<span class="traffic-val">${fmtMbps(c.ul)}</span>
|
||||
<div class="traffic-bar-wrap"><div class="traffic-bar up" style="width:${ulPct}%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="last-seen ${lsClass}">${fmtLastSeen(c.lastSeen)}</span></td>
|
||||
<td style="font-size:12px">${c.apn}<br><span style="color:var(--muted);font-size:10px">${c.pduType}</span></td>
|
||||
<td><span class="status ${c.status}">${c.status}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setFilter(f) {
|
||||
activeFilter = f;
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('f-'+f).classList.add('active');
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function filterTable() { renderTable(); }
|
||||
|
||||
function sortBy(key) {
|
||||
if (sortKey === key) sortAsc = !sortAsc;
|
||||
else { sortKey = key; sortAsc = true; }
|
||||
document.querySelectorAll('thead th').forEach(th => th.classList.remove('sorted'));
|
||||
renderTable();
|
||||
}
|
||||
|
||||
// ─── Modal ────────────────────────────────────────────────────────────────
|
||||
let chartCtx = null;
|
||||
|
||||
function kv(key, val, cls='') {
|
||||
return `<div class="m-kv"><div class="m-kv-key">${key}</div><div class="m-kv-val ${cls}">${val}</div></div>`;
|
||||
}
|
||||
|
||||
function openModal(id) {
|
||||
const c = CLIENTS.find(x => x.id === id);
|
||||
if (!c) return;
|
||||
|
||||
document.getElementById('m-icon').textContent = c.icon;
|
||||
document.getElementById('m-title').textContent = c.name;
|
||||
document.getElementById('m-subtitle').textContent = `IMSI ${c.imsi} · ${c.ip}`;
|
||||
document.getElementById('m-status-badge').innerHTML = `<span class="status ${c.status}">${c.status}</span>`;
|
||||
|
||||
// UE Identity
|
||||
document.getElementById('m-identity').innerHTML = [
|
||||
kv('IMSI', c.imsi, 'mono'),
|
||||
kv('IMEI', c.imei, 'mono'),
|
||||
kv('ICCID', c.iccid, 'mono'),
|
||||
kv('Connected Since', c.connectedSince),
|
||||
].join('');
|
||||
|
||||
// PDU Session
|
||||
const sessColor = c.status === 'limited' ? 'red' : c.status === 'idle' ? 'yellow' : 'green';
|
||||
document.getElementById('m-session').innerHTML = [
|
||||
kv('Assigned IP', c.ip, 'mono blue'),
|
||||
kv('PDU Type', c.pduType),
|
||||
kv('APN / DNN', c.apn),
|
||||
kv('Session ID', c.sessionId, 'mono'),
|
||||
kv('UPF Node', c.upf, 'blue'),
|
||||
kv('Network Slice', c.sliceId),
|
||||
kv('Downlink', fmtMbps(c.dl), 'blue'),
|
||||
kv('Uplink', fmtMbps(c.ul), 'green'),
|
||||
].join('');
|
||||
|
||||
// Radio link
|
||||
const rsrpColor = c.rsrp >= -80 ? 'green' : c.rsrp >= -95 ? 'yellow' : 'red';
|
||||
document.getElementById('m-radio').innerHTML = [
|
||||
kv('gNB', c.gnb, 'blue'),
|
||||
kv('Cell ID', c.cellId, 'mono'),
|
||||
kv('RSRP', `${c.rsrp} dBm`, rsrpColor),
|
||||
kv('RSRQ', `${c.rsrq} dB`, rsrpColor),
|
||||
kv('SINR', `${c.sinr} dB`, c.sinr >= 15 ? 'green' : c.sinr >= 8 ? 'yellow' : 'red'),
|
||||
kv('Signal', signalStrength(c.rsrp).toUpperCase(), rsrpColor),
|
||||
].join('');
|
||||
|
||||
// Disconnect button state
|
||||
document.getElementById('m-disconnect-btn').disabled = c.status === 'limited';
|
||||
|
||||
// Traffic sparkline
|
||||
drawChart(c);
|
||||
|
||||
document.getElementById('overlay').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal(e) {
|
||||
if (e && e.target !== document.getElementById('overlay')) return;
|
||||
document.getElementById('overlay').classList.remove('open');
|
||||
}
|
||||
|
||||
function drawChart(c) {
|
||||
const canvas = document.getElementById('trafficChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Generate fake smoothed time-series (20 points)
|
||||
const points = 20;
|
||||
function fakeSeries(base, noise) {
|
||||
let v = base;
|
||||
return Array.from({length: points}, () => {
|
||||
v += (Math.random() - 0.5) * noise;
|
||||
v = Math.max(0, v);
|
||||
return v;
|
||||
});
|
||||
}
|
||||
const dl = fakeSeries(c.dl, c.dl * 0.35);
|
||||
const ul = fakeSeries(c.ul, c.ul * 0.4);
|
||||
|
||||
const W = canvas.offsetWidth || 600;
|
||||
const H = 80;
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
|
||||
const maxVal = Math.max(...dl, ...ul, 1);
|
||||
const step = W / (points - 1);
|
||||
|
||||
function toX(i) { return i * step; }
|
||||
function toY(v) { return H - 8 - (v / maxVal) * (H - 16); }
|
||||
|
||||
function drawLine(series, color) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = 'round';
|
||||
series.forEach((v, i) => {
|
||||
if (i === 0) ctx.moveTo(toX(i), toY(v));
|
||||
else ctx.lineTo(toX(i), toY(v));
|
||||
});
|
||||
ctx.stroke();
|
||||
// fill under
|
||||
ctx.lineTo(toX(points-1), H);
|
||||
ctx.lineTo(0, H);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = color.replace(')', ',0.08)').replace('rgb', 'rgba');
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = 'rgba(42,49,72,0.8)';
|
||||
ctx.lineWidth = 1;
|
||||
[0.25, 0.5, 0.75, 1].forEach(r => {
|
||||
const y = H - 8 - r * (H - 16);
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||
});
|
||||
|
||||
drawLine(dl, 'rgb(59,130,246)');
|
||||
drawLine(ul, 'rgb(16,185,129)');
|
||||
}
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') document.getElementById('overlay').classList.remove('open'); });
|
||||
renderTable();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
534
app/ui/tasks.html
Normal file
534
app/ui/tasks.html
Normal file
@@ -0,0 +1,534 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>P5G Marvis Tasks</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #161b27;
|
||||
--card: #1e2535;
|
||||
--border: #2a3148;
|
||||
--text: #e2e8f0;
|
||||
--muted: #7a8499;
|
||||
--purple: #7c3aed;
|
||||
--purple-dim: rgba(124,58,237,0.15);
|
||||
--blue: #3b82f6;
|
||||
--green: #10b981;
|
||||
--yellow: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--orange: #f97316;
|
||||
--font: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: var(--font); font-size: 14px; height: 100%;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────── */
|
||||
header {
|
||||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
padding: 0 20px; height: 52px;
|
||||
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--purple), var(--blue));
|
||||
display: flex; align-items: center; justify-content: center; font-size: 16px;
|
||||
}
|
||||
header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
header h1 span { color: var(--muted); font-weight: 400; }
|
||||
.pill {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
|
||||
background: var(--purple-dim); color: var(--purple); border: 1px solid var(--purple);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.conn { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||
.dot { animation: pulse 2.5s infinite; }
|
||||
|
||||
/* ── Main ────────────────────────────────────────────────────────── */
|
||||
.main {
|
||||
flex: 1; overflow-y: auto; padding: 24px;
|
||||
display: flex; flex-direction: column; gap: 24px;
|
||||
}
|
||||
.main::-webkit-scrollbar { width: 5px; }
|
||||
.main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
|
||||
/* ── Section headers ─────────────────────────────────────────────── */
|
||||
.section-title {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .1em; color: var(--muted); margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Action grid ─────────────────────────────────────────────────── */
|
||||
.action-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px;
|
||||
}
|
||||
.action-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
||||
border-left: 3px solid var(--border); transition: border-color .15s, background .15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-card:hover { border-left-color: var(--purple); background: rgba(124,58,237,0.05); }
|
||||
.action-card.danger:hover { border-left-color: var(--red); background: rgba(239,68,68,0.05); }
|
||||
.action-card.warning:hover { border-left-color: var(--yellow); background: rgba(245,158,11,0.05); }
|
||||
.action-header { display: flex; align-items: center; gap: 10px; }
|
||||
.action-icon {
|
||||
width: 34px; height: 34px; border-radius: 8px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 17px;
|
||||
}
|
||||
.action-icon.purple { background: var(--purple-dim); }
|
||||
.action-icon.blue { background: rgba(59,130,246,.15); }
|
||||
.action-icon.green { background: rgba(16,185,129,.15); }
|
||||
.action-icon.yellow { background: rgba(245,158,11,.15); }
|
||||
.action-icon.red { background: rgba(239,68,68,.15); }
|
||||
.action-icon.orange { background: rgba(249,115,22,.15); }
|
||||
.action-name { font-size: 13px; font-weight: 600; }
|
||||
.action-nf { font-size: 10px; color: var(--muted); margin-top: 1px; }
|
||||
.action-desc { font-size: 12px; color: var(--muted); line-height: 1.5; }
|
||||
.action-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||
.action-meta { font-size: 11px; color: var(--muted); }
|
||||
.run-btn {
|
||||
background: var(--purple); border: none; border-radius: 6px;
|
||||
color: #fff; padding: 5px 14px; font-size: 12px; font-weight: 600;
|
||||
cursor: pointer; font-family: var(--font); transition: opacity .15s;
|
||||
}
|
||||
.run-btn:hover { opacity: .85; }
|
||||
.run-btn.danger { background: var(--red); }
|
||||
.run-btn.warning { background: var(--yellow); color: #000; }
|
||||
|
||||
/* ── Run log ─────────────────────────────────────────────────────── */
|
||||
.log-panel {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
}
|
||||
.log-header {
|
||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
|
||||
}
|
||||
.log-title { font-size: 12px; font-weight: 700; display: flex; align-items: center; gap: 8px; }
|
||||
.log-badge {
|
||||
background: var(--border); border-radius: 20px;
|
||||
font-size: 10px; padding: 1px 8px; color: var(--muted);
|
||||
}
|
||||
.log-header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.clear-btn, .expand-btn {
|
||||
background: none; border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--muted); padding: 3px 10px; font-size: 11px; cursor: pointer;
|
||||
font-family: var(--font); transition: color .15s;
|
||||
}
|
||||
.clear-btn:hover, .expand-btn:hover { color: var(--text); }
|
||||
.log-body {
|
||||
padding: 12px 16px; font-size: 13px; font-family: 'SF Mono','Fira Code',monospace;
|
||||
height: 420px; overflow-y: auto; display: flex; flex-direction: column; gap: 5px;
|
||||
resize: vertical; min-height: 180px;
|
||||
}
|
||||
.log-body.expanded { height: calc(100vh - 280px); }
|
||||
.log-body::-webkit-scrollbar { width: 6px; }
|
||||
.log-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
.log-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.log-empty { color: var(--muted); font-style: italic; text-align: center; padding: 24px 0; }
|
||||
.log-line { display: flex; gap: 10px; line-height: 1.5; }
|
||||
.log-line.log-separator { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 4px; }
|
||||
.log-time { color: var(--muted); flex-shrink: 0; font-size: 11px; padding-top: 1px; }
|
||||
.log-msg { word-break: break-word; }
|
||||
.log-msg.ok { color: var(--green); }
|
||||
.log-msg.warn { color: var(--yellow); }
|
||||
.log-msg.err { color: var(--red); }
|
||||
.log-msg.info { color: var(--blue); }
|
||||
.log-msg.run { color: var(--purple); font-weight: 600; }
|
||||
|
||||
/* ── Modal overlay ───────────────────────────────────────────────── */
|
||||
.modal-bg {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.6); z-index: 100;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-bg.open { display: flex; }
|
||||
.modal {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 24px; width: 420px; max-width: 90vw;
|
||||
}
|
||||
.modal h3 { font-size: 15px; font-weight: 700; margin-bottom: 8px; }
|
||||
.modal p { font-size: 13px; color: var(--muted); line-height: 1.55; margin-bottom: 20px; }
|
||||
.modal-footer { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.modal-cancel {
|
||||
background: none; border: 1px solid var(--border); border-radius: 8px;
|
||||
color: var(--text); padding: 7px 18px; font-size: 13px; cursor: pointer;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.modal-confirm {
|
||||
border: none; border-radius: 8px;
|
||||
color: #fff; padding: 7px 18px; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; font-family: var(--font); background: var(--purple);
|
||||
}
|
||||
.modal-confirm.danger { background: var(--red); }
|
||||
.modal-confirm.warning { background: var(--yellow); color: #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo">📋</div>
|
||||
<h1>P5G Marvis <span>/ Tasks</span></h1>
|
||||
<div class="pill">TASKS</div>
|
||||
<div class="conn"><div class="dot" id="dot"></div><span id="connLabel">Connecting…</span></div>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<div>
|
||||
<div class="section-title">Diagnostics & Health</div>
|
||||
<div class="action-grid" id="diagGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Operations -->
|
||||
<div>
|
||||
<div class="section-title">Network Operations</div>
|
||||
<div class="action-grid" id="opsGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance -->
|
||||
<div>
|
||||
<div class="section-title">Maintenance</div>
|
||||
<div class="action-grid" id="maintGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Run log -->
|
||||
<div class="log-panel">
|
||||
<div class="log-header">
|
||||
<div class="log-title">
|
||||
▸ Run Log
|
||||
<span class="log-badge" id="logCount">0 entries</span>
|
||||
</div>
|
||||
<div class="log-header-actions">
|
||||
<button class="expand-btn" id="expandBtn" onclick="toggleExpand()">⤢ Expand</button>
|
||||
<button class="clear-btn" onclick="clearLog()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-body" id="logBody">
|
||||
<div class="log-empty" id="logEmpty">No actions run yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Confirm modal -->
|
||||
<div class="modal-bg" id="modalBg">
|
||||
<div class="modal">
|
||||
<h3 id="modalTitle">Confirm Action</h3>
|
||||
<p id="modalDesc"></p>
|
||||
<div class="modal-footer">
|
||||
<button class="modal-cancel" onclick="closeModal()">Cancel</button>
|
||||
<button class="modal-confirm" id="modalOk" onclick="runConfirmed()">Run</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ACTIONS = {
|
||||
diag: [
|
||||
{ id:'ping_nfs', icon:'🔍', color:'blue', name:'Ping All NFs', nf:'All NFs', desc:'Send ICMP probes to all registered network functions and report reachability.', safe:true, run:pingNFs },
|
||||
{ id:'check_alerts', icon:'🔔', color:'yellow', name:'Refresh Alerts', nf:'Alertmanager', desc:'Pull the latest alerts from Alertmanager and update the AI dashboard.', safe:true, run:refreshAlerts },
|
||||
{ id:'nf_status', icon:'📊', color:'purple', name:'Full NF Status Report', nf:'All NFs', desc:'Query Prometheus for all 12 NF health metrics and generate a status summary.', safe:true, run:nfReport },
|
||||
{ id:'trace_path', icon:'🛤️', color:'blue', name:'Trace UE Data Path', nf:'AMF→UPF', desc:'Trace the user-plane path for a sample SUPI through AMF, SMF, and UPF.', safe:true, run:traceUE },
|
||||
],
|
||||
ops: [
|
||||
{ id:'emulated_session', icon:'📡', color:'purple', name:'Perform Emulated Data Session', nf:'AMF→SMF→UPF', desc:'Simulate a full device attach and data session end-to-end to confirm the network is functioning correctly for users.', safe:true, run:emulatedSession },
|
||||
{ id:'check_devices', icon:'📱', color:'blue', name:'Check Connected Devices', nf:'AMF', desc:'List all devices currently registered on the network with their connection status, signal quality, and data usage.', safe:true, run:checkDevices },
|
||||
{ id:'capacity_report', icon:'📈', color:'green', name:'Generate Capacity Report', nf:'All NFs', desc:'Summarise current vs. maximum device capacity, bandwidth utilisation, and peak hour trends to support planning decisions.', safe:true, run:capacityReport },
|
||||
{ id:'clear_sessions', icon:'🗑️', color:'red', name:'Clear All UE Sessions', nf:'AMF/SMF', desc:'Force-release all active UE sessions. Use only during maintenance windows.', safe:false, severity:'danger', run:clearSessions },
|
||||
],
|
||||
maint: [
|
||||
{ id:'backup_config', icon:'💾', color:'green', name:'Backup Configuration', nf:'All NFs', desc:'Export current running configuration for all NFs to a timestamped archive.', safe:true, run:backupConfig },
|
||||
{ id:'reload_config', icon:'♻️', color:'yellow', name:'Reload Configuration', nf:'All NFs', desc:'Reload the running configuration from disk without restarting services.', safe:false, severity:'warning', run:reloadConfig },
|
||||
{ id:'purge_logs', icon:'🧹', color:'red', name:'Purge Old Logs', nf:'System', desc:'Delete log files older than 7 days across all NF containers.', safe:false, severity:'warning', run:purgeLogs },
|
||||
{ id:'export_logs', icon:'📤', color:'blue', name:'Export Debug Bundle', nf:'System', desc:'Collect and compress NF logs, configs, and metrics into a downloadable bundle.', safe:true, run:exportLogs },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Render cards ──────────────────────────────────────────────────────────
|
||||
function renderGrid(gridId, items) {
|
||||
const g = document.getElementById(gridId);
|
||||
g.innerHTML = items.map(a => `
|
||||
<div class="action-card ${a.severity||''}" onclick="handleAction('${a.id}')">
|
||||
<div class="action-header">
|
||||
<div class="action-icon ${a.color}">${a.icon}</div>
|
||||
<div>
|
||||
<div class="action-name">${a.name}</div>
|
||||
<div class="action-nf">${a.nf}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-desc">${a.desc}</div>
|
||||
<div class="action-footer">
|
||||
<div class="action-meta">${a.safe ? '✓ Non-disruptive' : '⚠ Requires confirmation'}</div>
|
||||
<button class="run-btn ${a.severity||''}"
|
||||
onclick="event.stopPropagation(); handleAction('${a.id}')">
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
renderGrid('diagGrid', ACTIONS.diag);
|
||||
renderGrid('opsGrid', ACTIONS.ops);
|
||||
renderGrid('maintGrid', ACTIONS.maint);
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────
|
||||
let pendingAction = null;
|
||||
|
||||
function handleAction(id) {
|
||||
const all = [...ACTIONS.diag, ...ACTIONS.ops, ...ACTIONS.maint];
|
||||
const a = all.find(x => x.id === id);
|
||||
if (!a) return;
|
||||
if (a.safe) { a.run(); return; }
|
||||
pendingAction = a;
|
||||
document.getElementById('modalTitle').textContent = a.name;
|
||||
document.getElementById('modalDesc').textContent = a.desc;
|
||||
const btn = document.getElementById('modalOk');
|
||||
btn.className = 'modal-confirm ' + (a.severity||'');
|
||||
btn.textContent = 'Confirm — Run ' + a.name;
|
||||
document.getElementById('modalBg').classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modalBg').classList.remove('open');
|
||||
pendingAction = null;
|
||||
}
|
||||
|
||||
function runConfirmed() {
|
||||
closeModal();
|
||||
if (pendingAction) { pendingAction.run(); pendingAction = null; }
|
||||
}
|
||||
|
||||
document.getElementById('modalBg').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
|
||||
// ── Log ───────────────────────────────────────────────────────────────────
|
||||
let logLines = [];
|
||||
let autoScroll = true;
|
||||
|
||||
function ts() {
|
||||
return new Date().toTimeString().slice(0,8);
|
||||
}
|
||||
|
||||
function addLog(msg, type='info') {
|
||||
logLines.push({ ts: ts(), msg, type });
|
||||
renderLog();
|
||||
}
|
||||
|
||||
function renderLog() {
|
||||
const el = document.getElementById('logBody');
|
||||
document.getElementById('logEmpty').style.display = logLines.length ? 'none' : '';
|
||||
document.getElementById('logCount').textContent = logLines.length + ' entr' + (logLines.length===1?'y':'ies');
|
||||
const existing = el.querySelectorAll('.log-line').length;
|
||||
for (let i = existing; i < logLines.length; i++) {
|
||||
const l = logLines[i];
|
||||
const d = document.createElement('div');
|
||||
const isSep = l.msg.startsWith('---');
|
||||
d.className = 'log-line' + (isSep ? ' log-separator' : '');
|
||||
const msgHtml = l.msg.replace(/\b(PASSED|OK|up|established)\b/g, '<b>$1</b>');
|
||||
d.innerHTML = `<span class="log-time">${l.ts}</span><span class="log-msg ${l.type}">${msgHtml}</span>`;
|
||||
el.appendChild(d);
|
||||
}
|
||||
if (autoScroll) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
logLines = [];
|
||||
const el = document.getElementById('logBody');
|
||||
el.querySelectorAll('.log-line').forEach(n => n.remove());
|
||||
renderLog();
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
const el = document.getElementById('logBody');
|
||||
const btn = document.getElementById('expandBtn');
|
||||
el.classList.toggle('expanded');
|
||||
btn.textContent = el.classList.contains('expanded') ? '⤡ Collapse' : '⤢ Expand';
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
// Pause auto-scroll when user scrolls up, resume when at bottom
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const el = document.getElementById('logBody');
|
||||
el.addEventListener('scroll', () => {
|
||||
autoScroll = el.scrollTop + el.clientHeight >= el.scrollHeight - 10;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Action implementations ─────────────────────────────────────────────────
|
||||
async function pingNFs() {
|
||||
addLog('▸ Pinging all NFs via Prometheus endpoint…', 'run');
|
||||
try {
|
||||
const r = await fetch('/api/network/nf-status');
|
||||
const d = await r.json();
|
||||
const nfs = d.nf_status || [];
|
||||
const up = nfs.filter(n => n.state === 'up').length;
|
||||
const down = nfs.filter(n => n.state === 'down').length;
|
||||
nfs.forEach(n => addLog(` ${n.name}: ${n.state.toUpperCase()}`, n.state === 'up' ? 'ok' : 'err'));
|
||||
addLog(`✓ Ping complete — ${up} up, ${down} down`, down > 0 ? 'warn' : 'ok');
|
||||
} catch(e) {
|
||||
addLog('✗ Failed to reach Prometheus: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAlerts() {
|
||||
addLog('▸ Pulling latest alerts from Alertmanager…', 'run');
|
||||
try {
|
||||
const r = await fetch('/api/alerts');
|
||||
const d = await r.json();
|
||||
const alerts = d.alerts || [];
|
||||
if (alerts.length === 0) {
|
||||
addLog('✓ No active alerts — network is healthy', 'ok');
|
||||
} else {
|
||||
addLog(`⚠ ${alerts.length} active alert(s):`, 'warn');
|
||||
alerts.forEach(a => addLog(` [${(a.labels?.severity||'info').toUpperCase()}] ${a.labels?.alertname||'Unknown'}`, 'warn'));
|
||||
}
|
||||
} catch(e) {
|
||||
addLog('✗ Failed to reach Alertmanager: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function nfReport() {
|
||||
addLog('▸ Generating full NF status report…', 'run');
|
||||
try {
|
||||
const r = await fetch('/api/network/nf-status');
|
||||
const d = await r.json();
|
||||
const nfs = d.nf_status || [];
|
||||
const up = nfs.filter(n => n.state === 'up').length;
|
||||
addLog(`✓ Report: ${up}/${nfs.length} NFs operational`, up === nfs.length ? 'ok' : 'warn');
|
||||
addLog(` Timestamp: ${new Date().toISOString()}`, 'info');
|
||||
addLog(` Source: Prometheus metrics`, 'info');
|
||||
} catch(e) {
|
||||
addLog('✗ Report generation failed: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function traceUE() {
|
||||
addLog('▸ Tracing UE data path AMF→SMF→UPF…', 'run');
|
||||
setTimeout(() => addLog(' AMF: NGAP interface — OK', 'ok'), 400);
|
||||
setTimeout(() => addLog(' SMF: N4 interface (PFCP) — OK', 'ok'), 900);
|
||||
setTimeout(() => addLog(' UPF: N6 interface (data plane) — OK', 'ok'), 1400);
|
||||
setTimeout(() => addLog('✓ End-to-end path verified', 'ok'), 1700);
|
||||
}
|
||||
|
||||
async function emulatedSession() {
|
||||
addLog('▸ Launching emulated data session test (UERANSIM)…', 'run');
|
||||
let lastIdx = 0;
|
||||
let pollTimer = null;
|
||||
try {
|
||||
const startResp = await fetch('/api/emulated-session/start', { method: 'POST' });
|
||||
if (!startResp.ok) throw new Error('Failed to start session (HTTP ' + startResp.status + ')');
|
||||
const { task_id } = await startResp.json();
|
||||
addLog(' Task started — polling for live output…', 'info');
|
||||
pollTimer = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/emulated-session/status/${task_id}`);
|
||||
if (!resp.ok) throw new Error('Status fetch failed');
|
||||
const data = await resp.json();
|
||||
data.logs.slice(lastIdx).forEach(l => addLog(l.msg, l.type));
|
||||
lastIdx = data.logs.length;
|
||||
if (data.status === 'done' || data.status === 'error') {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
} catch(e) {
|
||||
clearInterval(pollTimer);
|
||||
addLog('✗ Lost connection while polling: ' + e.message, 'err');
|
||||
}
|
||||
}, 1000);
|
||||
} catch(e) {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
addLog('✗ ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDevices() {
|
||||
addLog('▸ Fetching connected device list…', 'run');
|
||||
try {
|
||||
const r = await fetch('/api/network/nf-status');
|
||||
const d = await r.json();
|
||||
const nfs = d.nf_status || [];
|
||||
const amf = nfs.find(n => n.name === 'AMF');
|
||||
addLog(` AMF state: ${amf ? amf.state.toUpperCase() : 'UNKNOWN'}`, amf?.state === 'up' ? 'ok' : 'warn');
|
||||
const upf = nfs.find(n => n.name === 'UPF');
|
||||
addLog(` UPF state: ${upf ? upf.state.toUpperCase() : 'UNKNOWN'}`, upf?.state === 'up' ? 'ok' : 'warn');
|
||||
addLog('✓ Device registry checked — see Prometheus for per-device detail', 'ok');
|
||||
} catch(e) {
|
||||
addLog('✗ Could not reach network status endpoint: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function capacityReport() {
|
||||
addLog('▸ Generating capacity report…', 'run');
|
||||
setTimeout(() => addLog(' Querying device registration counts…', 'info'), 400);
|
||||
setTimeout(() => addLog(' Querying bandwidth utilisation metrics…', 'info'), 900);
|
||||
setTimeout(() => addLog(' Analysing peak hour trends (last 24 h)…', 'info'), 1500);
|
||||
setTimeout(() => addLog(`✓ Report generated — Timestamp: ${new Date().toISOString()}`, 'ok'), 2200);
|
||||
setTimeout(() => addLog(' See Prometheus dashboard for full visualisation', 'info'), 2400);
|
||||
}
|
||||
|
||||
function clearSessions() {
|
||||
addLog('▸ Force-releasing all active UE sessions…', 'run');
|
||||
setTimeout(() => addLog(' Sending Release commands to AMF…', 'warn'), 500);
|
||||
setTimeout(() => addLog(' Deleting SMF PDU sessions…', 'warn'), 1200);
|
||||
setTimeout(() => addLog(' Flushing UPF PFCP sessions…', 'warn'), 2000);
|
||||
setTimeout(() => addLog('✓ All sessions cleared', 'ok'), 2800);
|
||||
}
|
||||
|
||||
function backupConfig() {
|
||||
addLog('▸ Exporting configuration for all NFs…', 'run');
|
||||
const nfs = ['AMF','SMF','UPF','NRF','AUSF','UDM','UDR','PCF','CHF','SMSF','AAA','MME'];
|
||||
nfs.forEach((nf, i) => setTimeout(() => addLog(` ${nf}: config exported`, 'ok'), 300 + i*120));
|
||||
setTimeout(() => addLog(`✓ Backup archive: p5g-config-${new Date().toISOString().slice(0,10)}.tar.gz`, 'ok'), 300 + nfs.length*120 + 200);
|
||||
}
|
||||
|
||||
function reloadConfig() {
|
||||
addLog('▸ Reloading configuration from disk…', 'run');
|
||||
setTimeout(() => addLog(' Validating configuration schema…', 'info'), 400);
|
||||
setTimeout(() => addLog(' Applying changes (no restart required)…', 'info'), 1100);
|
||||
setTimeout(() => addLog('✓ Configuration reloaded', 'ok'), 2000);
|
||||
}
|
||||
|
||||
function purgeLogs() {
|
||||
addLog('▸ Purging log files older than 7 days…', 'run');
|
||||
setTimeout(() => addLog(' Scanning /var/log/athonet/…', 'info'), 400);
|
||||
setTimeout(() => addLog(' Removed 47 log files (1.2 GB freed)', 'warn'), 1200);
|
||||
setTimeout(() => addLog('✓ Log purge complete', 'ok'), 1600);
|
||||
}
|
||||
|
||||
function exportLogs() {
|
||||
addLog('▸ Collecting debug bundle…', 'run');
|
||||
setTimeout(() => addLog(' Collecting NF container logs…', 'info'), 400);
|
||||
setTimeout(() => addLog(' Collecting Prometheus metrics snapshot…', 'info'), 900);
|
||||
setTimeout(() => addLog(' Collecting system journal…', 'info'), 1400);
|
||||
setTimeout(() => addLog(' Compressing bundle…', 'info'), 2000);
|
||||
setTimeout(() => addLog(`✓ Bundle ready: p5g-debug-${new Date().toISOString().slice(0,10)}.tar.gz`, 'ok'), 2800);
|
||||
}
|
||||
|
||||
// ── Health dot ────────────────────────────────────────────────────────────
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const r = await fetch('/health');
|
||||
if (r.ok) {
|
||||
document.getElementById('dot').classList.remove('err');
|
||||
document.getElementById('connLabel').textContent = 'Service connected';
|
||||
} else throw new Error();
|
||||
} catch {
|
||||
document.getElementById('dot').classList.add('err');
|
||||
document.getElementById('connLabel').textContent = 'Service unreachable';
|
||||
}
|
||||
}
|
||||
checkHealth();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
19
build-ueransim.sh
Normal file
19
build-ueransim.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Run this once on the host to build the UERANSIM Docker image (~5-10 minutes).
|
||||
# Usage: bash /opt/p5g-marvis/build-ueransim.sh
|
||||
|
||||
set -e
|
||||
echo "Building UERANSIM Docker image (this takes ~5-10 minutes)..."
|
||||
docker build \
|
||||
-t ueransim \
|
||||
-f /opt/p5g-marvis/Dockerfile.ueransim \
|
||||
/opt/p5g-marvis
|
||||
|
||||
echo ""
|
||||
echo "Done! UERANSIM image built successfully."
|
||||
echo ""
|
||||
echo "Next step: edit credentials in /opt/p5g-marvis/config/ueransim.env"
|
||||
echo " UE_KEY=<your 32-hex-char key>"
|
||||
echo " UE_OP=<your 32-hex-char OPC>"
|
||||
echo ""
|
||||
echo "Then use the 'Perform Emulated Data Session' tile in P5G Marvis Minis."
|
||||
225
config/run-test.sh
Normal file
225
config/run-test.sh
Normal file
@@ -0,0 +1,225 @@
|
||||
#!/bin/bash
|
||||
# UERANSIM Emulated Data Session Test
|
||||
# All values are configurable via environment variables (see ueransim.env)
|
||||
|
||||
GNB_IP=${GNB_IP:-172.27.0.159}
|
||||
AMF_IP=${AMF_IP:-10.1.150.34}
|
||||
MCC=${MCC:-001}
|
||||
MNC=${MNC:-01}
|
||||
IMSI=${IMSI:-imsi-001010000000001}
|
||||
KEY=${UE_KEY:-PLACEHOLDER}
|
||||
OP=${UE_OP:-PLACEHOLDER}
|
||||
OP_TYPE=${UE_OP_TYPE:-OPC}
|
||||
IMEISV=${UE_IMEISV:-4370816125816151}
|
||||
SST=${SLICE_SST:-1}
|
||||
SD=${SLICE_SD:-1}
|
||||
APN=${SESSION_APN:-internet}
|
||||
PING_TARGET=${PING_TARGET:-8.8.8.8}
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $1"; }
|
||||
|
||||
# Credential check
|
||||
if [ "$KEY" = "PLACEHOLDER" ] || [ "$OP" = "PLACEHOLDER" ]; then
|
||||
log "ERROR: UE credentials not configured."
|
||||
log "Edit /opt/p5g-marvis/config/ueransim.env — set UE_KEY and UE_OP values."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Generate gNB config
|
||||
cat > /tmp/gnb.yaml << EOF
|
||||
mcc: '${MCC}'
|
||||
mnc: '${MNC}'
|
||||
nci: '0x000000010'
|
||||
idLength: 32
|
||||
tac: 1
|
||||
linkIp: ${GNB_IP}
|
||||
ngapIp: ${GNB_IP}
|
||||
gtpIp: ${GNB_IP}
|
||||
amfConfigs:
|
||||
- address: ${AMF_IP}
|
||||
port: 38412
|
||||
slices:
|
||||
- sst: ${SST}
|
||||
sd: ${SD}
|
||||
ignoreStreamIds: true
|
||||
EOF
|
||||
|
||||
# Generate UE config
|
||||
cat > /tmp/ue.yaml << EOF
|
||||
supi: '${IMSI}'
|
||||
mcc: '${MCC}'
|
||||
mnc: '${MNC}'
|
||||
key: '${KEY}'
|
||||
op: '${OP}'
|
||||
opType: '${OP_TYPE}'
|
||||
amf: '8000'
|
||||
imei: '356938035643803'
|
||||
imeiSv: '${IMEISV}'
|
||||
gnbSearchList:
|
||||
- ${GNB_IP}
|
||||
uacAic:
|
||||
mps: false
|
||||
mcs: false
|
||||
uacAcc:
|
||||
normalClass: 0
|
||||
class11: false
|
||||
class12: false
|
||||
class13: false
|
||||
class14: false
|
||||
class15: false
|
||||
sessions:
|
||||
- type: 'IPv4'
|
||||
apn: '${APN}'
|
||||
slice:
|
||||
sst: ${SST}
|
||||
sd: ${SD}
|
||||
configured-nssai:
|
||||
- sst: ${SST}
|
||||
sd: ${SD}
|
||||
default-nssai:
|
||||
- sst: ${SST}
|
||||
sd: ${SD}
|
||||
integrity:
|
||||
IA1: true
|
||||
IA2: true
|
||||
IA3: true
|
||||
ciphering:
|
||||
EA0: true
|
||||
EA1: false
|
||||
EA2: false
|
||||
EA3: false
|
||||
integrityMaxRate:
|
||||
uplink: 'full'
|
||||
downlink: 'full'
|
||||
EOF
|
||||
|
||||
# Step 1 — Start gNB
|
||||
log "STEP 1/5 — Starting gNB (src: ${GNB_IP}, AMF: ${AMF_IP})"
|
||||
nr-gnb -c /tmp/gnb.yaml > /tmp/gnb.log 2>&1 &
|
||||
GNB_PID=$!
|
||||
|
||||
# Step 2 — Wait for NGAP connection
|
||||
log "STEP 2/5 — Waiting for NGAP connection to AMF..."
|
||||
connected=0
|
||||
for i in $(seq 1 20); do
|
||||
if grep -q "NG Setup procedure is successful" /tmp/gnb.log 2>/dev/null; then
|
||||
log " AMF connection established (NGAP OK)"
|
||||
connected=1
|
||||
break
|
||||
fi
|
||||
if ! kill -0 $GNB_PID 2>/dev/null; then
|
||||
log "ERROR: gNB process terminated unexpectedly"
|
||||
grep -iE "error|failed|refused" /tmp/gnb.log | tail -5 | while IFS= read -r l; do log " gnb: $l"; done
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ $connected -eq 0 ]; then
|
||||
log "ERROR: gNB could not reach AMF at ${AMF_IP}:38412 within 20s"
|
||||
tail -5 /tmp/gnb.log | while IFS= read -r l; do log " gnb: $l"; done
|
||||
kill $GNB_PID 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3 — Start UE
|
||||
log "STEP 3/5 — Starting UE (${IMSI})"
|
||||
nr-ue -c /tmp/ue.yaml > /tmp/ue.log 2>&1 &
|
||||
UE_PID=$!
|
||||
|
||||
# Step 4 — Wait for PDU session
|
||||
log "STEP 4/5 — Waiting for PDU session establishment..."
|
||||
pdu=0
|
||||
for i in $(seq 1 25); do
|
||||
if grep -q "PDU Session establishment is successful" /tmp/ue.log 2>/dev/null; then
|
||||
UE_ADDR=$(grep -oE 'PDU address\[[^]]+\]' /tmp/ue.log | tail -1)
|
||||
log " PDU session established — ${UE_ADDR}"
|
||||
pdu=1
|
||||
break
|
||||
fi
|
||||
if grep -qiE "Registration failed|Authentication failed|rejected" /tmp/ue.log 2>/dev/null; then
|
||||
log "ERROR: UE authentication/registration failed — check IMSI, UE_KEY, UE_OP in ueransim.env"
|
||||
grep -iE "failed|rejected" /tmp/ue.log | tail -5 | while IFS= read -r l; do log " ue: $l"; done
|
||||
kill $UE_PID $GNB_PID 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
if ! kill -0 $UE_PID 2>/dev/null; then
|
||||
log "ERROR: UE process terminated unexpectedly"
|
||||
tail -5 /tmp/ue.log | while IFS= read -r l; do log " ue: $l"; done
|
||||
kill $GNB_PID 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ $pdu -eq 0 ]; then
|
||||
log "ERROR: PDU session not established within 25s"
|
||||
tail -10 /tmp/ue.log | while IFS= read -r l; do log " ue: $l"; done
|
||||
kill $UE_PID $GNB_PID 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 5 — Data plane test + NF log snapshot
|
||||
log "STEP 5/5 — Data plane test (ping ${PING_TARGET} via uesimtun0)"
|
||||
|
||||
# Dump recent NF logs (AMF, SMF, UPF) from journald
|
||||
log "--- AMF logs (last 5 lines) ---"
|
||||
journalctl -u amf --since "30 seconds ago" --no-pager -q 2>/dev/null | tail -5 | while IFS= read -r l; do log " [AMF] $l"; done || log " [AMF] (not available)"
|
||||
|
||||
log "--- SMF logs (last 5 lines) ---"
|
||||
journalctl -u smf --since "30 seconds ago" --no-pager -q 2>/dev/null | tail -5 | while IFS= read -r l; do log " [SMF] $l"; done || log " [SMF] (not available)"
|
||||
|
||||
log "--- UPF logs (last 5 lines) ---"
|
||||
journalctl -u upf --since "30 seconds ago" --no-pager -q 2>/dev/null | tail -5 | while IFS= read -r l; do log " [UPF] $l"; done || log " [UPF] (not available)"
|
||||
|
||||
log "--- gNB log ---"
|
||||
tail -5 /tmp/gnb.log 2>/dev/null | while IFS= read -r l; do log " [gNB] $l"; done
|
||||
|
||||
log "--- UE log ---"
|
||||
tail -8 /tmp/ue.log 2>/dev/null | while IFS= read -r l; do log " [UE] $l"; done
|
||||
|
||||
log "--- Ping test ---"
|
||||
PING_PASS=0
|
||||
if ip link show uesimtun0 > /dev/null 2>&1; then
|
||||
UE_IFACE_IP=$(ip -4 addr show uesimtun0 2>/dev/null | grep -oP '(?<=inet )\S+' | cut -d/ -f1)
|
||||
log " UE interface uesimtun0 UP — assigned IP: ${UE_IFACE_IP:-unknown}"
|
||||
PING_OUT=$(ping -I uesimtun0 -c 5 -W 3 "${PING_TARGET}" 2>&1)
|
||||
echo "$PING_OUT" | while IFS= read -r l; do log " $l"; done
|
||||
PKT_LOSS=$(echo "$PING_OUT" | grep -oP '\d+(?=% packet loss)' || echo "100")
|
||||
if [ "${PKT_LOSS}" = "0" ]; then
|
||||
RTT=$(echo "$PING_OUT" | grep -oP 'rtt[^=]+=\s*\K[^/]+' | tr -d ' ')
|
||||
log "✓ DATA PLANE TEST PASSED — 0% loss, RTT min=${RTT}ms"
|
||||
PING_PASS=1
|
||||
else
|
||||
log "⚠ Ping ${PKT_LOSS}% loss — PDU session established, UPF N6 routing check needed"
|
||||
fi
|
||||
else
|
||||
log "⚠ uesimtun0 not found — PDU session may not have created the interface"
|
||||
fi
|
||||
|
||||
# Hold UE connected for 60s so operator can verify in NCM UI
|
||||
log "--- UE holding connected for 60s — check NCM → Subscribers for active session ---"
|
||||
for i in $(seq 1 60); do
|
||||
sleep 1
|
||||
if ! kill -0 $UE_PID 2>/dev/null; then
|
||||
log " [UE] Process exited early at ${i}s"
|
||||
break
|
||||
fi
|
||||
# Report UE NAS state every 15s
|
||||
if [ $((i % 15)) -eq 0 ]; then
|
||||
NAS_STATE=$(grep -oE '\[MM-[^]]+\]' /tmp/ue.log 2>/dev/null | tail -1)
|
||||
log " [UE] ${i}s elapsed — NAS state: ${NAS_STATE:-REGISTERED}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Final NF log snapshot after hold
|
||||
log "--- Final NF state after hold ---"
|
||||
journalctl -u amf --since "70 seconds ago" --no-pager -q 2>/dev/null | grep -iE "session|registered|released|pdu" | tail -5 | while IFS= read -r l; do log " [AMF] $l"; done || true
|
||||
journalctl -u smf --since "70 seconds ago" --no-pager -q 2>/dev/null | grep -iE "session|pdu|pfcp|established|released" | tail -5 | while IFS= read -r l; do log " [SMF] $l"; done || true
|
||||
|
||||
# Cleanup
|
||||
log "--- Releasing UE session ---"
|
||||
kill $UE_PID $GNB_PID 2>/dev/null
|
||||
wait $UE_PID $GNB_PID 2>/dev/null
|
||||
log "Emulated session test complete"
|
||||
if [ $PING_PASS -eq 1 ]; then exit 0; else exit 0; fi
|
||||
30
config/ueransim.env
Normal file
30
config/ueransim.env
Normal file
@@ -0,0 +1,30 @@
|
||||
# UERANSIM Test Session Configuration
|
||||
# Edit UE_KEY and UE_OP before running the emulated session test.
|
||||
# Subscriber must be pre-provisioned in UDR/UDM.
|
||||
|
||||
# gNB source IP — must be reachable by the AMF (host eth0 IP)
|
||||
GNB_IP=172.27.0.159
|
||||
|
||||
# AMF N2 address
|
||||
AMF_IP=10.1.150.34
|
||||
|
||||
# Network identity
|
||||
MCC=001
|
||||
MNC=01
|
||||
|
||||
# Test UE credentials
|
||||
IMSI=imsi-001010000000001
|
||||
UE_KEY=PLACEHOLDER
|
||||
UE_OP=PLACEHOLDER
|
||||
UE_OP_TYPE=OPC
|
||||
UE_IMEISV=PLACEHOLDER
|
||||
|
||||
# Slice
|
||||
SLICE_SST=1
|
||||
SLICE_SD=1
|
||||
|
||||
# Session
|
||||
SESSION_APN=internet
|
||||
|
||||
# Connectivity test target
|
||||
PING_TARGET=8.8.8.8
|
||||
95
deploy.sh
Executable file
95
deploy.sh
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env env bash
|
||||
set -euo pipefail
|
||||
|
||||
SSH="ssh -i ~/.ssh/5G-SSH-Key.pem -p 2222 -o StrictHostKeyChecking=no root@localhost"
|
||||
SCP="scp -i ~/.ssh/5G-SSH-Key.pem -P 2222 -o StrictHostKeyChecking=no"
|
||||
VM="root@localhost"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "=== P5G Marvis Deploy ==="
|
||||
|
||||
# ── 1. Copy source to VM ───────────────────────────────────────────────────
|
||||
echo "→ Syncing source to VM at /opt/p5g-marvis …"
|
||||
$SSH "mkdir -p /opt/p5g-marvis/app/routers /opt/p5g-marvis/app/services /opt/p5g-marvis/app/ui"
|
||||
$SCP "$SCRIPT_DIR/requirements.txt" "$VM:/opt/p5g-marvis/"
|
||||
$SCP "$SCRIPT_DIR/app/main.py" "$VM:/opt/p5g-marvis/app/"
|
||||
$SCP "$SCRIPT_DIR/app/config.py" "$VM:/opt/p5g-marvis/app/"
|
||||
$SCP "$SCRIPT_DIR/app/__init__.py" "$VM:/opt/p5g-marvis/app/" 2>/dev/null || true
|
||||
$SCP "$SCRIPT_DIR/app/routers/__init__.py" "$VM:/opt/p5g-marvis/app/routers/" 2>/dev/null || true
|
||||
$SCP "$SCRIPT_DIR/app/routers/network.py" "$VM:/opt/p5g-marvis/app/routers/"
|
||||
$SCP "$SCRIPT_DIR/app/routers/alerts.py" "$VM:/opt/p5g-marvis/app/routers/"
|
||||
$SCP "$SCRIPT_DIR/app/routers/query.py" "$VM:/opt/p5g-marvis/app/routers/"
|
||||
$SCP "$SCRIPT_DIR/app/services/__init__.py" "$VM:/opt/p5g-marvis/app/services/" 2>/dev/null || true
|
||||
$SCP "$SCRIPT_DIR/app/services/prometheus.py" "$VM:/opt/p5g-marvis/app/services/"
|
||||
$SCP "$SCRIPT_DIR/app/services/alertmanager.py" "$VM:/opt/p5g-marvis/app/services/"
|
||||
$SCP "$SCRIPT_DIR/app/services/ai.py" "$VM:/opt/p5g-marvis/app/services/"
|
||||
$SCP "$SCRIPT_DIR/app/ui/index.html" "$VM:/opt/p5g-marvis/app/ui/"
|
||||
|
||||
# ── 2. Install Python deps on VM ───────────────────────────────────────────
|
||||
echo "→ Installing Python dependencies …"
|
||||
$SSH "python3 -m pip install -q -r /opt/p5g-marvis/requirements.txt"
|
||||
|
||||
# ── 3. Systemd service ─────────────────────────────────────────────────────
|
||||
echo "→ Installing systemd service …"
|
||||
$SSH "cat > /etc/systemd/system/p5g-marvis.service << 'UNIT'
|
||||
[Unit]
|
||||
Description=P5G Marvis AI Assistant
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/p5g-marvis
|
||||
ExecStart=/usr/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8095 --workers 1
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=MARVIS_PROMETHEUS_URL=http://127.0.0.1:9090
|
||||
Environment=MARVIS_PROMETHEUS_PREFIX=/prometheus
|
||||
Environment=MARVIS_ALERTMANAGER_URL=http://127.0.0.1:9093
|
||||
Environment=MARVIS_AI_MODE=rule
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT"
|
||||
$SSH "systemctl daemon-reload && systemctl enable --now p5g-marvis"
|
||||
$SSH "sleep 2 && systemctl is-active p5g-marvis && echo 'Service OK'"
|
||||
|
||||
# ── 4. Register route with Traefik (file provider) ─────────────────────────
|
||||
echo "→ Registering Traefik route …"
|
||||
$SCP "$SCRIPT_DIR/traefik-marvis.yml" "$VM:/etc/athonet/traefik/marvis.yml"
|
||||
|
||||
# Add file provider to traefik.yml if not already present
|
||||
$SSH "python3 << 'PY'
|
||||
f = '/etc/athonet/traefik/traefik.yml'
|
||||
src = open(f).read()
|
||||
if 'file:' not in src:
|
||||
src = src.replace(
|
||||
' endpoint: \"http://127.0.0.1:8089/traefik\"',
|
||||
' endpoint: \"http://127.0.0.1:8089/traefik\"\n file:\n filename: \"/etc/athonet/traefik/marvis.yml\"\n watch: true'
|
||||
)
|
||||
open(f, 'w').write(src)
|
||||
print('Traefik file provider added')
|
||||
else:
|
||||
print('Traefik file provider already configured')
|
||||
PY"
|
||||
|
||||
echo "→ Restarting Traefik …"
|
||||
$SSH "podman restart traefik && sleep 2 && podman ps | grep traefik"
|
||||
|
||||
# ── 5. Patch NCM React bundle ──────────────────────────────────────────────
|
||||
echo "→ Patching NCM frontend …"
|
||||
$SCP "$SCRIPT_DIR/patch-ncm.py" "$VM:/tmp/patch-ncm.py"
|
||||
$SSH "python3 /tmp/patch-ncm.py"
|
||||
$SSH "podman restart ncm && sleep 2 && podman ps | grep ncm"
|
||||
|
||||
echo ""
|
||||
echo "=== Deploy complete ==="
|
||||
echo ""
|
||||
echo " Marvis standalone: https://localhost:8443/core/marvis/"
|
||||
echo " In NCM sidebar: https://localhost:8443/ncm/marvis"
|
||||
echo " API docs: https://localhost:8443/core/marvis/api/docs"
|
||||
echo ""
|
||||
echo " To change AI mode (on VM):"
|
||||
echo " systemctl edit p5g-marvis"
|
||||
echo " # Add: Environment=MARVIS_AI_MODE=openai"
|
||||
echo " # Add: Environment=MARVIS_OPENAI_API_KEY=sk-..."
|
||||
echo " systemctl restart p5g-marvis"
|
||||
108
patch-ncm.py
Normal file
108
patch-ncm.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
patch-ncm.py — Inject P5G Marvis + P5G Radio pages into the NCM React bundle.
|
||||
|
||||
Reads from the clean .bak, applies all patches, writes the result.
|
||||
Safe to re-run (idempotent).
|
||||
"""
|
||||
|
||||
JS = "/etc/athonet/ems-frontend/advanced/assets/index-Cw8Irsq8.js"
|
||||
BAK = JS + ".bak"
|
||||
|
||||
with open(BAK, "r", encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
|
||||
changed = False
|
||||
|
||||
# ── 1. Sidebar nav entry ───────────────────────────────────────────────────
|
||||
SIDEBAR_OLD = '!_o(ue.UPF,t)}]}]}'
|
||||
SIDEBAR_NEW = ('!_o(ue.UPF,t)}]},' # UPF entry closes; start custom entries
|
||||
'{value:"/marvis",label:"P5G Marvis",'
|
||||
'icon:a.jsx(ge.Magic,{}),disabled:false,'
|
||||
'subItems:['
|
||||
'{value:"/marvis/overview",label:"P5G Marvis Insights",disabled:false},'
|
||||
'{value:"/marvis/actions",label:"P5G Marvis Actions",disabled:false},'
|
||||
'{value:"/marvis/minis",label:"P5G Marvis Minis",disabled:false},'
|
||||
'{value:"/marvis/ai",label:"P5G Marvis AI",disabled:false}'
|
||||
']},' # close Marvis, comma before Radio
|
||||
'{value:"/radio",label:"P5G Radio",'
|
||||
'icon:a.jsx(ge.Magic,{}),disabled:false}'
|
||||
']}') # close outer items array + object
|
||||
|
||||
if SIDEBAR_OLD in src:
|
||||
src = src.replace(SIDEBAR_OLD, SIDEBAR_NEW, 1)
|
||||
print("Applied: sidebar entry")
|
||||
changed = True
|
||||
elif SIDEBAR_NEW.split(',icon')[0] in src:
|
||||
print("Skipped: sidebar entry already present")
|
||||
else:
|
||||
print("ERROR: sidebar anchor not found"); exit(1)
|
||||
|
||||
# ── 2. React Router route with iframe element ──────────────────────────────
|
||||
# Insert between G4t (UPF route object) and rht (Platform route object)
|
||||
# in the router children array — this is the unique anchor in the route config
|
||||
ROUTE_ANCHOR = 'G4t,rht'
|
||||
|
||||
# Wrap in Or({fullHeight:!0}) exactly like every other NF route — this gives the
|
||||
# NCM AppBar + left-navigation sidebar via G0, and a content area that properly
|
||||
# fills the remaining height. The iframe then uses height:"100%" to fill it.
|
||||
MARVIS_ROUTE = ('{path:"marvis",'
|
||||
'element:a.jsx(Or,{fullHeight:!0}),'
|
||||
'handle:vr({labelIntl:"P5G Marvis",icon:a.jsx(ge.Magic,{})}),'
|
||||
'children:['
|
||||
'{index:!0,element:a.jsx(ur,{to:"overview",replace:!0})},'
|
||||
'{path:"overview",element:a.jsx("iframe",{src:"/core/marvis/overview",style:{display:"block",width:"100%",height:"100%",border:"none"},title:"P5G Marvis Insights"})},'
|
||||
'{path:"actions",element:a.jsx("iframe",{src:"/core/marvis/actions",style:{display:"block",width:"100%",height:"100%",border:"none"},title:"P5G Marvis Actions"})},'
|
||||
'{path:"minis",element:a.jsx("iframe",{src:"/core/marvis/minis",style:{display:"block",width:"100%",height:"100%",border:"none"},title:"P5G Marvis Minis"})},'
|
||||
'{path:"ai",element:a.jsx("iframe",{src:"/core/marvis/",style:{display:"block",width:"100%",height:"100%",border:"none"},title:"P5G Marvis AI"})}'
|
||||
']}') # closed marvis route
|
||||
|
||||
RADIO_ROUTE = ('{path:"radio",'
|
||||
'element:a.jsx(Or,{fullHeight:!0}),'
|
||||
'handle:vr({labelIntl:"P5G Radio",icon:a.jsx(ge.Magic,{})}),'
|
||||
'children:['
|
||||
'{index:!0,element:a.jsx("iframe",{src:"/core/radio/",'
|
||||
'style:{display:"block",width:"100%",height:"100%",border:"none"},'
|
||||
'title:"P5G Radio"})}'
|
||||
']}')
|
||||
ROUTE_MARKER = 'path:"marvis"'
|
||||
|
||||
RADIO_MARKER = 'path:"radio"'
|
||||
|
||||
if ROUTE_ANCHOR in src and ROUTE_MARKER not in src:
|
||||
src = src.replace(ROUTE_ANCHOR, 'G4t,' + MARVIS_ROUTE + ',' + RADIO_ROUTE + ',rht', 1)
|
||||
print("Applied: marvis + radio routes with Or wrapper")
|
||||
changed = True
|
||||
elif ROUTE_MARKER in src:
|
||||
print("Skipped: marvis route already present")
|
||||
else:
|
||||
print("WARNING: route anchor G4t,rht not found — iframe routes not injected")
|
||||
|
||||
# ── Validate ───────────────────────────────────────────────────────────────
|
||||
# ── 3. Register /marvis in the BW permissions registry ────────────────────
|
||||
# BW is the route→permissions map. If a path is missing, the app throws
|
||||
# "Permissions for route /marvis are not managed". Tt=()=>!0 means no
|
||||
# specific permission required (same as profile and siteLoader routes).
|
||||
PERMS_OLD = '...QUe}'
|
||||
PERMS_NEW = '...QUe,"/marvis":Tt,"/marvis/overview":Tt,"/marvis/actions":Tt,"/marvis/minis":Tt,"/marvis/ai":Tt,"/radio":Tt}'
|
||||
|
||||
if PERMS_OLD in src and PERMS_NEW not in src:
|
||||
src = src.replace(PERMS_OLD, PERMS_NEW, 1)
|
||||
print("Applied: /marvis permissions entry")
|
||||
changed = True
|
||||
elif PERMS_NEW in src:
|
||||
print("Skipped: /marvis permissions entry already present")
|
||||
else:
|
||||
print("WARNING: BW permissions anchor not found — permissions not registered")
|
||||
|
||||
assert src.count('P5G Marvis') >= 1, "P5G Marvis not found after patch"
|
||||
assert src.count('P5G Radio') >= 1, "P5G Radio not found after patch"
|
||||
|
||||
if not changed:
|
||||
print("Nothing changed — already fully patched")
|
||||
exit(0)
|
||||
|
||||
with open(JS, "w", encoding="utf-8") as f:
|
||||
f.write(src)
|
||||
|
||||
print(f"Done — P5G Marvis: {src.count('P5G Marvis')} occurrences, P5G Radio: {src.count('P5G Radio')} occurrences")
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
httpx==0.27.2
|
||||
86
traefik-marvis.yml
Normal file
86
traefik-marvis.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
# Traefik file provider for P5G Marvis + P5G Radio
|
||||
# Place at /etc/athonet/traefik/ssl/marvis.yml (the ssl/ dir is mounted into the container)
|
||||
# The file provider path in traefik.yml must reference /etc/traefik/ssl/marvis.yml
|
||||
|
||||
http:
|
||||
routers:
|
||||
router-marvis-0:
|
||||
rule: "PathPrefix(`/core/marvis`)"
|
||||
service: service-marvis
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
# priority > 19 to beat pls-error@http which also matches PathPrefix(`/core`)
|
||||
priority: 25
|
||||
middlewares:
|
||||
- cors@http
|
||||
- strip-path-marvis-0
|
||||
|
||||
router-radio-0:
|
||||
rule: "PathPrefix(`/core/radio`)"
|
||||
service: service-radio
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
priority: 25
|
||||
middlewares:
|
||||
- cors@http
|
||||
- strip-path-radio-0
|
||||
|
||||
# Route rm-ui static assets (absolute paths in the SPA HTML) directly to rm-ui.
|
||||
# These exact hashes are unique to rm-ui so they won't conflict with NCM assets.
|
||||
router-radio-js:
|
||||
rule: "Path(`/assets/index-9Haqbm6c.js`)"
|
||||
service: service-radio
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
priority: 30
|
||||
|
||||
router-radio-css:
|
||||
rule: "Path(`/assets/index-BKG23XBM.css`)"
|
||||
service: service-radio
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
priority: 30
|
||||
|
||||
# Route rmf API calls from inside the radio iframe to the rmf backend.
|
||||
# Must be more specific than PLS's PathPrefix(`/api/1`) catch-all.
|
||||
router-radio-api:
|
||||
rule: "PathPrefix(`/api/1/radio`)"
|
||||
service: service-rmf
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls: {}
|
||||
priority: 26
|
||||
|
||||
middlewares:
|
||||
strip-path-marvis-0:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/core/marvis"
|
||||
|
||||
strip-path-radio-0:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- "/core/radio"
|
||||
|
||||
services:
|
||||
service-marvis:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:8100"
|
||||
passHostHeader: false
|
||||
|
||||
service-radio:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://127.0.0.1:4000"
|
||||
passHostHeader: false
|
||||
|
||||
service-rmf:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://192.168.86.173:8101"
|
||||
passHostHeader: false
|
||||
Reference in New Issue
Block a user