feat(monitor-frontend): add FaceAI Audit Monitor application with Vue.js and Vite setup
All checks were successful
Publish FaceAI Container / publish (push) Successful in 13m8s
All checks were successful
Publish FaceAI Container / publish (push) Successful in 13m8s
- Created App.vue for the main application interface with localization support. - Added main.js to bootstrap the Vue application. - Introduced styles.css for application styling. - Configured Vite for development and proxying API requests. - Updated docker-compose files to include the new monitor service. - Added Dockerfile for building the monitor frontend. - Configured Nginx for serving the frontend and proxying API requests. - Updated package.json and package-lock.json to include monitor-frontend workspace. - Added initial SQLite database for audit monitoring.
This commit is contained in:
parent
32db61c381
commit
a95ae56134
21 changed files with 1755 additions and 2 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
FACEAI_CLIENT_DEV_IMAGE=node:20-alpine
|
FACEAI_CLIENT_DEV_IMAGE=node:22-trixie-slim
|
||||||
FACEAI_PROCESSOR_DEV_IMAGE=regalami-faceai-processor-local
|
FACEAI_PROCESSOR_DEV_IMAGE=regalami-faceai-processor-local
|
||||||
FACEAI_PORT=3001
|
FACEAI_PORT=3001
|
||||||
FACEAI_PUBLISHED_PORT=3001
|
FACEAI_PUBLISHED_PORT=3001
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ This folder scaffolds the new FaceAI app described in the integration plan.
|
||||||
It includes:
|
It includes:
|
||||||
|
|
||||||
- a Vue frontend for the FaceAI upload and polling flow
|
- a Vue frontend for the FaceAI upload and polling flow
|
||||||
|
- a separate Vue monitor frontend for querying the FaceAI audit database
|
||||||
- a Node/Express backend for session exchange, queueing, and return handoff
|
- a Node/Express backend for session exchange, queueing, and return handoff
|
||||||
- a dedicated processor runner that consumes matcher jobs from Redis and executes `face_matcher`
|
- a dedicated processor runner that consumes matcher jobs from Redis and executes `face_matcher`
|
||||||
- a local Dockerized Tomcat/JSP stack so the launch and return flow can be tested against the real legacy race pages under `www/`
|
- a local Dockerized Tomcat/JSP stack so the launch and return flow can be tested against the real legacy race pages under `www/`
|
||||||
|
|
@ -16,6 +17,7 @@ faceai/
|
||||||
apps/
|
apps/
|
||||||
backend/
|
backend/
|
||||||
frontend/
|
frontend/
|
||||||
|
monitor-frontend/
|
||||||
processor/
|
processor/
|
||||||
docker/
|
docker/
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
|
@ -26,11 +28,13 @@ faceai/
|
||||||
The production-oriented application topology is still three services:
|
The production-oriented application topology is still three services:
|
||||||
|
|
||||||
- `faceai`: public HTTP service on port `3001`, serving the built Vue app and the authenticated API
|
- `faceai`: public HTTP service on port `3001`, serving the built Vue app and the authenticated API
|
||||||
|
- `faceai-monitor`: read-only audit monitor on port `3002`, serving a lightweight Vue dashboard backed by the audit API
|
||||||
- `processor`: background matcher runner consuming BullMQ jobs from Redis and executing the Linux `face_matcher` binary
|
- `processor`: background matcher runner consuming BullMQ jobs from Redis and executing the Linux `face_matcher` binary
|
||||||
- `redis`: short-lived queue and search-state store
|
- `redis`: short-lived queue and search-state store
|
||||||
|
|
||||||
The checked-in development override now expands that into the full local integration stack:
|
The checked-in development override now expands that into the full local integration stack:
|
||||||
|
|
||||||
|
- `faceai-monitor`: local audit dashboard that proxies read-only audit-monitor API calls to the backend
|
||||||
- `tomcat-www`: local Tomcat runtime serving the real legacy JSP race pages from `www/`
|
- `tomcat-www`: local Tomcat runtime serving the real legacy JSP race pages from `www/`
|
||||||
- `mysql`: local legacy database used by the Tomcat stack
|
- `mysql`: local legacy database used by the Tomcat stack
|
||||||
- `maildump`: local SMTP sink and viewer for the Tomcat stack
|
- `maildump`: local SMTP sink and viewer for the Tomcat stack
|
||||||
|
|
@ -81,6 +85,7 @@ The checked-in Compose setup now uses:
|
||||||
The local development stack started by the command above combines the base file and the override and starts:
|
The local development stack started by the command above combines the base file and the override and starts:
|
||||||
|
|
||||||
- FaceAI public site on `http://localhost:3001`
|
- FaceAI public site on `http://localhost:3001`
|
||||||
|
- FaceAI monitor on `http://localhost:3002`
|
||||||
- processor runner on the internal Compose network
|
- processor runner on the internal Compose network
|
||||||
- Redis on the internal Compose network
|
- Redis on the internal Compose network
|
||||||
- Tomcat serving the real local legacy site on `http://localhost:8080`
|
- Tomcat serving the real local legacy site on `http://localhost:8080`
|
||||||
|
|
@ -93,6 +98,8 @@ The local stack also mounts:
|
||||||
- `./logs` into both the public FaceAI container and the processor container as the persistent diagnostics directory
|
- `./logs` into both the public FaceAI container and the processor container as the persistent diagnostics directory
|
||||||
- `../www` into the Tomcat build/runtime inputs so the real legacy JSP pages and assets are used
|
- `../www` into the Tomcat build/runtime inputs so the real legacy JSP pages and assets are used
|
||||||
|
|
||||||
|
The separate monitor container does not mount the SQLite database directly. Instead it serves a static Vue app and proxies read-only requests under `/api/audit-monitor/*` to the existing backend service, which remains the only process that opens the audit database.
|
||||||
|
|
||||||
The `processor` service is built from `docker/processor.Dockerfile` using the repository root as Docker build context. That image copies only the checked-in Unix `face_matcher` into the image, so the matcher is baked into the processor runtime without bringing along the other Unix or Windows binaries.
|
The `processor` service is built from `docker/processor.Dockerfile` using the repository root as Docker build context. That image copies only the checked-in Unix `face_matcher` into the image, so the matcher is baked into the processor runtime without bringing along the other Unix or Windows binaries.
|
||||||
|
|
||||||
### Persistent Logs
|
### Persistent Logs
|
||||||
|
|
@ -131,6 +138,19 @@ Open:
|
||||||
http://localhost:8080/Foto2.abl?id_gara=1018557&pageRow=96&pageNumber=1
|
http://localhost:8080/Foto2.abl?id_gara=1018557&pageRow=96&pageNumber=1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For audit visibility during local runs, also open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3002
|
||||||
|
```
|
||||||
|
|
||||||
|
The monitor shows:
|
||||||
|
|
||||||
|
- lifetime and recent-window search totals from the SQLite audit log
|
||||||
|
- the latest searches with filter-by-status and free-text lookup for race, user, result, error code, selfie name, or search id
|
||||||
|
- per-search event timelines and stored match snapshots
|
||||||
|
- recent events and a recent top-race breakdown
|
||||||
|
|
||||||
That is the real local Tomcat race page. It loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the backend handoff endpoint configured through the JSP page.
|
That is the real local Tomcat race page. It loads the original race-page JavaScript from `www/_js/rus-ecom-240621.js`, lets the script replace the visible `tipoPuntoFoto` selector with the new `Face ID` button, and launches the backend handoff endpoint configured through the JSP page.
|
||||||
|
|
||||||
### Expected Local Flow
|
### Expected Local Flow
|
||||||
|
|
@ -473,6 +493,16 @@ FACEAI_FEATURE_ENABLED=1
|
||||||
|
|
||||||
The checked-in `docker-compose.override.yml` sets that on the local `tomcat-www` service so the race page can launch the FaceAI handoff flow locally.
|
The checked-in `docker-compose.override.yml` sets that on the local `tomcat-www` service so the race page can launch the FaceAI handoff flow locally.
|
||||||
|
|
||||||
|
### Audit Monitor API
|
||||||
|
|
||||||
|
The monitor frontend uses these read-only backend routes:
|
||||||
|
|
||||||
|
- `GET /api/audit-monitor/summary`
|
||||||
|
- `GET /api/audit-monitor/searches?status=...&query=...&limit=...`
|
||||||
|
- `GET /api/audit-monitor/searches/<searchId>`
|
||||||
|
|
||||||
|
They read the same SQLite audit database already configured through `FACEAI_AUDIT_DB_PATH`. The monitor container proxies those requests to `faceai`, so no browser-side direct SQLite access is involved.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Search orchestration now uses Redis and a dedicated processor worker.
|
- Search orchestration now uses Redis and a dedicated processor worker.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,89 @@ function jsonOrNull(value) {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseJsonOrNull(value) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBoundedInteger(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(max, Math.max(min, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSearchRow(row) {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchId: row.search_id,
|
||||||
|
requestedAt: row.requested_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
completedAt: row.completed_at,
|
||||||
|
redirectIssuedAt: row.redirect_issued_at,
|
||||||
|
status: row.status,
|
||||||
|
completionCode: row.completion_code,
|
||||||
|
errorCode: row.error_code,
|
||||||
|
errorMessage: row.error_message,
|
||||||
|
userId: row.user_id,
|
||||||
|
userDisplayName: row.user_display_name,
|
||||||
|
userMembershipStatus: row.user_membership_status,
|
||||||
|
raceId: row.race_id,
|
||||||
|
raceName: row.race_name,
|
||||||
|
raceStorage: row.race_storage,
|
||||||
|
lang: row.lang,
|
||||||
|
requestIp: row.request_ip,
|
||||||
|
requestUserAgent: row.request_user_agent,
|
||||||
|
returnUrl: row.return_url,
|
||||||
|
selfieName: row.selfie_name,
|
||||||
|
selfieSha256: row.selfie_sha256,
|
||||||
|
selfieSizeBytes: row.selfie_size_bytes,
|
||||||
|
uploadPath: row.upload_path,
|
||||||
|
resultId: row.result_id,
|
||||||
|
matchCount: row.match_count,
|
||||||
|
matches: parseJsonOrNull(row.matches_json)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEventRow(row) {
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
searchId: row.search_id,
|
||||||
|
eventType: row.event_type,
|
||||||
|
happenedAt: row.happened_at,
|
||||||
|
status: row.status,
|
||||||
|
userId: row.user_id,
|
||||||
|
userDisplayName: row.user_display_name,
|
||||||
|
raceId: row.race_id,
|
||||||
|
raceName: row.race_name,
|
||||||
|
raceStorage: row.race_storage,
|
||||||
|
requestIp: row.request_ip,
|
||||||
|
requestUserAgent: row.request_user_agent,
|
||||||
|
selfieSha256: row.selfie_sha256,
|
||||||
|
resultId: row.result_id,
|
||||||
|
matchCount: row.match_count,
|
||||||
|
completionCode: row.completion_code,
|
||||||
|
errorCode: row.error_code,
|
||||||
|
payload: parseJsonOrNull(row.payload_json)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function openDatabase(dbPath) {
|
function openDatabase(dbPath) {
|
||||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
|
||||||
|
|
@ -239,6 +322,231 @@ export function createAuditStore({ dbPath, retentionDays }) {
|
||||||
WHERE search_id IS NULL AND happened_at < @cutoff
|
WHERE search_id IS NULL AND happened_at < @cutoff
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const detailSearchStatement = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
search_id,
|
||||||
|
requested_at,
|
||||||
|
updated_at,
|
||||||
|
completed_at,
|
||||||
|
redirect_issued_at,
|
||||||
|
status,
|
||||||
|
completion_code,
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
user_id,
|
||||||
|
user_display_name,
|
||||||
|
user_membership_status,
|
||||||
|
race_id,
|
||||||
|
race_name,
|
||||||
|
race_storage,
|
||||||
|
lang,
|
||||||
|
request_ip,
|
||||||
|
request_user_agent,
|
||||||
|
return_url,
|
||||||
|
selfie_name,
|
||||||
|
selfie_sha256,
|
||||||
|
selfie_size_bytes,
|
||||||
|
upload_path,
|
||||||
|
result_id,
|
||||||
|
match_count,
|
||||||
|
matches_json
|
||||||
|
FROM faceai_audit_searches
|
||||||
|
WHERE search_id = @search_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
const searchEventsStatement = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
search_id,
|
||||||
|
event_type,
|
||||||
|
happened_at,
|
||||||
|
status,
|
||||||
|
user_id,
|
||||||
|
user_display_name,
|
||||||
|
race_id,
|
||||||
|
race_name,
|
||||||
|
race_storage,
|
||||||
|
request_ip,
|
||||||
|
request_user_agent,
|
||||||
|
selfie_sha256,
|
||||||
|
result_id,
|
||||||
|
match_count,
|
||||||
|
completion_code,
|
||||||
|
error_code,
|
||||||
|
payload_json
|
||||||
|
FROM faceai_audit_events
|
||||||
|
WHERE search_id = @search_id
|
||||||
|
ORDER BY happened_at DESC, id DESC
|
||||||
|
LIMIT @limit
|
||||||
|
`);
|
||||||
|
|
||||||
|
const recentSearchesStatement = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
search_id,
|
||||||
|
requested_at,
|
||||||
|
updated_at,
|
||||||
|
completed_at,
|
||||||
|
redirect_issued_at,
|
||||||
|
status,
|
||||||
|
completion_code,
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
user_id,
|
||||||
|
user_display_name,
|
||||||
|
user_membership_status,
|
||||||
|
race_id,
|
||||||
|
race_name,
|
||||||
|
race_storage,
|
||||||
|
lang,
|
||||||
|
request_ip,
|
||||||
|
request_user_agent,
|
||||||
|
return_url,
|
||||||
|
selfie_name,
|
||||||
|
selfie_sha256,
|
||||||
|
selfie_size_bytes,
|
||||||
|
upload_path,
|
||||||
|
result_id,
|
||||||
|
match_count,
|
||||||
|
matches_json
|
||||||
|
FROM faceai_audit_searches
|
||||||
|
ORDER BY requested_at DESC
|
||||||
|
LIMIT @limit
|
||||||
|
`);
|
||||||
|
|
||||||
|
const recentEventsStatement = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
search_id,
|
||||||
|
event_type,
|
||||||
|
happened_at,
|
||||||
|
status,
|
||||||
|
user_id,
|
||||||
|
user_display_name,
|
||||||
|
race_id,
|
||||||
|
race_name,
|
||||||
|
race_storage,
|
||||||
|
request_ip,
|
||||||
|
request_user_agent,
|
||||||
|
selfie_sha256,
|
||||||
|
result_id,
|
||||||
|
match_count,
|
||||||
|
completion_code,
|
||||||
|
error_code,
|
||||||
|
payload_json
|
||||||
|
FROM faceai_audit_events
|
||||||
|
ORDER BY happened_at DESC, id DESC
|
||||||
|
LIMIT @limit
|
||||||
|
`);
|
||||||
|
|
||||||
|
const latestFailureStatement = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
search_id,
|
||||||
|
requested_at,
|
||||||
|
updated_at,
|
||||||
|
completed_at,
|
||||||
|
redirect_issued_at,
|
||||||
|
status,
|
||||||
|
completion_code,
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
user_id,
|
||||||
|
user_display_name,
|
||||||
|
user_membership_status,
|
||||||
|
race_id,
|
||||||
|
race_name,
|
||||||
|
race_storage,
|
||||||
|
lang,
|
||||||
|
request_ip,
|
||||||
|
request_user_agent,
|
||||||
|
return_url,
|
||||||
|
selfie_name,
|
||||||
|
selfie_sha256,
|
||||||
|
selfie_size_bytes,
|
||||||
|
upload_path,
|
||||||
|
result_id,
|
||||||
|
match_count,
|
||||||
|
matches_json
|
||||||
|
FROM faceai_audit_searches
|
||||||
|
WHERE status = 'failed'
|
||||||
|
ORDER BY COALESCE(completed_at, updated_at, requested_at) DESC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const recentRaceBreakdownStatement = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
race_id,
|
||||||
|
race_name,
|
||||||
|
race_storage,
|
||||||
|
COUNT(*) AS search_count,
|
||||||
|
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed_count,
|
||||||
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
|
||||||
|
MAX(requested_at) AS last_requested_at
|
||||||
|
FROM faceai_audit_searches
|
||||||
|
WHERE requested_at >= @requested_after
|
||||||
|
GROUP BY race_id, race_name, race_storage
|
||||||
|
ORDER BY search_count DESC, last_requested_at DESC
|
||||||
|
LIMIT @limit
|
||||||
|
`);
|
||||||
|
|
||||||
|
function summarizeSearches(whereClause = '', params = {}) {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_searches,
|
||||||
|
COUNT(DISTINCT user_id) AS unique_users,
|
||||||
|
SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued_searches,
|
||||||
|
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) AS processing_searches,
|
||||||
|
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed_searches,
|
||||||
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_searches,
|
||||||
|
COALESCE(AVG(CASE WHEN completed_at IS NOT NULL THEN completed_at - requested_at END), 0) AS average_duration_ms,
|
||||||
|
MAX(requested_at) AS latest_requested_at
|
||||||
|
FROM faceai_audit_searches
|
||||||
|
${whereClause}
|
||||||
|
`).get(params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSearches: Number(row?.total_searches || 0),
|
||||||
|
uniqueUsers: Number(row?.unique_users || 0),
|
||||||
|
queuedSearches: Number(row?.queued_searches || 0),
|
||||||
|
processingSearches: Number(row?.processing_searches || 0),
|
||||||
|
completedSearches: Number(row?.completed_searches || 0),
|
||||||
|
failedSearches: Number(row?.failed_searches || 0),
|
||||||
|
averageDurationMs: Math.round(Number(row?.average_duration_ms || 0)),
|
||||||
|
latestRequestedAt: row?.latest_requested_at || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchFilters({ status = 'all', query = '' } = {}) {
|
||||||
|
const clauses = [];
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
clauses.push('status = @status');
|
||||||
|
params.status = String(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = String(query || '').trim();
|
||||||
|
if (normalizedQuery) {
|
||||||
|
clauses.push(`(
|
||||||
|
search_id = @exact_query OR
|
||||||
|
user_id LIKE @query_like OR
|
||||||
|
COALESCE(user_display_name, '') LIKE @query_like OR
|
||||||
|
race_id LIKE @query_like OR
|
||||||
|
COALESCE(race_name, '') LIKE @query_like OR
|
||||||
|
COALESCE(selfie_name, '') LIKE @query_like OR
|
||||||
|
COALESCE(selfie_sha256, '') LIKE @query_like OR
|
||||||
|
COALESCE(result_id, '') LIKE @query_like OR
|
||||||
|
COALESCE(error_code, '') LIKE @query_like
|
||||||
|
)`);
|
||||||
|
params.exact_query = normalizedQuery;
|
||||||
|
params.query_like = `%${normalizedQuery}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
whereClause: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '',
|
||||||
|
params
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function maybePrune(now = Date.now()) {
|
function maybePrune(now = Date.now()) {
|
||||||
if (now - lastPrunedAt < ONE_DAY_MS) {
|
if (now - lastPrunedAt < ONE_DAY_MS) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -456,10 +764,117 @@ export function createAuditStore({ dbPath, retentionDays }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMonitorSummary({
|
||||||
|
windowHours = 24,
|
||||||
|
recentSearchLimit = 12,
|
||||||
|
recentEventLimit = 20,
|
||||||
|
topRaceLimit = 8
|
||||||
|
} = {}) {
|
||||||
|
maybePrune();
|
||||||
|
|
||||||
|
const boundedWindowHours = toBoundedInteger(windowHours, 24, { min: 1, max: 24 * 30 });
|
||||||
|
const requestedAfter = Date.now() - (boundedWindowHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dbPath,
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
windowHours: boundedWindowHours,
|
||||||
|
lifetime: summarizeSearches(),
|
||||||
|
recentWindow: summarizeSearches('WHERE requested_at >= @requested_after', { requested_after: requestedAfter }),
|
||||||
|
recentSearches: recentSearchesStatement
|
||||||
|
.all({ limit: toBoundedInteger(recentSearchLimit, 12, { min: 1, max: 100 }) })
|
||||||
|
.map(mapSearchRow),
|
||||||
|
recentEvents: recentEventsStatement
|
||||||
|
.all({ limit: toBoundedInteger(recentEventLimit, 20, { min: 1, max: 100 }) })
|
||||||
|
.map(mapEventRow),
|
||||||
|
topRaces: recentRaceBreakdownStatement.all({
|
||||||
|
requested_after: requestedAfter,
|
||||||
|
limit: toBoundedInteger(topRaceLimit, 8, { min: 1, max: 25 })
|
||||||
|
}).map((row) => ({
|
||||||
|
raceId: row.race_id,
|
||||||
|
raceName: row.race_name,
|
||||||
|
raceStorage: row.race_storage,
|
||||||
|
searchCount: Number(row.search_count || 0),
|
||||||
|
completedCount: Number(row.completed_count || 0),
|
||||||
|
failedCount: Number(row.failed_count || 0),
|
||||||
|
lastRequestedAt: row.last_requested_at || null
|
||||||
|
})),
|
||||||
|
latestFailure: mapSearchRow(latestFailureStatement.get())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listSearches({ status = 'all', query = '', limit = 50 } = {}) {
|
||||||
|
maybePrune();
|
||||||
|
|
||||||
|
const { whereClause, params } = buildSearchFilters({ status, query });
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
search_id,
|
||||||
|
requested_at,
|
||||||
|
updated_at,
|
||||||
|
completed_at,
|
||||||
|
redirect_issued_at,
|
||||||
|
status,
|
||||||
|
completion_code,
|
||||||
|
error_code,
|
||||||
|
error_message,
|
||||||
|
user_id,
|
||||||
|
user_display_name,
|
||||||
|
user_membership_status,
|
||||||
|
race_id,
|
||||||
|
race_name,
|
||||||
|
race_storage,
|
||||||
|
lang,
|
||||||
|
request_ip,
|
||||||
|
request_user_agent,
|
||||||
|
return_url,
|
||||||
|
selfie_name,
|
||||||
|
selfie_sha256,
|
||||||
|
selfie_size_bytes,
|
||||||
|
upload_path,
|
||||||
|
result_id,
|
||||||
|
match_count,
|
||||||
|
matches_json
|
||||||
|
FROM faceai_audit_searches
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY requested_at DESC
|
||||||
|
LIMIT @limit
|
||||||
|
`).all({
|
||||||
|
...params,
|
||||||
|
limit: toBoundedInteger(limit, 50, { min: 1, max: 200 })
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map(mapSearchRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSearchDetail(searchId, { eventLimit = 100 } = {}) {
|
||||||
|
maybePrune();
|
||||||
|
|
||||||
|
const search = mapSearchRow(detailSearchStatement.get({ search_id: String(searchId) }));
|
||||||
|
if (!search) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = searchEventsStatement
|
||||||
|
.all({
|
||||||
|
search_id: String(searchId),
|
||||||
|
limit: toBoundedInteger(eventLimit, 100, { min: 1, max: 500 })
|
||||||
|
})
|
||||||
|
.map(mapEventRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
events
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
maybePrune();
|
maybePrune();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dbPath,
|
dbPath,
|
||||||
|
getMonitorSummary,
|
||||||
|
getSearchDetail,
|
||||||
|
listSearches,
|
||||||
recordEvent,
|
recordEvent,
|
||||||
recordSearchRequested,
|
recordSearchRequested,
|
||||||
markSearchCompleted,
|
markSearchCompleted,
|
||||||
|
|
|
||||||
|
|
@ -821,6 +821,50 @@ app.get('/api/health/queue', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/audit-monitor/summary', (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(auditStore.getMonitorSummary({
|
||||||
|
windowHours: req.query.windowHours,
|
||||||
|
recentSearchLimit: req.query.recentSearchLimit,
|
||||||
|
recentEventLimit: req.query.recentEventLimit,
|
||||||
|
topRaceLimit: req.query.topRaceLimit
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message || 'Unable to read audit summary.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/audit-monitor/searches', (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
items: auditStore.listSearches({
|
||||||
|
status: req.query.status,
|
||||||
|
query: req.query.query,
|
||||||
|
limit: req.query.limit
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message || 'Unable to read audit searches.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/audit-monitor/searches/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const detail = auditStore.getSearchDetail(req.params.id, {
|
||||||
|
eventLimit: req.query.eventLimit
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
res.status(404).json({ error: 'Search not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(detail);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message || 'Unable to read audit search detail.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (fs.existsSync(frontendDist)) {
|
if (fs.existsSync(frontendDist)) {
|
||||||
app.use(express.static(frontendDist));
|
app.use(express.static(frontendDist));
|
||||||
app.get('*', (req, res, next) => {
|
app.get('*', (req, res, next) => {
|
||||||
|
|
|
||||||
1
faceai/apps/monitor-frontend/dist/assets/index-D3u-qXp5.css
vendored
Normal file
1
faceai/apps/monitor-frontend/dist/assets/index-D3u-qXp5.css
vendored
Normal file
File diff suppressed because one or more lines are too long
17
faceai/apps/monitor-frontend/dist/assets/index-DlzypR8h.js
vendored
Normal file
17
faceai/apps/monitor-frontend/dist/assets/index-DlzypR8h.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
faceai/apps/monitor-frontend/dist/index.html
vendored
Normal file
13
faceai/apps/monitor-frontend/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FaceAI Audit Monitor</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-DlzypR8h.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-D3u-qXp5.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
faceai/apps/monitor-frontend/index.html
Normal file
12
faceai/apps/monitor-frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>FaceAI Audit Monitor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
faceai/apps/monitor-frontend/package.json
Normal file
17
faceai/apps/monitor-frontend/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "@regalami/faceai-monitor-frontend",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
618
faceai/apps/monitor-frontend/src/App.vue
Normal file
618
faceai/apps/monitor-frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,618 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 15000;
|
||||||
|
const STATUS_KEYS = {
|
||||||
|
queued: 'statusQueued',
|
||||||
|
processing: 'statusProcessing',
|
||||||
|
completed: 'statusCompleted',
|
||||||
|
failed: 'statusFailed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
it: {
|
||||||
|
pageTitle: 'FaceAI Audit Monitor',
|
||||||
|
heroEyebrow: 'Monitor Audit FaceAI',
|
||||||
|
heroTitle: 'Telemetria in sola lettura delle ricerche dal log SQLite.',
|
||||||
|
heroCopy: 'Il monitor interroga lo stesso database audit leggero usato dal backend e si aggiorna automaticamente ogni 15 secondi.',
|
||||||
|
windowLabel: 'Finestra',
|
||||||
|
lastRefreshLabel: 'Ultimo aggiornamento',
|
||||||
|
pendingLabel: 'in attesa',
|
||||||
|
refreshNow: 'Aggiorna ora',
|
||||||
|
refreshing: 'Aggiornamento...',
|
||||||
|
languageLabel: 'Lingua',
|
||||||
|
languageEnglish: 'English',
|
||||||
|
languageItalian: 'Italiano',
|
||||||
|
recentSearchesLabel: 'Ricerche recenti',
|
||||||
|
recentSearchesDetail: '{count} utenti unici nelle ultime {hours}h',
|
||||||
|
completedLabel: 'Completate',
|
||||||
|
completedDetail: '{failed} fallite, {processing} ancora attive',
|
||||||
|
averageRuntimeLabel: 'Durata media',
|
||||||
|
averageRuntimeDetail: 'Basata sulle ricerche completate nella finestra recente',
|
||||||
|
lifetimeSearchesLabel: 'Ricerche totali',
|
||||||
|
lifetimeSearchesDetail: 'Ultima richiesta {value}',
|
||||||
|
searchesTitle: 'Ricerche',
|
||||||
|
searchesIntro: 'Filtra per stato o testo libero su utente, gara, risultato, selfie, codice errore o id ricerca.',
|
||||||
|
shownCount: '{count} visibili',
|
||||||
|
statusFilterLabel: 'Stato',
|
||||||
|
statusAll: 'Tutti',
|
||||||
|
statusQueued: 'In coda',
|
||||||
|
statusProcessing: 'In lavorazione',
|
||||||
|
statusCompleted: 'Completata',
|
||||||
|
statusFailed: 'Fallita',
|
||||||
|
statusUnknown: 'Sconosciuto',
|
||||||
|
lookupLabel: 'Ricerca',
|
||||||
|
lookupPlaceholder: 'gara, utente, risultato, selfie, errore, id ricerca',
|
||||||
|
limitLabel: 'Limite',
|
||||||
|
applyFilters: 'Applica',
|
||||||
|
noSelfieName: 'nessun nome selfie',
|
||||||
|
matchesCount: '{count} corrispondenze',
|
||||||
|
noSearches: 'Nessuna ricerca corrisponde ai filtri correnti.',
|
||||||
|
searchDetailTitle: 'Dettaglio ricerca',
|
||||||
|
searchDetailIntro: 'Metadati della ricerca selezionata, corrispondenze e cronologia eventi.',
|
||||||
|
loadingSearchDetail: 'Caricamento dettaglio ricerca...',
|
||||||
|
chooseSearch: 'Scegli una ricerca per ispezionarne la cronologia.',
|
||||||
|
fieldRace: 'Gara',
|
||||||
|
fieldUser: 'Utente',
|
||||||
|
fieldRequested: 'Richiesta',
|
||||||
|
fieldCompleted: 'Completata',
|
||||||
|
fieldResult: 'Risultato',
|
||||||
|
fieldError: 'Errore',
|
||||||
|
notAvailable: 'n/d',
|
||||||
|
matchPayloadTitle: 'Payload corrispondenze',
|
||||||
|
eventTimelineTitle: 'Cronologia eventi',
|
||||||
|
eventSummary: 'stato={status}, risultato={result}, errore={error}, corrispondenze={matches}',
|
||||||
|
noEventsForSearch: 'Nessun evento registrato per questa ricerca.',
|
||||||
|
topRacesTitle: 'Gare principali',
|
||||||
|
topRacesIntro: 'Distribuzione del carico recente per gara.',
|
||||||
|
noStoragePath: 'nessun percorso storage',
|
||||||
|
topRacesTotal: '{count} totali',
|
||||||
|
topRacesDone: '{count} completate',
|
||||||
|
topRacesFailed: '{count} fallite',
|
||||||
|
noRecentRaceActivity: 'Nessuna attivita recente sulle gare.',
|
||||||
|
recentEventsTitle: 'Eventi recenti',
|
||||||
|
recentEventsIntro: 'Ultimi eventi audit su tutte le ricerche.',
|
||||||
|
noSearchId: 'nessun id ricerca',
|
||||||
|
noRecentEvents: 'Nessun evento recente.',
|
||||||
|
requestFailed: 'Richiesta fallita con stato {status}'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
pageTitle: 'FaceAI Audit Monitor',
|
||||||
|
heroEyebrow: 'FaceAI Audit Monitor',
|
||||||
|
heroTitle: 'Read-only search telemetry from the SQLite audit log.',
|
||||||
|
heroCopy: 'The monitor queries the same lightweight audit database used by the backend and refreshes automatically every 15 seconds.',
|
||||||
|
windowLabel: 'Window',
|
||||||
|
lastRefreshLabel: 'Last refresh',
|
||||||
|
pendingLabel: 'pending',
|
||||||
|
refreshNow: 'Refresh now',
|
||||||
|
refreshing: 'Refreshing...',
|
||||||
|
languageLabel: 'Language',
|
||||||
|
languageEnglish: 'English',
|
||||||
|
languageItalian: 'Italiano',
|
||||||
|
recentSearchesLabel: 'Recent searches',
|
||||||
|
recentSearchesDetail: '{count} unique users in the last {hours}h',
|
||||||
|
completedLabel: 'Completed',
|
||||||
|
completedDetail: '{failed} failed, {processing} still active',
|
||||||
|
averageRuntimeLabel: 'Average runtime',
|
||||||
|
averageRuntimeDetail: 'Based on completed searches in the recent window',
|
||||||
|
lifetimeSearchesLabel: 'Lifetime searches',
|
||||||
|
lifetimeSearchesDetail: 'Latest request {value}',
|
||||||
|
searchesTitle: 'Searches',
|
||||||
|
searchesIntro: 'Filter by status or free text across user, race, result, selfie, error code, or search id.',
|
||||||
|
shownCount: '{count} shown',
|
||||||
|
statusFilterLabel: 'Status',
|
||||||
|
statusAll: 'All',
|
||||||
|
statusQueued: 'Queued',
|
||||||
|
statusProcessing: 'Processing',
|
||||||
|
statusCompleted: 'Completed',
|
||||||
|
statusFailed: 'Failed',
|
||||||
|
statusUnknown: 'Unknown',
|
||||||
|
lookupLabel: 'Lookup',
|
||||||
|
lookupPlaceholder: 'race, user, result, selfie, error, search id',
|
||||||
|
limitLabel: 'Limit',
|
||||||
|
applyFilters: 'Apply',
|
||||||
|
noSelfieName: 'no selfie name',
|
||||||
|
matchesCount: '{count} matches',
|
||||||
|
noSearches: 'No searches matched the current filters.',
|
||||||
|
searchDetailTitle: 'Search detail',
|
||||||
|
searchDetailIntro: 'Selected search metadata, matches, and event trail.',
|
||||||
|
loadingSearchDetail: 'Loading search detail...',
|
||||||
|
chooseSearch: 'Choose a search to inspect its timeline.',
|
||||||
|
fieldRace: 'Race',
|
||||||
|
fieldUser: 'User',
|
||||||
|
fieldRequested: 'Requested',
|
||||||
|
fieldCompleted: 'Completed',
|
||||||
|
fieldResult: 'Result',
|
||||||
|
fieldError: 'Error',
|
||||||
|
notAvailable: 'n/a',
|
||||||
|
matchPayloadTitle: 'Match payload',
|
||||||
|
eventTimelineTitle: 'Event timeline',
|
||||||
|
eventSummary: 'status={status}, result={result}, error={error}, matches={matches}',
|
||||||
|
noEventsForSearch: 'No events recorded for this search.',
|
||||||
|
topRacesTitle: 'Top races',
|
||||||
|
topRacesIntro: 'Recent-window workload split by race.',
|
||||||
|
noStoragePath: 'no storage path',
|
||||||
|
topRacesTotal: '{count} total',
|
||||||
|
topRacesDone: '{count} done',
|
||||||
|
topRacesFailed: '{count} failed',
|
||||||
|
noRecentRaceActivity: 'No recent race activity yet.',
|
||||||
|
recentEventsTitle: 'Recent events',
|
||||||
|
recentEventsIntro: 'Latest audit events across all searches.',
|
||||||
|
noSearchId: 'no search id',
|
||||||
|
noRecentEvents: 'No recent events yet.',
|
||||||
|
requestFailed: 'Request failed with {status}'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeLocale(language) {
|
||||||
|
return String(language || '').toLowerCase().startsWith('en') ? 'en' : 'it';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBrowserLocale() {
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
return normalizeLocale(navigator.languages?.[0] || navigator.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return normalizeLocale(document.documentElement.lang || 'it');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'it';
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = ref(null);
|
||||||
|
const searches = ref([]);
|
||||||
|
const selectedDetail = ref(null);
|
||||||
|
const selectedSearchId = ref('');
|
||||||
|
const selectedLocale = ref(resolveBrowserLocale());
|
||||||
|
const dashboardError = ref('');
|
||||||
|
const searchError = ref('');
|
||||||
|
const detailError = ref('');
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
const isLoadingDetail = ref(false);
|
||||||
|
const filters = reactive({
|
||||||
|
status: 'all',
|
||||||
|
query: '',
|
||||||
|
limit: 40
|
||||||
|
});
|
||||||
|
|
||||||
|
let pollHandle = null;
|
||||||
|
|
||||||
|
const currentLocale = computed(() => normalizeLocale(selectedLocale.value));
|
||||||
|
const currentLanguageTag = computed(() => currentLocale.value === 'en' ? 'en-GB' : 'it-IT');
|
||||||
|
const hasSearches = computed(() => searches.value.length > 0);
|
||||||
|
const recentWindow = computed(() => summary.value?.recentWindow || null);
|
||||||
|
const lifetime = computed(() => summary.value?.lifetime || null);
|
||||||
|
const topRaces = computed(() => summary.value?.topRaces || []);
|
||||||
|
const recentEvents = computed(() => summary.value?.recentEvents || []);
|
||||||
|
|
||||||
|
function t(key, params = {}) {
|
||||||
|
const message = copy[currentLocale.value]?.[key] || copy.it[key] || key;
|
||||||
|
return Object.keys(params).reduce((value, paramKey) => value.replace(`{${paramKey}}`, String(params[paramKey])), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeDisplayName(locale) {
|
||||||
|
return locale === 'en' ? t('languageEnglish') : t('languageItalian');
|
||||||
|
}
|
||||||
|
|
||||||
|
function localizedStatus(status) {
|
||||||
|
const key = STATUS_KEYS[status] || 'statusUnknown';
|
||||||
|
return t(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(payload.error || t('requestFailed', { status: response.status }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value) {
|
||||||
|
if (!value) {
|
||||||
|
return t('notAvailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(currentLanguageTag.value, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'medium'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return t('notAvailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = value - Date.now();
|
||||||
|
const absMs = Math.abs(diffMs);
|
||||||
|
const formatter = new Intl.RelativeTimeFormat(currentLocale.value, { numeric: 'auto' });
|
||||||
|
|
||||||
|
if (absMs < 60_000) {
|
||||||
|
return formatter.format(Math.round(diffMs / 1000), 'second');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absMs < 3_600_000) {
|
||||||
|
return formatter.format(Math.round(diffMs / 60_000), 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absMs < 86_400_000) {
|
||||||
|
return formatter.format(Math.round(diffMs / 3_600_000), 'hour');
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatter.format(Math.round(diffMs / 86_400_000), 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value) {
|
||||||
|
const durationMs = Number(value || 0);
|
||||||
|
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||||
|
return t('notAvailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationMs < 1000) {
|
||||||
|
return `${durationMs} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSeconds = Math.round(durationMs / 1000);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const minutes = Math.floor(totalSeconds / 60) % 60;
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
parts.push(`${hours}h`);
|
||||||
|
}
|
||||||
|
if (minutes > 0 || hours > 0) {
|
||||||
|
parts.push(`${minutes}m`);
|
||||||
|
}
|
||||||
|
parts.push(`${seconds}s`);
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status) {
|
||||||
|
return status || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
dashboardError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
summary.value = await fetchJson('/api/audit-monitor/summary');
|
||||||
|
} catch (error) {
|
||||||
|
dashboardError.value = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSearches({ keepSelection = true } = {}) {
|
||||||
|
searchError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
status: filters.status,
|
||||||
|
query: filters.query,
|
||||||
|
limit: String(filters.limit)
|
||||||
|
});
|
||||||
|
const payload = await fetchJson(`/api/audit-monitor/searches?${params.toString()}`);
|
||||||
|
searches.value = Array.isArray(payload.items) ? payload.items : [];
|
||||||
|
|
||||||
|
const hasSelected = keepSelection && selectedSearchId.value
|
||||||
|
&& searches.value.some((item) => item.searchId === selectedSearchId.value);
|
||||||
|
|
||||||
|
if (!hasSelected) {
|
||||||
|
selectedSearchId.value = searches.value[0]?.searchId || '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
searches.value = [];
|
||||||
|
searchError.value = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(searchId) {
|
||||||
|
if (!searchId) {
|
||||||
|
selectedDetail.value = null;
|
||||||
|
detailError.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingDetail.value = true;
|
||||||
|
detailError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
selectedDetail.value = await fetchJson(`/api/audit-monitor/searches/${encodeURIComponent(searchId)}`);
|
||||||
|
} catch (error) {
|
||||||
|
selectedDetail.value = null;
|
||||||
|
detailError.value = error.message;
|
||||||
|
} finally {
|
||||||
|
isLoadingDetail.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll({ keepSelection = true } = {}) {
|
||||||
|
isRefreshing.value = true;
|
||||||
|
await Promise.all([
|
||||||
|
loadSummary(),
|
||||||
|
loadSearches({ keepSelection })
|
||||||
|
]);
|
||||||
|
await loadDetail(selectedSearchId.value);
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyFilters() {
|
||||||
|
await loadSearches({ keepSelection: false });
|
||||||
|
await loadDetail(selectedSearchId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSearch(searchId) {
|
||||||
|
if (selectedSearchId.value === searchId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedSearchId.value = searchId;
|
||||||
|
loadDetail(searchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentLocale, (locale) => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
document.title = copy[locale].pageTitle;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshAll({ keepSelection: false });
|
||||||
|
pollHandle = window.setInterval(() => {
|
||||||
|
refreshAll();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollHandle) {
|
||||||
|
window.clearInterval(pollHandle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="monitor-shell">
|
||||||
|
<header class="hero-panel">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{{ t('heroEyebrow') }}</p>
|
||||||
|
<h1>{{ t('heroTitle') }}</h1>
|
||||||
|
<p class="hero-copy">{{ t('heroCopy') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<div>
|
||||||
|
<span>{{ t('windowLabel') }}</span>
|
||||||
|
<strong>{{ summary?.windowHours || 24 }}h</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{{ t('lastRefreshLabel') }}</span>
|
||||||
|
<strong>{{ summary ? formatTimestamp(summary.generatedAt) : t('pendingLabel') }}</strong>
|
||||||
|
</div>
|
||||||
|
<label class="locale-field">
|
||||||
|
<span>{{ t('languageLabel') }}</span>
|
||||||
|
<select v-model="selectedLocale">
|
||||||
|
<option value="it">{{ localeDisplayName('it') }}</option>
|
||||||
|
<option value="en">{{ localeDisplayName('en') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="primary-button" type="button" @click="refreshAll()" :disabled="isRefreshing">
|
||||||
|
{{ isRefreshing ? t('refreshing') : t('refreshNow') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="stat-grid">
|
||||||
|
<article class="stat-card accent-sand">
|
||||||
|
<span class="stat-label">{{ t('recentSearchesLabel') }}</span>
|
||||||
|
<strong>{{ recentWindow?.totalSearches ?? 0 }}</strong>
|
||||||
|
<small>{{ t('recentSearchesDetail', { count: recentWindow?.uniqueUsers ?? 0, hours: summary?.windowHours || 24 }) }}</small>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card accent-olive">
|
||||||
|
<span class="stat-label">{{ t('completedLabel') }}</span>
|
||||||
|
<strong>{{ recentWindow?.completedSearches ?? 0 }}</strong>
|
||||||
|
<small>{{ t('completedDetail', { failed: recentWindow?.failedSearches ?? 0, processing: recentWindow?.processingSearches ?? 0 }) }}</small>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card accent-sky">
|
||||||
|
<span class="stat-label">{{ t('averageRuntimeLabel') }}</span>
|
||||||
|
<strong>{{ formatDuration(recentWindow?.averageDurationMs) }}</strong>
|
||||||
|
<small>{{ t('averageRuntimeDetail') }}</small>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card accent-stone">
|
||||||
|
<span class="stat-label">{{ t('lifetimeSearchesLabel') }}</span>
|
||||||
|
<strong>{{ lifetime?.totalSearches ?? 0 }}</strong>
|
||||||
|
<small>{{ t('lifetimeSearchesDetail', { value: formatRelativeTime(lifetime?.latestRequestedAt) }) }}</small>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-if="dashboardError" class="banner-error">{{ dashboardError }}</p>
|
||||||
|
|
||||||
|
<section class="content-grid">
|
||||||
|
<div class="panel stack-gap">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('searchesTitle') }}</h2>
|
||||||
|
<p>{{ t('searchesIntro') }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="pill">{{ t('shownCount', { count: searches.length }) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="filter-grid" @submit.prevent="applyFilters">
|
||||||
|
<label>
|
||||||
|
<span>{{ t('statusFilterLabel') }}</span>
|
||||||
|
<select v-model="filters.status">
|
||||||
|
<option value="all">{{ t('statusAll') }}</option>
|
||||||
|
<option value="queued">{{ t('statusQueued') }}</option>
|
||||||
|
<option value="processing">{{ t('statusProcessing') }}</option>
|
||||||
|
<option value="completed">{{ t('statusCompleted') }}</option>
|
||||||
|
<option value="failed">{{ t('statusFailed') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t('lookupLabel') }}</span>
|
||||||
|
<input v-model.trim="filters.query" type="search" :placeholder="t('lookupPlaceholder')" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{{ t('limitLabel') }}</span>
|
||||||
|
<input v-model.number="filters.limit" type="number" min="1" max="200" />
|
||||||
|
</label>
|
||||||
|
<button class="secondary-button" type="submit">{{ t('applyFilters') }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="searchError" class="banner-error">{{ searchError }}</p>
|
||||||
|
|
||||||
|
<div v-if="hasSearches" class="search-list">
|
||||||
|
<button
|
||||||
|
v-for="item in searches"
|
||||||
|
:key="item.searchId"
|
||||||
|
type="button"
|
||||||
|
class="search-row"
|
||||||
|
:class="{ selected: item.searchId === selectedSearchId }"
|
||||||
|
@click="selectSearch(item.searchId)"
|
||||||
|
>
|
||||||
|
<div class="search-row-top">
|
||||||
|
<strong>{{ item.searchId }}</strong>
|
||||||
|
<span class="status-badge" :data-status="statusTone(item.status)">{{ localizedStatus(item.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="search-row-meta">
|
||||||
|
<span>{{ item.raceName || item.raceId }}</span>
|
||||||
|
<span>{{ item.userDisplayName || item.userId }}</span>
|
||||||
|
<span>{{ formatRelativeTime(item.requestedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="search-row-bottom">
|
||||||
|
<span>{{ item.selfieName || t('noSelfieName') }}</span>
|
||||||
|
<span>{{ t('matchesCount', { count: item.matchCount || 0 }) }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty-state">{{ t('noSearches') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack-gap">
|
||||||
|
<section class="panel stack-gap">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('searchDetailTitle') }}</h2>
|
||||||
|
<p>{{ t('searchDetailIntro') }}</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="selectedDetail?.search" class="pill">{{ localizedStatus(selectedDetail.search.status) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="detailError" class="banner-error">{{ detailError }}</p>
|
||||||
|
<p v-else-if="isLoadingDetail" class="empty-state">{{ t('loadingSearchDetail') }}</p>
|
||||||
|
<p v-else-if="!selectedDetail?.search" class="empty-state">{{ t('chooseSearch') }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<dl class="detail-grid">
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('fieldRace') }}</dt>
|
||||||
|
<dd>{{ selectedDetail.search.raceName || selectedDetail.search.raceId }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('fieldUser') }}</dt>
|
||||||
|
<dd>{{ selectedDetail.search.userDisplayName || selectedDetail.search.userId }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('fieldRequested') }}</dt>
|
||||||
|
<dd>{{ formatTimestamp(selectedDetail.search.requestedAt) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('fieldCompleted') }}</dt>
|
||||||
|
<dd>{{ formatTimestamp(selectedDetail.search.completedAt) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('fieldResult') }}</dt>
|
||||||
|
<dd>{{ selectedDetail.search.resultId || t('notAvailable') }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('fieldError') }}</dt>
|
||||||
|
<dd>{{ selectedDetail.search.errorCode || t('notAvailable') }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="code-block">
|
||||||
|
<h3>{{ t('matchPayloadTitle') }}</h3>
|
||||||
|
<pre>{{ JSON.stringify(selectedDetail.search.matches, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="code-block">
|
||||||
|
<h3>{{ t('eventTimelineTitle') }}</h3>
|
||||||
|
<div v-if="selectedDetail.events?.length" class="event-list">
|
||||||
|
<article v-for="event in selectedDetail.events" :key="event.id" class="event-row">
|
||||||
|
<div class="event-header">
|
||||||
|
<strong>{{ event.eventType }}</strong>
|
||||||
|
<span>{{ formatTimestamp(event.happenedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{{ t('eventSummary', {
|
||||||
|
status: event.status ? localizedStatus(event.status) : t('notAvailable'),
|
||||||
|
result: event.resultId || t('notAvailable'),
|
||||||
|
error: event.errorCode || t('notAvailable'),
|
||||||
|
matches: event.matchCount ?? t('notAvailable')
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
<pre v-if="event.payload">{{ JSON.stringify(event.payload, null, 2) }}</pre>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty-state">{{ t('noEventsForSearch') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel split-panel">
|
||||||
|
<div>
|
||||||
|
<div class="panel-header compact">
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('topRacesTitle') }}</h2>
|
||||||
|
<p>{{ t('topRacesIntro') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="topRaces.length" class="mini-list">
|
||||||
|
<article v-for="race in topRaces" :key="`${race.raceId}-${race.lastRequestedAt}`" class="mini-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ race.raceName || race.raceId }}</strong>
|
||||||
|
<p>{{ race.raceStorage || t('noStoragePath') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mini-metrics">
|
||||||
|
<span>{{ t('topRacesTotal', { count: race.searchCount }) }}</span>
|
||||||
|
<span>{{ t('topRacesDone', { count: race.completedCount }) }}</span>
|
||||||
|
<span>{{ t('topRacesFailed', { count: race.failedCount }) }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty-state">{{ t('noRecentRaceActivity') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="panel-header compact">
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('recentEventsTitle') }}</h2>
|
||||||
|
<p>{{ t('recentEventsIntro') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="recentEvents.length" class="mini-list">
|
||||||
|
<article v-for="event in recentEvents" :key="event.id" class="mini-row compact-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ event.eventType }}</strong>
|
||||||
|
<p>{{ event.searchId || t('noSearchId') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mini-metrics">
|
||||||
|
<span>{{ event.status ? localizedStatus(event.status) : t('notAvailable') }}</span>
|
||||||
|
<span>{{ formatRelativeTime(event.happenedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-else class="empty-state">{{ t('noRecentEvents') }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
5
faceai/apps/monitor-frontend/src/main.js
Normal file
5
faceai/apps/monitor-frontend/src/main.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
479
faceai/apps/monitor-frontend/src/styles.css
Normal file
479
faceai/apps/monitor-frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--canvas: #f6f1e8;
|
||||||
|
--surface: rgba(255, 251, 245, 0.88);
|
||||||
|
--surface-strong: #fffdf9;
|
||||||
|
--ink: #1f1a14;
|
||||||
|
--muted: #6c6258;
|
||||||
|
--line: rgba(60, 43, 26, 0.14);
|
||||||
|
--shadow: 0 24px 60px rgba(70, 49, 28, 0.12);
|
||||||
|
--sand: #d49f61;
|
||||||
|
--olive: #70835f;
|
||||||
|
--sky: #5f8399;
|
||||||
|
--stone: #7e6c5c;
|
||||||
|
--danger: #9c3c2c;
|
||||||
|
--queued: #8a6f43;
|
||||||
|
--processing: #336b87;
|
||||||
|
--completed: #567a46;
|
||||||
|
--failed: #9a3b35;
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(212, 159, 97, 0.35), transparent 30%),
|
||||||
|
radial-gradient(circle at top right, rgba(95, 131, 153, 0.22), transparent 26%),
|
||||||
|
linear-gradient(180deg, #fbf6ef 0%, var(--canvas) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-shell {
|
||||||
|
width: min(1440px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel,
|
||||||
|
.panel,
|
||||||
|
.stat-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2.2fr) minmax(280px, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel h1,
|
||||||
|
.panel h2,
|
||||||
|
.code-block h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel h1 {
|
||||||
|
max-width: 12ch;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||||
|
line-height: 0.96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.panel-header p,
|
||||||
|
.mini-row p,
|
||||||
|
.empty-state,
|
||||||
|
.detail-grid dt,
|
||||||
|
.stat-card small,
|
||||||
|
.hero-meta span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
margin: 16px 0 0;
|
||||||
|
max-width: 62ch;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta div,
|
||||||
|
.stat-card {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta .locale-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta .locale-field select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta strong,
|
||||||
|
.stat-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
color: #fff8ef;
|
||||||
|
background: linear-gradient(135deg, #8f532c, #b77436);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
color: var(--ink);
|
||||||
|
background: rgba(118, 104, 91, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover,
|
||||||
|
.secondary-button:hover,
|
||||||
|
.search-row:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid,
|
||||||
|
.content-grid,
|
||||||
|
.split-panel,
|
||||||
|
.filter-grid,
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
min-height: 154px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card strong {
|
||||||
|
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-sand {
|
||||||
|
background: linear-gradient(180deg, rgba(233, 199, 159, 0.42), rgba(255, 251, 245, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-olive {
|
||||||
|
background: linear-gradient(180deg, rgba(151, 176, 129, 0.33), rgba(255, 251, 245, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-sky {
|
||||||
|
background: linear-gradient(180deg, rgba(141, 180, 201, 0.35), rgba(255, 251, 245, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-stone {
|
||||||
|
background: linear-gradient(180deg, rgba(170, 150, 132, 0.28), rgba(255, 251, 245, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: minmax(360px, 0.95fr) minmax(0, 1.35fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-gap {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header.compact {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill,
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
background: rgba(118, 104, 91, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: 140px minmax(0, 1fr) 120px 110px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid span {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid input,
|
||||||
|
.filter-grid select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-error,
|
||||||
|
.empty-state {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(156, 60, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-list,
|
||||||
|
.event-list,
|
||||||
|
.mini-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row,
|
||||||
|
.event-row,
|
||||||
|
.mini-row,
|
||||||
|
.code-block {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 253, 249, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row.selected {
|
||||||
|
border-color: rgba(143, 83, 44, 0.42);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(143, 83, 44, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row-top,
|
||||||
|
.search-row-meta,
|
||||||
|
.search-row-bottom,
|
||||||
|
.event-header,
|
||||||
|
.mini-row,
|
||||||
|
.mini-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row-top strong,
|
||||||
|
.mini-row strong,
|
||||||
|
.event-header strong,
|
||||||
|
.detail-grid dd,
|
||||||
|
.code-block h3 {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row-meta,
|
||||||
|
.search-row-bottom,
|
||||||
|
.mini-row p,
|
||||||
|
.mini-metrics,
|
||||||
|
.event-row p {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status='queued'] {
|
||||||
|
background: rgba(138, 111, 67, 0.14);
|
||||||
|
color: var(--queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status='processing'] {
|
||||||
|
background: rgba(51, 107, 135, 0.12);
|
||||||
|
color: var(--processing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status='completed'] {
|
||||||
|
background: rgba(86, 122, 70, 0.14);
|
||||||
|
color: var(--completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status='failed'] {
|
||||||
|
background: rgba(154, 59, 53, 0.12);
|
||||||
|
color: var(--failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge[data-status='unknown'] {
|
||||||
|
background: rgba(118, 104, 91, 0.12);
|
||||||
|
color: var(--stone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid div {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgba(255, 253, 249, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid dt {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid dd {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block pre,
|
||||||
|
.event-row pre {
|
||||||
|
overflow: auto;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #201a17;
|
||||||
|
color: #f8f1e7;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-row {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-row p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-panel {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-row {
|
||||||
|
padding: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-row {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.stat-grid,
|
||||||
|
.detail-grid,
|
||||||
|
.split-panel {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid,
|
||||||
|
.hero-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.monitor-shell {
|
||||||
|
width: min(100vw - 20px, 100%);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel,
|
||||||
|
.hero-panel {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid,
|
||||||
|
.detail-grid,
|
||||||
|
.split-panel,
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
faceai/apps/monitor-frontend/vite.config.js
Normal file
12
faceai/apps/monitor-frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api/audit-monitor': 'http://localhost:3001'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -38,6 +38,15 @@ services:
|
||||||
- ../test_pkl:${FACEAI_PKL_ROOT:-/data/pkl}:ro
|
- ../test_pkl:${FACEAI_PKL_ROOT:-/data/pkl}:ro
|
||||||
- faceai-runtime:${FACEAI_RUNTIME_ROOT:-/data/runtime}
|
- faceai-runtime:${FACEAI_RUNTIME_ROOT:-/data/runtime}
|
||||||
|
|
||||||
|
faceai-monitor:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: faceai/docker/monitor-frontend.Dockerfile
|
||||||
|
image: ${FACEAI_MONITOR_DEV_IMAGE:-regalami-faceai-monitor-local}
|
||||||
|
depends_on:
|
||||||
|
faceai:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
processor:
|
processor:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,22 @@ services:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
faceai-monitor:
|
||||||
|
image: ${FACEAI_MONITOR_IMAGE:-forgejo.maddoscientisto.net/maddo/faceai-monitor:latest}
|
||||||
|
container_name: ${FACEAI_MONITOR_CONTAINER_NAME:-regalami-faceai-monitor}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${FACEAI_MONITOR_PUBLISHED_PORT:-127.0.0.1:3002:80}"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/healthz >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 10s
|
||||||
|
depends_on:
|
||||||
|
faceai:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
processor:
|
processor:
|
||||||
image: ${FACEAI_PROCESSOR_IMAGE:-forgejo.maddoscientisto.net/maddo/faceai-processor:latest}
|
image: ${FACEAI_PROCESSOR_IMAGE:-forgejo.maddoscientisto.net/maddo/faceai-processor:latest}
|
||||||
container_name: ${FACEAI_PROCESSOR_CONTAINER_NAME:-regalami-faceai-processor}
|
container_name: ${FACEAI_PROCESSOR_CONTAINER_NAME:-regalami-faceai-processor}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ WORKDIR /app
|
||||||
|
|
||||||
COPY faceai/package.json ./package.json
|
COPY faceai/package.json ./package.json
|
||||||
COPY faceai/apps/frontend/package.json apps/frontend/package.json
|
COPY faceai/apps/frontend/package.json apps/frontend/package.json
|
||||||
|
COPY faceai/apps/monitor-frontend/package.json apps/monitor-frontend/package.json
|
||||||
COPY faceai/apps/backend/package.json apps/backend/package.json
|
COPY faceai/apps/backend/package.json apps/backend/package.json
|
||||||
COPY faceai/apps/processor/package.json apps/processor/package.json
|
COPY faceai/apps/processor/package.json apps/processor/package.json
|
||||||
|
|
||||||
|
|
|
||||||
22
faceai/docker/monitor-frontend.Dockerfile
Normal file
22
faceai/docker/monitor-frontend.Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
FROM node:22-trixie-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY faceai/package.json ./package.json
|
||||||
|
COPY faceai/apps/frontend/package.json apps/frontend/package.json
|
||||||
|
COPY faceai/apps/monitor-frontend/package.json apps/monitor-frontend/package.json
|
||||||
|
COPY faceai/apps/backend/package.json apps/backend/package.json
|
||||||
|
COPY faceai/apps/processor/package.json apps/processor/package.json
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY faceai/ .
|
||||||
|
|
||||||
|
RUN npm run build --workspace @regalami/faceai-monitor-frontend
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
|
||||||
|
COPY faceai/docker/monitor-nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/apps/monitor-frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
25
faceai/docker/monitor-nginx.conf
Normal file
25
faceai/docker/monitor-nginx.conf
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/audit-monitor/ {
|
||||||
|
proxy_pass http://faceai:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
faceai/package-lock.json
generated
15
faceai/package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
||||||
"name": "faceai",
|
"name": "faceai",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/frontend",
|
"apps/frontend",
|
||||||
|
"apps/monitor-frontend",
|
||||||
"apps/backend",
|
"apps/backend",
|
||||||
"apps/processor"
|
"apps/processor"
|
||||||
],
|
],
|
||||||
|
|
@ -37,6 +38,16 @@
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/monitor-frontend": {
|
||||||
|
"name": "@regalami/faceai-monitor-frontend",
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"apps/processor": {
|
"apps/processor": {
|
||||||
"name": "@regalami/faceai-processor",
|
"name": "@regalami/faceai-processor",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -646,6 +657,10 @@
|
||||||
"resolved": "apps/frontend",
|
"resolved": "apps/frontend",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@regalami/faceai-monitor-frontend": {
|
||||||
|
"resolved": "apps/monitor-frontend",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@regalami/faceai-processor": {
|
"node_modules/@regalami/faceai-processor": {
|
||||||
"resolved": "apps/processor",
|
"resolved": "apps/processor",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/frontend",
|
"apps/frontend",
|
||||||
|
"apps/monitor-frontend",
|
||||||
"apps/backend",
|
"apps/backend",
|
||||||
"apps/processor"
|
"apps/processor"
|
||||||
],
|
],
|
||||||
|
|
@ -10,12 +11,13 @@
|
||||||
"dev": "concurrently \"npm:dev:backend\" \"npm:dev:frontend\"",
|
"dev": "concurrently \"npm:dev:backend\" \"npm:dev:frontend\"",
|
||||||
"dev:backend": "npm run dev --workspace @regalami/faceai-backend",
|
"dev:backend": "npm run dev --workspace @regalami/faceai-backend",
|
||||||
"dev:frontend": "npm run dev --workspace @regalami/faceai-frontend",
|
"dev:frontend": "npm run dev --workspace @regalami/faceai-frontend",
|
||||||
|
"dev:monitor": "npm run dev --workspace @regalami/faceai-monitor-frontend",
|
||||||
"dev:processor": "npm run dev --workspace @regalami/faceai-processor",
|
"dev:processor": "npm run dev --workspace @regalami/faceai-processor",
|
||||||
"compose:prod:up": "docker compose -f docker-compose.yml --env-file .env.production up -d",
|
"compose:prod:up": "docker compose -f docker-compose.yml --env-file .env.production up -d",
|
||||||
"compose:prod:down": "docker compose -f docker-compose.yml --env-file .env.production down",
|
"compose:prod:down": "docker compose -f docker-compose.yml --env-file .env.production down",
|
||||||
"compose:dev:up": "docker compose --env-file .env.development up --build",
|
"compose:dev:up": "docker compose --env-file .env.development up --build",
|
||||||
"compose:dev:down": "docker compose --env-file .env.development down",
|
"compose:dev:down": "docker compose --env-file .env.development down",
|
||||||
"build": "npm run build --workspace @regalami/faceai-frontend && npm run build --workspace @regalami/faceai-backend",
|
"build": "npm run build --workspace @regalami/faceai-frontend && npm run build --workspace @regalami/faceai-monitor-frontend && npm run build --workspace @regalami/faceai-backend",
|
||||||
"start": "npm run start --workspace @regalami/faceai-backend",
|
"start": "npm run start --workspace @regalami/faceai-backend",
|
||||||
"start:processor": "npm run start --workspace @regalami/faceai-processor",
|
"start:processor": "npm run start --workspace @regalami/faceai-processor",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
|
|
||||||
BIN
faceai/tmp/audit-monitor-smoke.sqlite
Normal file
BIN
faceai/tmp/audit-monitor-smoke.sqlite
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue