Skip to content

Commit a559b2d

Browse files
CaYaturclaude
andcommitted
Stage 15 (audit trail, slice 4): the desktop audit viewer
The audit trail is now visible and searchable from the desktop — the payoff of slices 1-3. A global "Audit log" view (sidebar, alongside Site/Web/Settings) lists every recorded action with filters that mirror the store's query surface: - filter by source (All / Console / Panel / Web panel / Public / System) with a live per-source count, by outcome (All / Success / Failed), and a debounced free-text search over actor / IP / action / target. - a table showing when, source (badged), action (localized, raw string for unknown actions), actor, IP, target/detail, and a success/failure mark. Wiring mirrors the existing queryEvents seam exactly: `audit:query` channel → preload → `H(IPC.auditQuery, …)` → the pure `filterAudit`. New global ViewId 'audit'; EN/TR in lockstep (34/34 keys). Verified: typecheck + build clean; the main SMOKE (exit 0) now records two audit entries, opens the Audit view from the sidebar in the real renderer, and asserts the table renders both — actor 'operator', a denied login with its IP, all localized. So the view is verified end-to-end (record → IPC → rendered table), not just by inspection. Joins / alt-account tracking (name+IP+account count) and the admin-gated web audit endpoint remain for slice 5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1017d2b commit a559b2d

11 files changed

Lines changed: 356 additions & 5 deletions

File tree

src/main/ipc/register.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ export function registerIpc(): void {
332332
// --- timeline events ---
333333
H(IPC.eventsQuery, (_e, id: string, q?: events.EventQuery) => events.query(id, q))
334334
H(IPC.eventsUptime, (_e, id: string, from: number, to: number) => events.uptime(id, from, to))
335+
H(IPC.auditQuery, (_e, q?: audit.AuditQuery) => audit.query(q))
335336

336337
// --- telemetry history ---
337338
H(IPC.metricsQuery, (_e, id: string, opts: metrics.QueryOptions) => metrics.query(id, opts))

src/main/smoke.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2383,6 +2383,39 @@ export async function runSmoke(): Promise<void> {
23832383
console.log('SMOKE: mods update control OK (button enabled with a plugin present)')
23842384
}
23852385

2386+
// ---- audit view (Stage 15 slice 4): the global audit log renders in the UI ----
2387+
{
2388+
const af = join(auditDir(), 'audit.jsonl')
2389+
const asnap = existsSync(af) ? readFileSync(af, 'utf-8') : null
2390+
try {
2391+
rmSync(af, { force: true })
2392+
auditMod._reset()
2393+
auditMod.record({ source: 'panel', action: 'server.start', actor: 'operator', serverId: 'smoke-srv' })
2394+
auditMod.record({ source: 'webpanel', action: 'login', actor: 'smokeadmin', ok: false, ip: '203.0.113.7' })
2395+
await win.webContents.executeJavaScript(
2396+
`[...document.querySelectorAll('.sidebar-foot .btn')].find(b=>/Audit|Denetim/i.test(b.textContent||''))?.click()`
2397+
)
2398+
await sleep(700)
2399+
const av = JSON.parse(
2400+
await win.webContents.executeJavaScript(
2401+
`(()=>{const rows=[...document.querySelectorAll('.audit-table tbody tr')];
2402+
const txt=document.querySelector('.audit-table')?.textContent||'';
2403+
return JSON.stringify({rows:rows.length,hasTable:!!document.querySelector('.audit-table'),
2404+
title:(document.querySelector('.section-title')?.textContent||'').trim(),
2405+
hasOperator:/operator/.test(txt),hasFailIp:txt.indexOf('203.0.113.7')>=0})})()`
2406+
)
2407+
) as { rows: number; hasTable: boolean; title: string; hasOperator: boolean; hasFailIp: boolean }
2408+
if (!av.hasTable) return fail('audit view did not render its table')
2409+
if (av.rows < 2) return fail('audit view showed ' + av.rows + ' row(s), expected 2')
2410+
if (!av.hasOperator || !av.hasFailIp) return fail('audit rows missing actor/IP content')
2411+
if (/audit\.|auditAct\.|\{\{/.test(av.title)) return fail('audit view not translated: ' + av.title)
2412+
console.log('SMOKE: audit view OK (table renders — actor, source badge, denied login + IP)')
2413+
} finally {
2414+
if (asnap == null) rmSync(af, { force: true })
2415+
else writeFileSync(af, asnap, 'utf-8')
2416+
}
2417+
}
2418+
23862419
const bk = await backupsMod.createBackup(id, { kind: 'full' })
23872420
if (!backupsMod.listBackups(id).find((b) => b.id === bk.id)) return fail('backup not listed')
23882421
backupsMod.deleteBackup(bk.id)

src/preload/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const api: MsmsApi = {
107107

108108
queryEvents: (id, q) => ipcRenderer.invoke(IPC.eventsQuery, id, q),
109109
getUptime: (id, from, to) => ipcRenderer.invoke(IPC.eventsUptime, id, from, to),
110+
queryAudit: (q) => ipcRenderer.invoke(IPC.auditQuery, q),
110111
queryMetrics: (id, opts) => ipcRenderer.invoke(IPC.metricsQuery, id, opts),
111112
getTelemetryConfig: () => ipcRenderer.invoke(IPC.metricsConfigGet),
112113
setTelemetryConfig: (patch) => ipcRenderer.invoke(IPC.metricsConfigSet, patch),

src/renderer/src/App.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { HistoryView } from './views/HistoryView'
3939
import { StoreView } from './views/StoreView'
4040
import { WebPanelView } from './views/WebPanelView'
4141
import { SiteView } from './views/SiteView'
42+
import { AuditView } from './views/AuditView'
4243
import { useState, useRef, useEffect } from 'react'
4344

4445
const TABS: { id: ViewId; icon: JSX.Element; labelKey: string }[] = [
@@ -143,11 +144,19 @@ function MainArea(): JSX.Element {
143144
}, [view])
144145

145146
if (view === 'create') return <CreateView />
146-
if (view === 'settings' || view === 'web' || view === 'site')
147+
if (view === 'settings' || view === 'web' || view === 'site' || view === 'audit')
147148
return (
148149
<div className="content">
149150
<ErrorBoundary key={view}>
150-
{view === 'web' ? <WebPanelView /> : view === 'site' ? <SiteView /> : <SettingsView />}
151+
{view === 'web' ? (
152+
<WebPanelView />
153+
) : view === 'site' ? (
154+
<SiteView />
155+
) : view === 'audit' ? (
156+
<AuditView />
157+
) : (
158+
<SettingsView />
159+
)}
151160
</ErrorBoundary>
152161
</div>
153162
)

src/renderer/src/components/Sidebar.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useTranslation } from 'react-i18next'
2-
import { Plus, FolderPlus, RefreshCw, Settings as SettingsIcon, Server, FolderOpen, Globe, Globe2 } from 'lucide-react'
2+
import { Plus, FolderPlus, RefreshCw, Settings as SettingsIcon, Server, FolderOpen, Globe, Globe2, ScrollText } from 'lucide-react'
33
import { useStore } from '../store'
44
import { StatusDot } from './ui'
55
import { Logo } from './Logo'
@@ -57,14 +57,14 @@ export function Sidebar(): JSX.Element {
5757
servers.map((s) => {
5858
const st = statuses[s.id]?.status ?? 'stopped'
5959
const active =
60-
s.id === activeId && !['settings', 'create', 'web', 'site'].includes(view)
60+
s.id === activeId && !['settings', 'create', 'web', 'site', 'audit'].includes(view)
6161
return (
6262
<div
6363
key={s.id}
6464
className={`server-item ${active ? 'active' : ''}`}
6565
onClick={() => {
6666
selectServer(s.id)
67-
if (['settings', 'create', 'web', 'site'].includes(view)) setView('console')
67+
if (['settings', 'create', 'web', 'site', 'audit'].includes(view)) setView('console')
6868
}}
6969
>
7070
<StatusDot status={st} />
@@ -105,6 +105,13 @@ export function Sidebar(): JSX.Element {
105105
>
106106
<Globe size={15} /> {t('web.title')}
107107
</button>
108+
<button
109+
className={`btn ghost sm block ${view === 'audit' ? 'active' : ''}`}
110+
onClick={() => setView('audit')}
111+
style={{ marginBottom: 4 }}
112+
>
113+
<ScrollText size={15} /> {t('audit.title')}
114+
</button>
108115
<button
109116
className={`btn ghost sm block ${view === 'settings' ? 'active' : ''}`}
110117
onClick={() => setView('settings')}

src/renderer/src/locales/en.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,42 @@ export default {
113113
settings: 'Settings',
114114
openFolder: 'Open folder'
115115
},
116+
audit: {
117+
title: 'Audit log',
118+
all: 'All',
119+
okOnly: 'Success',
120+
failOnly: 'Failed',
121+
search: 'Search actor, IP, action, target…',
122+
refresh: 'Refresh',
123+
results: '{{n}} entries',
124+
empty: 'No matching audit entries.',
125+
when: 'When',
126+
source: 'Source',
127+
action: 'Action',
128+
actor: 'Actor',
129+
ip: 'IP',
130+
target: 'Target',
131+
outcome: 'Outcome',
132+
src: {
133+
console: 'Console',
134+
panel: 'Panel',
135+
webpanel: 'Web panel',
136+
public: 'Public site',
137+
system: 'System'
138+
}
139+
},
140+
auditAct: {
141+
login: 'Login',
142+
command: 'Console command',
143+
start: 'Start server',
144+
stop: 'Stop server',
145+
restart: 'Restart server',
146+
kill: 'Force kill',
147+
create: 'Create server',
148+
remove: 'Remove server',
149+
register: 'Account created',
150+
purchase: 'Purchase'
151+
},
116152
status: {
117153
stopped: 'Stopped',
118154
starting: 'Starting',

src/renderer/src/locales/tr.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,42 @@ const tr: typeof en = {
115115
settings: 'Ayarlar',
116116
openFolder: 'Klasörü aç'
117117
},
118+
audit: {
119+
title: 'Denetim kaydı',
120+
all: 'Tümü',
121+
okOnly: 'Başarılı',
122+
failOnly: 'Başarısız',
123+
search: 'Aktör, IP, eylem, hedef ara…',
124+
refresh: 'Yenile',
125+
results: '{{n}} kayıt',
126+
empty: 'Eşleşen denetim kaydı yok.',
127+
when: 'Zaman',
128+
source: 'Kaynak',
129+
action: 'Eylem',
130+
actor: 'Aktör',
131+
ip: 'IP',
132+
target: 'Hedef',
133+
outcome: 'Sonuç',
134+
src: {
135+
console: 'Konsol',
136+
panel: 'Panel',
137+
webpanel: 'Web panel',
138+
public: 'Herkese açık',
139+
system: 'Sistem'
140+
}
141+
},
142+
auditAct: {
143+
login: 'Giriş',
144+
command: 'Konsol komutu',
145+
start: 'Sunucu başlat',
146+
stop: 'Sunucu durdur',
147+
restart: 'Sunucu yeniden başlat',
148+
kill: 'Zorla kapat',
149+
create: 'Sunucu oluştur',
150+
remove: 'Sunucu kaldır',
151+
register: 'Hesap oluşturuldu',
152+
purchase: 'Satın alma'
153+
},
118154
status: {
119155
stopped: 'Durdu',
120156
starting: 'Başlatılıyor',

src/renderer/src/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type ViewId =
3131
| 'create'
3232
| 'web'
3333
| 'site'
34+
| 'audit'
3435

3536
export interface Toast {
3637
id: string

src/renderer/src/styles.css

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,62 @@ textarea.input:focus {
696696
font-family: var(--mono);
697697
font-size: 12px;
698698
}
699+
.audit-table {
700+
width: 100%;
701+
border-collapse: collapse;
702+
font-size: 12.5px;
703+
}
704+
.audit-table th {
705+
text-align: left;
706+
padding: 9px 12px;
707+
color: var(--text-dim);
708+
font-weight: 700;
709+
font-size: 10.5px;
710+
text-transform: uppercase;
711+
letter-spacing: 0.04em;
712+
border-bottom: 1px solid var(--border);
713+
position: sticky;
714+
top: 0;
715+
background: var(--bg-elevated);
716+
z-index: 1;
717+
}
718+
.audit-table td {
719+
padding: 8px 12px;
720+
border-bottom: 1px solid var(--border);
721+
vertical-align: middle;
722+
}
723+
.audit-table tr:last-child td {
724+
border-bottom: none;
725+
}
726+
.audit-table tr:hover td {
727+
background: color-mix(in srgb, var(--text-dim) 10%, transparent);
728+
}
729+
.audit-badge {
730+
display: inline-block;
731+
padding: 2px 9px;
732+
border-radius: 999px;
733+
font-size: 11px;
734+
font-weight: 700;
735+
border: 1px solid var(--border);
736+
white-space: nowrap;
737+
}
738+
.audit-badge.accent {
739+
color: var(--accent);
740+
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
741+
}
742+
.audit-badge.warn {
743+
color: #f59e0b;
744+
border-color: color-mix(in srgb, #f59e0b 45%, transparent);
745+
}
746+
.audit-badge.muted {
747+
color: var(--text-dim);
748+
}
749+
.audit-ok {
750+
color: #4ade80;
751+
}
752+
.audit-fail {
753+
color: #f87171;
754+
}
699755
.dim {
700756
color: var(--text-dim);
701757
}

0 commit comments

Comments
 (0)