Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web User Interface | Integration of new Python module #61

Open
wants to merge 2 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 20 additions & 34 deletions management-ui/README.md
Original file line number Diff line number Diff line change
@@ -1,63 +1,55 @@
# Management UI

## Introduction
This is a full-stack web application based on Python classes and Vanilla JS. It interacts with the 5GMS Application Function via M1 Interface and enables publishing of the M8 json file.
It can be utilized as the part of overall 5GMS architecture or isolated with 5GMS AF for development purposes.
Set of provisioning operations implemented in this application is defiend in the TS26.512.
Backend server is based on FastAPI framework to convert CLI Python methods into web endpoints. Frontend JavaScript `modules` are sending dedicated HTTP requests to the backend, with the required payload generated through the stylized HTML `forms`.

A web-based Graphical User Interface for 5GMS management that uses the [Python library](../python/README.md) to
interact with the 5GMS Application Function. The source files and the documentation are located in
the `management-ui` folder of this repository.

## Installation

There are two ways to run this application. The recommendation is to use the **Docker Compose** service, because
lightweight container building and activation will solve the entire scope of dependencies and the rest of the
requirements.
### Option 1: Docker image

### Option 1. Docker Compose
To install Docker Compose, follow the official [documentation](https://docs.docker.com/compose/install/).

To install Docker Compose service follow the official [documentation](https://docs.docker.com/compose/install/). Next,
clone this
repository:
Clone 5GMS Application Provider repository:

```
cd
git clone https://github.com/5G-MAG/rt-5gms-application-provider
cd ~/rt-5gms-application-provider
```

Building the Docker image will effectively install all dependencies for the 5GMS Application Function and the Management
UI:
Building the Docker image will effectively install all dependencies for the 5GMS Application Function and the Management UI:

```
sudo docker-compose build
```

Upon successful completion, activate Application Provider with:

Upon successful completion, activate 5GMS Application Provider with:
```
sudo docker-compose up
```

Access the module at: `localhost:8000`

### Option 2. Separate installation
### Option 2: Separate installation

If you prefer to run the 5GMS Application Function separately,
without setting it up in the Docker environment, you have to build and install it as a local user. For that
purpose, please follow
Firstly install 5GMS Application Function following
this [documentation](https://5g-mag.github.io/Getting-Started/pages/5g-media-streaming/usage/application-function/installation-local-user-5GMSAF.html).

Once installed and built, run the 5GMS Application Function:
Subsequently, install the Python dependencies required for the Management UI:

```
~/rt-5gms-application-function/install/bin/open5gs-msafd
cd ~/rt-5gms-application-provider
sudo python3 -m pip install ./python
pip3 install -r requirements.txt
```

Subsequently, install the Python dependencies required for Management UI:
Activate 5GMS Application Function:

```
cd ~/rt-5gms-application-provider
python3 -m pip install ./python
pip3 install -r requirements.txt
~/rt-5gms-application-function/install/bin/open5gs-msafd
```

Activate the Management UI application:
Expand All @@ -70,25 +62,19 @@ uvicorn server:app --reload
The Management UI will be accessible at port `8000`.

In case that you're receving any kind of web proxy errors, you can bypass that by using empty `http` and `https` proxy flags when running the server:

```
cd management-ui/
http_proxy= https_proxy= uvicorn server:app --reload
```

## Testing

This repository contains CI/CD workflows for building native Docker image, Docker Compose and integration test for
Management UI application.
All endpoints are covered with this test, which runs one provisioning cycle and activates all network procedures, checks the responses before deleting all resources.

Automated integration test is written to provide entire provisioning cycle, starting with creation of provisioning
session, activating all network procedures, and finalizing with deletion of all resources. It effectively conducts
sequence of HTTP requests to every Management UI web server's endpoint.

Run integration test :
Run automated test:

```
cd ~/rt-5gms-application-provider/management-ui/tests
pytest integration_test.py
```

Please be aware that this procedure is already provided with the repository's CI/CD pipeline.
151 changes: 135 additions & 16 deletions management-ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@

import os
import json
import aiofiles
import requests
import asyncio
import httpx
import logging
from fastapi import Body
from urllib.parse import urljoin
from dotenv import load_dotenv
from typing import Optional
from typing import List, Optional
from pydantic import BaseModel
from fastapi import FastAPI, Query, Depends, HTTPException, Response, Request
from fastapi.responses import JSONResponse, FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
Expand All @@ -29,20 +34,38 @@
from rt_m1_client.session import M1Session
from rt_m1_client.data_store import JSONFileDataStore
from rt_m1_client.exceptions import M1Error
from rt_m1_client import app_configuration
from rt_media_configuration import MediaConfiguration, M1SessionImporter, MediaEntry, MediaDistribution, MediaEntryPoint, MediaAppDistribution

config = Configuration()
stojkovicv marked this conversation as resolved.
Show resolved Hide resolved
#config = app_configuration

OPTIONS_ENDPOINT = os.getenv("OPTIONS_ENDPOINT", "http://" + config.get('m1_address', 'localhost') + ":" + config.get('m1_port',7777) + "/3gpp-m1/v2/provisioning-sessions/")
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://0.0.0.0:8000,http://127.0.0.1:8000,http://localhost:8000").split(',')

app = FastAPI()
_m1_session = None

_media_configuration: Optional[MediaConfiguration] = None

# Auxiliary function to pass proper configuration as dependency injection parameter
def get_config():
return Configuration()

# Logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('webui_server.log')
]
)
#logging.info("Logging setted up.")

BASE_URL_PREFIX = "http://rt.5g-mag.com/m4d"

class NamesPayload(BaseModel):
names: list[str]

async def get_session(config: Configuration) -> M1Session:
global _m1_session
Expand All @@ -58,54 +81,150 @@ async def get_session(config: Configuration) -> M1Session:
config.get('certificate_signing_class'))
return _m1_session


async def initialize_media_configuration(session: M1Session) -> MediaConfiguration:
global _media_configuration
data_store_dir = config.get('data_store')
if data_store_dir is not None:
data_store = await JSONFileDataStore(config.get('data_store'))
else:
data_store = None
if _media_configuration is None:
#_media_configuration = await MediaConfiguration(persistent_data_store=data_store, m1_session=session)
_media_configuration = await MediaConfiguration(persistent_data_store=data_store)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can get MediaConfiguration to use your M1Session object for its synchronisation by providing it in the m1_session argument to the MediaConfiguration initialisation. So this line would become:

_media_configuration = await MediaConfiguration(persistent_data_store=data_store, m1_session=session)

importer = M1SessionImporter(session)
await importer.import_to(_media_configuration)
Comment on lines +95 to +96
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be equivalent to:

_media_configuration.restoreModel()

return _media_configuration

# Error handling
@app.exception_handler(M1Error)
async def m1_error_handler(request: Request, exc: M1Error):
if exc.args[2] is not None:
return JSONResponse(status_code=exc.args[1], content=exc.args[2])
return PlainTextResponse(status_code=exc.args[1], content=exc.args[0])

# UI page rendering
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# UI rendering
app.mount("/src", StaticFiles(directory="src"), name="src")
templates = Jinja2Templates(directory="src/templates")
@app.get("/")
def landing_page():
return FileResponse("templates/index.html")

return FileResponse("src/templates/index.html")

"""
Endpoint: Create Provisioning Session
Endpoint: Create Provisioning & Media Session
HTTP Method: POST
Path: /create_session
Description: This endpoint will create a empty provisioning session.
Description: This endpoint will create a empty provisioning and media session.
"""

@app.post("/create_session")
async def new_provisioning_session(app_id: Optional[str] = None, asp_id: Optional[str] = None):

session = await get_session(config)
app_id = app_id or config.get('external_app_id')
asp_id = asp_id or config.get('asp_id')

provisioning_session_id: Optional[ResourceId] = await session.createDownlinkPullProvisioningSession(
ApplicationId(app_id),
ApplicationId(asp_id) if asp_id else None)
ApplicationId(asp_id) if asp_id else None
)

if provisioning_session_id is None:
raise HTTPException(status_code=400, detail="Failed to create a new provisioning session")

return {"provisioning_session_id": provisioning_session_id}

media_configuration = await initialize_media_configuration(session)
media_session = await media_configuration.newMediaSession(
is_downlink=True,
external_app_id=app_id,
provisioning_session_id=str(provisioning_session_id),
asp_id=asp_id)

await media_configuration.addMediaSession(media_session)

return {
"provisioning_session_id": provisioning_session_id,
"media_session_id": media_session.id
}


def generate_relative_path(media_entry_name, provisioning_session_id):
if media_entry_name == "VoD: Elephant's Dream":
return f"provisioning-session-{provisioning_session_id}/elephants_dream/1/client_manifest-all.mpd"
elif media_entry_name == "VoD: Big Buck Bunny":
return f"provisioning-session-{provisioning_session_id}/bbb/2/client_manifest-common_init.mpd"
elif media_entry_name == "VoD: Testcard":
return f"provisioning-session-{provisioning_session_id}/testcard/vod/manifests/avc-full.mpd"
return None


@app.post("/generate-m8/{provisioning_session_id}/{media_session_id}")
async def generate_m8(provisioning_session_id: str, media_session_id: str, payload: NamesPayload):

try:
service_list = []

for name in payload.names:
relative_path = generate_relative_path(name, provisioning_session_id)
if not relative_path:
raise HTTPException(status_code=400, detail=f"Invalid media entry name: {name}")

locator = f"{BASE_URL_PREFIX}/{relative_path}"
service_entry = {
"provisioningSessionId": provisioning_session_id,
"name": name,
"entryPoints": [{
"locator": locator,
"contentType": "application/dash+xml",
"profiles": ["urn:mpeg:dash:profile:isoff-live:2011"]
}]
}
service_list.append(service_entry)

m8_content = {
"m5BaseUrl": "http://rt.5g-mag.com:7778/3gpp-m5/v2/",
"serviceList": service_list
}

# TO BE TUNED
m8_output_dir = ""
m8_file_path = f"{m8_output_dir}/m8.json"

async with aiofiles.open(m8_file_path, mode='w') as m8_file:
await m8_file.write(json.dumps(m8_content, indent=2))

logging.info(f"Generated M8 JSON:\n{json.dumps(m8_content, indent=2)}")

return {"message": "M8 JSON generated successfully", "file_path": m8_file_path}

except Exception as e:
logging.error(f"Error occurred during execution: {e}")
raise HTTPException(status_code=500, detail="Internal server error")


"""
Endpoint: Fetch all provisioning sessions
Endpoint: Fetch all provisioning and media sessions
HTTP Method: GET
Path: /fetch_all_sessions
Description: This endpoint will a list of all provisioning sessions.
"""
@app.get("/fetch_all_sessions")
async def get_all_sessions():
session = await get_session(config)
session_ids = await session.provisioningSessionIds()
return {"session_ids": list(session_ids)}
try:
session = await get_session(config)
provisioning_session_ids = await session.provisioningSessionIds()

media_configuration = await initialize_media_configuration(session)
media_sessions = await media_configuration.mediaSessions()
media_session_ids = [media_session.id for media_session in media_sessions]

return {
"provisioning_session_ids": list(provisioning_session_ids),
"media_session_ids": media_session_ids
}
except Exception as e:
logging.error(f"Error fetching sessions: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch sessions")

"""
Endpoint: Remove all provisioning sessions
HTTP Method: DELETE
Expand Down
18 changes: 18 additions & 0 deletions management-ui/src/forms/consumptionReporting.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<input id="swal-input1" class="swal2-input" type="number" placeholder="Reporting Interval">
<input id="swal-input2" class="swal2-input" type="number" placeholder="Sample Percentage">
<br>
<br>
<label for="swal-input3">Location Reporting: </label>
<br>
<select id="swal-input3" class="swal2-input">
<option value="true">True</option>
<option value="false">False</option>
</select>
<br>
<br>
<label for="swal-input4">Access Reporting: </label>
<br>
<select id="swal-input4" class="swal2-input">
<option value="true">True</option>
<option value="false">False</option>
</select>
41 changes: 41 additions & 0 deletions management-ui/src/forms/dynamicPolicies.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<input id="externalReference" class="swal2-input" type="number" placeholder="External Policy ID" required>

<br><br><p>Application Session Context:</p>
<input id="sst" class="swal2-input" type="number" placeholder="SST">
<input id="sd" class="swal2-input" placeholder="SD">
<input id="dnn" class="swal2-input" type="text" placeholder="DNN">

<br><br><p font-weight="bold">QoS Specification:</p>
<input id="qosReference" class="swal2-input" placeholder="QoS Reference"><br>
<br><input id="maxAuthBtrUl" class="swal2-input" type="number" placeholder="Max Auth Btr Ul">
<select id="maxAuthBtrUlUnit" class="swal2-input">
<option value="bps">Bps</option>
<option value="kbps">Kbps</option>
<option value="mbps">Mbps</option>
<option value="gbps">Gbps</option>
<option value="tbps">Tbps</option>
</select>
<br><input id="maxAuthBtrDl" class="swal2-input" type="number" placeholder="Max Auth Btr Dl">
<select id="maxAuthBtrDlUnit" class="swal2-input">
<option value="bps">Bps</option>
<option value="kbps">Kbps</option>
<option value="mbps">Mbps</option>
<option value="gbps">Gbps</option>
<option value="tbps">Tbps</option>
</select>
<br>
<input id="defPacketLossRateDl" class="swal2-input" placeholder="Def Packet Loss Rate Dl">
<input id="defPacketLossRateUl" class="swal2-input" placeholder="Def Packet Loss Rate Ul">

<br><br><p>Charging Specification</p>
<input id="sponId" class="swal2-input" placeholder="Sponsor ID">
<select id="sponStatus" class="swal2-input">
<option value="">Select Sponsor Status</option>
<option value="SPONSOR_ENABLED">ENABLED</option>
<option value="SPONSOR_DISABLED">DISABLED</option>
</select>
<input id="gpsi" class="swal2-input" placeholder="GPSI">


<input id="state" class="swal2-input" placeholder="State">
<input id="type" class="swal2-input" placeholder="Type">
Loading