Initial commit from Martins Github

This commit is contained in:
Jake Kasper
2026-04-23 13:50:31 -05:00
parent 488a0d01ef
commit 3228db3097
30 changed files with 4377 additions and 1 deletions

18
Dockerfile Normal file
View 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
View 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
View File

@@ -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
View 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 &amp; 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
View File

26
app/config.py Normal file
View 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
View 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
View File

16
app/routers/actions.py Normal file
View 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
View 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}

View 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
View 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
View 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
View File

207
app/services/ai.py Normal file
View 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)

View 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

View 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)

View 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
View 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
View 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 &amp; 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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 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
View 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
View 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
View 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 &amp; 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx==0.27.2

86
traefik-marvis.yml Normal file
View 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