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)