Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,39 @@
RedLog release history. Each entry links to the tag; run `gh release view v0.6.x`
for full commit body + generated notes.

## v0.14.3 — 2026-08-19

**§9.5 chain-health tier split in CaptureHealthCard.** Closes the last
v0.14 spec deviation. The Dashboard's capture card grows a small footer
showing the chained · logged tier split plus the "last fed" age of the
newest logged-tier row, so operators can spot at a glance whether
mitmproxy / DNS / other logged-tier producers are actually feeding
data vs silently idle.

- New `getLatestLoggedTs()` helper in `db/events.ts` — single
`SELECT MAX(timestamp) FROM events_logged`, no row bodies pulled.
- New IPC `events:getLatestLoggedTs` + preload binding.
- CaptureHealthCard gains an optional `tierSplit` prop. Footer renders
only when the logged tier has ≥1 row — pre-v0.13 projects and
empty engagements keep the exact pre-v0.14.3 look. Uses the same 1s
`nowTick` as source-row ages, so freshness ticks smoothly. Age
colour scales green <60s → amber <5min → zinc otherwise (mirrors
source-row age colour ladder).
- +1 unit test in `test/db/logged-insert.test.ts` — asserts null on
empty, tracks newest write, ignores chained-tier writes.

Bug fixes that landed on main between v0.14.2 and v0.14.3 (all authored
elsewhere, surfaced by PR #15's CI run):
- `db/events.ts queryEvents` now sorts by `(timestamp DESC, rowid DESC)`
— same-millisecond inserts previously came back in indeterminate
order, flaking `test/pause-gate.test.ts` on faster CI runners.
- `test/api-server.test.ts` now calls `onApiProjectOpen()` after
configuring — since the "early API server start" refactor the server
boots before a project is open and returns 503 until the gate flips.
- `test/mcp-tools.test.ts` count bumped 18 → 19 (new
`redlog_session_register` tool from the session-registration-gate
work).

## v0.14.2 — 2026-08-19

**§9.4 StatusBar tier counter opens the auditor view.** The chained ·
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "redlog",
"version": "0.14.2",
"version": "0.14.3",
"description": "Red Team Operation Log",
"author": "guan4tou2",
"repository": {
Expand Down
9 changes: 9 additions & 0 deletions src/core/db/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,15 @@ export function getEventCount(opts?: { tier?: 'chained' | 'logged' | 'all' }): n
return chained + logged
}

// v0.14.3 §9.5: timestamp of the newest logged-tier row, or null if none.
// Powers the CaptureHealthCard "last fed …" freshness readout without pulling
// row bodies — a single SELECT MAX() against events_logged's timestamp index.
export function getLatestLoggedTs(): number | null {
const db = getDB()
const row = db.prepare('SELECT MAX(timestamp) as ts FROM events_logged').get() as { ts: number | null }
return row.ts ?? null
}

export function searchEvents(query: string, limit = 100): RedLogEvent[] {
const db = getDB()
const pattern = `%${query}%`
Expand Down
3 changes: 2 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { AlertRuntime, type IPStatusShape } from './services/alert-runtime'
import yaml from 'js-yaml'
import { loadConfig, saveConfig, loadScopeFile, RedLogConfig } from '../core/config'
import { initDB, closeDB, getProjectDir } from '../core/db/index'
import { insertEvent, queryEvents, queryEventById, getEventCount, searchEvents, queryScopeFilteredEvents, type RedLogEvent } from '../core/db/events'
import { insertEvent, queryEvents, queryEventById, getEventCount, getLatestLoggedTs, searchEvents, queryScopeFilteredEvents, type RedLogEvent } from '../core/db/events'
import {
createQuickMark, updateQuickMark, getQuickMark, listQuickMarks, deleteQuickMark
} from '../core/db/findings'
Expand Down Expand Up @@ -1091,6 +1091,7 @@ app.whenReady().then(() => {
// --- Events ---
ipcMain.handle('events:query', (_e, opts) => activeProject ? queryEvents(opts) : [])
ipcMain.handle('events:getCount', (_e, tier?: import('../core/db/events').EventTierFilter) => activeProject ? getEventCount(tier ? { tier } : undefined) : 0)
ipcMain.handle('events:getLatestLoggedTs', () => activeProject ? getLatestLoggedTs() : null)
ipcMain.handle('events:search', (_e, query: string, limit?: number) => activeProject ? searchEvents(query, limit) : [])
// Four-layer redaction, layer 3 — reveal action logs a chained event so
// the audit trail shows raw secret bytes were viewed, by whom, when.
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ contextBridge.exposeInMainWorld('redlog', {
// both to show the two-tier row count. Undefined = 'chained' (audit
// count) preserved for legacy callers.
getCount: (tier?: import('../core/db/events').EventTierFilter) => ipcRenderer.invoke('events:getCount', tier),
getLatestLoggedTs: () => ipcRenderer.invoke('events:getLatestLoggedTs') as Promise<number | null>,
search: (query: string, limit?: number) => ipcRenderer.invoke('events:search', query, limit),
onNew: (cb: (event: unknown) => void) => {
const handler = (_e: Electron.IpcRendererEvent, event: unknown) => cb(event)
Expand Down
42 changes: 41 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,10 +337,14 @@ function CaptureOnboarding({ readiness, sources, busy, onInstall, onEnable, onNa
)
}

export function CaptureHealthCard({ capture, onNavigate, onRefresh }: {
export function CaptureHealthCard({ capture, onNavigate, onRefresh, tierSplit }: {
capture: CaptureHealthInfo
onNavigate: (v: string) => void
onRefresh: () => void
// v0.14.3 §9.5: chained·logged split for the card footer. Optional so
// callers that don't care (tests, older Dashboard mounts) keep working;
// when omitted the tier line just doesn't render.
tierSplit?: { chained: number; logged: number; lastLoggedTs: number | null }
}): JSX.Element {
const { t } = useI18n()

Expand Down Expand Up @@ -554,6 +558,30 @@ export function CaptureHealthCard({ capture, onNavigate, onRefresh }: {
</p>
)}
</div>
{/* v0.14.3 §9.5: two-tier chain-health footer. Renders only when
* the logged tier has at least one row — mirrors the StatusBar
* behaviour so pre-v0.13 projects and empty engagements stay
* visually identical to before. Chained is the brighter number
* (audit chain); logged renders muted (supporting evidence).
* "Last fed" is the newest logged-row age — a slow tick is fine
* because it uses the same 1s nowTick as the source-row ages. */}
{tierSplit && tierSplit.logged > 0 && (
<div className="mt-2 pt-2 border-t border-zinc-800/70 flex items-center justify-between text-[10px] font-mono">
<div className="flex items-center gap-2">
<span className="text-zinc-500 uppercase tracking-[0.1em]">{t('capture.tierChain')}</span>
<span className="text-zinc-300 tabular-nums">{tierSplit.chained.toLocaleString()}</span>
<span className="text-zinc-700">·</span>
<span className="text-zinc-500 uppercase tracking-[0.1em]">{t('capture.tierLogged')}</span>
<span className="text-zinc-500 tabular-nums">{tierSplit.logged.toLocaleString()}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-zinc-600">{t('capture.tierLastFed')}</span>
<span className={`tabular-nums ${ageColor(tierSplit.lastLoggedTs, nowTick)}`}>
{fmtAge(tierSplit.lastLoggedTs, nowTick)}
</span>
</div>
</div>
)}
</div>
</section>
)
Expand Down Expand Up @@ -607,6 +635,11 @@ function DashboardView({ onNavigate }: { onNavigate: (v: string) => void }): JSX
const [lootCount, setLootCount] = useState(0)
const [chainLen, setChainLen] = useState(0)
const [scopeViolations, setScopeViolations] = useState(0)
// v0.14.3 §9.5: tier split for the CaptureHealthCard footer. Both
// start at 0 / null so the card doesn't flash a spurious "no logged
// rows" line while the initial fetch is in flight.
const [loggedCount, setLoggedCount] = useState(0)
const [latestLoggedTs, setLatestLoggedTs] = useState<number | null>(null)
const [config, setConfig] = useState<Record<string, Record<string, unknown>> | null>(null)
const [capture, setCapture] = useState<CaptureHealthInfo | null>(null)
const refreshCaptureRef = useRef<() => void>(() => {})
Expand Down Expand Up @@ -666,9 +699,15 @@ function DashboardView({ onNavigate }: { onNavigate: (v: string) => void }): JSX
// match on-disk. Refreshing chainLen here closes the drift.
const refreshCounts = (): void => {
window.redlog.events.getCount().then(setEventCount).catch(() => {})
window.redlog.events.getCount('logged').then(setLoggedCount).catch(() => {})
window.redlog.events.getLatestLoggedTs?.().then(setLatestLoggedTs).catch(() => {})
window.redlog.loot.getCount().then(setLootCount).catch(() => {})
window.redlog.chain.length().then(setChainLen).catch(() => {})
}
// Seed the tier split on first paint so the card doesn't wait for
// the first onNew tick to fill in.
window.redlog.events.getCount('logged').then(setLoggedCount).catch(() => {})
window.redlog.events.getLatestLoggedTs?.().then(setLatestLoggedTs).catch(() => {})
const unsub = window.redlog.events.onNew(() => { loadCapture(); loadAnchor(); refreshCounts() })
const anchorTimer = setInterval(loadAnchor, 60_000)
return () => { unsub(); clearInterval(anchorTimer) }
Expand Down Expand Up @@ -696,6 +735,7 @@ function DashboardView({ onNavigate }: { onNavigate: (v: string) => void }): JSX
capture={capture}
onNavigate={onNavigate}
onRefresh={() => refreshCaptureRef.current()}
tierSplit={{ chained: eventCount, logged: loggedCount, lastLoggedTs: latestLoggedTs }}
/>
)}

Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ interface RedLogAPI {
* count — every existing caller means this. 'logged' returns the
* supporting-evidence count. 'all' returns both summed. */
getCount: (tier?: import('../../core/db/events').EventTierFilter) => Promise<number>
/** v0.14.3 §9.5: timestamp of the newest logged-tier row, or null
* if none have been written. Drives the CaptureHealthCard "last
* fed" freshness readout without pulling row bodies. */
getLatestLoggedTs: () => Promise<number | null>
search: (query: string, limit?: number) => Promise<RedLogEvent[]>
onNew: (cb: (event: RedLogEvent) => void) => () => void
onNewBatch: (cb: (events: RedLogEvent[]) => void) => () => void
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
"capture.state.idle": "idle",
"capture.state.absent": "off",
"capture.ago": "ago",
"capture.tierChain": "Chain",
"capture.tierLogged": "Logged",
"capture.tierLastFed": "Last fed",
"dashboard.networkStatus": "Network Status",
"dashboard.sessionStats": "Session Stats",
"dashboard.events": "Events",
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/i18n/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
"capture.state.idle": "閒置",
"capture.state.absent": "關閉",
"capture.ago": "前",
"capture.tierChain": "證據鏈",
"capture.tierLogged": "紀錄",
"capture.tierLastFed": "最新",
"dashboard.networkStatus": "網路狀態",
"dashboard.sessionStats": "工作階段統計",
"dashboard.events": "事件",
Expand Down
20 changes: 20 additions & 0 deletions test/db/logged-insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,24 @@ describeDB('insertEvent — two-tier dispatch (v0.13.0)', () => {
expect(() => events.insertEvent('dns', { subtype: 'dns_query', query_name: 'x' }, {}))
.toThrow(/operatorId is required/)
})

// v0.14.3 §9.5: powers the CaptureHealthCard "last fed" freshness readout.
it('getLatestLoggedTs returns null on empty logged tier, tracks the newest write', () => {
expect(events.getLatestLoggedTs()).toBeNull()

const t0 = Date.now()
events.insertEvent('dns', { subtype: 'dns_query', query_name: 'a.test' }, { operatorId })
const t1 = events.getLatestLoggedTs()
expect(t1).not.toBeNull()
expect(t1!).toBeGreaterThanOrEqual(t0)

events.insertEvent('dns', { subtype: 'dns_query', query_name: 'b.test' }, { operatorId })
const t2 = events.getLatestLoggedTs()
expect(t2!).toBeGreaterThanOrEqual(t1!)

// Chained inserts must NOT affect the logged-tier freshness reading —
// that's the whole point of a per-tier "last fed" number.
events.insertEvent('shell', { subtype: 'command_start', command: 'noise' }, { operatorId })
expect(events.getLatestLoggedTs()).toBe(t2)
})
})
Loading