Skip to content
Merged
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
6 changes: 6 additions & 0 deletions client-v3/e2e/tests/03-system-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ test('switches to the System tab', async () => {
});
});

test('System tab shows server info rows', async () => {
await expect(page.locator('strong:has-text("Hostname")')).toBeVisible();
await expect(page.locator('strong:has-text("IP Address")')).toBeVisible();
await expect(page.locator('strong:has-text("Port")')).toBeVisible();
});

test('can open the View Clients modal', async () => {
await page.click('button:has-text("View Clients")');
await waitForModal(page, 'Connected Clients');
Expand Down
23 changes: 21 additions & 2 deletions client-v3/src/components/config/ConfigSystem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@
</BButton>
</BTd>
</BTr>
<BTr>
<BTd><strong>Hostname</strong></BTd>
<BTd>{{ serverInfo?.hostname ?? 'Unknown' }}</BTd>
<BTd />
</BTr>
<BTr>
<BTd><strong>IP Address</strong></BTd>
<BTd>{{ serverInfo?.ip_address ?? 'Unknown' }}</BTd>
<BTd />
</BTr>
<BTr>
<BTd><strong>Port</strong></BTd>
<BTd>{{ serverInfo?.port ?? 'Unknown' }}</BTd>
<BTd />
</BTr>
</BTbody>
</BTableSimple>

Expand Down Expand Up @@ -80,7 +95,7 @@ import { useSystemStore } from '@/stores/system';
import { toast } from '@/js/toast';

const systemStore = useSystemStore();
const { connectedSessions, versionStatus } = storeToRefs(systemStore);
const { connectedSessions, versionStatus, serverInfo } = storeToRefs(systemStore);

const loading = ref(true);
const isCheckingVersion = ref(false);
Expand Down Expand Up @@ -137,7 +152,11 @@ function scheduleSessionPoll(): void {
}

onMounted(async () => {
await Promise.all([systemStore.getVersionStatus(), systemStore.getConnectedSessions()]);
await Promise.all([
systemStore.getVersionStatus(),
systemStore.getConnectedSessions(),
systemStore.getServerInfo(),
]);
loading.value = false;
scheduleSessionPoll();
});
Expand Down
15 changes: 15 additions & 0 deletions client-v3/src/stores/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ interface VersionStatus {
check_error: string | null;
}

interface ServerInfo {
hostname: string;
ip_address: string;
port: number;
}

interface RbacRole {
key: string;
value: number;
Expand All @@ -49,6 +55,7 @@ export const useSystemStore = defineStore('system', {
currentShow: null as Show | null,
connectedSessions: [] as ConnectedSession[],
versionStatus: null as VersionStatus | null,
serverInfo: null as ServerInfo | null,
}),
getters: {
isAdminUser(): boolean {
Expand Down Expand Up @@ -246,6 +253,14 @@ export const useSystemStore = defineStore('system', {
log.error('Unable to fetch version status');
}
},
async getServerInfo() {
const response = await fetch(makeURL('/api/v1/system/info'));
if (response.ok) {
this.serverInfo = await response.json();
} else {
log.error('Unable to fetch server info');
}
},
async checkForUpdates() {
const response = await fetch(makeURL('/api/v1/version/check'), {
method: 'POST',
Expand Down
34 changes: 33 additions & 1 deletion client/src/vue_components/config/ConfigSystem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@
</b-button>
</b-td>
</b-tr>
<b-tr>
<b-td><b>Hostname</b></b-td>
<b-td>{{ systemInfo ? systemInfo.hostname : 'Unknown' }}</b-td>
<b-td />
</b-tr>
<b-tr>
<b-td><b>IP Address</b></b-td>
<b-td>{{ systemInfo ? systemInfo.ip_address : 'Unknown' }}</b-td>
<b-td />
</b-tr>
<b-tr>
<b-td><b>Port</b></b-td>
<b-td>{{ systemInfo ? systemInfo.port : 'Unknown' }}</b-td>
<b-td />
</b-tr>
</b-tbody>
</b-table-simple>
<b-modal
Expand Down Expand Up @@ -125,10 +140,15 @@ export default defineComponent({
isCheckingVersion: false,
currentTime: Date.now(),
timeUpdateInterval: null as ReturnType<typeof setInterval> | null,
systemInfo: null as {
hostname: string | null;
ip_address: string | null;
port: number | null;
} | null,
};
},
async mounted() {
await Promise.all([this.getConnectedClients(), this.getVersionStatus()]);
await Promise.all([this.getConnectedClients(), this.getVersionStatus(), this.getSystemInfo()]);
this.loading = false;
this.timeUpdateInterval = setInterval(() => {
this.currentTime = Date.now();
Expand Down Expand Up @@ -201,6 +221,18 @@ export default defineComponent({
if (this.versionStatus.update_available) return 'Update Available';
return 'Up to date';
},
async getSystemInfo(): Promise<void> {
try {
const response = await fetch(`${makeURL('/api/v1/system/info')}`);
if (response.ok) {
this.systemInfo = await response.json();
} else {
log.error('Unable to get system info');
}
} catch (error) {
log.error('Error fetching system info:', error);
}
},
formatTimeAgo(isoTimestamp: string | null): string {
if (!isoTimestamp) return 'Never';

Expand Down
6 changes: 4 additions & 2 deletions docs/pages/user_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ The **System Config** section, accessible from the top navigation bar, provides

The **System** tab provides an overview of the current system state:

- **Current Show**: Displays the currently loaded show name, with buttons to load an existing show or set up a new one.
- **Connected Clients**: Shows the number of WebSocket clients currently connected to the server. Click "View Clients" to see details about each connected session.
- **Version**: Displays the current DigiScript version and checks for available updates.
- **Connected Clients**: Shows the number of WebSocket clients currently connected to the server. Click "View Clients" to see details about each connected session.
- **Hostname**: The fully-qualified domain name (FQDN) of the server machine.
- **IP Address**: The primary outbound IP address the server is reachable on.
- **Port**: The port the server is listening on.

#### Version Checker

Expand Down
53 changes: 53 additions & 0 deletions server/controllers/api/system_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import socket

from utils.web.base_controller import BaseAPIController
from utils.web.route import ApiRoute, ApiVersion
from utils.web.web_decorators import api_authenticated, require_admin


# Well-known public DNS address used only to determine the local outbound interface.
# No data is ever sent — the UDP socket is closed immediately after getsockname().
_PROBE_HOST = "8.8.8.8"

Check warning on line 10 in server/controllers/api/system_info.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using this hardcoded IP address "8.8.8.8" is safe here.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ6Y9C8Wqf6Ielam80KL&open=AZ6Y9C8Wqf6Ielam80KL&pullRequest=1141
_PROBE_PORT = 80


@ApiRoute("system/info", ApiVersion.V1)
class SystemInfoController(BaseAPIController):
@api_authenticated
@require_admin
async def get(self):
"""
Get server network information.

Returns the server's fully-qualified hostname, primary outbound IP address,
and the port it is listening on.

:returns: JSON response with hostname, ip_address, and port.
"""
await self.finish(
{
"hostname": socket.getfqdn(),
"ip_address": _get_primary_ip(),
"port": self.application._port,
}
)


def _get_primary_ip() -> str:
"""
Return the primary outbound IP address.

Uses the connect-to-8.8.8.8 trick: opening a UDP socket and calling connect()
causes the OS to select the correct source address without sending any data.

:returns: IP address string, or ``"Unknown"`` on failure.
"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((_PROBE_HOST, _PROBE_PORT))
return s.getsockname()[0]
finally:
s.close()
except Exception:
return "Unknown"
32 changes: 32 additions & 0 deletions server/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os

from sqlalchemy import inspect
from tornado import escape
from tornado.testing import AsyncHTTPTestCase

from digi_server.app_server import DigiScriptServer
Expand Down Expand Up @@ -43,3 +44,34 @@
models.db.engine.dispose()

super().tearDown()

def _create_and_login_admin(self, username="admin", password="adminpass"):

Check warning on line 48 in server/test/conftest.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

"password" detected here, review this potentially hard-coded credential.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ6Y9C0qqf6Ielam80KJ&open=AZ6Y9C0qqf6Ielam80KJ&pullRequest=1141
self.fetch(
"/api/v1/auth/create",
method="POST",
body=escape.json_encode(
{"username": username, "password": password, "is_admin": True}
),
)
resp = self.fetch(
"/api/v1/auth/login",
method="POST",
body=escape.json_encode({"username": username, "password": password}),
)
return escape.json_decode(resp.body)["access_token"]

def _create_and_login_user(self, admin_token, username="user", password="userpass"):

Check warning on line 63 in server/test/conftest.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

"password" detected here, review this potentially hard-coded credential.

See more on https://sonarcloud.io/project/issues?id=dreamteamprod_DigiScript&issues=AZ6Y9C0qqf6Ielam80KK&open=AZ6Y9C0qqf6Ielam80KK&pullRequest=1141
self.fetch(
"/api/v1/auth/create",
method="POST",
body=escape.json_encode(
{"username": username, "password": password, "is_admin": False}
),
headers={"Authorization": f"Bearer {admin_token}"},
)
resp = self.fetch(
"/api/v1/auth/login",
method="POST",
body=escape.json_encode({"username": username, "password": password}),
)
return escape.json_decode(resp.body)["access_token"]
31 changes: 0 additions & 31 deletions server/test/controllers/api/test_db_backups.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,37 +34,6 @@ def _create_backup(self, timestamp: int) -> str:
f.write(b"x" * 1024)
return backup_path

def _create_and_login_admin(self, username="admin", password="adminpass"):
self.fetch(
"/api/v1/auth/create",
method="POST",
body=escape.json_encode(
{"username": username, "password": password, "is_admin": True}
),
)
resp = self.fetch(
"/api/v1/auth/login",
method="POST",
body=escape.json_encode({"username": username, "password": password}),
)
return escape.json_decode(resp.body)["access_token"]

def _create_and_login_user(self, admin_token, username="user", password="userpass"):
self.fetch(
"/api/v1/auth/create",
method="POST",
body=escape.json_encode(
{"username": username, "password": password, "is_admin": False}
),
headers={"Authorization": f"Bearer {admin_token}"},
)
resp = self.fetch(
"/api/v1/auth/login",
method="POST",
body=escape.json_encode({"username": username, "password": password}),
)
return escape.json_decode(resp.body)["access_token"]

def _fetch_backups(self, token=None):
headers = {}
if token:
Expand Down
52 changes: 52 additions & 0 deletions server/test/controllers/api/test_system_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Integration tests for GET /api/v1/system/info."""

from tornado import escape

from test.conftest import DigiScriptTestCase


class TestSystemInfoController(DigiScriptTestCase):
def _fetch_system_info(self, token=None):
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
return self.fetch("/api/v1/system/info", headers=headers, raise_error=False)

def test_unauthenticated_returns_401(self):
resp = self._fetch_system_info()
self.assertEqual(401, resp.code)

def test_non_admin_returns_401(self):
admin_token = self._create_and_login_admin()
user_token = self._create_and_login_user(admin_token)
resp = self._fetch_system_info(token=user_token)
self.assertEqual(401, resp.code)

def test_authenticated_returns_200_with_expected_keys(self):
token = self._create_and_login_admin()
resp = self._fetch_system_info(token=token)
self.assertEqual(200, resp.code)
body = escape.json_decode(resp.body)
self.assertIn("hostname", body)
self.assertIn("ip_address", body)
self.assertIn("port", body)

def test_hostname_is_non_empty_string(self):
token = self._create_and_login_admin()
resp = self._fetch_system_info(token=token)
body = escape.json_decode(resp.body)
self.assertIsInstance(body["hostname"], str)
self.assertGreater(len(body["hostname"]), 0)

def test_ip_address_is_non_empty_string(self):
token = self._create_and_login_admin()
resp = self._fetch_system_info(token=token)
body = escape.json_decode(resp.body)
self.assertIsInstance(body["ip_address"], str)
self.assertGreater(len(body["ip_address"]), 0)

def test_port_is_integer(self):
token = self._create_and_login_admin()
resp = self._fetch_system_info(token=token)
body = escape.json_decode(resp.body)
self.assertIsInstance(body["port"], int)
Loading