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.
`;
+ 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
+
+
Loading health…
Loading…
@@ -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)}