marvis docker container, ignore ueransim
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.DS_Store
|
||||
README.md
|
||||
ai-slides.html
|
||||
deploy.sh
|
||||
+14
-5
@@ -1,18 +1,27 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ ./app/
|
||||
|
||||
EXPOSE 8095
|
||||
COPY requirements.txt .
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
docker.io \
|
||||
podman \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
COPY config/ ./config/
|
||||
|
||||
EXPOSE 8100
|
||||
|
||||
# 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
|
||||
ENV MARVIS_CONTAINER_RUNTIME=docker
|
||||
ENV MARVIS_UERANSIM_ENV_FILE=/app/config/ueransim.env
|
||||
# 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"]
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8100"]
|
||||
|
||||
@@ -5,6 +5,44 @@ This document describes the complete architecture and deployment procedure for t
|
||||
|
||||
---
|
||||
|
||||
## Deployment Model
|
||||
|
||||
The target environment for this project is a host where services are started by
|
||||
`systemd`, including Docker-backed services. Marvis is intended to run the same
|
||||
way:
|
||||
|
||||
- the FastAPI app runs on `127.0.0.1:8100`
|
||||
- Traefik exposes it at `/core/marvis/*`
|
||||
- `patch-ncm.py` injects the sidebar entries and iframe routes into the NCM UI
|
||||
- the injected entries should only be added for services that are actually
|
||||
reachable on the host when the patch is applied
|
||||
|
||||
For a containerised deployment without Compose, this repo includes an example
|
||||
unit file at `config/p5g-marvis.service`.
|
||||
|
||||
### Build the image
|
||||
|
||||
```bash
|
||||
docker build -t p5g-marvis:latest .
|
||||
```
|
||||
|
||||
### Install the systemd unit
|
||||
|
||||
```bash
|
||||
cp config/p5g-marvis.service /usr/lib/systemd/system/p5g-marvis.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now p5g-marvis
|
||||
```
|
||||
|
||||
Marvis will then be reachable on:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8100
|
||||
http://127.0.0.1:8100/api/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
@@ -206,11 +244,17 @@ JS="/etc/athonet/ems-frontend/advanced/assets/index-Cw8Irsq8.js"
|
||||
cd /opt/p5g-marvis && python3 patch-ncm.py'
|
||||
```
|
||||
|
||||
`patch-ncm.py` injects only the services it detects as reachable on the host:
|
||||
|
||||
- `P5G_MARVIS_ENABLED=true|false` overrides Marvis detection
|
||||
- `P5G_RADIO_ENABLED=true|false` overrides Radio detection
|
||||
- by default it probes `127.0.0.1:8100` for Marvis and `127.0.0.1:4000` for Radio
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Applied: sidebar entry
|
||||
Applied: marvis + radio routes with Or wrapper
|
||||
Applied: /marvis permissions entry
|
||||
Applied: injected iframe routes
|
||||
Applied: permissions entry
|
||||
Done — P5G Marvis: 10 occurrences, P5G Radio: 3 occurrences
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,15 @@ OPENAI_BASE_URL = os.getenv("MARVIS_OPENAI_BASE_URL", "https://api.openai.co
|
||||
OLLAMA_URL = os.getenv("MARVIS_OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL = os.getenv("MARVIS_OLLAMA_MODEL", "llama3")
|
||||
|
||||
# Container/runtime integration
|
||||
CONTAINER_RUNTIME = os.getenv("MARVIS_CONTAINER_RUNTIME", "docker")
|
||||
CONTAINER_HOST = os.getenv("CONTAINER_HOST", "")
|
||||
UERANSIM_IMAGE = os.getenv("MARVIS_UERANSIM_IMAGE", "p5g-marvis-ueransim")
|
||||
UERANSIM_ENV_FILE = os.getenv("MARVIS_UERANSIM_ENV_FILE", "/app/config/ueransim.env")
|
||||
UERANSIM_NETWORK_MODE = os.getenv("MARVIS_UERANSIM_NETWORK_MODE", "host")
|
||||
UERANSIM_PRIVILEGED = os.getenv("MARVIS_UERANSIM_PRIVILEGED", "true").lower() in {"1", "true", "yes", "on"}
|
||||
UERANSIM_CONTAINER_NAME = os.getenv("MARVIS_UERANSIM_CONTAINER_NAME", "marvis-ueransim-test")
|
||||
|
||||
# Maps Prometheus target_type label → display name
|
||||
TARGET_TYPE_MAP = {
|
||||
"amf": "AMF", "smf": "SMF", "upf": "UPF",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
-3
@@ -6,7 +6,15 @@ Phase 2: swap MARVIS_AI_MODE=openai or MARVIS_AI_MODE=ollama to route through LL
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from app.config import AI_MODE, OPENAI_API_KEY, OPENAI_MODEL, OPENAI_BASE_URL, OLLAMA_URL, OLLAMA_MODEL
|
||||
from app.config import (
|
||||
AI_MODE,
|
||||
CONTAINER_RUNTIME,
|
||||
OPENAI_API_KEY,
|
||||
OPENAI_MODEL,
|
||||
OPENAI_BASE_URL,
|
||||
OLLAMA_MODEL,
|
||||
OLLAMA_URL,
|
||||
)
|
||||
|
||||
|
||||
async def answer(query: str, network_state: dict, alerts: list) -> str:
|
||||
@@ -69,7 +77,7 @@ def _health_summary(up: list, down: list, alerts: list) -> str:
|
||||
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")
|
||||
lines.append(f" ⚡ Action: check `{CONTAINER_RUNTIME} logs <nf>` in the runtime host")
|
||||
|
||||
if alerts:
|
||||
lines.append(f"\n⚠️ **{len(alerts)} alert(s)** — {len(crit)} critical, {len(warn)} warning")
|
||||
@@ -91,7 +99,7 @@ def _nf_detail(nf_name: str, nfs: list, alerts: list) -> str:
|
||||
|
||||
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()}`")
|
||||
f"Check: `{CONTAINER_RUNTIME} ps | grep {nf_name.lower()}`")
|
||||
|
||||
icon = "✅" if nf["state"] == "up" else "🔴"
|
||||
lines = [f"{icon} **{nf_name}** is **{nf['state'].upper()}**",
|
||||
|
||||
@@ -10,6 +10,8 @@ import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import CONTAINER_HOST, CONTAINER_RUNTIME
|
||||
|
||||
# ── In-memory history (up to 96 snapshots ≈ 48 min at 30 s refresh) ────────
|
||||
_history: deque = deque(maxlen=96)
|
||||
|
||||
@@ -137,14 +139,18 @@ _container_cache_ts: float = 0.0
|
||||
|
||||
|
||||
async def _discover_containers() -> dict[str, str]:
|
||||
"""Run `podman ps` and map NF names to actual container names."""
|
||||
"""Run the configured container runtime 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:
|
||||
cmd = [CONTAINER_RUNTIME]
|
||||
if CONTAINER_HOST:
|
||||
cmd.extend(["--host", CONTAINER_HOST])
|
||||
cmd.extend(["ps", "--format", "{{.Names}}"])
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"podman", "ps", "--format", "{{.Names}}",
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -167,10 +173,14 @@ async def _discover_containers() -> dict[str, str]:
|
||||
|
||||
|
||||
async def _read_logs(container: str, tail: int = 400) -> str:
|
||||
"""Read recent logs from a podman container (stdout + stderr)."""
|
||||
"""Read recent logs from a container (stdout + stderr)."""
|
||||
try:
|
||||
cmd = [CONTAINER_RUNTIME]
|
||||
if CONTAINER_HOST:
|
||||
cmd.extend(["--host", CONTAINER_HOST])
|
||||
cmd.extend(["logs", "--tail", str(tail), container])
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"podman", "logs", "--tail", str(tail), container,
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
@@ -277,7 +287,7 @@ async def analyze_logs() -> dict:
|
||||
"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']} "
|
||||
"remediation": (f"Run `{CONTAINER_RUNTIME} ps` and check if {nf_st['name']} "
|
||||
f"container is running; inspect its logs."),
|
||||
"source": "prometheus",
|
||||
})
|
||||
|
||||
+29
-11
@@ -3,6 +3,16 @@ import uuid
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.config import (
|
||||
CONTAINER_HOST,
|
||||
CONTAINER_RUNTIME,
|
||||
UERANSIM_CONTAINER_NAME,
|
||||
UERANSIM_ENV_FILE,
|
||||
UERANSIM_IMAGE,
|
||||
UERANSIM_NETWORK_MODE,
|
||||
UERANSIM_PRIVILEGED,
|
||||
)
|
||||
|
||||
_tasks: Dict[str, dict] = {}
|
||||
|
||||
|
||||
@@ -28,31 +38,39 @@ async def run_test(task_id: str) -> None:
|
||||
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")
|
||||
runtime_cmd = [CONTAINER_RUNTIME]
|
||||
if CONTAINER_HOST:
|
||||
runtime_cmd.extend(["--host", CONTAINER_HOST])
|
||||
|
||||
log(f"▸ Checking UERANSIM image via {CONTAINER_RUNTIME}…", "run")
|
||||
check = await asyncio.create_subprocess_exec(
|
||||
"docker", "images", "-q", "ueransim",
|
||||
*runtime_cmd, "images", "-q", UERANSIM_IMAGE,
|
||||
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")
|
||||
log(" Build it with `docker compose build ueransim` or set MARVIS_UERANSIM_IMAGE.", "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:
|
||||
run_cmd = [*runtime_cmd, "run", "--rm", "--name", UERANSIM_CONTAINER_NAME]
|
||||
if UERANSIM_NETWORK_MODE:
|
||||
run_cmd.append(f"--network={UERANSIM_NETWORK_MODE}")
|
||||
if UERANSIM_PRIVILEGED:
|
||||
run_cmd.append("--privileged")
|
||||
run_cmd.extend([
|
||||
"--env-file", UERANSIM_ENV_FILE,
|
||||
UERANSIM_IMAGE,
|
||||
])
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "run", "--rm",
|
||||
"--network=host",
|
||||
"--privileged",
|
||||
"--env-file", env_file,
|
||||
"ueransim",
|
||||
*run_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
@@ -81,7 +99,7 @@ async def run_test(task_id: str) -> None:
|
||||
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")
|
||||
log(f"⚠ Credentials not configured — edit {UERANSIM_ENV_FILE}", "warn")
|
||||
task["status"] = "error"
|
||||
else:
|
||||
log(f"✗ Test exited with code {proc.returncode}", "err")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=P5G Marvis container
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=0
|
||||
|
||||
ExecStartPre=-/usr/bin/docker rm -f p5g-marvis
|
||||
ExecStart=/usr/bin/docker run \
|
||||
--name p5g-marvis \
|
||||
--publish 127.0.0.1:8100:8100 \
|
||||
--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 \
|
||||
p5g-marvis:latest
|
||||
ExecStop=/usr/bin/docker stop p5g-marvis
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+77
-16
@@ -6,6 +6,11 @@ Reads from the clean .bak, applies all patches, writes the result.
|
||||
Safe to re-run (idempotent).
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
JS = "/etc/athonet/ems-frontend/advanced/assets/index-Cw8Irsq8.js"
|
||||
BAK = JS + ".bak"
|
||||
|
||||
@@ -14,9 +19,39 @@ with open(BAK, "r", encoding="utf-8") as f:
|
||||
|
||||
changed = False
|
||||
|
||||
|
||||
def env_flag(name: str) -> str | None:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
def is_enabled(name: str, default_host: str, default_port: int) -> bool:
|
||||
flag = env_flag(name)
|
||||
if flag in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if flag in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
host = os.getenv(f"{name}_HOST", default_host)
|
||||
port = int(os.getenv(f"{name}_PORT", str(default_port)))
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.5)
|
||||
return sock.connect_ex((host, port)) == 0
|
||||
|
||||
|
||||
ENABLE_MARVIS = is_enabled("P5G_MARVIS_ENABLED", "127.0.0.1", 8100)
|
||||
ENABLE_RADIO = is_enabled("P5G_RADIO_ENABLED", "127.0.0.1", 4000)
|
||||
|
||||
if not ENABLE_MARVIS and not ENABLE_RADIO:
|
||||
print("ERROR: neither P5G Marvis nor P5G Radio appears reachable; refusing to patch")
|
||||
sys.exit(1)
|
||||
|
||||
# ── 1. Sidebar nav entry ───────────────────────────────────────────────────
|
||||
SIDEBAR_OLD = '!_o(ue.UPF,t)}]}]}'
|
||||
SIDEBAR_NEW = ('!_o(ue.UPF,t)}]},' # UPF entry closes; start custom entries
|
||||
sidebar_items = []
|
||||
if ENABLE_MARVIS:
|
||||
sidebar_items.append(
|
||||
'{value:"/marvis",label:"P5G Marvis",'
|
||||
'icon:a.jsx(ge.Magic,{}),disabled:false,'
|
||||
'subItems:['
|
||||
@@ -24,16 +59,20 @@ SIDEBAR_NEW = ('!_o(ue.UPF,t)}]},' # UPF entry closes; start custom entries
|
||||
'{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
|
||||
']}'
|
||||
)
|
||||
if ENABLE_RADIO:
|
||||
sidebar_items.append(
|
||||
'{value:"/radio",label:"P5G Radio",'
|
||||
'icon:a.jsx(ge.Magic,{}),disabled:false}'
|
||||
']}') # close outer items array + object
|
||||
)
|
||||
SIDEBAR_NEW = '!_o(ue.UPF,t)}]},' + ",".join(sidebar_items) + ']}'
|
||||
|
||||
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:
|
||||
elif any(label in src for label in ["P5G Marvis", "P5G Radio"]):
|
||||
print("Skipped: sidebar entry already present")
|
||||
else:
|
||||
print("ERROR: sidebar anchor not found"); exit(1)
|
||||
@@ -69,12 +108,18 @@ 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")
|
||||
route_parts = []
|
||||
if ENABLE_MARVIS:
|
||||
route_parts.append(MARVIS_ROUTE)
|
||||
if ENABLE_RADIO:
|
||||
route_parts.append(RADIO_ROUTE)
|
||||
|
||||
if ROUTE_ANCHOR in src and ROUTE_MARKER not in src and RADIO_MARKER not in src:
|
||||
src = src.replace(ROUTE_ANCHOR, 'G4t,' + ",".join(route_parts) + ',rht', 1)
|
||||
print("Applied: injected iframe routes")
|
||||
changed = True
|
||||
elif ROUTE_MARKER in src:
|
||||
print("Skipped: marvis route already present")
|
||||
elif ROUTE_MARKER in src or RADIO_MARKER in src:
|
||||
print("Skipped: route entry already present")
|
||||
else:
|
||||
print("WARNING: route anchor G4t,rht not found — iframe routes not injected")
|
||||
|
||||
@@ -84,19 +129,32 @@ else:
|
||||
# "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}'
|
||||
perm_entries = []
|
||||
if ENABLE_MARVIS:
|
||||
perm_entries.extend([
|
||||
'"/marvis":Tt',
|
||||
'"/marvis/overview":Tt',
|
||||
'"/marvis/actions":Tt',
|
||||
'"/marvis/minis":Tt',
|
||||
'"/marvis/ai":Tt',
|
||||
])
|
||||
if ENABLE_RADIO:
|
||||
perm_entries.append('"/radio":Tt')
|
||||
PERMS_NEW = '...QUe,' + ",".join(perm_entries) + '}'
|
||||
|
||||
if PERMS_OLD in src and PERMS_NEW not in src:
|
||||
src = src.replace(PERMS_OLD, PERMS_NEW, 1)
|
||||
print("Applied: /marvis permissions entry")
|
||||
print("Applied: permissions entry")
|
||||
changed = True
|
||||
elif PERMS_NEW in src:
|
||||
print("Skipped: /marvis permissions entry already present")
|
||||
elif all(entry in src for entry in perm_entries):
|
||||
print("Skipped: 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 ENABLE_MARVIS:
|
||||
assert src.count('P5G Marvis') >= 1, "P5G Marvis not found after patch"
|
||||
if ENABLE_RADIO:
|
||||
assert src.count('P5G Radio') >= 1, "P5G Radio not found after patch"
|
||||
|
||||
if not changed:
|
||||
print("Nothing changed — already fully patched")
|
||||
@@ -105,4 +163,7 @@ if not changed:
|
||||
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")
|
||||
print(
|
||||
f"Done — P5G Marvis: {src.count('P5G Marvis')} occurrences, "
|
||||
f"P5G Radio: {src.count('P5G Radio')} occurrences"
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user