expanded log activity
This commit is contained in:
@ -7,13 +7,14 @@
|
|||||||
* Recorded activity (not routine website page loads):
|
* Recorded activity (not routine website page loads):
|
||||||
* - app_sync: GET /app_questionnaires (mobile sync)
|
* - app_sync: GET /app_questionnaires (mobile sync)
|
||||||
* - app_change: POST/PUT/PATCH/DELETE from the Android app
|
* - app_change: POST/PUT/PATCH/DELETE from the Android app
|
||||||
* - web_change: POST/PUT/PATCH/DELETE from the management website
|
* - web_change: POST/PUT/PATCH/DELETE from the management website (incl. translation labels)
|
||||||
|
* - web_export: Website downloads (CSV/ZIP/JSON exports, translation bundle)
|
||||||
*
|
*
|
||||||
* Set QDB_API_LOG=0 to disable. Logs live under logs/api/ (gitignored).
|
* Set QDB_API_LOG=0 to disable. Logs live under logs/api/ (gitignored).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change'];
|
const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change', 'web_export'];
|
||||||
|
|
||||||
function qdb_api_log_enabled(): bool
|
function qdb_api_log_enabled(): bool
|
||||||
{
|
{
|
||||||
@ -69,13 +70,30 @@ function qdb_api_log_classify_activity(string $method, string $client, string $r
|
|||||||
return $client === 'web' ? 'web_change' : 'app_change';
|
return $client === 'web' ? 'web_change' : 'app_change';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'GET' && $route === 'app_questionnaires' && $client !== 'web') {
|
if ($method === 'GET') {
|
||||||
return 'app_sync';
|
if ($route === 'app_questionnaires' && $client !== 'web') {
|
||||||
|
return 'app_sync';
|
||||||
|
}
|
||||||
|
if (qdb_api_log_is_website_download($route)) {
|
||||||
|
return 'web_export';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Website GET that downloads or exports data (not routine list/load). */
|
||||||
|
function qdb_api_log_is_website_download(string $route): bool
|
||||||
|
{
|
||||||
|
if (in_array($route, ['export', 'backup'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($route === 'translations' && !empty($_GET['exportBundle'])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $context */
|
/** @param array<string, mixed> $context */
|
||||||
function qdb_api_log_begin(string $method, string $route, array $context = []): void
|
function qdb_api_log_begin(string $method, string $route, array $context = []): void
|
||||||
{
|
{
|
||||||
@ -753,19 +771,26 @@ function qdb_api_log_sort_changes_for_display(array $changes): array
|
|||||||
{
|
{
|
||||||
$priority = [
|
$priority = [
|
||||||
'username' => 0,
|
'username' => 0,
|
||||||
'role' => 1,
|
'type' => 1,
|
||||||
'clientcode' => 2,
|
'text' => 2,
|
||||||
'action' => 3,
|
'languagecode' => 3,
|
||||||
'query.id' => 4,
|
'id' => 4,
|
||||||
'query.clients' => 5,
|
'role' => 5,
|
||||||
'query.clientcode' => 6,
|
'clientcode' => 6,
|
||||||
'query.answers' => 7,
|
'action' => 7,
|
||||||
'query.translations' => 8,
|
'query.id' => 10,
|
||||||
'supervisorid' => 9,
|
'query.clients' => 11,
|
||||||
'userid' => 10,
|
'query.clientcode' => 12,
|
||||||
'mustchangepassword' => 11,
|
'query.answers' => 13,
|
||||||
'password' => 12,
|
'query.translations' => 14,
|
||||||
'location' => 13,
|
'query.bundle' => 15,
|
||||||
|
'query.exportall' => 16,
|
||||||
|
'query.exportbundle' => 17,
|
||||||
|
'supervisorid' => 18,
|
||||||
|
'userid' => 19,
|
||||||
|
'mustchangepassword' => 20,
|
||||||
|
'password' => 21,
|
||||||
|
'location' => 22,
|
||||||
];
|
];
|
||||||
|
|
||||||
usort($changes, static function (array $a, array $b) use ($priority): int {
|
usort($changes, static function (array $a, array $b) use ($priority): int {
|
||||||
|
|||||||
@ -326,6 +326,7 @@ code {
|
|||||||
.badge-activity-app_sync { background: var(--badge-coach-bg); color: var(--badge-coach-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-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); }
|
.badge-activity-web_change { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); }
|
||||||
|
.badge-activity-web_export { background: var(--badge-admin-bg); color: var(--badge-admin-fg); }
|
||||||
.activity-log-card .table-wrapper { max-height: 420px; overflow: auto; }
|
.activity-log-card .table-wrapper { max-height: 420px; overflow: auto; }
|
||||||
.activity-log-detail {
|
.activity-log-detail {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
@ -92,3 +92,11 @@ export function apiDownloadUrl(endpoint) {
|
|||||||
const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1');
|
const cleanEndpoint = endpoint.replace(/\.php(\?|$)/, '$1');
|
||||||
return `${API_BASE}/${cleanEndpoint}${cleanEndpoint.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
|
return `${API_BASE}/${cleanEndpoint}${cleanEndpoint.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Authenticated file/download fetch (marks request as website for activity logging). */
|
||||||
|
export async function apiDownloadFetch(url, options = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = { ...(options.headers || {}), 'X-QDB-Client': 'web' };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return fetch(url, { ...options, headers });
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiPost } from '../api.js';
|
import { apiGet, apiPost, apiDownloadFetch } 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';
|
||||||
|
|
||||||
@ -6,6 +6,7 @@ const ACTIVITY_LABELS = {
|
|||||||
app_sync: 'App sync',
|
app_sync: 'App sync',
|
||||||
app_change: 'App change',
|
app_change: 'App change',
|
||||||
web_change: 'Website change',
|
web_change: 'Website change',
|
||||||
|
web_export: 'Website export',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function devPage() {
|
export function devPage() {
|
||||||
@ -61,8 +62,8 @@ export function devPage() {
|
|||||||
<div class="card activity-log-card" style="margin-top:16px">
|
<div class="card activity-log-card" style="margin-top:16px">
|
||||||
<h2 style="margin:0 0 8px;font-size:1.1rem">API activity log</h2>
|
<h2 style="margin:0 0 8px;font-size:1.1rem">API activity log</h2>
|
||||||
<p class="field-hint" style="margin:0 0 14px">
|
<p class="field-hint" style="margin:0 0 14px">
|
||||||
App syncs (<code>GET /app_questionnaires</code>) and data changes
|
App syncs, website edits (translations, users, questionnaires, …),
|
||||||
(POST, PUT, PATCH, DELETE). Routine website page loads are not logged.
|
and exports/downloads. Routine page loads (lists, dashboards) are not logged.
|
||||||
</p>
|
</p>
|
||||||
<div class="filter-bar" style="margin-bottom:12px">
|
<div class="filter-bar" style="margin-bottom:12px">
|
||||||
<label style="font-size:.85rem;font-weight:500">Date</label>
|
<label style="font-size:.85rem;font-weight:500">Date</label>
|
||||||
@ -73,6 +74,7 @@ export function devPage() {
|
|||||||
<option value="app_sync">App sync</option>
|
<option value="app_sync">App sync</option>
|
||||||
<option value="app_change">App change</option>
|
<option value="app_change">App change</option>
|
||||||
<option value="web_change">Website change</option>
|
<option value="web_change">Website change</option>
|
||||||
|
<option value="web_export">Website export</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn btn-sm" id="activityLogRefresh">Refresh</button>
|
<button type="button" class="btn btn-sm" id="activityLogRefresh">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
@ -405,13 +407,7 @@ function filenameFromContentDisposition(res, fallback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadExportZip(url, defaultFilename) {
|
async function downloadExportZip(url, defaultFilename) {
|
||||||
const token = localStorage.getItem('qdb_token');
|
const res = await apiDownloadFetch(url);
|
||||||
const res = await fetch(url, {
|
|
||||||
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' }));
|
||||||
throw new Error(err.error?.message || err.error || 'Download failed');
|
throw new Error(err.error?.message || err.error || 'Download failed');
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { apiGet } from '../api.js';
|
import { apiGet, apiDownloadFetch } from '../api.js';
|
||||||
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
|
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
|
||||||
let questionnairesList = [];
|
let questionnairesList = [];
|
||||||
let filterSearch = '';
|
let filterSearch = '';
|
||||||
@ -153,10 +153,7 @@ function wireExportActions() {
|
|||||||
const prev = btn.textContent;
|
const prev = btn.textContent;
|
||||||
btn.textContent = 'Preparing…';
|
btn.textContent = 'Preparing…';
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('qdb_token');
|
const res = await apiDownloadFetch('../api/export?bundle=1');
|
||||||
const res = await fetch('../api/export?bundle=1', {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.error?.message || err.error || 'Export failed');
|
throw new Error(err.error?.message || err.error || 'Export failed');
|
||||||
@ -180,10 +177,7 @@ function wireExportActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadExportCsv(btn, url, filename) {
|
async function downloadExportCsv(btn, url, filename) {
|
||||||
const token = localStorage.getItem('qdb_token');
|
const res = await apiDownloadFetch(url);
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
const err = await res.json().catch(() => ({ error: 'Download failed' }));
|
||||||
throw new Error(err.error?.message || err.error || 'Download failed');
|
throw new Error(err.error?.message || err.error || 'Download failed');
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
|
import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch } from '../api.js';
|
||||||
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
|
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
|
||||||
import {
|
import {
|
||||||
SOURCE_LANG,
|
SOURCE_LANG,
|
||||||
@ -406,10 +406,7 @@ function bindTranslationBundleActions() {
|
|||||||
const prev = exportBtn.textContent;
|
const prev = exportBtn.textContent;
|
||||||
exportBtn.textContent = 'Preparing…';
|
exportBtn.textContent = 'Preparing…';
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('qdb_token');
|
const res = await apiDownloadFetch('../api/translations?exportBundle=1');
|
||||||
const res = await fetch('../api/translations?exportBundle=1', {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.error?.message || err.error || 'Export failed');
|
throw new Error(err.error?.message || err.error || 'Export failed');
|
||||||
|
|||||||
Reference in New Issue
Block a user