Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DA-Web/api/connect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
90 changes: 80 additions & 10 deletions DA-Web/api/server_info.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 44 additions & 4 deletions DA-Web/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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; }
Expand All @@ -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) {
Expand All @@ -4820,16 +4820,49 @@
}
}

function serverHealthHtml(h, error) {
if (!h) return `<div class="alert alert-info">${escHtml(error || 'Health unavailable')}</div>`;
const val = v => v == null ? 'Unavailable / not measured' : escHtml(String(v));
const rows = Object.entries(h).filter(([k]) => k !== 'semantics').map(([k,v]) =>
`<tr><th style="text-align:left;padding:5px">${escHtml(k.replaceAll('_',' '))}</th><td style="padding:5px">${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)}</td></tr>`).join('');
return `<h3>Server health</h3><div style="overflow:auto"><table>${rows}</table></div><details><summary>Count meanings and raw JSON</summary><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${escHtml(JSON.stringify(h,null,2))}</pre></details>`;
}

function showManagementLogin(container, tabId, dd, resp) {
container.innerHTML = `<div class="alert alert-info">${escHtml(resp.error || 'Management login required')}</div>
<p>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.</p>
<form style="display:flex;flex-direction:column;gap:12px;max-width:480px" id="mg-login-${tabId}"><label>Management user <input name="user" autocomplete="off" maxlength="256" required></label>
<label>Password <input name="password" type="password" autocomplete="off" maxlength="4096"></label>
<button class="btn" type="submit">Connect management</button></form><div id="mg-error-${tabId}"></div>`;
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 = `<div class="alert alert-info" style="margin:8px;">Loading…</div>`;

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 = `<div class="alert alert-error" style="margin:8px;">${escHtml(resp.error)}</div>`;
return;
Expand All @@ -4843,12 +4876,14 @@
<div class="health-panel">
<div class="health-toolbar">
<button class="btn btn-sm" id="srvinfo-refresh-${tabId}">Refresh</button>
<button class="btn btn-sm" id="mg-logout-${tabId}">Forget management login</button>
<span>${escHtml(dd)}</span>
<span class="health-note" style="color:#a6adc8;">Up ${a.upTime.days}d ${a.upTime.hours}h ${a.upTime.minutes}m ${a.upTime.seconds}s</span>
${isLocal ? '<span class="health-note">local connection — Disconnect is a no-op</span>' : ''}
${loginsDisabled ? '<span class="health-note" style="color:#f38ba8;">Logins disabled</span>' : ''}
<span class="health-note" id="srvinfo-filter-note-${tabId}" style="display:none;color:#89b4fa;"></span>
</div>
<div>${serverHealthHtml(resp.health,resp.health_error)}</div>
<div class="health-summary" style="grid-template-columns:repeat(6,minmax(100px,1fr));">
${countBox('Users', a.users.inUse)}
${countBox('Connections', a.connections.inUse)}
Expand Down Expand Up @@ -4879,6 +4914,11 @@
</div>
</div>`;

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 */
Expand Down
53 changes: 53 additions & 0 deletions contrib/oads_hb/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading