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
|
|
@ -21,6 +21,89 @@ function jsonOrNull(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) {
|
||||
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
|
||||
`);
|
||||
|
||||
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()) {
|
||||
if (now - lastPrunedAt < ONE_DAY_MS) {
|
||||
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();
|
||||
|
||||
return {
|
||||
dbPath,
|
||||
getMonitorSummary,
|
||||
getSearchDetail,
|
||||
listSearches,
|
||||
recordEvent,
|
||||
recordSearchRequested,
|
||||
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)) {
|
||||
app.use(express.static(frontendDist));
|
||||
app.get('*', (req, res, next) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue