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

- 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:
MaddoScientisto 2026-05-20 18:57:20 +02:00
commit a95ae56134
21 changed files with 1755 additions and 2 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>

View 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>

View 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"
}
}

View 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>

View file

@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).mount('#app');

View 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;
}
}

View 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'
}
}
});