From fc8a05d2b955941534ce470273313b3a5b6063c8 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 10 Oct 2026 04:37:48 +0000 Subject: [PATCH] Harbour and portal health consumers --- DA-Web/api/connect.php | 2 + DA-Web/api/server_info.php | 90 +++++++++++++-- DA-Web/js/app.js | 48 +++++++- contrib/oads_hb/README.md | 53 +++++++++ contrib/oads_hb/oads_hb.c | 77 +++++++++++++ contrib/oads_hb/oads_stats_display.prg | 126 +++++++++++++++++++++ docs/health-consumers.md | 81 +++++++++++++ tests/health-consumers/portal_fixture.py | 73 ++++++++++++ tests/health-consumers/render_health.js | 15 +++ tests/smoke/harbour/serverstats_remote.prg | 38 +++++++ tests/smoke/harbour/stats_display.prg | 30 +++++ tests/smoke/harbour/stats_show.prg | 98 ++++++++++++++++ tests/smoke/harbour/stats_show_example.prg | 9 ++ tools/serverd/http_server.cpp | 27 +++++ tools/serverd/spa_index.h | 24 ++++ 15 files changed, 777 insertions(+), 14 deletions(-) create mode 100644 contrib/oads_hb/oads_stats_display.prg create mode 100644 docs/health-consumers.md create mode 100644 tests/health-consumers/portal_fixture.py create mode 100644 tests/health-consumers/render_health.js create mode 100644 tests/smoke/harbour/serverstats_remote.prg create mode 100644 tests/smoke/harbour/stats_display.prg create mode 100644 tests/smoke/harbour/stats_show.prg create mode 100644 tests/smoke/harbour/stats_show_example.prg diff --git a/DA-Web/api/connect.php b/DA-Web/api/connect.php index d8bb09c5..0fa5f632 100644 --- a/DA-Web/api/connect.php +++ b/DA-Web/api/connect.php @@ -102,6 +102,7 @@ function persistSuccessfulConnMeta(string $file, string $name, string $connType, if (!isset($_SESSION['connections'])) { $_SESSION['connections'] = []; } + unset($_SESSION['management_auth'][$name]); $_SESSION['connections'][$name] = [ 'path' => $opts['path'] ?? $path, 'sourcePath' => $path, @@ -121,6 +122,7 @@ function persistSuccessfulConnMeta(string $file, string $name, string $connType, $name = trim($body['name'] ?? ''); if (isset($_SESSION['connections'][$name])) { unset($_SESSION['connections'][$name]); + unset($_SESSION['management_auth'][$name]); } echo json_encode(['ok' => true]); exit; diff --git a/DA-Web/api/server_info.php b/DA-Web/api/server_info.php index 8b958620..2f47baf7 100644 --- a/DA-Web/api/server_info.php +++ b/DA-Web/api/server_info.php @@ -31,14 +31,27 @@ * Requires: php.ini ffi.enable=true (or ffi.enable=preload) */ header('Content-Type: application/json'); +header('Cache-Control: no-store'); session_start(); require_once __DIR__ . '/common.php'; api_require_session(); $method = $_SERVER['REQUEST_METHOD']; +if (empty($_SESSION['management_csrf'])) { + $_SESSION['management_csrf'] = bin2hex(random_bytes(32)); +} + if ($method === 'POST') { - $body = json_decode(file_get_contents('php://input'), true) ?? []; + // Browser form POSTs cannot mint/use management authority cross-site. + if (stripos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') !== 0) { + api_error(415, 'JSON request required'); + } + $body = json_decode(file_get_contents('php://input'), true) ?? []; + if (!is_array($body)) api_error(400, 'JSON object required'); + if (!hash_equals($_SESSION['management_csrf'], (string)($body['csrf'] ?? ''))) { + api_error(403, 'Management request expired; refresh Server Info'); + } $ddName = trim($body['dd'] ?? ''); } else { $body = []; @@ -84,7 +97,7 @@ // ── FFI declarations ───────────────────────────────────────────────────────── $cdef = ' -typedef unsigned long long ADSHANDLE; +typedef unsigned int ADSHANDLE; typedef unsigned short UNSIGNED16; typedef unsigned int UNSIGNED32; typedef unsigned char UNSIGNED8; @@ -214,20 +227,54 @@ function ffiCStr(FFI $ffi, string $s): FFI\CData { // state). A remote DD (host:port from the session's own connection) // connects the mgmt handle to that same server, so activity/kill reflect // the server actually serving the DD instead of this PHP process. -// Pass the DD's own connected username so this mgmt session registers -// under that name instead of showing up as "(anonymous)" alongside the -// user's real connections in the Connected Users / Active Queries grids. +// Use separate, endpoint-scoped daemon-management credentials. $hMgmt = $ffi->new('ADSHANDLE'); $hMgmt->cdata = 0; $serverArg = $mgServer !== null ? ffiCStr($ffi, $mgServer) : null; -$mgUsername = trim((string)($connInfo['username'] ?? '')); -$userArg = $mgUsername !== '' ? ffiCStr($ffi, $mgUsername) : null; -$rc = $ffi->AdsMgConnect($serverArg, $userArg, null, FFI::addr($hMgmt)); +// Never reuse the DD username/password as daemon-management credentials. +$endpointKey = $mgServer ?? 'local'; +$mgAuth = $_SESSION['management_auth'][$ddName] ?? null; +if ($mgAuth && (($mgAuth['endpoint'] ?? '') !== $endpointKey || + ($mgAuth['expires'] ?? 0) <= time())) { + unset($_SESSION['management_auth'][$ddName]); + $mgAuth = null; +} +$action = (string)($body['action'] ?? ''); +if ($method === 'POST' && in_array($action, ['management_login', 'management_logout'], true)) { + if ($action === 'management_logout') { + unset($_SESSION['management_auth'][$ddName]); + echo json_encode(['ok' => true]); + exit; + } + // Frontend credential entry must use HTTPS, or a literal loopback browser + // connection. This does not add encryption to the AdsMg wire connection. + $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; + $loopback = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true); + if (!$https && !$loopback) api_error(403, 'Use HTTPS to enter management credentials'); + $user = (string)($body['management_user'] ?? ''); + $password = (string)($body['management_password'] ?? ''); + if ($user === '' || strlen($user) > 256 || strlen($password) > 4096 || + str_contains($user, "\0") || str_contains($password, "\0")) { + api_error(400, 'Invalid management credential fields'); + } + $mgAuth = ['endpoint' => $endpointKey, 'user' => $user, 'password' => $password, + 'expires' => time() + 1200]; +} +$userArg = $mgAuth ? ffiCStr($ffi, $mgAuth['user']) : null; +$passwordArg = $mgAuth ? ffiCStr($ffi, $mgAuth['password']) : null; +$rc = $ffi->AdsMgConnect($serverArg, $userArg, $passwordArg, FFI::addr($hMgmt)); if ($rc !== 0) { - http_response_code(500); - echo json_encode(['error' => "AdsMgConnect failed (rc=$rc)"]); + unset($_SESSION['management_auth'][$ddName]); + api_error(401, 'Management credentials required or connection unavailable', $rc, + ['management_required' => true, 'management_csrf' => $_SESSION['management_csrf']]); +} +if ($method === 'POST' && $action === 'management_login') { + $_SESSION['management_auth'][$ddName] = $mgAuth; + $ffi->AdsMgDisconnect($hMgmt->cdata); + echo json_encode(['ok' => true]); exit; } + $h = $hMgmt->cdata; try { @@ -331,6 +378,26 @@ function ffiCStr(FFI $ffi, string $s): FFI\CData { exit; } + $health = null; + $healthError = null; + try { + $healthFfi = FFI::cdef('typedef unsigned int UNSIGNED32; + UNSIGNED32 OAdsGetServerStats(unsigned int, unsigned char*, UNSIGNED32*);', $dllPath); + $healthBuffer = $healthFfi->new('unsigned char[8192]'); + $healthLength = $healthFfi->new('UNSIGNED32'); + $healthLength->cdata = 8192; + $healthRc = $healthFfi->OAdsGetServerStats($h, $healthBuffer, FFI::addr($healthLength)); + if ($healthRc !== 0) $healthError = 'Health statistics unavailable (rc=' . $healthRc . ')'; + else { + $health = json_decode(FFI::string(FFI::cast('char*', $healthBuffer)), true, 32, JSON_THROW_ON_ERROR); + if (($health['schema_version'] ?? 0) !== 1) { + $health = null; $healthError = 'Unsupported health schema'; + } + } + } catch (Throwable $e) { + $healthError = 'Health extension unavailable; use a matching DLL and server with OAdsGetServerStats support'; + } + // ── Activity info (counts) ──────────────────────────────────────────────── $actInfo = $ffi->new('ADS_MGMT_ACTIVITY_INFO'); $actSize = $ffi->new('UNSIGNED16'); @@ -439,6 +506,9 @@ function ffiCStr(FFI $ffi, string $s): FFI\CData { 'ok' => true, 'connType' => $connType, 'activity' => $activity, + 'health' => $health, + 'health_error' => $healthError, + 'management_csrf' => $_SESSION['management_csrf'], 'users' => $users, 'tables' => $tables, 'queries' => $queries, diff --git a/DA-Web/js/app.js b/DA-Web/js/app.js index 97d6f41d..e9ce1f42 100644 --- a/DA-Web/js/app.js +++ b/DA-Web/js/app.js @@ -4761,7 +4761,7 @@ try { const r = await apiFetch('api/server_info.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'kill', dd, connNo: row.connNo, user: row.name }), + body: JSON.stringify({ action: 'kill', csrf: document.getElementById('srvinfo-' + tabId)?.dataset.managementCsrf, dd, connNo: row.connNo, user: row.name }), }); if (r.error) { setStatus(`Disconnect failed: ${r.error}`); return; } setStatus(r.note || `Disconnected ${label}`); @@ -4778,7 +4778,7 @@ try { const r = await apiFetch('api/server_info.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'sql', dd, threadNo: row.threadNo }), + body: JSON.stringify({ action: 'sql', csrf: document.getElementById('srvinfo-' + tabId)?.dataset.managementCsrf, dd, threadNo: row.threadNo }), }); if (r.error) { box.textContent = `Error: ${r.error}`; return; } if (!r.sql) { box.textContent = 'This thread has not run any SQL yet.'; return; } @@ -4796,7 +4796,7 @@ try { const r = await apiFetch('api/server_info.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'locks', dd, tableName: row.name }), + body: JSON.stringify({ action: 'locks', csrf: document.getElementById('srvinfo-' + tabId)?.dataset.managementCsrf, dd, tableName: row.name }), }); if (r.error) { box.textContent = `Error: ${r.error}`; return; } if (!r.locks || r.locks.length === 0) { @@ -4820,16 +4820,49 @@ } } + function serverHealthHtml(h, error) { + if (!h) return `
${escHtml(error || 'Health unavailable')}
`; + const val = v => v == null ? 'Unavailable / not measured' : escHtml(String(v)); + const rows = Object.entries(h).filter(([k]) => k !== 'semantics').map(([k,v]) => + `${escHtml(k.replaceAll('_',' '))}${v && typeof v === 'object' ? `Current: ${val(v.current)} | Max used: ${val(v.max_used)} | Rejected: ${val(v.rejected)}` : k === 'max_sessions' && v === 0 ? 'Unlimited (0)' : val(v)}`).join(''); + return `

Server health

${rows}
Count meanings and raw JSON
${escHtml(JSON.stringify(h,null,2))}
`; + } + + function showManagementLogin(container, tabId, dd, resp) { + container.innerHTML = `
${escHtml(resp.error || 'Management login required')}
+

Separate daemon-management credentials, not Data Dictionary login. Kept in this PHP session for 20 minutes only. Use HTTPS for the portal and a trusted/firewalled management route or TLS proxy.

+
+ +
`; + const form = document.getElementById('mg-login-' + tabId); + form.addEventListener('submit', async e => { + e.preventDefault(); + const user = form.elements.user.value, password = form.elements.password.value; + form.elements.password.value = ''; + try { + const r = await apiFetch('api/server_info.php', {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({action:'management_login',dd,csrf:resp.management_csrf,management_user:user,management_password:password})}); + if (r.error) document.getElementById('mg-error-'+tabId).textContent = r.error; + else loadServerInfo(tabId,dd); + } catch (err) { document.getElementById('mg-error-'+tabId).textContent = err.message; } + }); + } + function loadServerInfo(tabId, dd) { const container = document.getElementById('srvinfo-' + tabId); if (!container) return; container.innerHTML = `
Loading…
`; Promise.all([ - apiFetch(`api/server_info.php?dd=${encodeURIComponent(dd)}`), + apiFetch(`api/server_info.php?dd=${encodeURIComponent(dd)}`).catch(err => { + if (err.data?.management_required) return err.data; + throw err; + }), apiFetch(`api/db_props.php?dd=${encodeURIComponent(dd)}`).catch(() => null), ]) .then(([resp, props]) => { + container.dataset.managementCsrf = resp.management_csrf || ''; + if (resp.management_required) { showManagementLogin(container,tabId,dd,resp); return; } if (resp.error) { container.innerHTML = `
${escHtml(resp.error)}
`; return; @@ -4843,12 +4876,14 @@
+ ${escHtml(dd)} Up ${a.upTime.days}d ${a.upTime.hours}h ${a.upTime.minutes}m ${a.upTime.seconds}s ${isLocal ? 'local connection — Disconnect is a no-op' : ''} ${loginsDisabled ? 'Logins disabled' : ''}
+
${serverHealthHtml(resp.health,resp.health_error)}
${countBox('Users', a.users.inUse)} ${countBox('Connections', a.connections.inUse)} @@ -4879,6 +4914,11 @@
`; + document.getElementById('mg-logout-' + tabId)?.addEventListener('click', async () => { + await apiFetch('api/server_info.php',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({action:'management_logout',dd,csrf:resp.management_csrf})}); + loadServerInfo(tabId,dd); + }); document.getElementById('srvinfo-refresh-' + tabId)?.addEventListener('click', () => loadServerInfo(tabId, dd)); /* global Tabulator */ diff --git a/contrib/oads_hb/README.md b/contrib/oads_hb/README.md index df0d8f96..8ea88042 100644 --- a/contrib/oads_hb/README.md +++ b/contrib/oads_hb/README.md @@ -157,3 +157,56 @@ openads_serverd --port 6262 --data /Users/anto/OpenADS/data set OPENADS_TEST_REMOTE=tcp://192.168.18.184:6262//Users/anto/OpenADS/data test_oads_file.exe ``` + +### Columnar server stats + +Compile `oads_stats_display.prg` with your app and `oads_hb.c`, then use: + +```harbour +QOut( OAds_ServerStatsText( hStats ) ) +``` + +This text-only helper accepts the local or remote stats hash. It makes no +connection and prints no credentials. The 76-column display uses Statistic +(24), Current (20), Max used (16) and Rejected (16). Missing values say +`Unavailable`; configured `max_sessions = 0` says `Unlimited (0)`. All returned +scalar, usage and semantics fields are included; long labels/text wrap rather +than being discarded. Compile/run `tests/smoke/harbour/stats_display.prg` with +the helper for the native sandbox fixture. + +### Draw stats in a console/window rectangle + +`OAds_ServerStatsShow(hStats, nTop, nLeft, nBottom, nRight)` draws using +`DispOutAt`, in the current console/CT window coordinates and color. The +rectangle is inclusive. It clears only that rectangle, clips long lines, +omits excess rows and returns the number of text rows drawn. It preserves +cursor position and does not change color or draw a border. For an existing +box, pass the box's inner rectangle. Omitted coordinates default to the +current row/column through the current window's maximum row/column. Invalid +or reversed coordinates return0 without drawing; right/bottom beyond the +window are clamped. A rectangle narrower than76columns clips the formatter, +so use at least76columns to see all four columns. It is a display-only helper, +not a query or scrollable browser. `OAds_ServerStatsText` remains available +for logs/text output. + +```harbour +nRows := OAds_ServerStatsShow(hStats, nTop+1, nLeft+1, nBottom-1, nRight-1) +``` + +Native sandbox tests: `tests/smoke/harbour/stats_show.prg` checks actual GT +screen cells, color, cursor, bounds, clipping and invalid inputs. Compile +with the helper and `-gtstd -mt`. The existing text fixture remains unchanged. +For an actual Clipper Tools window test, also compile/link hbct, define +`STATS_SHOW_CTW` and run the same fixture. It checks a nonzero-origin CT +window with local1,1coordinates. The helper adds no hbct dependency to apps. + +### Aggregate health query helpers + +`OAds_ServerStats(hMgmt, @nError)` returns a hash from an AdsMgConnect handle. +`OAds_ServerStatsRemote(host, port, user, password, @nError)` owns a separate +management handle and serializes its connect/read/disconnect lifecycle for MT +callers. Neither uses or changes a default data handle. Failure returns NIL +and a numeric error. Null JSON values remain NIL; wide counters stay wide. +Use a literal hostname/IPv4 address, not a URI/path. Management remains plain +TCP; load credentials from secure settings. See `docs/health-consumers.md` for +transport, fixed-buffer and deployment limits. diff --git a/contrib/oads_hb/oads_hb.c b/contrib/oads_hb/oads_hb.c index 6c040ffb..116efd42 100644 --- a/contrib/oads_hb/oads_hb.c +++ b/contrib/oads_hb/oads_hb.c @@ -42,6 +42,9 @@ #include "hbapi.h" #include "hbapiitm.h" #include "hbdate.h" +#include "hbjson.h" +#include "hbthread.h" +#include "hbvm.h" #include "ace.h" #include @@ -94,6 +97,80 @@ extern UNSIGNED32 ENTRYPOINT AdsGetServerVersion( ADSHANDLE hConnect, UNSIGNED8 * pucBuf, UNSIGNED16 * pusLen ); +/* Additive OpenADS export, declared here for older ACE SDK headers. */ +extern UNSIGNED32 ENTRYPOINT OAdsGetServerStats(ADSHANDLE hMgmt, + UNSIGNED8* pucJson, + UNSIGNED32* pulLen); + +/* OAds_ServerStats(hMgmt, @nError) -> hash, NIL on failure. + hMgmt must come from AdsMgConnect; deliberately no default data handle. + Harbour JSON preserves null as NIL and uses wide numeric values on x86. */ +HB_FUNC( OADS_SERVERSTATS ) +{ + char buffer[8192]; + UNSIGNED32 len = ( UNSIGNED32 ) sizeof(buffer); + UNSIGNED32 rc = OAdsGetServerStats(( ADSHANDLE ) hb_parnint(1), + ( UNSIGNED8* ) buffer, &len); + PHB_ITEM value = hb_itemNew(NULL); + if (rc == 0 && (len < 2 || len > sizeof(buffer) || + hb_jsonDecode(buffer, value) != (HB_SIZE)(len - 1) || !HB_IS_HASH(value))) + rc = 5001; + hb_stornint((HB_MAXINT)rc, 2); + if (rc == 0) hb_itemReturnRelease(value); + else { hb_itemRelease(value); hb_ret(); } +} + +/* OAds_ServerStatsRemote(host, port, adminUser, adminPass, @error). + * Own handle, no change to rddads' shared management/data handles. This + * wrapper serializes its connect/read/disconnect lifecycle for MT callers. + * Host is a literal hostname/IP, not a path or URI; credentials never log. + */ +static HB_CRITICAL_NEW( s_oads_health_mutex ); +HB_FUNC( OADS_SERVERSTATSREMOTE ) +{ + const char * host = hb_parc( 1 ); + int port = hb_parni( 2 ); + UNSIGNED32 rc = 5001, len = 8192; + ADSHANDLE handle = 0; + char buffer[8192]; + char * endpoint; + PHB_ITEM value = hb_itemNew( NULL ); + if( !host || !*host || strlen( host ) > 253 || strlen( host ) != hb_parclen( 1 ) || + strpbrk( host, ":/\\ \t\r\n" ) || !HB_ISNUM( 2 ) || + hb_parnd( 2 ) != port || port < 1 || port > 65535 || + !HB_ISCHAR( 3 ) || !HB_ISCHAR( 4 ) || + hb_parclen( 3 ) > 256 || hb_parclen( 4 ) > 4096 || + strlen( hb_parc( 3 ) ) != hb_parclen( 3 ) || + strlen( hb_parc( 4 ) ) != hb_parclen( 4 ) ) + { + hb_stornint( ( HB_MAXINT ) rc, 5 ); + hb_itemRelease( value ); + hb_ret(); + return; + } + endpoint = ( char * ) hb_xgrab( strlen( host ) + 8 ); + hb_snprintf( endpoint, strlen( host ) + 8, "%s:%d", host, port ); + hb_threadEnterCriticalSectionGC( &s_oads_health_mutex ); + rc = AdsMgConnect( ( UNSIGNED8 * ) endpoint, + ( UNSIGNED8 * ) HB_UNCONST( hb_parc( 3 ) ), + ( UNSIGNED8 * ) HB_UNCONST( hb_parc( 4 ) ), &handle ); + if( rc == 0 ) + { + UNSIGNED32 close_rc; + rc = OAdsGetServerStats( handle, ( UNSIGNED8 * ) buffer, &len ); + close_rc = AdsMgDisconnect( handle ); + if( rc == 0 ) rc = close_rc; + } + hb_threadLeaveCriticalSection( &s_oads_health_mutex ); + hb_xfree( endpoint ); + if( rc == 0 && (len < 2 || len > sizeof(buffer) || + hb_jsonDecode(buffer, value) != (HB_SIZE)(len - 1) || !HB_IS_HASH(value))) + rc = 5001; + hb_stornint( ( HB_MAXINT ) rc, 5 ); + if( rc == 0 ) hb_itemReturnRelease( value ); + else { hb_itemRelease( value ); hb_ret(); } +} + /* ------------------------------------------------------------------ */ /* OADS_SETCONNECTION( hConn ) -> lOk */ /* Set both the ACE current-thread default and the shared OAds default. */ diff --git a/contrib/oads_hb/oads_stats_display.prg b/contrib/oads_hb/oads_stats_display.prg new file mode 100644 index 00000000..decaec91 --- /dev/null +++ b/contrib/oads_hb/oads_stats_display.prg @@ -0,0 +1,126 @@ +/* Console helper. Compile this PRG alongside the kit's oads_hb.c. + Returns text only. It does not connect, change data or print secrets. */ +FUNCTION OAds_ServerStatsText( hStats ) +LOCAL cText := "", cKey, xValue, cSub, nWidth := 76 +IF ! HB_ISHash( hStats ) + RETURN "Server stats unavailable" + hb_eol() +ENDIF +cText += PadR( "Statistic", 24 ) + PadL( "Current", 20 ) + ; + PadL( "Max used", 16 ) + PadL( "Rejected", 16 ) + hb_eol() +cText += Replicate( "-", nWidth ) + hb_eol() +FOR EACH cKey IN HB_HKeys( hStats ) + xValue := hStats[ cKey ] + IF HB_ISHash( xValue ) .AND. hb_HHasKey( xValue, "current" ) + cText += UsageLabel( Label( cKey ) ) + PadL( Value( xValue["current"] ), 20 ) + ; + PadL( Value( StatsField( xValue, "max_used" ) ), 16 ) + PadL( Value( StatsField( xValue, "rejected" ) ), 16 ) + hb_eol() + ELSEIF HB_ISHash( xValue ) + FOR EACH cSub IN HB_HKeys( xValue ) + cText += Lines( Label( cKey ) + "/" + Label( cSub ), Value( xValue[cSub] ) ) + NEXT + ELSE + cText += Lines( Label( cKey ), ; + iif( cKey == "max_sessions" .AND. HB_ISNumeric(xValue) .AND. xValue == 0, ; + "Unlimited (0)", Value(xValue) ) ) + ENDIF +NEXT +RETURN cText + +STATIC FUNCTION Label( cKey ) +RETURN StrTran( cKey, "_", " " ) + +STATIC FUNCTION Value( x ) +DO CASE +CASE x == NIL + RETURN "Unavailable" +CASE HB_ISNumeric(x) + RETURN AllTrim( Str(x, 20, iif(x == Int(x), 0, 2)) ) +CASE HB_ISString(x) + RETURN StrTran( StrTran( x, Chr(13), " " ), Chr(10), " " ) +CASE HB_ISLogical(x) + RETURN iif(x, "True", "False") +ENDCASE +RETURN hb_ValToStr(x) + +STATIC FUNCTION Lines( cLabel, cValue ) +LOCAL cText := "", lFirst := .T. +/* Long labels get their own line, never collide with the value column. */ +IF Len(cLabel) > 24 + DO WHILE Len(cLabel) > 0 + cText += Left(cLabel,76) + hb_eol() + cLabel := SubStr(cLabel,77) + ENDDO +ENDIF +IF Len(cValue) == 0 + RETURN PadR(cLabel,24) + hb_eol() +ENDIF +DO WHILE Len(cValue) > 0 + cText += PadR( iif(lFirst,cLabel,""),24 ) + ; + iif(Len(cValue) <= 20, PadL(cValue,20), Left(cValue,52)) + hb_eol() + cValue := SubStr(cValue,53) + lFirst := .F. +ENDDO +RETURN cText + +STATIC FUNCTION StatsField( h, cKey ) +IF hb_HHasKey( h, cKey ) + RETURN h[cKey] +ENDIF +RETURN NIL + +STATIC FUNCTION UsageLabel( cLabel ) +LOCAL cText := "" +IF Len(cLabel) > 24 + DO WHILE Len(cLabel) > 0 + cText += Left(cLabel,76) + hb_eol() + cLabel := SubStr(cLabel,77) + ENDDO +ENDIF +RETURN cText + PadR(cLabel,24) + +/* Draw inside the inclusive rectangle in CURRENT window coordinates. + No border is drawn. Pass the inner rectangle of an existing box. + Defaults: current Row()/Col(), through MaxRow()/MaxCol(). + Clears only that rectangle in the current color. Long lines are clipped, + excess rows omitted. Returns rows drawn; cursor and color are preserved. */ +FUNCTION OAds_ServerStatsShow( hStats, nTop, nLeft, nBottom, nRight ) + LOCAL nOldRow := Row( ), nOldCol := Col( ), nRows, nWidth, nRow + LOCAL aLines, cText := OAds_ServerStatsText( hStats ), nCount + IF nTop == NIL + nTop := nOldRow + ENDIF + IF nLeft == NIL + nLeft := nOldCol + ENDIF + IF nBottom == NIL + nBottom := MaxRow( ) + ENDIF + IF nRight == NIL + nRight := MaxCol( ) + ENDIF + IF ! HB_ISNumeric( nTop ) .OR. ! HB_ISNumeric( nLeft ) .OR. ; + ! HB_ISNumeric( nBottom ) .OR. ! HB_ISNumeric( nRight ) + RETURN 0 + ENDIF + nTop := Int( nTop ) + nLeft := Int( nLeft ) + nBottom := Min( Int( nBottom ), MaxRow( ) ) + nRight := Min( Int( nRight ), MaxCol( ) ) + IF nTop < 0 .OR. nLeft < 0 .OR. nTop > nBottom .OR. nLeft > nRight + RETURN 0 + ENDIF + nRows := nBottom - nTop + 1 + nWidth := nRight - nLeft + 1 + /* Strip the formatter's final EOL, not a real empty data row. */ + cText := Left( cText, Len( cText ) - Len( hb_eol( ) ) ) + aLines := hb_ATokens( cText, hb_eol( ) ) + nCount := Min( Len( aLines ), nRows ) + DispBegin( ) + FOR nRow := 0 TO nRows - 1 + DispOutAt( nTop + nRow, nLeft, Space( nWidth ) ) + IF nRow < nCount + DispOutAt( nTop + nRow, nLeft, Left( aLines[nRow + 1], nWidth ) ) + ENDIF + NEXT + SetPos( nOldRow, nOldCol ) + DispEnd( ) + RETURN nCount diff --git a/docs/health-consumers.md b/docs/health-consumers.md new file mode 100644 index 00000000..ba792fe0 --- /dev/null +++ b/docs/health-consumers.md @@ -0,0 +1,81 @@ +# Health consumers + +These consumers use the aggregate schema1 backend in `docs/health-backend.md`. +They do not change the backend wire contract, lock ownership or count caching. + +## Harbour + +`OAds_ServerStats(hMgmt, @nError)` returns a hash, or NIL and a numeric error. +The handle must come from `AdsMgConnect`, not a default data connection. +`OAds_ServerStatsRemote(host, port, user, password, @nError)` owns its management +connect/read/disconnect lifecycle, serialized for MT callers. It accepts a +literal hostname/IPv4 address and port1..65535, not a URI/path or IPv6 literal. +It never changes rddads management/data handles. Load credentials from secure +settings, not command-line arguments. Management transport remains plain TCP. + +Both wrappers use an8192-byte buffer and fail rather than return partial data; +validate a complete JSON hash, preserve null as NIL and wide numeric values. +Matching DLL/server support is required. Older pairs return failure/unavailable. + +Compile `contrib/oads_hb/oads_stats_display.prg` with the app for +`OAds_ServerStatsText(hStats)` and `OAds_ServerStatsShow(hStats,top,left,bottom,right)`. +Text is76columns: Statistic24,Current20,Max used16,Rejected16. All scalar, +usage and semantics fields appear; unknowns say Unavailable and cap0 says +Unlimited(0). The screen helper clears only its inclusive rectangle, clips +long lines, omits excess rows and preserves cursor/color. Existing CT-window +coordinates are local to the current window; the app supplies its box interior. +No implicit connect, refresh loop or hbct dependency is added. + +Smoke fixtures in `tests/smoke/harbour/` cover remote MT/validation/login, +text formatting, GT bounds/color/cursor and optional CT window rendering. + +## Embedded Studio + +The Server tab adds manual Refresh server health and count meanings/raw JSON. +`GET /api/server/health` uses the existing HTTP authentication gate and sends +Cache-Control:no-store. An attached wire server supplies server-side values; +an embedded local console without one reports local_process. No new listener, +authentication bypass or polling timer. Existing HTTP empty-user development +mode is unchanged and must not be treated as a protected deployment. + +## DA-Web + +Server Info adds the same health table. Unknowns stay unavailable, never false +zero. Cap0 means unlimited. All dynamic text/raw JSON is escaped. Refresh is +manual, with the existing Server Info refresh control. + +Daemon-management credentials are separate from Data Dictionary credentials. +Successful credentials are held in the server-side PHP session, scoped to the +selected dictionary endpoint, for20minutes. Expiry, endpoint changes, dictionary +reconnect/disconnect or Forget management login clear them. Password is cleared +from the browser field immediately after submit, never put in URL/localStorage, +logs or returned JSON. Credentials need HTTPS from browser to portal, except +literal loopback development. This does not encrypt the AdsMg management socket; +use a trusted/firewalled route or configured TLS proxy. + +Every Server Info JSON POST requires the session CSRF token: login/logout and +existing kill/sql/locks requests. Direct API consumers must first GET Server Info +to obtain management_csrf and include it in subsequent JSON POSTs. The selected +DD connection must exist in the PHP session. ADSHANDLE FFI declarations use the +actual32-bit ABI, including on64-bit PHP. Health failures do not invent values +or discard otherwise available legacy Server Info data. + +DA-Web files must be deployed separately from binary DLL/server packages. +No new portal-wide authentication system is claimed; existing session security, +protected storage, HTTPS and network isolation remain deployment responsibilities. +Windows32-bit Harbour and full portal deployment testing are separate from local +Linux fixtures. + +## Sandbox checks + +`node tests/health-consumers/render_health.js` checks escaping, nulls, wide +numbers, unlimited caps and older-extension fallback text. +`OPENADS_BUILD=/absolute/build PHP_BIN=php python3 tests/health-consumers/portal_fixture.py` +starts a loopback-only disposable daemon and PHP server. PHP_FFI_EXTENSION may +name ffi or its absolute module path. It uses fixed sandbox ports26391..26393, +only test credentials and its own temporary PHP session/data directory. Free +those ports first. It covers nativeFFI/auth/expiry/endpoint/local mode, CSRF, +HTTPS gate and the new Studio route. The HTTPS test uses a sandbox copy of the +PHP endpoint with stdin substituted for php://input, not a production change. +The fixture never kills a real user: legacy kill is tested only with invalid +CSRF, so the action is rejected before any management request. diff --git a/tests/health-consumers/portal_fixture.py b/tests/health-consumers/portal_fixture.py new file mode 100644 index 00000000..06e23880 --- /dev/null +++ b/tests/health-consumers/portal_fixture.py @@ -0,0 +1,73 @@ +# Sandbox fixture only. PHP_FFI_EXTENSION may be ffi or an absolute module path. +# OPENADS_BUILD=build PHP_BIN=php python3 tests/health-consumers/portal_fixture.py +import subprocess,os,tempfile,urllib.request,urllib.error,json,time,socket,pathlib,ctypes +root=tempfile.mkdtemp(prefix='stage7-portal-');php=os.environ.get('PHP_BIN','php');ffi=os.environ.get('PHP_FFI_EXTENSION','ffi');src=str(pathlib.Path(__file__).resolve().parents[2]);build=os.environ['OPENADS_BUILD'] +def wait(port): + for _ in range(80): + try: + with socket.create_connection(('127.0.0.1',port),timeout=.2):return + except OSError:time.sleep(.1) + raise RuntimeError('listener timeout') +cfg=root+'/daemon.ini';open(cfg,'w').write('host=127.0.0.1\nport=26391\nhttp_port=26392\ndata='+root+'\nauth_user=testadmin:test-only-password\nhttp_user=webadmin:web-test-password\nmax_sessions=11\n') +e=dict(os.environ,OPENADS_DLL=build+'/src/libopenace64.so');daemon=subprocess.Popen([build+'/tools/serverd/openads_serverd','--config',cfg],cwd=root,env=e,stdout=open(root+'/daemon.log','w'),stderr=subprocess.STDOUT) +open(root+'/seed.php','w').write(''''remote','host'=>'127.0.0.1','port'=>26391];session_write_close();''') +phpflags=[php,'-n','-d','extension='+ffi,'-d','ffi.enable=true','-d','session.save_path='+root] +subprocess.run(phpflags+[root+'/seed.php'],check=True) +server=subprocess.Popen(phpflags+['-S','127.0.0.1:26393','-t',src+'/DA-Web'],env=e,stdout=open(root+'/php.log','w'),stderr=subprocess.STDOUT) +def req(path,body=None,ct='application/json',cookie=True): + headers={'Content-Type':ct} + if cookie:headers['Cookie']='PHPSESSID=stage7test' + request=urllib.request.Request('http://127.0.0.1:26393/'+path,headers=headers,data=json.dumps(body).encode() if body is not None else None) + try: + with urllib.request.urlopen(request)as r:return r.status,json.load(r) + except urllib.error.HTTPError as ex:return ex.code,json.load(ex) +try: + wait(26391);wait(26392);wait(26393);time.sleep(2) + time.sleep(2) + status,j=req('api/server_info.php?dd=fixture');assert status==401 and j['management_required'];csrf=j['management_csrf'];print('loginrequired PASS') + time.sleep(2) + for token in ['', 'wrong']: + status,j=req('api/server_info.php',{'dd':'fixture','action':'management_login','csrf':token,'management_user':'testadmin','management_password':'test-only-password'});assert status==403 + print('CSRF PASS') + status,j=req('api/server_info.php',{'dd':'fixture'},ct='text/plain');assert status==415 + # wrong-password check follows successful login to avoid lockout throttling + status,j=req('api/server_info.php',{'dd':'fixture','action':'management_login','csrf':csrf,'management_user':'testadmin','management_password':'test-only-password'});print('LOGIN',status,j);assert status==200 and j['ok'] + status,j=req('api/server_info.php?dd=fixture');assert status==200 and j['health']['scope']=='server' and j['health']['max_sessions']==11 + assert 'test-only-password' not in json.dumps(j);print('remotehealth/privacy PASS') + status,j=req('api/server_info.php',{'dd':'fixture','action':'kill','csrf':'wrong','connNo':1});assert status==403;print('legacyactionCSRF PASS') + def session_change(code): + open(root+'/change.php','w').write("'local']") + status,j=req('api/server_info.php?dd=localfixture');assert status==200 and j['health']['scope']=='local_process';print('local mode PASS') + + status,j=req('api/server_info.php?dd=fixture',cookie=False);assert status==401 + session_change("unset($_SESSION['management_auth']['fixture'])") + time.sleep(2) + status,j=req('api/server_info.php',{'dd':'fixture','action':'management_login','csrf':csrf,'management_user':'testadmin','management_password':'wrong'});assert status==401;print('wrong credentials PASS') + # Direct CLI request with non-loopback browser address exercises HTTPS gate. + open(root+'/https.php','w').write(" String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]))}; +vm.createContext(context); vm.runInContext(fn, context); +const html = context.serverHealthHtml({schema_version:1, version:'', + max_sessions:0, bytes_in:4294967296, workareas:{current:1,max_used:5,rejected:null}, + worker_threads:{current:2,max_used:null,rejected:null}, semantics:{snapshot:''}}); +for (const token of ['Unlimited (0)', '4294967296', 'Unavailable / not measured', '<img', '<private>']) + if (!html.includes(token)) throw new Error('missing ' + token); +if (html.includes('')) throw new Error('unescaped text'); +if (!context.serverHealthHtml(null, 'older ').includes('older <server>')) throw new Error('fallback'); +console.log('health renderer: escaping/null/wide/zero/fallback PASS'); diff --git a/tests/smoke/harbour/serverstats_remote.prg b/tests/smoke/harbour/serverstats_remote.prg new file mode 100644 index 00000000..dcc9f8df --- /dev/null +++ b/tests/smoke/harbour/serverstats_remote.prg @@ -0,0 +1,38 @@ +PROCEDURE Main( cHost, cPort, cUser, cPass ) +LOCAL nError := 0, h, aThreads := {}, i, x +h := OAds_ServerStatsRemote( cHost, Val(cPort), cUser, cPass, @nError ) +IF h == NIL .OR. nError != 0 .OR. h["scope"] != "server" + ErrorLevel(1) + RETURN +ENDIF +FOR i := 1 TO 4 + AAdd( aThreads, hb_threadStart( {|| Query( cHost, Val(cPort), cUser, cPass )} ) ) +NEXT +FOR EACH x IN aThreads + hb_threadJoin( x, @h ) + IF !h + ErrorLevel(2) + RETURN + ENDIF +NEXT +h := OAds_ServerStatsRemote( cHost, 0, cUser, cPass, @nError ) +IF h != NIL .OR. nError == 0 + ErrorLevel(3) + RETURN +ENDIF +h := OAds_ServerStatsRemote( "tcp://" + cHost, Val(cPort), cUser, cPass, @nError ) +IF h != NIL .OR. nError == 0 + ErrorLevel(4) + RETURN +ENDIF +h := OAds_ServerStatsRemote( cHost, Val(cPort), cUser, "wrong-test-password", @nError ) +IF h != NIL .OR. nError == 0 + ErrorLevel(5) + RETURN +ENDIF +? "REMOTE-HEALTH-WRAPPER-OK" +RETURN +STATIC FUNCTION Query(cHost,nPort,cUser,cPass) +LOCAL h, n := 0 +h := OAds_ServerStatsRemote(cHost,nPort,cUser,cPass,@n) +RETURN h != NIL .AND. n == 0 diff --git a/tests/smoke/harbour/stats_display.prg b/tests/smoke/harbour/stats_display.prg new file mode 100644 index 00000000..a5f1d6fc --- /dev/null +++ b/tests/smoke/harbour/stats_display.prg @@ -0,0 +1,30 @@ +/* Native fixture: hbmk2 stats_display.prg ../../../contrib/oads_hb/oads_stats_display.prg -mt */ +PROCEDURE Main() +LOCAL h := {=>}, s, aLines, cLine, cLong := Replicate("label",40) +h["version"] := "1.09.72-mtfix30" +h["workareas"] := {"current"=>298,"max_used"=>1002,"rejected"=>NIL} +h["partial"] := {"current"=>1} +h["max_sessions"] := 0 +h["max_sessions_source"] := "environment:OPENADS_SERVER_MAX_SESSIONS" +h["bytes_in"] := 4294967296 +h["os_fd_count"] := NIL +h["semantics"] := {"snapshot"=>Replicate("long wording ",20)} +h[cLong] := "label not clipped" +s := OAds_ServerStatsText(h) +IF !(PadL("298",20)+PadL("1002",16)+PadL("Unavailable",16) $ s) .OR. ; + !(PadL("4294967296",20) $ s) .OR. !("Unlimited (0)" $ s) .OR. ; + !(PadL("1",20)+PadL("Unavailable",16)+PadL("Unavailable",16) $ s) .OR. ; + !("label not clipped" $ s) .OR. !(Left(cLong,76) $ s) .OR. ; + !("Server stats unavailable" $ OAds_ServerStatsText(NIL)) + ErrorLevel(1) + RETURN +ENDIF +aLines := hb_ATokens(s,hb_eol()) +FOR EACH cLine IN aLines + IF Len(cLine) > 76 + ErrorLevel(2) + RETURN + ENDIF +NEXT +? "STATS-DISPLAY-OK" +RETURN diff --git a/tests/smoke/harbour/stats_show.prg b/tests/smoke/harbour/stats_show.prg new file mode 100644 index 00000000..d5dcefe0 --- /dev/null +++ b/tests/smoke/harbour/stats_show.prg @@ -0,0 +1,98 @@ +/* hbmk2 stats_show.prg ../../../contrib/oads_hb/oads_stats_display.prg -gtstd -mt */ +#include "hbgtinfo.ch" +#ifdef STATS_SHOW_CTW +REQUEST HB_GT_CTW +#endif +PROCEDURE Main() +LOCAL h := {"version"=>"1.09.72-mtfix31", ; + "workareas"=>{"current"=>9,"max_used"=>1002,"rejected"=>NIL}, ; + "max_sessions"=>500,"max_sessions_source"=>"default", ; + "os_fd_count"=>64}, n, r, c, after, cell +LOCAL nR := 2, nC := 4, color := "W+/B", text := "" +SetMode(25,100) +SetColor("W/N") +FOR r := 0 TO MaxRow() + DispOutAt(r,0,Replicate(".",MaxCol()+1)) +NEXT +SetColor(color) +SetPos(nR,nC) +n := OAds_ServerStatsShow(h,4,10,16,85) +IF n != 7 .OR. Row()!=nR .OR. Col()!=nC .OR. SetColor()!=color + Fail("count/cursor/color") +ENDIF +FOR r := 0 TO MaxRow() + FOR c := 0 TO MaxCol() + cell := TestCell(r,c) + IF (r<4 .OR. r>16 .OR. c<10 .OR. c>85) .AND. cell[2] != Asc(".") + Fail("outside rectangle changed") + ENDIF + IF r>=4 .AND. r<=16 .AND. c>=10 .AND. c<=85 .AND. cell[1]!=31 + Fail("color changed") + ENDIF + NEXT +NEXT +IF TestCell(4,10)[2]!=Asc("S") .OR. TestCell(7,53)[2]!=Asc("9") + Fail("coordinates/content") +ENDIF +/* Export the actual screen cells as text for visual review. */ +FOR r := 0 TO MaxRow() + FOR c := 0 TO MaxCol() + text += Chr(TestCell(r,c)[2]) + NEXT + text += hb_eol() +NEXT +hb_MemoWrit("stats_show_screen.txt",text) +/* A short narrow rectangle clips horizontally and vertically without scroll. */ +n := OAds_ServerStatsShow(h,20,90,21,99) +IF n!=2 .OR. TestCell(20,89)[2]!=Asc(".") .OR. TestCell(22,90)[2]!=Asc(".") + Fail("narrow clipping") +ENDIF +/* Last-row default rectangle, invalid input and empty/NIL input. */ +SetPos(MaxRow(),0) +n := OAds_ServerStatsShow(NIL) +IF n!=1 .OR. Row()!=MaxRow() .OR. Col()!=0 + Fail("default rectangle") +ENDIF +after := SaveScreen(0,0,MaxRow(),MaxCol()) +IF OAds_ServerStatsShow(h,-1,0,5,10)!=0 .OR. ; + OAds_ServerStatsShow(h,10,10,5,5)!=0 .OR. ; + OAds_ServerStatsShow(h,"bad",0,5,5)!=0 .OR. ; + SaveScreen(0,0,MaxRow(),MaxCol())!=after + Fail("invalid input changed screen") +ENDIF +#ifdef STATS_SHOW_CTW +/* Current CT window uses local coordinates, not root-screen offsets. */ +WSetShadow(0) +n := WOpen(3,12,18,91,.T.) +IF n <= 0 + Fail("window open") +ENDIF +SetColor(color) +SetPos(2,3) +n := OAds_ServerStatsShow(h,1,1,12,76) +IF n!=7 .OR. Row()!=2 .OR. Col()!=3 .OR. ; + TestCell(1,1)[2]!=Asc("S") .OR. TestCell(4,44)[2]!=Asc("9") + Fail("window-local coordinates") +ENDIF +WClose() +#endif +OutStd("STATS-SHOW-OK"+hb_eol()) +RETURN +STATIC PROCEDURE Fail(cWhy) +OutStd("STATS-SHOW-FAIL: "+cWhy+hb_eol()) +ErrorLevel(1) +QUIT +RETURN +#pragma BEGINDUMP +#include "hbapi.h" +#include "hbapiitm.h" +#include "hbapigt.h" +HB_FUNC( TESTCELL ) +{ + int color=0; HB_BYTE attr=0; HB_USHORT ch=0; + PHB_ITEM a=hb_itemArrayNew(2); + hb_gtGetChar(hb_parni(1),hb_parni(2),&color,&attr,&ch); + hb_arraySetNI(a,1,color); hb_arraySetNI(a,2,ch); + hb_itemReturnRelease(a); +} +#pragma ENDDUMP diff --git a/tests/smoke/harbour/stats_show_example.prg b/tests/smoke/harbour/stats_show_example.prg new file mode 100644 index 00000000..7bfea52e --- /dev/null +++ b/tests/smoke/harbour/stats_show_example.prg @@ -0,0 +1,9 @@ +/* Compile with kit contrib/oads_hb/oads_hb.c and oads_stats_display.prg. */ +FUNCTION ShowServerHealthBox(cServerIP,nPort,cAdminUser,cAdminPass,nTop,nLeft,nBottom,nRight) +LOCAL hStats, nError := 0 +hStats := OAds_ServerStatsRemote(cServerIP,nPort,cAdminUser,cAdminPass,@nError) +IF hStats == NIL + RETURN -nError +ENDIF +/* Coordinates are INSIDE your existing box; no border is drawn here. */ +RETURN OAds_ServerStatsShow(hStats,nTop,nLeft,nBottom,nRight) diff --git a/tools/serverd/http_server.cpp b/tools/serverd/http_server.cpp index 53ee5c6b..a8569652 100644 --- a/tools/serverd/http_server.cpp +++ b/tools/serverd/http_server.cpp @@ -8,6 +8,7 @@ #include "engine/data_dict.h" #include "mgmt/error_log.h" +#include "mgmt/mg_health.h" #include "network/server.h" #include "openads/ace.h" #include "openads/error.h" @@ -1549,6 +1550,32 @@ bool HttpConsole::start(const std::string& host, res.set_content(j.dump(), "application/json"); }); + // Same schema/formatter as the Harbour health helper, under the existing + // pre-routing auth gate. Do not use a PHP/client-local snapshot for serverd. + srv.Get("/api/server/health", [this](const httplib::Request&, httplib::Response& res) { + res.set_header("Cache-Control", "no-store"); + if (wire_srv_) { + res.set_content(openads::mgmt::health_json(wire_srv_->build_mg_snapshot(), + OPENADS_VERSION_STR), "application/json"); + return; + } + UNSIGNED8 local[] = "local"; + ADSHANDLE handle = 0; + UNSIGNED8 buffer[8192]; + UNSIGNED32 size = sizeof(buffer); + UNSIGNED32 rc = AdsMgConnect(local, nullptr, nullptr, &handle); + if (rc == 0) { + rc = OAdsGetServerStats(handle, buffer, &size); + AdsMgDisconnect(handle); + } + if (rc != 0) { + res.status = 503; + res.set_content(json_error("health statistics unavailable", 503).dump(), "application/json"); + return; + } + res.set_content(reinterpret_cast(buffer), "application/json"); + }); + // studio.web.0.12 — backup data dir as a STORE-only ZIP. srv.Get("/api/server/backup", [this](const httplib::Request&, httplib::Response& res) { diff --git a/tools/serverd/spa_index.h b/tools/serverd/spa_index.h index 6fac82ef..fe5a6612 100644 --- a/tools/serverd/spa_index.h +++ b/tools/serverd/spa_index.h @@ -384,6 +384,8 @@ inline constexpr const char kSpaPart2[] = R"OPENADS_SPA( padding:4px 10px;borde @@ -1074,7 +1076,29 @@ function fmtBytes(n) { if (n < 1024*1024*1024) return (n/1024/1024).toFixed(2) + " MB"; return (n/1024/1024/1024).toFixed(2) + " GB"; } +// Render every scalar/usage entry without treating unavailable as zero. +function renderServerHealth(h) { + if (!h || h.schema_version !== 1) throw new Error("Unsupported health schema"); + const val = v => v == null ? "Unavailable / not measured" : esc(String(v)); + let html = `

Server health

`; + for (const [key, value] of Object.entries(h)) { + if (key === "semantics") continue; + const label = key.replaceAll("_", " "); + if (value && typeof value === "object") { + html += `
${esc(label)}
Current: ${val(value.current)} | Max used: ${val(value.max_used)} | Rejected: ${val(value.rejected)}
`; + } else html += `
${esc(label)}
${key === "max_sessions" && value === 0 ? "Unlimited (0)" : val(value)}
`; + } + html += `
Count meanings and raw JSON
${esc(JSON.stringify(h, null, 2))}
`; + return html; +} +$("server-refresh").addEventListener("click", loadServerInfo); +async function loadServerHealth() { + try { $("server-health").innerHTML = renderServerHealth(await api("/api/server/health")); $("server-health").classList.remove("empty"); } + catch (e) { $("server-health").innerHTML = `
${esc(e.message)}
`; } +} + async function loadServerInfo() { + loadServerHealth(); try { const d = await api("/api/server/info"); const osIcon = ({Windows:"🪟", Linux:"🐧", macOS:"🍎"}[d.os]