marvis docker container, ignore ueransim

This commit is contained in:
Jake Kasper
2026-04-24 10:29:19 -04:00
parent 3228db3097
commit a0e77aabd6
18 changed files with 278 additions and 51 deletions
+12
View File
@@ -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
View File
@@ -1,18 +1,27 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app 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 # When running standalone, override these to point at your Prometheus/Alertmanager
ENV MARVIS_PROMETHEUS_URL=http://127.0.0.1:9090 ENV MARVIS_PROMETHEUS_URL=http://127.0.0.1:9090
ENV MARVIS_PROMETHEUS_PREFIX=/prometheus ENV MARVIS_PROMETHEUS_PREFIX=/prometheus
ENV MARVIS_ALERTMANAGER_URL=http://127.0.0.1:9093 ENV MARVIS_ALERTMANAGER_URL=http://127.0.0.1:9093
ENV MARVIS_AI_MODE=rule 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=openai → set MARVIS_OPENAI_API_KEY
# MARVIS_AI_MODE=ollama → set MARVIS_OLLAMA_URL + MARVIS_OLLAMA_MODEL # 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"]
+46 -2
View File
@@ -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 ## Architecture Overview
``` ```
@@ -206,11 +244,17 @@ JS="/etc/athonet/ems-frontend/advanced/assets/index-Cw8Irsq8.js"
cd /opt/p5g-marvis && python3 patch-ncm.py' 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: Expected output:
``` ```
Applied: sidebar entry Applied: sidebar entry
Applied: marvis + radio routes with Or wrapper Applied: injected iframe routes
Applied: /marvis permissions entry Applied: permissions entry
Done — P5G Marvis: 10 occurrences, P5G Radio: 3 occurrences Done — P5G Marvis: 10 occurrences, P5G Radio: 3 occurrences
``` ```
Binary file not shown.
Binary file not shown.
+9
View File
@@ -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_URL = os.getenv("MARVIS_OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("MARVIS_OLLAMA_MODEL", "llama3") 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 # Maps Prometheus target_type label → display name
TARGET_TYPE_MAP = { TARGET_TYPE_MAP = {
"amf": "AMF", "smf": "SMF", "upf": "UPF", "amf": "AMF", "smf": "SMF", "upf": "UPF",
Binary file not shown.
Binary file not shown.
+11 -3
View File
@@ -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 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: 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)}") lines.append(f"✅ **{len(up)} UP**: {', '.join(n['name'] for n in up)}")
if down: if down:
lines.append(f"🔴 **{len(down)} DOWN**: {', '.join(n['name'] for n in 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: if alerts:
lines.append(f"\n⚠️ **{len(alerts)} alert(s)** — {len(crit)} critical, {len(warn)} warning") 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": if not nf or nf["state"] == "unknown":
return (f"️ No Prometheus data found for **{nf_name}**.\n" 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 "🔴" icon = "" if nf["state"] == "up" else "🔴"
lines = [f"{icon} **{nf_name}** is **{nf['state'].upper()}**", lines = [f"{icon} **{nf_name}** is **{nf['state'].upper()}**",
+15 -5
View File
@@ -10,6 +10,8 @@ import time
from collections import deque from collections import deque
from datetime import datetime 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) ──────── # ── In-memory history (up to 96 snapshots ≈ 48 min at 30 s refresh) ────────
_history: deque = deque(maxlen=96) _history: deque = deque(maxlen=96)
@@ -137,14 +139,18 @@ _container_cache_ts: float = 0.0
async def _discover_containers() -> dict[str, str]: 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 global _container_cache, _container_cache_ts
now = time.monotonic() now = time.monotonic()
if _container_cache and now - _container_cache_ts < 60: if _container_cache and now - _container_cache_ts < 60:
return _container_cache return _container_cache
try: try:
cmd = [CONTAINER_RUNTIME]
if CONTAINER_HOST:
cmd.extend(["--host", CONTAINER_HOST])
cmd.extend(["ps", "--format", "{{.Names}}"])
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"podman", "ps", "--format", "{{.Names}}", *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=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: 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: 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( proc = await asyncio.create_subprocess_exec(
"podman", "logs", "--tail", str(tail), container, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
@@ -277,7 +287,7 @@ async def analyze_logs() -> dict:
"severity": "critical", "severity": "critical",
"count": 1, "count": 1,
"description": f"{nf_st['name']} is unreachable", "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."), f"container is running; inspect its logs."),
"source": "prometheus", "source": "prometheus",
}) })
+29 -11
View File
@@ -3,6 +3,16 @@ import uuid
import time import time
from typing import Dict, Optional 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] = {} _tasks: Dict[str, dict] = {}
@@ -28,31 +38,39 @@ async def run_test(task_id: str) -> None:
def log(msg: str, type: str = "info") -> None: def log(msg: str, type: str = "info") -> None:
task["logs"].append({"msg": msg, "type": type, "ts": time.strftime("%H:%M:%S")}) 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( check = await asyncio.create_subprocess_exec(
"docker", "images", "-q", "ueransim", *runtime_cmd, "images", "-q", UERANSIM_IMAGE,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
out, _ = await check.communicate() out, _ = await check.communicate()
if not out.strip(): if not out.strip():
log("✗ UERANSIM image not found.", "err") 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" task["status"] = "error"
return return
log(" UERANSIM image ready", "ok") log(" UERANSIM image ready", "ok")
log("▸ Starting test container — allow up to 60s…", "run") log("▸ Starting test container — allow up to 60s…", "run")
env_file = "/opt/p5g-marvis/config/ueransim.env"
try: 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( proc = await asyncio.create_subprocess_exec(
"docker", "run", "--rm", *run_cmd,
"--network=host",
"--privileged",
"--env-file", env_file,
"ueransim",
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
) )
@@ -81,7 +99,7 @@ async def run_test(task_id: str) -> None:
log("✓ Emulated data session completed successfully", "ok") log("✓ Emulated data session completed successfully", "ok")
task["status"] = "done" task["status"] = "done"
elif proc.returncode == 2: 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" task["status"] = "error"
else: else:
log(f"✗ Test exited with code {proc.returncode}", "err") log(f"✗ Test exited with code {proc.returncode}", "err")
+24
View File
@@ -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
+86 -25
View File
@@ -6,6 +6,11 @@ Reads from the clean .bak, applies all patches, writes the result.
Safe to re-run (idempotent). Safe to re-run (idempotent).
""" """
import os
import socket
import sys
JS = "/etc/athonet/ems-frontend/advanced/assets/index-Cw8Irsq8.js" JS = "/etc/athonet/ems-frontend/advanced/assets/index-Cw8Irsq8.js"
BAK = JS + ".bak" BAK = JS + ".bak"
@@ -14,26 +19,60 @@ with open(BAK, "r", encoding="utf-8") as f:
changed = False 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 ─────────────────────────────────────────────────── # ── 1. Sidebar nav entry ───────────────────────────────────────────────────
SIDEBAR_OLD = '!_o(ue.UPF,t)}]}]}' SIDEBAR_OLD = '!_o(ue.UPF,t)}]}]}'
SIDEBAR_NEW = ('!_o(ue.UPF,t)}]},' # UPF entry closes; start custom entries sidebar_items = []
'{value:"/marvis",label:"P5G Marvis",' if ENABLE_MARVIS:
'icon:a.jsx(ge.Magic,{}),disabled:false,' sidebar_items.append(
'subItems:[' '{value:"/marvis",label:"P5G Marvis",'
'{value:"/marvis/overview",label:"P5G Marvis Insights",disabled:false},' 'icon:a.jsx(ge.Magic,{}),disabled:false,'
'{value:"/marvis/actions",label:"P5G Marvis Actions",disabled:false},' 'subItems:['
'{value:"/marvis/minis",label:"P5G Marvis Minis",disabled:false},' '{value:"/marvis/overview",label:"P5G Marvis Insights",disabled:false},'
'{value:"/marvis/ai",label:"P5G Marvis AI",disabled:false}' '{value:"/marvis/actions",label:"P5G Marvis Actions",disabled:false},'
']},' # close Marvis, comma before Radio '{value:"/marvis/minis",label:"P5G Marvis Minis",disabled:false},'
'{value:"/radio",label:"P5G Radio",' '{value:"/marvis/ai",label:"P5G Marvis AI",disabled:false}'
'icon:a.jsx(ge.Magic,{}),disabled:false}' ']}'
']}') # close outer items array + object )
if ENABLE_RADIO:
sidebar_items.append(
'{value:"/radio",label:"P5G Radio",'
'icon:a.jsx(ge.Magic,{}),disabled:false}'
)
SIDEBAR_NEW = '!_o(ue.UPF,t)}]},' + ",".join(sidebar_items) + ']}'
if SIDEBAR_OLD in src: if SIDEBAR_OLD in src:
src = src.replace(SIDEBAR_OLD, SIDEBAR_NEW, 1) src = src.replace(SIDEBAR_OLD, SIDEBAR_NEW, 1)
print("Applied: sidebar entry") print("Applied: sidebar entry")
changed = True 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") print("Skipped: sidebar entry already present")
else: else:
print("ERROR: sidebar anchor not found"); exit(1) print("ERROR: sidebar anchor not found"); exit(1)
@@ -69,12 +108,18 @@ ROUTE_MARKER = 'path:"marvis"'
RADIO_MARKER = 'path:"radio"' RADIO_MARKER = 'path:"radio"'
if ROUTE_ANCHOR in src and ROUTE_MARKER not in src: route_parts = []
src = src.replace(ROUTE_ANCHOR, 'G4t,' + MARVIS_ROUTE + ',' + RADIO_ROUTE + ',rht', 1) if ENABLE_MARVIS:
print("Applied: marvis + radio routes with Or wrapper") 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 changed = True
elif ROUTE_MARKER in src: elif ROUTE_MARKER in src or RADIO_MARKER in src:
print("Skipped: marvis route already present") print("Skipped: route entry already present")
else: else:
print("WARNING: route anchor G4t,rht not found — iframe routes not injected") 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 # "Permissions for route /marvis are not managed". Tt=()=>!0 means no
# specific permission required (same as profile and siteLoader routes). # specific permission required (same as profile and siteLoader routes).
PERMS_OLD = '...QUe}' 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: if PERMS_OLD in src and PERMS_NEW not in src:
src = src.replace(PERMS_OLD, PERMS_NEW, 1) src = src.replace(PERMS_OLD, PERMS_NEW, 1)
print("Applied: /marvis permissions entry") print("Applied: permissions entry")
changed = True changed = True
elif PERMS_NEW in src: elif all(entry in src for entry in perm_entries):
print("Skipped: /marvis permissions entry already present") print("Skipped: permissions entry already present")
else: else:
print("WARNING: BW permissions anchor not found — permissions not registered") print("WARNING: BW permissions anchor not found — permissions not registered")
assert src.count('P5G Marvis') >= 1, "P5G Marvis not found after patch" if ENABLE_MARVIS:
assert src.count('P5G Radio') >= 1, "P5G Radio not found after patch" 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: if not changed:
print("Nothing changed — already fully patched") print("Nothing changed — already fully patched")
@@ -105,4 +163,7 @@ if not changed:
with open(JS, "w", encoding="utf-8") as f: with open(JS, "w", encoding="utf-8") as f:
f.write(src) 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
+16
View File
File diff suppressed because one or more lines are too long