diff --git a/client-v3/e2e/tests/03-system-config.spec.ts b/client-v3/e2e/tests/03-system-config.spec.ts index 85dad177..66b8cf2d 100644 --- a/client-v3/e2e/tests/03-system-config.spec.ts +++ b/client-v3/e2e/tests/03-system-config.spec.ts @@ -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'); diff --git a/client-v3/src/components/config/ConfigSystem.vue b/client-v3/src/components/config/ConfigSystem.vue index 28427d39..ae5e7413 100644 --- a/client-v3/src/components/config/ConfigSystem.vue +++ b/client-v3/src/components/config/ConfigSystem.vue @@ -46,6 +46,21 @@ + + Hostname + {{ serverInfo?.hostname ?? 'Unknown' }} + + + + IP Address + {{ serverInfo?.ip_address ?? 'Unknown' }} + + + + Port + {{ serverInfo?.port ?? 'Unknown' }} + + @@ -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); @@ -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(); }); diff --git a/client-v3/src/stores/system.ts b/client-v3/src/stores/system.ts index 3c5690a6..b6cd72f6 100644 --- a/client-v3/src/stores/system.ts +++ b/client-v3/src/stores/system.ts @@ -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; @@ -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 { @@ -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', diff --git a/client/src/vue_components/config/ConfigSystem.vue b/client/src/vue_components/config/ConfigSystem.vue index 901405eb..9a139ef2 100644 --- a/client/src/vue_components/config/ConfigSystem.vue +++ b/client/src/vue_components/config/ConfigSystem.vue @@ -54,6 +54,21 @@ + + Hostname + {{ systemInfo ? systemInfo.hostname : 'Unknown' }} + + + + IP Address + {{ systemInfo ? systemInfo.ip_address : 'Unknown' }} + + + + Port + {{ systemInfo ? systemInfo.port : 'Unknown' }} + + | 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(); @@ -201,6 +221,18 @@ export default defineComponent({ if (this.versionStatus.update_available) return 'Update Available'; return 'Up to date'; }, + async getSystemInfo(): Promise { + 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'; diff --git a/docs/pages/user_config.md b/docs/pages/user_config.md index ee2e82f4..32b23a5b 100644 --- a/docs/pages/user_config.md +++ b/docs/pages/user_config.md @@ -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 diff --git a/server/controllers/api/system_info.py b/server/controllers/api/system_info.py new file mode 100644 index 00000000..787148e0 --- /dev/null +++ b/server/controllers/api/system_info.py @@ -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" +_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" diff --git a/server/test/conftest.py b/server/test/conftest.py index 7f0ab85f..6c5a6e49 100644 --- a/server/test/conftest.py +++ b/server/test/conftest.py @@ -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 @@ -43,3 +44,34 @@ def tearDown(self): models.db.engine.dispose() super().tearDown() + + 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"] diff --git a/server/test/controllers/api/test_db_backups.py b/server/test/controllers/api/test_db_backups.py index 8f7424be..4aa53fec 100644 --- a/server/test/controllers/api/test_db_backups.py +++ b/server/test/controllers/api/test_db_backups.py @@ -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: diff --git a/server/test/controllers/api/test_system_info.py b/server/test/controllers/api/test_system_info.py new file mode 100644 index 00000000..3c89428d --- /dev/null +++ b/server/test/controllers/api/test_system_info.py @@ -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)