implement activity logging feature with UI for viewing logs
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -14,6 +14,8 @@ uploads/*.lock
|
|||||||
|
|
||||||
# Logs / dumps / exports
|
# Logs / dumps / exports
|
||||||
*.log
|
*.log
|
||||||
|
logs/api/*
|
||||||
|
!logs/api/.gitkeep
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.db
|
*.db
|
||||||
*.dump
|
*.dump
|
||||||
|
|||||||
@ -7,6 +7,20 @@ require_once __DIR__ . '/../common.php';
|
|||||||
require_once __DIR__ . '/../db_init.php';
|
require_once __DIR__ . '/../db_init.php';
|
||||||
require_once __DIR__ . '/../lib/response.php';
|
require_once __DIR__ . '/../lib/response.php';
|
||||||
require_once __DIR__ . '/../lib/validate.php';
|
require_once __DIR__ . '/../lib/validate.php';
|
||||||
|
require_once __DIR__ . '/../lib/api_log.php';
|
||||||
|
|
||||||
|
// Parse route early (for access logs)
|
||||||
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
|
$path = parse_url($requestUri, PHP_URL_PATH);
|
||||||
|
$base = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
|
$route = trim(substr($path, strlen($base)), '/');
|
||||||
|
$route = preg_replace('/\.php$/', '', $route);
|
||||||
|
$route = strtolower($route);
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
qdb_api_log_begin($method, $route !== '' ? $route : '(root)');
|
||||||
|
register_shutdown_function('qdb_api_log_finish');
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
header('Access-Control-Allow-Origin: *');
|
header('Access-Control-Allow-Origin: *');
|
||||||
@ -20,16 +34,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|||||||
|
|
||||||
header('Content-Type: application/json; charset=UTF-8');
|
header('Content-Type: application/json; charset=UTF-8');
|
||||||
|
|
||||||
// Parse route: strip /api/ prefix and .php suffix, normalize
|
|
||||||
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
|
||||||
$path = parse_url($requestUri, PHP_URL_PATH);
|
|
||||||
$base = dirname($_SERVER['SCRIPT_NAME']);
|
|
||||||
$route = trim(substr($path, strlen($base)), '/');
|
|
||||||
$route = preg_replace('/\.php$/', '', $route);
|
|
||||||
$route = strtolower($route);
|
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
|
||||||
|
|
||||||
// Dispatch table: route -> handler file
|
// Dispatch table: route -> handler file
|
||||||
$routes = [
|
$routes = [
|
||||||
'questionnaires' => __DIR__ . '/../handlers/questionnaires.php',
|
'questionnaires' => __DIR__ . '/../handlers/questionnaires.php',
|
||||||
@ -51,6 +55,7 @@ $routes = [
|
|||||||
'dev' => __DIR__ . '/../handlers/dev.php',
|
'dev' => __DIR__ . '/../handlers/dev.php',
|
||||||
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
||||||
'health' => __DIR__ . '/../handlers/health.php',
|
'health' => __DIR__ . '/../handlers/health.php',
|
||||||
|
'activity-log' => __DIR__ . '/../handlers/activity_log.php',
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -66,6 +71,7 @@ try {
|
|||||||
require $handlerFile;
|
require $handlerFile;
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
qdb_api_log_note_exception($e);
|
||||||
error_log("Unhandled error on $method $route: " . $e->getMessage());
|
error_log("Unhandled error on $method $route: " . $e->getMessage());
|
||||||
json_error('SERVER_ERROR', 'Internal server error', 500);
|
json_error('SERVER_ERROR', 'Internal server error', 500);
|
||||||
}
|
}
|
||||||
|
|||||||
20
handlers/activity_log.php
Normal file
20
handlers/activity_log.php
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: read API activity log (app sync + data changes only).
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/api_log.php';
|
||||||
|
|
||||||
|
$tokenRec = require_valid_token_web();
|
||||||
|
require_role(['admin'], $tokenRec);
|
||||||
|
|
||||||
|
if ($method !== 'GET') {
|
||||||
|
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = trim((string)($_GET['date'] ?? date('Y-m-d')));
|
||||||
|
$activity = trim((string)($_GET['activity'] ?? ''));
|
||||||
|
$limit = (int)($_GET['limit'] ?? 200);
|
||||||
|
|
||||||
|
json_success(qdb_api_log_read_entries($date, $activity, $limit));
|
||||||
820
lib/api_log.php
Normal file
820
lib/api_log.php
Normal file
@ -0,0 +1,820 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity API logs (JSON Lines). One file per calendar day; rotates when a file
|
||||||
|
* exceeds QDB_API_LOG_MAX_BYTES (default 10 MiB) to api-YYYY-MM-DD-002.log, etc.
|
||||||
|
*
|
||||||
|
* Recorded activity (not routine website page loads):
|
||||||
|
* - app_sync: GET /app_questionnaires (mobile sync)
|
||||||
|
* - app_change: POST/PUT/PATCH/DELETE from the Android app
|
||||||
|
* - web_change: POST/PUT/PATCH/DELETE from the management website
|
||||||
|
*
|
||||||
|
* Set QDB_API_LOG=0 to disable. Logs live under logs/api/ (gitignored).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change'];
|
||||||
|
|
||||||
|
function qdb_api_log_enabled(): bool
|
||||||
|
{
|
||||||
|
$v = qdb_env_get('QDB_API_LOG');
|
||||||
|
if ($v === null || $v === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$v = strtolower(trim($v));
|
||||||
|
return !in_array($v, ['0', 'false', 'no', 'off'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_dir(): string
|
||||||
|
{
|
||||||
|
$custom = qdb_env_get('QDB_API_LOG_DIR');
|
||||||
|
if ($custom !== null && $custom !== '') {
|
||||||
|
return rtrim($custom, '/\\');
|
||||||
|
}
|
||||||
|
return dirname(__DIR__) . '/logs/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_max_bytes(): int
|
||||||
|
{
|
||||||
|
$v = qdb_env_get('QDB_API_LOG_MAX_BYTES');
|
||||||
|
if ($v !== null && $v !== '' && ctype_digit($v)) {
|
||||||
|
return max(1024 * 1024, (int)$v);
|
||||||
|
}
|
||||||
|
return 10 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_body_max_chars(): int
|
||||||
|
{
|
||||||
|
$v = qdb_env_get('QDB_API_LOG_BODY_MAX');
|
||||||
|
if ($v !== null && $v !== '' && ctype_digit($v)) {
|
||||||
|
return max(256, (int)$v);
|
||||||
|
}
|
||||||
|
return 4096;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this request should be written to the activity log.
|
||||||
|
*/
|
||||||
|
function qdb_api_log_classify_activity(string $method, string $client, string $route): ?string
|
||||||
|
{
|
||||||
|
$method = strtoupper($method);
|
||||||
|
$client = strtolower(trim($client));
|
||||||
|
$route = strtolower(trim($route));
|
||||||
|
|
||||||
|
if ($method === 'OPTIONS' || $route === 'health' || $route === 'activity-log') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
||||||
|
return $client === 'web' ? 'web_change' : 'app_change';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'GET' && $route === 'app_questionnaires' && $client !== 'web') {
|
||||||
|
return 'app_sync';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $context */
|
||||||
|
function qdb_api_log_begin(string $method, string $route, array $context = []): void
|
||||||
|
{
|
||||||
|
if (!qdb_api_log_enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? ''));
|
||||||
|
$activity = qdb_api_log_classify_activity($method, $client, $route);
|
||||||
|
if ($activity === null) {
|
||||||
|
$GLOBALS['qdb_api_log_ctx'] = ['skip' => true];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$GLOBALS['qdb_api_log_ctx'] = array_merge([
|
||||||
|
'started_at' => microtime(true),
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
'route' => $route,
|
||||||
|
'activity' => $activity,
|
||||||
|
'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''),
|
||||||
|
'ip' => qdb_api_log_client_ip(),
|
||||||
|
'client' => $client,
|
||||||
|
'user_agent' => trim((string)($_SERVER['HTTP_USER_AGENT'] ?? '')),
|
||||||
|
], $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_note_exception(Throwable $e): void
|
||||||
|
{
|
||||||
|
if (empty($GLOBALS['qdb_api_log_ctx']) || !empty($GLOBALS['qdb_api_log_ctx']['skip'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$GLOBALS['qdb_api_log_ctx']['exception'] = $e->getMessage();
|
||||||
|
$GLOBALS['qdb_api_log_ctx']['exception_class'] = $e::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_finish(): void
|
||||||
|
{
|
||||||
|
$ctx = $GLOBALS['qdb_api_log_ctx'] ?? null;
|
||||||
|
unset($GLOBALS['qdb_api_log_ctx']);
|
||||||
|
if (!is_array($ctx) || !qdb_api_log_enabled() || !empty($ctx['skip'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$started = (float)($ctx['started_at'] ?? microtime(true));
|
||||||
|
$status = http_response_code();
|
||||||
|
if ($status === false || $status === 0) {
|
||||||
|
$status = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = get_bearer_token();
|
||||||
|
$auth = ['role' => null, 'userID' => null, 'token_hint' => null];
|
||||||
|
if ($token !== null && $token !== '') {
|
||||||
|
$auth['token_hint'] = qdb_api_log_token_hint($token);
|
||||||
|
try {
|
||||||
|
$rec = token_get_record($token);
|
||||||
|
if (is_array($rec)) {
|
||||||
|
$auth['role'] = $rec['role'] ?? null;
|
||||||
|
$auth['userID'] = $rec['userID'] ?? null;
|
||||||
|
if (!empty($rec['temp'])) {
|
||||||
|
$auth['temp_token'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
$auth['token_lookup'] = 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawBody = function_exists('qdb_raw_request_body') ? qdb_raw_request_body() : '';
|
||||||
|
$bodyLog = qdb_api_log_format_body($rawBody);
|
||||||
|
$queryLog = qdb_api_log_redact_query_string((string)($_SERVER['QUERY_STRING'] ?? ''));
|
||||||
|
|
||||||
|
$actorUsername = qdb_api_log_lookup_username($auth['userID'] ?? null);
|
||||||
|
$targetUsername = qdb_api_log_extract_target_username($bodyLog, $queryLog);
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'ts' => gmdate('c'),
|
||||||
|
'activity' => $ctx['activity'] ?? null,
|
||||||
|
'method' => $ctx['method'] ?? '',
|
||||||
|
'route' => $ctx['route'] ?? '',
|
||||||
|
'status' => $status,
|
||||||
|
'duration_ms' => (int)round((microtime(true) - $started) * 1000),
|
||||||
|
'ip' => $ctx['ip'] ?? '',
|
||||||
|
'client' => $ctx['client'] ?? '',
|
||||||
|
'uri' => $ctx['uri'] ?? '',
|
||||||
|
'query' => $queryLog,
|
||||||
|
'role' => $auth['role'],
|
||||||
|
'userID' => $auth['userID'],
|
||||||
|
'actor_username' => $actorUsername,
|
||||||
|
'target_username' => $targetUsername,
|
||||||
|
'token_hint' => $auth['token_hint'],
|
||||||
|
'temp_token' => $auth['temp_token'] ?? null,
|
||||||
|
'changes' => qdb_api_log_build_changes(
|
||||||
|
(string)($ctx['method'] ?? ''),
|
||||||
|
(string)($ctx['route'] ?? ''),
|
||||||
|
$bodyLog,
|
||||||
|
$queryLog
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($ctx['exception'])) {
|
||||||
|
$entry['error'] = (string)$ctx['exception'];
|
||||||
|
if (!empty($ctx['exception_class'])) {
|
||||||
|
$entry['error_class'] = (string)$ctx['exception_class'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($ctx['user_agent'] ?? '') !== '') {
|
||||||
|
$entry['user_agent'] = $ctx['user_agent'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($line === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
qdb_api_log_append_line($line . "\n");
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('api_log write failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_client_ip(): string
|
||||||
|
{
|
||||||
|
foreach (['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'] as $key) {
|
||||||
|
$v = trim((string)($_SERVER[$key] ?? ''));
|
||||||
|
if ($v === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($key === 'HTTP_X_FORWARDED_FOR') {
|
||||||
|
$v = trim(explode(',', $v)[0]);
|
||||||
|
}
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_token_hint(string $token): string
|
||||||
|
{
|
||||||
|
$len = strlen($token);
|
||||||
|
if ($len <= 8) {
|
||||||
|
return '[token]';
|
||||||
|
}
|
||||||
|
return substr($token, 0, 4) . '…' . substr($token, -4) . " ($len)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_redact_query_string(string $qs): ?string
|
||||||
|
{
|
||||||
|
if ($qs === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
parse_str($qs, $params);
|
||||||
|
if (!is_array($params) || $params === []) {
|
||||||
|
return $qs;
|
||||||
|
}
|
||||||
|
foreach ($params as $k => $_) {
|
||||||
|
if (in_array(strtolower((string)$k), ['token', 'password', 'authorization'], true)) {
|
||||||
|
$params[$k] = '[REDACTED]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return http_build_query($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|string|null
|
||||||
|
*/
|
||||||
|
function qdb_api_log_format_body(string $raw): array|string|null
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$max = qdb_api_log_body_max_chars();
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$redacted = qdb_api_log_redact_value($decoded);
|
||||||
|
$json = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
return '[unencodable body]';
|
||||||
|
}
|
||||||
|
if (strlen($json) > $max) {
|
||||||
|
return substr($json, 0, $max) . '…[truncated]';
|
||||||
|
}
|
||||||
|
return $redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($raw) > $max) {
|
||||||
|
return substr($raw, 0, $max) . '…[truncated]';
|
||||||
|
}
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param mixed $value @return mixed */
|
||||||
|
function qdb_api_log_redact_value(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isList = array_keys($value) === range(0, count($value) - 1);
|
||||||
|
$out = [];
|
||||||
|
foreach ($value as $k => $v) {
|
||||||
|
$lk = strtolower((string)$k);
|
||||||
|
if (in_array($lk, [
|
||||||
|
'password', 'old_password', 'new_password', 'passwordhash',
|
||||||
|
'token', 'authorization', 'secret',
|
||||||
|
], true)) {
|
||||||
|
$out[$k] = '[REDACTED]';
|
||||||
|
} elseif ($lk === 'encrypted' && is_string($v)) {
|
||||||
|
$out[$k] = '[ENCRYPTED:' . strlen($v) . ' chars]';
|
||||||
|
} elseif (is_array($v)) {
|
||||||
|
$out[$k] = qdb_api_log_redact_value($v);
|
||||||
|
} else {
|
||||||
|
$out[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $isList ? array_values($out) : $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field-level change list for the activity log UI (passwords stay redacted).
|
||||||
|
*
|
||||||
|
* @param mixed $body Redacted decoded body or null
|
||||||
|
* @return list<array{field: string, value: string|int|float|bool|null}>
|
||||||
|
*/
|
||||||
|
function qdb_api_log_build_changes(string $method, string $route, mixed $body, ?string $queryString): array
|
||||||
|
{
|
||||||
|
$changes = [];
|
||||||
|
$max = 100;
|
||||||
|
|
||||||
|
if ($queryString !== null && $queryString !== '') {
|
||||||
|
parse_str($queryString, $params);
|
||||||
|
if (is_array($params)) {
|
||||||
|
foreach ($params as $k => $v) {
|
||||||
|
if (count($changes) >= $max) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!is_scalar($v)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$changes[] = [
|
||||||
|
'field' => 'query.' . (string)$k,
|
||||||
|
'value' => qdb_api_log_change_scalar($v),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($body)) {
|
||||||
|
qdb_api_log_flatten_for_changes($body, '', $changes, 0, 4, $max);
|
||||||
|
} elseif (is_string($body) && $body !== '') {
|
||||||
|
$changes[] = ['field' => 'body', 'value' => qdb_api_log_change_scalar($body)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === [] && strtoupper($method) === 'DELETE') {
|
||||||
|
$changes[] = ['field' => '_action', 'value' => 'delete'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{field: string, value: string|int|float|bool|null}> $out
|
||||||
|
*/
|
||||||
|
function qdb_api_log_flatten_for_changes(
|
||||||
|
array $data,
|
||||||
|
string $prefix,
|
||||||
|
array &$out,
|
||||||
|
int $depth,
|
||||||
|
int $maxDepth,
|
||||||
|
int $maxFields
|
||||||
|
): void {
|
||||||
|
if ($depth >= $maxDepth || count($out) >= $maxFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data as $k => $v) {
|
||||||
|
if (count($out) >= $maxFields) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$key = (string)$k;
|
||||||
|
$field = $prefix === '' ? $key : $prefix . '.' . $key;
|
||||||
|
|
||||||
|
if (is_array($v)) {
|
||||||
|
$childList = array_keys($v) === range(0, count($v) - 1);
|
||||||
|
$n = count($v);
|
||||||
|
if ($n === 0) {
|
||||||
|
$out[] = ['field' => $field, 'value' => '[]'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($childList && $n > 8) {
|
||||||
|
$out[] = ['field' => $field, 'value' => '[list: ' . $n . ' items]'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$childList && $depth + 1 >= $maxDepth) {
|
||||||
|
$out[] = ['field' => $field, 'value' => '[object: ' . $n . ' keys]'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
qdb_api_log_flatten_for_changes($v, $field, $out, $depth + 1, $maxDepth, $maxFields);
|
||||||
|
} else {
|
||||||
|
$out[] = ['field' => $field, 'value' => qdb_api_log_change_scalar($v)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string|int|float|bool|null */
|
||||||
|
function qdb_api_log_change_scalar(mixed $v): string|int|float|bool|null
|
||||||
|
{
|
||||||
|
if ($v === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (is_bool($v) || is_int($v) || is_float($v)) {
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
$s = is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($s === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (strlen($s) > 800) {
|
||||||
|
return substr($s, 0, 800) . '…';
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_append_line(string $line): void
|
||||||
|
{
|
||||||
|
$dir = qdb_api_log_dir();
|
||||||
|
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
||||||
|
throw new RuntimeException("Cannot create log directory: $dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = qdb_api_log_resolve_write_path($dir);
|
||||||
|
if (@file_put_contents($path, $line, FILE_APPEND | LOCK_EX) === false) {
|
||||||
|
throw new RuntimeException("Cannot write API log: $path");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_resolve_write_path(string $dir): string
|
||||||
|
{
|
||||||
|
$date = date('Y-m-d');
|
||||||
|
$base = $dir . '/api-' . $date;
|
||||||
|
$max = qdb_api_log_max_bytes();
|
||||||
|
|
||||||
|
$primary = $base . '.log';
|
||||||
|
if (!is_file($primary) || filesize($primary) < $max) {
|
||||||
|
return $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($part = 2; $part <= 999; $part++) {
|
||||||
|
$path = sprintf('%s-%03d.log', $base, $part);
|
||||||
|
if (!is_file($path) || filesize($path) < $max) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s-%03d.log', $base, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read activity log entries for admin UI (newest first).
|
||||||
|
*
|
||||||
|
* @return array{date: string, activity: string, entries: list<array<string, mixed>>, files: list<string>, truncated: bool}
|
||||||
|
*/
|
||||||
|
function qdb_api_log_read_entries(string $date, string $activityFilter = '', int $limit = 200): array
|
||||||
|
{
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
$date = date('Y-m-d');
|
||||||
|
}
|
||||||
|
$limit = max(1, min(1000, $limit));
|
||||||
|
$activityFilter = strtolower(trim($activityFilter));
|
||||||
|
if ($activityFilter !== '' && !in_array($activityFilter, QDB_API_LOG_ACTIVITIES, true)) {
|
||||||
|
$activityFilter = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = qdb_api_log_dir();
|
||||||
|
$files = qdb_api_log_files_for_date($dir, $date);
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
if ($lines === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$row = json_decode($line, true);
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($activityFilter !== '' && ($row['activity'] ?? '') !== $activityFilter) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$entries[] = qdb_api_log_entry_for_ui($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($entries, static function (array $a, array $b): int {
|
||||||
|
return strcmp($b['ts'] ?? '', $a['ts'] ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
$truncated = count($entries) > $limit;
|
||||||
|
if ($truncated) {
|
||||||
|
$entries = array_slice($entries, 0, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = qdb_api_log_enrich_entries_usernames($entries);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'date' => $date,
|
||||||
|
'activity' => $activityFilter,
|
||||||
|
'entries' => $entries,
|
||||||
|
'files' => array_map('basename', $files),
|
||||||
|
'truncated' => $truncated,
|
||||||
|
'kinds' => QDB_API_LOG_ACTIVITIES,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
function qdb_api_log_files_for_date(string $dir, string $date): array
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$pattern = $dir . '/api-' . $date . '*.log';
|
||||||
|
$files = glob($pattern) ?: [];
|
||||||
|
sort($files);
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $row @return array<string, mixed> */
|
||||||
|
function qdb_api_log_entry_for_ui(array $row): array
|
||||||
|
{
|
||||||
|
$changes = $row['changes'] ?? null;
|
||||||
|
if (!is_array($changes) || $changes === []) {
|
||||||
|
$changes = qdb_api_log_build_changes(
|
||||||
|
(string)($row['method'] ?? ''),
|
||||||
|
(string)($row['route'] ?? ''),
|
||||||
|
$row['body'] ?? null,
|
||||||
|
isset($row['query']) ? (string)$row['query'] : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$changes = qdb_api_log_sort_changes_for_display($changes);
|
||||||
|
|
||||||
|
$actorUsername = $row['actor_username'] ?? null;
|
||||||
|
$targetUsername = $row['target_username'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ts' => $row['ts'] ?? '',
|
||||||
|
'activity' => $row['activity'] ?? '',
|
||||||
|
'method' => $row['method'] ?? '',
|
||||||
|
'route' => $row['route'] ?? '',
|
||||||
|
'status' => $row['status'] ?? null,
|
||||||
|
'duration_ms' => $row['duration_ms'] ?? null,
|
||||||
|
'role' => $row['role'] ?? null,
|
||||||
|
'userID' => $row['userID'] ?? null,
|
||||||
|
'actor_username' => is_string($actorUsername) ? $actorUsername : null,
|
||||||
|
'target_username' => is_string($targetUsername) ? $targetUsername : null,
|
||||||
|
'client' => $row['client'] ?? '',
|
||||||
|
'ip' => $row['ip'] ?? '',
|
||||||
|
'query' => $row['query'] ?? null,
|
||||||
|
'changes' => $changes,
|
||||||
|
'summary' => qdb_api_log_summarize_changes($changes, $targetUsername),
|
||||||
|
'error' => $row['error'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_lookup_username(?string $userID): ?string
|
||||||
|
{
|
||||||
|
if ($userID === null || $userID === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require_once __DIR__ . '/../db_init.php';
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
$stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
|
||||||
|
$stmt->execute([':uid' => $userID]);
|
||||||
|
$name = $stmt->fetchColumn();
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
return $name !== false ? (string)$name : null;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_api_log_extract_target_username(mixed $body, ?string $queryString): ?string
|
||||||
|
{
|
||||||
|
if (is_array($body) && !empty($body['username']) && is_scalar($body['username'])) {
|
||||||
|
return trim((string)$body['username']);
|
||||||
|
}
|
||||||
|
if ($queryString !== null && $queryString !== '') {
|
||||||
|
parse_str($queryString, $params);
|
||||||
|
if (!empty($params['username']) && is_scalar($params['username'])) {
|
||||||
|
return trim((string)$params['username']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve userID / supervisorID in log rows to usernames for retraceability.
|
||||||
|
*
|
||||||
|
* @param list<array<string, mixed>> $entries
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
function qdb_api_log_enrich_entries_usernames(array $entries): array
|
||||||
|
{
|
||||||
|
if ($entries === []) {
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userIds = [];
|
||||||
|
$supervisorIds = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (!empty($entry['userID']) && is_string($entry['userID'])) {
|
||||||
|
$userIds[$entry['userID']] = true;
|
||||||
|
}
|
||||||
|
if (empty($entry['actor_username']) && !empty($entry['userID'])) {
|
||||||
|
$userIds[$entry['userID']] = true;
|
||||||
|
}
|
||||||
|
foreach ($entry['changes'] ?? [] as $change) {
|
||||||
|
if (!is_array($change)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$field = strtolower((string)($change['field'] ?? ''));
|
||||||
|
$value = $change['value'] ?? null;
|
||||||
|
if (!is_string($value) || $value === '' || str_starts_with($value, '[')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($field === 'userid' || str_ends_with($field, '.userid')) {
|
||||||
|
$userIds[$value] = true;
|
||||||
|
}
|
||||||
|
if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
|
||||||
|
$supervisorIds[$value] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$userMap = qdb_api_log_load_user_map(array_keys($userIds));
|
||||||
|
$svMap = qdb_api_log_load_supervisor_map(array_keys($supervisorIds));
|
||||||
|
|
||||||
|
foreach ($entries as $i => $entry) {
|
||||||
|
if (empty($entry['actor_username']) && !empty($entry['userID'])) {
|
||||||
|
$entry['actor_username'] = $userMap[$entry['userID']] ?? null;
|
||||||
|
}
|
||||||
|
if (empty($entry['target_username'])) {
|
||||||
|
$entry['target_username'] = qdb_api_log_target_from_changes($entry['changes'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
$enriched = [];
|
||||||
|
foreach ($entry['changes'] ?? [] as $change) {
|
||||||
|
if (!is_array($change)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$change['display'] = qdb_api_log_change_display_label($change, $userMap, $svMap);
|
||||||
|
$enriched[] = $change;
|
||||||
|
}
|
||||||
|
$entry['changes'] = qdb_api_log_sort_changes_for_display($enriched);
|
||||||
|
$entry['summary'] = qdb_api_log_summarize_changes(
|
||||||
|
$entry['changes'],
|
||||||
|
$entry['target_username'] ?? null
|
||||||
|
);
|
||||||
|
$entries[$i] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $userIds @return array<string, string> */
|
||||||
|
function qdb_api_log_load_user_map(array $userIds): array
|
||||||
|
{
|
||||||
|
$userIds = array_values(array_filter($userIds, static fn($id) => $id !== ''));
|
||||||
|
if ($userIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require_once __DIR__ . '/../db_init.php';
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT userID, username FROM users WHERE userID IN ($placeholders)"
|
||||||
|
);
|
||||||
|
$stmt->execute($userIds);
|
||||||
|
$map = [];
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$map[(string)$row['userID']] = (string)$row['username'];
|
||||||
|
}
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
return $map;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $supervisorIds @return array<string, string> */
|
||||||
|
function qdb_api_log_load_supervisor_map(array $supervisorIds): array
|
||||||
|
{
|
||||||
|
$supervisorIds = array_values(array_filter($supervisorIds, static fn($id) => $id !== ''));
|
||||||
|
if ($supervisorIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
require_once __DIR__ . '/../db_init.php';
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
$placeholders = implode(',', array_fill(0, count($supervisorIds), '?'));
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT supervisorID, username FROM supervisor WHERE supervisorID IN ($placeholders)"
|
||||||
|
);
|
||||||
|
$stmt->execute($supervisorIds);
|
||||||
|
$map = [];
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||||
|
$map[(string)$row['supervisorID']] = (string)$row['username'];
|
||||||
|
}
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
return $map;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{field?: string, value?: mixed}> $changes */
|
||||||
|
function qdb_api_log_target_from_changes(array $changes): ?string
|
||||||
|
{
|
||||||
|
foreach ($changes as $c) {
|
||||||
|
$field = strtolower((string)($c['field'] ?? ''));
|
||||||
|
if ($field === 'username' && isset($c['value']) && is_scalar($c['value'])) {
|
||||||
|
return trim((string)$c['value']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $userMap
|
||||||
|
* @param array<string, string> $svMap
|
||||||
|
*/
|
||||||
|
function qdb_api_log_change_display_label(array $change, array $userMap, array $svMap): string
|
||||||
|
{
|
||||||
|
$field = strtolower((string)($change['field'] ?? ''));
|
||||||
|
$value = $change['value'] ?? null;
|
||||||
|
if ($value === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
if (!is_scalar($value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$str = (string)$value;
|
||||||
|
|
||||||
|
if ($field === 'username') {
|
||||||
|
return $str;
|
||||||
|
}
|
||||||
|
if ($field === 'userid' || str_ends_with($field, '.userid')) {
|
||||||
|
return $userMap[$str] ?? $str;
|
||||||
|
}
|
||||||
|
if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
|
||||||
|
return $svMap[$str] ?? $str;
|
||||||
|
}
|
||||||
|
if ($field === 'clientcode' || str_ends_with($field, 'clientcode')) {
|
||||||
|
return $str;
|
||||||
|
}
|
||||||
|
if ($field === 'mustchangepassword') {
|
||||||
|
return ((int)$value === 1) ? 'yes (temporary password)' : 'no (permanent password)';
|
||||||
|
}
|
||||||
|
if ($field === 'password' || str_contains($field, 'password')) {
|
||||||
|
return (string)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{field?: string, value?: mixed}> $changes
|
||||||
|
* @return list<array{field?: string, value?: mixed}>
|
||||||
|
*/
|
||||||
|
function qdb_api_log_sort_changes_for_display(array $changes): array
|
||||||
|
{
|
||||||
|
$priority = [
|
||||||
|
'username' => 0,
|
||||||
|
'role' => 1,
|
||||||
|
'clientcode' => 2,
|
||||||
|
'action' => 3,
|
||||||
|
'query.id' => 4,
|
||||||
|
'query.clients' => 5,
|
||||||
|
'query.clientcode' => 6,
|
||||||
|
'query.answers' => 7,
|
||||||
|
'query.translations' => 8,
|
||||||
|
'supervisorid' => 9,
|
||||||
|
'userid' => 10,
|
||||||
|
'mustchangepassword' => 11,
|
||||||
|
'password' => 12,
|
||||||
|
'location' => 13,
|
||||||
|
];
|
||||||
|
|
||||||
|
usort($changes, static function (array $a, array $b) use ($priority): int {
|
||||||
|
$fa = strtolower((string)($a['field'] ?? ''));
|
||||||
|
$fb = strtolower((string)($b['field'] ?? ''));
|
||||||
|
$pa = $priority[$fa] ?? 50;
|
||||||
|
$pb = $priority[$fb] ?? 50;
|
||||||
|
if ($pa !== $pb) {
|
||||||
|
return $pa <=> $pb;
|
||||||
|
}
|
||||||
|
return strcmp($fa, $fb);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array{field: string, value: mixed, display?: string}> $changes */
|
||||||
|
function qdb_api_log_summarize_changes(array $changes, ?string $targetUsername = null): string
|
||||||
|
{
|
||||||
|
if ($targetUsername !== null && $targetUsername !== '') {
|
||||||
|
return 'user=' . $targetUsername;
|
||||||
|
}
|
||||||
|
if ($changes === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$parts = [];
|
||||||
|
foreach (array_slice($changes, 0, 8) as $c) {
|
||||||
|
$field = (string)($c['field'] ?? '');
|
||||||
|
if ($field === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$label = (string)($c['display'] ?? '');
|
||||||
|
$value = $c['value'] ?? '';
|
||||||
|
if ($label !== '' && $label !== (string)$value) {
|
||||||
|
$parts[] = $field . '=' . $label;
|
||||||
|
} elseif ($value === null) {
|
||||||
|
$parts[] = $field . '=null';
|
||||||
|
} elseif (is_bool($value)) {
|
||||||
|
$parts[] = $field . '=' . ($value ? 'true' : 'false');
|
||||||
|
} else {
|
||||||
|
$parts[] = $field . '=' . (string)$value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$summary = implode(', ', $parts);
|
||||||
|
if (count($changes) > 8) {
|
||||||
|
$summary .= ', …';
|
||||||
|
}
|
||||||
|
if (strlen($summary) > 240) {
|
||||||
|
$summary = substr($summary, 0, 240) . '…';
|
||||||
|
}
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
@ -16,8 +16,18 @@ function json_error(string $code, string $message, int $status = 400): never {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raw request body (cached; php://input is single-read). */
|
||||||
|
function qdb_raw_request_body(): string {
|
||||||
|
static $raw = null;
|
||||||
|
if ($raw === null) {
|
||||||
|
$read = file_get_contents('php://input');
|
||||||
|
$raw = $read === false ? '' : $read;
|
||||||
|
}
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
function read_json_body(): array {
|
function read_json_body(): array {
|
||||||
$raw = file_get_contents('php://input');
|
$raw = qdb_raw_request_body();
|
||||||
$data = json_decode($raw, true);
|
$data = json_decode($raw, true);
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
json_error('INVALID_BODY', 'Request body must be valid JSON', 400);
|
json_error('INVALID_BODY', 'Request body must be valid JSON', 400);
|
||||||
|
|||||||
0
logs/api/.gitkeep
Normal file
0
logs/api/.gitkeep
Normal file
@ -323,6 +323,64 @@ code {
|
|||||||
.badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); }
|
.badge-completed { background: var(--badge-active-bg); color: var(--badge-active-fg); }
|
||||||
.badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); }
|
.badge-in_progress { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); }
|
||||||
.badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); }
|
.badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-fg); }
|
||||||
|
.badge-activity-app_sync { background: var(--badge-coach-bg); color: var(--badge-coach-fg); }
|
||||||
|
.badge-activity-app_change { background: var(--badge-in-progress-bg); color: var(--badge-in-progress-fg); }
|
||||||
|
.badge-activity-web_change { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); }
|
||||||
|
.activity-log-card .table-wrapper { max-height: 420px; overflow: auto; }
|
||||||
|
.activity-log-detail {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-width: 280px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.activity-log-changes-cell {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 420px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.activity-log-subject,
|
||||||
|
.activity-log-actor {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.activity-log-subject strong,
|
||||||
|
.activity-log-actor strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.activity-log-role {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.activity-change-raw {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.activity-changes-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.activity-changes-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 10px 2px 0;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.activity-changes-table td {
|
||||||
|
padding: 2px 0;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.activity-changes-table tr + tr th,
|
||||||
|
.activity-changes-table tr + tr td {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Page header */
|
/* Page header */
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import { apiPost } from '../api.js';
|
import { apiGet, apiPost } from '../api.js';
|
||||||
import { getRole, pageHeaderHTML, showToast } from '../app.js';
|
import { getRole, pageHeaderHTML, showToast } from '../app.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
|
|
||||||
|
const ACTIVITY_LABELS = {
|
||||||
|
app_sync: 'App sync',
|
||||||
|
app_change: 'App change',
|
||||||
|
web_change: 'Website change',
|
||||||
|
};
|
||||||
|
|
||||||
export function devPage() {
|
export function devPage() {
|
||||||
if (getRole() !== 'admin') {
|
if (getRole() !== 'admin') {
|
||||||
navigate('#/');
|
navigate('#/');
|
||||||
@ -9,6 +15,7 @@ export function devPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const app = document.getElementById('app');
|
const app = document.getElementById('app');
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
${pageHeaderHTML('Admin Settings')}
|
${pageHeaderHTML('Admin Settings')}
|
||||||
<div class="card" style="max-width:720px;margin-bottom:16px">
|
<div class="card" style="max-width:720px;margin-bottom:16px">
|
||||||
@ -51,6 +58,46 @@ export function devPage() {
|
|||||||
</p>
|
</p>
|
||||||
<button type="button" class="btn btn-danger" id="devWipeBtn">Remove all data (keep admins)</button>
|
<button type="button" class="btn btn-danger" id="devWipeBtn">Remove all data (keep admins)</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card activity-log-card" style="margin-top:16px">
|
||||||
|
<h2 style="margin:0 0 8px;font-size:1.1rem">API activity log</h2>
|
||||||
|
<p class="field-hint" style="margin:0 0 14px">
|
||||||
|
App syncs (<code>GET /app_questionnaires</code>) and data changes
|
||||||
|
(POST, PUT, PATCH, DELETE). Routine website page loads are not logged.
|
||||||
|
</p>
|
||||||
|
<div class="filter-bar" style="margin-bottom:12px">
|
||||||
|
<label style="font-size:.85rem;font-weight:500">Date</label>
|
||||||
|
<input type="date" id="activityLogDate" value="${today}">
|
||||||
|
<label style="font-size:.85rem;font-weight:500">Activity</label>
|
||||||
|
<select id="activityLogKind">
|
||||||
|
<option value="">All kinds</option>
|
||||||
|
<option value="app_sync">App sync</option>
|
||||||
|
<option value="app_change">App change</option>
|
||||||
|
<option value="web_change">Website change</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-sm" id="activityLogRefresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<p id="activityLogMeta" class="field-hint" style="margin:0 0 10px"></p>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table class="data-table activity-log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time (UTC)</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Route</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Values</th>
|
||||||
|
<th>Performed by</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="activityLogBody">
|
||||||
|
<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:20px">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let parsedFixture = null;
|
let parsedFixture = null;
|
||||||
@ -163,6 +210,7 @@ export function devPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
wireAdminZipExport();
|
wireAdminZipExport();
|
||||||
|
wireActivityLog();
|
||||||
|
|
||||||
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
||||||
if (!parsedFixture) return;
|
if (!parsedFixture) return;
|
||||||
@ -200,6 +248,155 @@ export function devPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireActivityLog() {
|
||||||
|
const dateEl = document.getElementById('activityLogDate');
|
||||||
|
const kindEl = document.getElementById('activityLogKind');
|
||||||
|
document.getElementById('activityLogRefresh')?.addEventListener('click', loadActivityLog);
|
||||||
|
dateEl?.addEventListener('change', loadActivityLog);
|
||||||
|
kindEl?.addEventListener('change', loadActivityLog);
|
||||||
|
loadActivityLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActivityLog() {
|
||||||
|
const tbody = document.getElementById('activityLogBody');
|
||||||
|
const meta = document.getElementById('activityLogMeta');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
const date = document.getElementById('activityLogDate')?.value || new Date().toISOString().slice(0, 10);
|
||||||
|
const activity = document.getElementById('activityLogKind')?.value || '';
|
||||||
|
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px">Loading…</td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ date, limit: '300' });
|
||||||
|
if (activity) q.set('activity', activity);
|
||||||
|
const data = await apiGet(`activity-log?${q}`);
|
||||||
|
if (!data.success) throw new Error(data.error || 'Failed to load activity log');
|
||||||
|
|
||||||
|
const entries = data.entries || [];
|
||||||
|
const files = (data.files || []).join(', ') || 'none';
|
||||||
|
const trunc = data.truncated ? ' (newest 300 shown)' : '';
|
||||||
|
if (meta) {
|
||||||
|
meta.textContent = entries.length
|
||||||
|
? `${entries.length} entries from ${files}${trunc}`
|
||||||
|
: `No entries for this date/filter (files: ${files})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;color:var(--text-secondary);padding:24px">No activity recorded</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = entries.map((e) => {
|
||||||
|
const kind = e.activity || '';
|
||||||
|
const label = ACTIVITY_LABELS[kind] || kind || '—';
|
||||||
|
const badgeClass = kind ? `badge-activity-${kind}` : '';
|
||||||
|
const time = formatActivityTime(e.ts);
|
||||||
|
const status = e.status != null ? String(e.status) : '—';
|
||||||
|
const dur = e.duration_ms != null ? `${e.duration_ms} ms` : '—';
|
||||||
|
const subject = formatActivitySubject(e);
|
||||||
|
const performedBy = formatActivityPerformer(e);
|
||||||
|
const errPart = e.error
|
||||||
|
? `<div class="error-text" style="margin-top:6px;font-size:.8rem">${escHtml(e.error)}</div>`
|
||||||
|
: '';
|
||||||
|
return `<tr>
|
||||||
|
<td>${escHtml(time)}</td>
|
||||||
|
<td><span class="badge ${badgeClass}">${escHtml(label)}</span></td>
|
||||||
|
<td class="activity-log-subject">${subject}</td>
|
||||||
|
<td><code>${escHtml(e.method || '')}</code></td>
|
||||||
|
<td><code>${escHtml(e.route || '')}</code></td>
|
||||||
|
<td>${escHtml(status)}</td>
|
||||||
|
<td>${escHtml(dur)}</td>
|
||||||
|
<td class="activity-log-changes-cell">${renderActivityChanges(e.changes)}</td>
|
||||||
|
<td class="activity-log-actor">${performedBy}${errPart}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
if (meta) meta.textContent = '';
|
||||||
|
tbody.innerHTML = `<tr><td colspan="9" class="error-text" style="padding:16px">${escHtml(err.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivitySubject(entry) {
|
||||||
|
if (entry.target_username) {
|
||||||
|
return `<strong>${escHtml(entry.target_username)}</strong>`;
|
||||||
|
}
|
||||||
|
const changes = entry.changes || [];
|
||||||
|
const userChange = changes.find((c) => (c.field || '').toLowerCase() === 'username');
|
||||||
|
if (userChange) {
|
||||||
|
const name = userChange.display || formatActivityChangeValue(userChange.value);
|
||||||
|
return `<strong>${escHtml(name)}</strong>`;
|
||||||
|
}
|
||||||
|
const client = changes.find((c) => (c.field || '').toLowerCase().includes('clientcode'));
|
||||||
|
if (client) {
|
||||||
|
const code = client.display || formatActivityChangeValue(client.value);
|
||||||
|
return `client <strong>${escHtml(code)}</strong>`;
|
||||||
|
}
|
||||||
|
const qClient = changes.find((c) => (c.field || '') === 'query.clientCode');
|
||||||
|
if (qClient) {
|
||||||
|
return `client <strong>${escHtml(formatActivityChangeValue(qClient.value))}</strong>`;
|
||||||
|
}
|
||||||
|
return '<span style="color:var(--text-secondary)">—</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivityPerformer(entry) {
|
||||||
|
if (entry.actor_username) {
|
||||||
|
const role = entry.role ? ` <span class="activity-log-role">(${escHtml(entry.role)})</span>` : '';
|
||||||
|
return `<strong>${escHtml(entry.actor_username)}</strong>${role}`;
|
||||||
|
}
|
||||||
|
if (entry.role) {
|
||||||
|
return escHtml(entry.role);
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivityChanges(changes) {
|
||||||
|
const list = Array.isArray(changes) ? changes : [];
|
||||||
|
if (!list.length) {
|
||||||
|
return '<span style="color:var(--text-secondary)">—</span>';
|
||||||
|
}
|
||||||
|
const rows = list.map((c) => {
|
||||||
|
const field = c.field ?? '';
|
||||||
|
const raw = formatActivityChangeValue(c.value);
|
||||||
|
const display = (c.display != null && String(c.display) !== '')
|
||||||
|
? String(c.display)
|
||||||
|
: raw;
|
||||||
|
const showRaw = display !== raw
|
||||||
|
&& !raw.startsWith('[REDACTED]')
|
||||||
|
&& !raw.startsWith('[ENCRYPTED');
|
||||||
|
return `<tr>
|
||||||
|
<th scope="row">${escHtml(field)}</th>
|
||||||
|
<td>
|
||||||
|
<strong>${escHtml(display)}</strong>
|
||||||
|
${showRaw ? `<div class="activity-change-raw">${escHtml(raw)}</div>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
return `<table class="activity-changes-table"><tbody>${rows}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivityChangeValue(value) {
|
||||||
|
if (value === null || value === undefined) return 'null';
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivityTime(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s ?? '';
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function filenameFromContentDisposition(res, fallback) {
|
function filenameFromContentDisposition(res, fallback) {
|
||||||
const disp = res.headers.get('Content-Disposition');
|
const disp = res.headers.get('Content-Disposition');
|
||||||
if (!disp) return fallback;
|
if (!disp) return fallback;
|
||||||
@ -210,7 +407,10 @@ function filenameFromContentDisposition(res, fallback) {
|
|||||||
async function downloadExportZip(url, defaultFilename) {
|
async function downloadExportZip(url, defaultFilename) {
|
||||||
const token = localStorage.getItem('qdb_token');
|
const token = localStorage.getItem('qdb_token');
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-QDB-Client': 'web',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user