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
.dockerignore Normal file
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

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"]

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.

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.

Binary file not shown.

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()}**",

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",
}) })

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
config/p5g-marvis.service Normal file
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

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,9 +19,39 @@ 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 = []
if ENABLE_MARVIS:
sidebar_items.append(
'{value:"/marvis",label:"P5G Marvis",' '{value:"/marvis",label:"P5G Marvis",'
'icon:a.jsx(ge.Magic,{}),disabled:false,' 'icon:a.jsx(ge.Magic,{}),disabled:false,'
'subItems:[' '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/actions",label:"P5G Marvis Actions",disabled:false},'
'{value:"/marvis/minis",label:"P5G Marvis Minis",disabled:false},' '{value:"/marvis/minis",label:"P5G Marvis Minis",disabled:false},'
'{value:"/marvis/ai",label:"P5G Marvis AI",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",' '{value:"/radio",label:"P5G Radio",'
'icon:a.jsx(ge.Magic,{}),disabled:false}' '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: 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"
)

12
userfiles/label.txt Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
userfiles/subitems.txt Normal file

File diff suppressed because one or more lines are too long

16
userfiles/upf.txt Normal file

File diff suppressed because one or more lines are too long