Merge feature/activity-log: persistent activity/audit log

Adds a persistent, queryable activity/audit log surfaced in the WebUI:

- storage: dedicated indexed activity_log table + repository (keyset pagination, prune, streaming export); migration 002

- recorder + actor ContextVar + retention engine + lifecycle wiring; activity_logged realtime event

- instrumentation across auth / device / entity-CRUD / capture / system (best-effort, no secrets logged)

- REST API: list/filter, CSV/JSON export, retention settings, clear (auth-gated)

- WebUI: Activity tab (smart filtering, live updates, localized descriptions, export), Dashboard widget, Settings retention panel; en/ru/zh

Reviewed via independent per-phase + final + security agents; full suite 2563 passed.

Includes 17dd2e0 (review-findings fixes) that the feature branch was based on.
This commit is contained in:
2026-06-10 15:31:12 +03:00
111 changed files with 23900 additions and 9454 deletions
+3 -2
View File
@@ -19,6 +19,7 @@ semantic = true
# Automatically run `vex update` before search if the index is stale
auto_update = true
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
# Embedder used for semantic indexing. IDs: minilm-l6-v2 (default, CPU-fast),
# jina-code (code-specialized, GPU-worthy), bge-base-en-v1.5, bge-large-en-v1.5.
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
embedder = "jina-code"
+13
View File
@@ -82,6 +82,19 @@ LedGrab speaks many protocols, so a single setup can drive everything from a DIY
- Real-time FPS, latency, and uptime charts
- Localized in English, Russian, and Chinese
### Activity Log
The **Activity** tab is a persistent, queryable audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions.
- Filter by category (auth, device, entity, capture, system), severity, actor, entity type, date range, or free text
- Live-append of new events as they happen
- Export as CSV or JSON (authentication required)
- Entity crosslinks navigate directly to the relevant card
- **Retention settings** (Settings → Activity Log): configure max age, max entry count, and toggle recording on/off
- **Clear log** (Settings → Activity Log, requires authentication) — audited: a system entry records who cleared the log and when
> **Note:** The Activity Log is distinct from the **debug Log Viewer** (Settings → General → Open Log Viewer). The Log Viewer is an ephemeral real-time tail of the server's Python log stream (WARNING/ERROR lines, resets on disconnect). The Activity Log is a structured, persistent SQLite-backed record of semantic application events.
### Home Assistant Integration
- HACS-compatible custom component (separate repository)
+4
View File
@@ -210,6 +210,10 @@ dependencies {
implementation("androidx.core:core-splashscreen:1.0.1")
// QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3")
// EncryptedSharedPreferences (Android Keystore-backed) for the per-install
// server API key (see ApiKeyManager). Falls back to plain SharedPreferences
// when the keystore is unavailable.
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for
// driving Adalight/AmbiLED controllers plugged into Android TV boxes.
implementation("com.github.mik3y:usb-serial-for-android:3.8.1")
+2 -1
View File
@@ -14,7 +14,8 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"
tools:targetApi="s" />
<!-- BLE hardware — required=false so non-BT boxes still install. -->
<uses-feature
@@ -1,7 +1,10 @@
package com.ledgrab.android
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import java.security.SecureRandom
/**
@@ -23,8 +26,23 @@ import java.security.SecureRandom
*/
class ApiKeyManager(context: Context) {
private val prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val appContext = context.applicationContext
// Prefer Android-Keystore-backed EncryptedSharedPreferences for the API
// key. If the keystore is unavailable (some OEM TV-box ROMs ship a broken
// or absent keystore, or a key got corrupted), creation throws — fall back
// to plain SharedPreferences so a keystore failure NEVER bricks the local
// API key (which would 401 every LAN client). [encrypted] records which
// path we took so we don't repeatedly attempt migration.
private val encrypted: Boolean
private val prefs: SharedPreferences
init {
val (store, isEncrypted) = buildPrefs(appContext)
prefs = store
encrypted = isEncrypted
if (isEncrypted) migrateLegacyKeyIfPresent()
}
// Once we've materialised a key in this process, cache it so
// subsequent reads don't hit prefs and don't risk re-checking
@@ -60,6 +78,20 @@ class ApiKeyManager(context: Context) {
cached = existing
return existing
}
// Before minting a fresh key, fall back to any key still in the
// legacy plain store (covers a failed/partial encrypted migration:
// commit() can return false WITHOUT throwing, so migration may have
// left the live key only in the legacy file). Rotating the
// per-install key would 401 every already-paired client, so we
// generate a brand-new key ONLY when no key exists anywhere.
recoverLegacyKey()?.let { recovered ->
// Best-effort persist into the encrypted store; cache regardless
// so we still return the recovered key if the write keeps failing.
runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() }
cached = recovered
Log.i(TAG, "Recovered existing API key from legacy storage")
return recovered
}
val generated = generateKey()
// commit() (synchronous disk write) on the FIRST write so
// the key is durable before MainActivity encodes it into a
@@ -74,6 +106,88 @@ class ApiKeyManager(context: Context) {
}
}
/**
* Build the backing store, preferring EncryptedSharedPreferences. Returns
* (store, isEncrypted). Any keystore failure falls back to the plain prefs
* file so the local API key is never lost on a broken-keystore device.
*/
private fun buildPrefs(context: Context): Pair<SharedPreferences, Boolean> {
return try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val store = EncryptedSharedPreferences.create(
context,
ENCRYPTED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
store to true
} catch (e: Exception) {
// Keystore unavailable/corrupt — degrade to plain prefs rather
// than crashing. Worst case the key is stored unencrypted on a
// single-user TV box, which is the pre-existing behaviour.
Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}")
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false
}
}
/**
* One-time migration: if a key exists in the legacy plain-text prefs file
* (from before encrypted storage), copy it into the encrypted store and
* remove the plain copy. Preserves the existing key so already-scanned QR
* clients keep working — generating a fresh key here would silently 401
* every LAN client (see the Data Migration Policy in CLAUDE.md).
*/
private fun migrateLegacyKeyIfPresent() {
// Don't migrate if the encrypted store already holds a key.
if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return
runCatching {
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val legacyKey = legacy.getString(KEY_API_KEY, null)
if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) {
// commit() returns false on write failure WITHOUT throwing, so the
// runCatching wrapper alone does NOT protect this path. Verify the
// encrypted store both committed AND reads back the identical value
// before touching the legacy copy — otherwise a silent write
// failure could delete the only surviving copy of the key and
// rotate it on next launch (401s every paired client — the exact
// silent-data-loss the Data Migration Policy forbids).
val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit()
if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) {
// Keep the value as a .migrated backup (don't hard-delete) per
// the migration policy; remove only the live legacy key so the
// plaintext copy no longer answers reads.
legacy.edit()
.putString(KEY_API_KEY_MIGRATED, legacyKey)
.remove(KEY_API_KEY)
.apply()
Log.i(TAG, "Migrated API key from plain to encrypted storage")
} else {
// Leave the legacy key untouched; getOrCreateKey() will recover
// it via recoverLegacyKey() rather than minting a fresh one.
Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating")
}
}
}.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") }
}
/**
* Recover a still-present key from the legacy plain store — either the live
* key (failed/never-run migration) or the `.migrated` backup. Returns null
* when on the plain-prefs path (no legacy/encrypted split) or no valid key
* survives. Guarantees [getOrCreateKey] never rotates an existing key as long
* as the legacy file survives.
*/
private fun recoverLegacyKey(): String? {
if (!encrypted) return null
val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val candidate = legacy.getString(KEY_API_KEY, null)
?: legacy.getString(KEY_API_KEY_MIGRATED, null)
return candidate?.takeIf { it.length >= MIN_KEY_LENGTH }
}
private fun generateKey(): String {
val bytes = ByteArray(KEY_BYTES)
SecureRandom().nextBytes(bytes)
@@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) {
companion object {
private const val TAG = "ApiKeyManager"
private const val PREFS_NAME = "ledgrab_auth"
private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc"
private const val KEY_API_KEY = "api_key"
// Backup of a migrated legacy key, kept in the plain store per the
// Data Migration Policy (never hard-delete user data on rename/move).
private const val KEY_API_KEY_MIGRATED = "api_key_migrated"
private const val KEY_BYTES = 32
private const val MIN_KEY_LENGTH = 32
@@ -103,12 +103,32 @@ object BleBridge {
}
try {
bleHandler.post { scanner.startScan(callback) }
// startScan runs on the BLE handler thread; a denied
// BLUETOOTH_SCAN throws SecurityException there, which would
// crash the whole process (an uncaught exception on a handler
// thread is fatal). Catch it inside the posted body and report.
bleHandler.post {
try {
scanner.startScan(callback)
} catch (e: SecurityException) {
Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e)
} catch (e: Exception) {
Log.w(TAG, "BLE startScan failed: ${e.message}")
}
}
Thread.sleep(timeoutMs)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
} finally {
try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {}
bleHandler.post {
try {
scanner.stopScan(callback)
} catch (e: SecurityException) {
Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e)
} catch (e: Exception) {
Log.w(TAG, "BLE stopScan failed: ${e.message}")
}
}
}
return seen.values.toList()
}
@@ -136,7 +156,18 @@ object BleBridge {
newState == BluetoothProfile.STATE_CONNECTED
&& status == BluetoothGatt.GATT_SUCCESS -> {
Log.d(TAG, "GATT connected to $address, discovering services")
gatt.discoverServices()
// Runs on the BLE handler thread; a denied
// BLUETOOTH_CONNECT throws SecurityException here, which
// would crash the process. Catch and fail the connect.
try {
gatt.discoverServices()
} catch (e: SecurityException) {
Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e)
readyDeferred.complete(false)
} catch (e: Exception) {
Log.w(TAG, "discoverServices failed: ${e.message}")
readyDeferred.complete(false)
}
}
newState == BluetoothProfile.STATE_DISCONNECTED -> {
Log.w(TAG, "GATT disconnected from $address (status=$status)")
@@ -105,12 +105,48 @@ class CaptureService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false
// CRITICAL: startForeground must be called IMMEDIATELY — before
// any other work, especially before getMediaProjection(). The
// service type must match the work; pass it explicitly via
// ServiceCompat so we stay compatible back to API 24.
// CRITICAL (Android 14+): for the MediaProjection path, validate the
// projection token BEFORE promoting to a foreground service with the
// mediaProjection FGS type. On service recreation (system redelivery
// or a stale relaunch) the consent token is gone — promoting first and
// then discovering the dead token causes a spurious foreground-service
// start + immediate stop, which on strict OEMs flickers the
// notification or trips a stopSelf loop. Bail out cleanly here, before
// startForeground, when the MediaProjection consent data is missing.
val localIp = NetworkUtils.getLocalIpAddress(this) ?: ""
val url = "http://$localIp:$SERVER_PORT"
val mediaProjectionResultData: Intent? =
if (!useRoot) extractProjectionResultData(intent) else null
if (!useRoot && (intent == null || mediaProjectionResultData == null)) {
// MediaProjection mode can't recover from a redelivery —
// the consent token in the original intent is single-use.
//
// We were launched via startForegroundService(), so the OS REQUIRES
// a startForeground() within ~5s even on this immediate-stop path,
// or it raises the fatal ForegroundServiceDidNotStartInTimeException.
// Promote with a benign SPECIAL_USE type (NOT mediaProjection — we
// have no valid consent token, and requesting that type without an
// active projection is exactly what we're avoiding) just long enough
// to satisfy the contract, then stop.
Log.w(TAG, "MediaProjection start without a valid consent token — stopping")
runCatching {
val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
}
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType)
}.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") }
stopSelf()
return START_NOT_STICKY
}
// startForeground must be called IMMEDIATELY after the token check —
// before any heavier work like getMediaProjection(). The service type
// must match the work; pass it explicitly via ServiceCompat so we stay
// compatible back to API 24. The MEDIA_PROJECTION type is only used
// here once resultData is confirmed non-null (checked above).
try {
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
var t = if (useRoot) {
@@ -152,20 +188,13 @@ class CaptureService : Service() {
// otherwise `isRunning=true` sticks forever when startForeground throws.
isRunning = true
if (intent == null && !useRoot) {
// MediaProjection mode can't recover from a redelivery —
// the consent token in the original intent is single-use.
Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping")
isRunning = false
stopSelf()
return START_NOT_STICKY
}
try {
if (useRoot) {
startRootCapture(url)
} else {
startMediaProjectionCapture(intent!!, url)
// mediaProjectionResultData is guaranteed non-null here — the
// token was validated before startForeground above.
startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
@@ -294,20 +323,24 @@ class CaptureService : Service() {
}
}
private fun startMediaProjectionCapture(intent: Intent, url: String) {
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
/**
* Extract the single-use MediaProjection consent token from the start
* intent, or null if the intent is missing/redelivered without it.
* Called BEFORE startForeground so the mediaProjection FGS type is only
* ever requested when a valid token is present (see onStartCommand).
*/
private fun extractProjectionResultData(intent: Intent?): Intent? {
if (intent == null) return null
@Suppress("DEPRECATION")
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent.getParcelableExtra(EXTRA_RESULT_DATA)
}
}
if (resultData == null) {
Log.e(TAG, "No MediaProjection result data")
stopSelf()
return
}
private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) {
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
@@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification
import android.util.Log
import com.chaquo.python.Python
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
/**
* Captures posted OS notifications and forwards the posting app's display
@@ -25,7 +27,20 @@ class LedGrabNotificationListener : NotificationListenerService() {
// Serial executor: the Python receiver does a (non-concurrency-safe) history
// disk write and may play a sound, so pushes must not overlap. Off the main
// looper to keep the system service responsive.
private val pushExecutor = Executors.newSingleThreadExecutor()
//
// Tied to the listener-connection lifecycle (onListenerConnected /
// onListenerDisconnected), NOT onDestroy: this is a system-rebindable
// service, so it can be connected/disconnected multiple times across a
// single onCreate..onDestroy span. Managing the executor here — combined
// with the runCatching guard at the submit site — keeps a notification
// that races teardown from triggering RejectedExecutionException on a
// shut-down executor. @Volatile so the connect/disconnect callbacks (which
// may run on a different thread than onNotificationPosted) publish safely.
@Volatile private var pushExecutor: ExecutorService? = null
// Guards executor creation so the lazy submit-site fallback and
// onListenerConnected can't race two executors into existence.
private val executorLock = Any()
// packageName -> resolved human-readable label. Matches the app_name the
// Windows/Linux backends pass, so per-app colors/filters keep working.
@@ -51,17 +66,34 @@ class LedGrabNotificationListener : NotificationListenerService() {
val label = resolveAppLabel(notification.packageName)
pushExecutor.execute {
try {
Python.getInstance()
.getModule(PY_MODULE)
.callAttr("push_notification", label)
} catch (t: Throwable) {
// Never crash a system-bound service. Python.getInstance() throws
// IllegalStateException if Python.start() hasn't run (e.g. the
// service was bound at boot before the app process initialized).
// Log at debug — the label is potentially sensitive on a shared TV.
Log.d(TAG, "push_notification failed: ${t.message}")
// Obtain (creating if needed) the executor. onListenerConnected normally
// creates it, but that callback is not reliably invoked on every
// OEM/version (re)bind, and a notification can arrive before it fires —
// lazily creating here keeps a missing/late onListenerConnected from
// permanently disabling notification forwarding. A late submit onto an
// executor that onListenerDisconnected is shutting down throws
// RejectedExecutionException — guard with runCatching so a notification
// racing teardown can never crash this system-bound service.
val executor = ensureExecutor()
runCatching {
executor.execute {
try {
Python.getInstance()
.getModule(PY_MODULE)
.callAttr("push_notification", label)
} catch (t: Throwable) {
// Never crash a system-bound service. Python.getInstance() throws
// IllegalStateException if Python.start() hasn't run (e.g. the
// service was bound at boot before the app process initialized).
// Log at debug — the label is potentially sensitive on a shared TV.
Log.d(TAG, "push_notification failed: ${t.message}")
}
}
}.onFailure { e ->
if (e is RejectedExecutionException) {
Log.d(TAG, "push rejected — listener disconnecting")
} else {
throw e
}
}
}
@@ -69,24 +101,59 @@ class LedGrabNotificationListener : NotificationListenerService() {
/** Resolve (and cache) a package's human-readable label; fall back to the package name. */
private fun resolveAppLabel(pkg: String): String {
labelCache[pkg]?.let { return it }
// Only cache SUCCESSFUL resolutions. Caching the package-name fallback
// would permanently pin a wrong label if the PackageManager lookup
// failed transiently (e.g. the app was mid-install / still updating).
val resolved = runCatching {
val info = packageManager.getApplicationInfo(pkg, 0)
packageManager.getApplicationLabel(info).toString()
}.getOrDefault(pkg)
labelCache[pkg] = resolved
return resolved
}.getOrNull()
if (resolved != null) {
labelCache[pkg] = resolved
return resolved
}
return pkg
}
/**
* Return the push executor, creating it under [executorLock] if absent.
* Safe against a concurrent onListenerConnected/onNotificationPosted race
* (single executor) and against a missing onListenerConnected callback.
*/
private fun ensureExecutor(): ExecutorService {
pushExecutor?.let { return it }
synchronized(executorLock) {
return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it }
}
}
override fun onListenerConnected() {
Log.i(TAG, "Notification listener connected")
// Spin up the push executor on connect. The system can disconnect and
// later reconnect this service without destroying it, so own the
// executor here rather than in onCreate/onDestroy. onNotificationPosted
// also lazily creates it (via ensureExecutor) in case this callback is
// late or skipped on some ROMs.
ensureExecutor()
}
override fun onListenerDisconnected() {
Log.i(TAG, "Notification listener disconnected")
// Tear the executor down on disconnect; a fresh one is created on the
// next onListenerConnected. Null out first so any in-flight
// onNotificationPosted snapshots see null (skips submit) rather than
// racing a shutdown executor.
pushExecutor?.let { exec ->
pushExecutor = null
exec.shutdown()
}
}
override fun onDestroy() {
pushExecutor.shutdown()
// Defensive: onListenerDisconnected normally clears this first, but
// shut down here too in case onDestroy fires without a prior disconnect.
pushExecutor?.shutdown()
pushExecutor = null
super.onDestroy()
}
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
/**
@@ -56,6 +57,7 @@ class MainActivity : Activity() {
private const val REQUEST_POST_NOTIFICATIONS = 1002
private const val REQUEST_RECORD_AUDIO = 1003
private const val REQUEST_CAMERA = 1004
private const val REQUEST_BLUETOOTH = 1005
private const val QR_SIZE_PX = 560
private const val NOTIF_PREFS = "ledgrab_notif"
private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted"
@@ -189,7 +191,13 @@ class MainActivity : Activity() {
toggleButton.text = getString(R.string.btn_starting)
statusText.text = getString(R.string.status_checking_root)
uiScope.launch(Dispatchers.IO) {
val rooted = Root.requestGrant()
// runInterruptible so a config change (rotation) during the
// up-to-10s `su` probe cancels the coroutine AND interrupts the
// blocking probe thread — Root.requestGrant honours the interrupt,
// destroys the su child, and rethrows, so we don't leak the
// process + drain thread. Without this, IO-dispatcher cancellation
// would not interrupt the blocking waitFor().
val rooted = runInterruptible { Root.requestGrant() }
withContext(Dispatchers.Main) {
toggleButton.isEnabled = true
toggleButton.text = originalText
@@ -214,6 +222,7 @@ class MainActivity : Activity() {
ensureNotificationPermission()
ensureNotificationListenerAccess()
ensureCameraPermission()
ensureBluetoothPermissions()
ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this))
updateUI()
}
@@ -236,6 +245,7 @@ class MainActivity : Activity() {
ensureNotificationListenerAccess()
ensureAudioPermission()
ensureCameraPermission()
ensureBluetoothPermissions()
val intent = CaptureService.createIntent(this, resultCode, resultData)
ContextCompat.startForegroundService(this, intent)
updateUI()
@@ -536,6 +546,30 @@ class MainActivity : Activity() {
}
}
/**
* Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded
* server can discover and drive BLE LED controllers (SP110E / Triones /
* Zengge). On API < 31 these are install-time legacy permissions
* (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and
* need no runtime grant — so this is a no-op there. Fire-and-forget,
* like [ensureAudioPermission]: screen capture works without BLE, and
* BleBridge degrades gracefully (empty scan / failed connect) when the
* grant is denied, so we don't block on the result. If first granted
* here, BLE devices become reachable on the next scan/connect.
*/
private fun ensureBluetoothPermissions() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val needed = listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
).filter {
checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
}
if (needed.isEmpty()) return
@Suppress("DEPRECATION")
requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH)
}
/** Whether the user has granted notification-listener access to this app. */
private fun isNotificationAccessGranted(): Boolean =
NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)
@@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit
object Root {
private const val TAG = "Root"
// Slice length for the cancellation-aware su probe wait loop. Short
// enough that coroutine cancellation is honoured promptly, long enough
// to avoid busy-spinning while Magisk's grant dialog is up.
private const val POLL_SLICE_MS = 100L
private val SU_PATHS = listOf(
"/system/bin/su",
"/system/xbin/su",
@@ -49,17 +54,19 @@ object Root {
return false
}
var process: Process? = null
val granted = try {
// redirectErrorStream merges stderr into stdout so a single
// drain thread is enough — avoids the classic pipe-buffer
// deadlock where waitFor() blocks because stderr filled up.
val process = ProcessBuilder("su", "-c", "id")
val proc = ProcessBuilder("su", "-c", "id")
.redirectErrorStream(true)
.start()
process = proc
val outputBuilder = StringBuilder()
val drain = Thread({
try {
BufferedReader(InputStreamReader(process.inputStream)).use { r ->
BufferedReader(InputStreamReader(proc.inputStream)).use { r ->
val buf = CharArray(512)
while (true) {
val n = r.read(buf)
@@ -72,17 +79,35 @@ object Root {
}
}, "Root-su-drain").apply { isDaemon = true; start() }
val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
// Cancellation-aware wait: callers run this on a coroutine
// (MainActivity wraps it in runInterruptible), so a config change
// mid-probe cancels the coroutine and interrupts this thread.
// Poll waitFor() in short slices and honour interruption so we
// don't leak the `su` child + its drain thread for up to 10s.
// The catch(InterruptedException) below destroys the process; we
// re-arm the interrupt and rethrow so coroutine cancellation
// propagates cleanly.
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
var finished = false
while (System.nanoTime() < deadlineNanos) {
if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
finished = true
break
}
// Throws InterruptedException if the thread was interrupted
// by coroutine cancellation — handled below to tear down.
if (Thread.interrupted()) throw InterruptedException("su probe cancelled")
}
if (!finished) {
process.destroyForcibly()
proc.destroyForcibly()
drain.join(500)
Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s")
false
} else {
drain.join(500)
val output = synchronized(outputBuilder) { outputBuilder.toString() }
if (process.exitValue() != 0) {
Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'")
if (proc.exitValue() != 0) {
Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'")
false
} else {
val rooted = output.contains("uid=0")
@@ -90,8 +115,17 @@ object Root {
rooted
}
}
} catch (e: InterruptedException) {
// Coroutine cancelled mid-probe (e.g. config change). Kill the
// su child so it doesn't outlive the cancelled work, re-arm the
// interrupt flag, and rethrow so the coroutine cancels cleanly.
// Do NOT cache a result — the probe never completed.
runCatching { process?.destroyForcibly() }
Thread.currentThread().interrupt()
throw e
} catch (e: Exception) {
Log.w(TAG, "su invocation failed: ${e.message}")
runCatching { process?.destroyForcibly() }
false
}
@@ -89,14 +89,15 @@ class RootScreenrecord(
running = true
try {
imageReader = buildImageReader()
decoder = buildDecoder(imageReader!!)
process = spawnScreenrecord() ?: run {
val reader = buildImageReader().also { imageReader = it }
val codec = buildDecoder(reader).also { decoder = it }
val proc = spawnScreenrecord() ?: run {
stop()
return false
}
startInputPump(process!!.inputStream, decoder!!)
startOutputDrain(decoder!!)
process = proc
startInputPump(proc.inputStream, codec)
startOutputDrain(codec)
Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)")
return true
} catch (e: Exception) {
@@ -178,6 +179,14 @@ class RootScreenrecord(
buffer.get(frameBuffer, row * rowBytes, rowBytes)
}
}
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
// reader callback — no copy here). Safety depends on the Python
// receiver copying the bytes before this callback returns and
// overwrites the buffer for the next frame. It does:
// PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame
// (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py)
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
// pixels independently of this buffer. Do NOT remove that copy.
bridge.pushRootFrame(frameBuffer, width, height)
framesDeliveredCounter.incrementAndGet()
} catch (e: Exception) {
@@ -147,6 +147,14 @@ class ScreenCapture(
}
}
// CONTRACT: frameBuffer is REUSED across frames (single-threaded
// capture handler — no copy here). Safety depends on the Python
// receiver copying the bytes before this callback returns and
// overwrites the buffer for the next frame. It does:
// PythonBridge.pushFrame → mediaprojection_engine.push_frame
// (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py)
// does `rgba[:, :, :3].copy()`, so the queued frame owns its
// pixels independently of this buffer. Do NOT remove that copy.
bridge.pushFrame(frameBuffer, captureWidth, captureHeight)
// Advance the pacing accumulator. If we fell badly behind
+73
View File
@@ -0,0 +1,73 @@
# CONTEXT — Activity / Audit Log
Living scratchpad for the feature. The orchestrator updates this between phases. Tier-2
context (survives across phases; graduates to CLAUDE.md only if it's a lasting project truth).
## Config (from approval)
- **Mode:** Automated · **Execution:** Orchestrator · **Strategy:** Incremental
- **Base/merge target:** `master` · **Branch:** `feature/activity-log` · **Branch point:** `17dd2e0`
- Final merge ALWAYS requires user approval (even in Automated mode).
## Product decisions (locked)
- Placement: BOTH a top-level **Activity** tab AND a Dashboard **Recent Activity** widget,
plus a Settings retention panel.
- Scope: all four categories — entity CRUD, auth, device connect/disconnect, capture & system.
- Detail: **action metadata only** (no before/after diffs).
- Durability: **export on demand (CSV/JSON)** + existing whole-DB backup. **No** separate
backup subsystem.
- WebUI work uses the `frontend-design` skill (Phases 56).
## Key codebase facts (verified during planning)
- `fire_entity_event(entity_type, action, entity_id)` @ `api/dependencies.py:202` — central
hook, called by every entity route synchronously in-request; has `_deps` store access.
- `ProcessorManager.fire_event(dict)` / `subscribe_events()` @ `core/processing/processor_manager.py`
back `/api/v1/events/ws` (`api/routes/output_targets_control.py:206`). `fire_event` does
`put_nowait` (no `call_soon_threadsafe`) — fine for the existing consumer; recorder marshals.
- Frontend realtime: `core/events-ws.ts` re-dispatches `server:<type>`; `_ALLOWED_SERVER_EVENT_TYPES`
(line 39) is parity-checked by `tests/test_events_ws_parity.py`. Must add `activity_logged`.
- Actor: `request.state.auth_label` set in `api/auth.py` (129 authenticated, 83 anonymous).
- Storage: `Database` singleton (`storage/database.py`, single conn, RLock, WAL,
`synchronous=FULL`, `get_setting/set_setting`). `BaseSqliteStore` loads ALL rows to memory →
AVOID for the log. Migrations: `storage/data_migrations.py` (`ALL_MIGRATIONS`, idempotent).
- Backup/restore is **whole-DB** (`Database.backup_to`/`restore_from`, no STORE_MAP allowlist)
→ the new `activity_log` table is auto-covered. No `system.py` STORE_MAP edit needed.
- Background-engine pattern: `core/backup/auto_backup.py` (start/stop loop, `_prune`, settings).
- Thread-marshal precedent: `utils/log_broadcaster.py` (`ensure_loop` + `call_soon_threadsafe`).
- Device seams: `device_health_changed` (`core/processing/device_health.py`, on transition) +
`device_discovered`/`device_lost` (`core/devices/discovery_watcher.py`, **zeroconf thread**).
- **No** API-key create/rotate/revoke routes exist (only `GET /system/api-keys`) → those auth
events are DESCOPED.
- Existing **Log Viewer** (`utils/log_broadcaster.py`) = ephemeral debug-log tail; the audit
log is a different, persistent, structured feature. Differentiate; do not duplicate.
## Frozen contracts (fill as phases complete)
- ActivityLogEntry fields / dict shape: **frozen** — see phase-1-storage.md Handoff section. 11 fields: `id`, `ts`, `category`, `action`, `severity`, `actor`, `message`, `entity_type`, `entity_id`, `entity_name`, `metadata`. `seq` is DB-only (not on dataclass).
- ActivityLogFilters shape: **frozen** — 8 optional fields: `categories`, `severities`, `actor`, `entity_type`, `entity_id`, `since`, `until`, `message_like`. See phase-1-storage.md Handoff.
- recorder.record(...) signature + actor ContextVar import path: **frozen** — see phase-2-recorder-retention.md Handoff section. Signature: `record(category, action, *, severity="info", actor=None, entity_type=None, entity_id=None, entity_name=None, message, metadata=None, _bypass_enabled=False)`. ContextVar: `from ledgrab.core.activity_log.context import current_actor`. Module accessor: `from ledgrab.core.activity_log.recorder import get_module_recorder`. Event payload: `{"type": "activity_logged", "entry": {11-field dict with ts as ISO string, metadata as dict}}`. DI getters: `get_activity_recorder()`, `get_activity_log_repo()`, `get_activity_log_retention_engine()`.
- API endpoints + query params + page envelope + settings bounds: **frozen** — see phase-4-api.md Handoff section. Endpoints: `GET /api/v1/activity-log` (list, AuthRequired), `GET /api/v1/activity-log/export` (stream CSV/JSON, require_authenticated), `GET|PUT /api/v1/activity-log/settings` (AuthRequired), `DELETE /api/v1/activity-log` (clear, require_authenticated). Page envelope: `entries`, `next_before_seq`, `has_more`, `total`. Settings fields: `enabled` (bool), `max_days` (03650), `max_entries` (010_000_000). Export: `?format=csv|json`.
## Failed approaches / rejected designs
- Buffered async-writer subsystem (asyncio.Queue): REJECTED — unsafe from the zeroconf thread
and adds a shutdown-flush ordering hazard. Using direct synchronous-on-loop writes with
`call_soon_threadsafe` marshaling instead (simpler + correct).
- Using `BaseSqliteStore` for the log: REJECTED — loads all rows into memory.
- Separate activity-log backup subsystem: REJECTED — whole-DB backup already covers the table;
export-on-demand is the portability story.
## Deferred / open
- Setup-scaffold first-run noise suppression (batch/suppress flag) — deferred (nice-to-have).
## Phase progress notes
Phase 1 landed (2026-06-09): `activity_log.py` (dataclass + enums + filters + codec), `AddActivityLogTableMigration` (`002_add_activity_log`) appended to `ALL_MIGRATIONS`, `ActivityLogRepository` (record/query/count/prune/clear/iter_export), 41 new tests — all green. Full suite 2226 passed, 0 failed. Schema and method signatures frozen in phase-1-storage.md Handoff. Gotcha: `Database.execute` takes a positional tuple — use `?` placeholders (not `:name`), otherwise Python 3.14 will raise `ProgrammingError`.
Phase 2 landed (2026-06-09): `core/activity_log/` package (`context.py`, `recorder.py`, `retention.py`, `__init__.py`); actor ContextVar set in `api/auth.py` (both branches); `ActivityLogRetentionEngine` mirroring AutoBackupEngine; full wiring in `main.py` (repo at module level, recorder+engine in lifespan, `server.shutting_down` first shutdown action, engine stop before db.close); DI getters in `api/dependencies.py`; `activity_logged` added to `_ALLOWED_SERVER_EVENT_TYPES` in `events-ws.ts`; `set_module_recorder` exposes recorder to non-DI sites; 24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean.
Phase 4 landed (2026-06-09): schemas (`api/schemas/activity_log.py`), routes (`api/routes/activity_log.py`: list/export/settings/clear), router registration in `api/__init__.py`, `get_seq_for_id` helper on `ActivityLogRepository`. 49 new tests — all green. Full suite 2486 passed, 2 skipped, 0 failed. Ruff clean. Pagination bug found and fixed (limit+1 probe must drop oldest row when has_more, not tail).
Phase 3 landed (2026-06-09): instrumented all four categories — entity CRUD via `fire_entity_event` choke-point (`dependencies.py`), auth failures + WS session in `auth.py`, device online/offline in `device_health.py`, device discovered/lost in `discovery_watcher.py`, ADB connect/disconnect in `system_settings.py`, capture start/stop (individual + bulk) in `output_targets_control.py`, scene/playlist/automation activate in their respective route/engine files, backup/restore/delete + restart/shutdown/update/calibration/settings in `backup.py`/`update.py`/`calibration.py`; all 11 entity delete handlers pass `entity_name` to `fire_entity_event`; 22 new tests (security: token never in any field, explicitly asserted) — all green. Full suite 2369 passed, 2 skipped, 0 failed. Ruff clean. Complete (category, action) inventory in phase-3-instrumentation.md Handoff section.
Phase 5 landed (2026-06-09): Activity tab frontend — `features/activity-log.ts` (loadActivityLog, filter toolbar with category/severity chips + presets + debounced search + date range + actor/entity-type text fields, keyset-paginated list, expandable detail drawer, live-prepend via `server:activity_logged`, authed CSV/JSON blob export); `core/ui.ts` additions (`formatTimestamp`, `formatRelativeTime`); `core/icon-paths.ts` + `core/icons.ts` additions (scrollText/audit, circleAlert, info, filter, xCircle); `core/tab-registry.ts` registered; `templates/index.html` tab button + panel; `app.ts` + `global.d.ts` wired; `static/css/activity-log.css` (precision-ledger design, category color rail, live-dot pulse, detail drawer, responsive breakpoints); all three locales updated (activity_log.* + time.* relative-time keys + tour.activity_log); `features/tutorials.ts` getting-started tour extended. `tsc --noEmit` clean, `npm run build` passes. Reusable helpers for Phase 6 documented in phase-5-frontend-tab.md Handoff section.
Phase 6 landed (2026-06-09): Dashboard "Recent Activity" widget (live SSE, View-all link, `.dal-*` CSS, loading/empty states) + Settings "Activity Log" panel (enabled toggle, max_days/max_entries, Save toast, authed CSV/JSON export, confirmed Clear, audit-vs-debug cross-links) + 32 i18n keys per locale + README "Activity Log" section. `tsc --noEmit` clean, `npm run build` passes. All six phases complete — feature ready for final review.
+141
View File
@@ -0,0 +1,141 @@
# Feature: Activity / Audit Log
**Branch:** `feature/activity-log`
**Base branch:** `master` (merge target)
**Branch point:** `17dd2e02bab4d00a93479eb6af1a8c6ddc0c7224` (use for clean review diffs)
**Created:** 2026-06-09
**Status:** 🟢 All phases complete — awaiting final review + merge
**Strategy:** Incremental
**Mode:** Automated
**Execution:** Orchestrator
**Remote:** origin → https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git
## Summary
A persistent, queryable audit log of meaningful LedGrab actions, surfaced in the WebUI.
Captures four categories — entity CRUD, authentication, device connect/disconnect, and
capture & system events — as **action-metadata-only** records (who/what/when + entity
type/name/id + a human-readable message + small structured metadata; **no before/after
diffs**). Surfaced as a dedicated top-level **Activity** tab with smart filtering + live
updates, a compact **Recent Activity** widget on the Dashboard, and a **Settings** panel
for retention. Durability rides on the existing whole-DB `ledgrab.db` backup; portability
is an on-demand CSV/JSON **export** (no separate backup subsystem).
## Design pillars (the load-bearing decisions)
1. **Dedicated `activity_log` table + repository — NOT `BaseSqliteStore`.** That base loads
every row into an in-memory cache and uses a generic `id/name/data` blob — wrong for an
append-heavy, unbounded log. We use a purpose-built indexed table with query-on-demand
keyset pagination.
2. **Central choke-point instrumentation.** `fire_entity_event()` (`api/dependencies.py:202`)
is already called by every entity route on create/update/delete and has `_deps` access to
resolve names. The recorder hooks there for all entity CRUD. Non-entity events get explicit
`recorder.record(...)` calls.
3. **Actor via `ContextVar`.** Set inside `verify_api_key` (next to `request.state.auth_label`),
default `"system"`, reset per-request. The recorder reads it without threading actor
through every call.
4. **Direct synchronous write on the event-loop thread** (no separate buffered-writer
subsystem — simpler, and the request already did a `synchronous=FULL` entity write).
Cross-thread callers (zeroconf discovery thread) marshal via `loop.call_soon_threadsafe`,
mirroring `utils/log_broadcaster.py`. The "server shutting down" event is recorded as the
FIRST action in the lifespan shutdown block, before any teardown.
5. **Reuse the existing realtime bus.** A new `activity_logged` event over `/api/v1/events/ws`
(one `events-ws.ts` allowlist entry + `test_events_ws_parity.py` update). No new socket.
6. **Never log secrets.** API-key *tokens* are never stored — only labels/ids.
7. **Differentiate from the existing Log Viewer.** `utils/log_broadcaster.py` is an ephemeral
500-line debug-log tail. The audit log is persistent, structured, semantic. Cross-link in
the Settings panel; never duplicate.
## Build & Test Commands
- **Build (frontend):** `cd server && npm run build`
- **Type-check (TS):** `cd server && npx tsc --noEmit`
- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q`
- **Lint (Python):** `cd server && ruff check src/ tests/ --fix`
- **Events parity test (load-bearing for P2):** included in pytest (`tests/test_events_ws_parity.py`)
> Scope checks to the files actually edited: backend phases run ruff + pytest; frontend-only
> phases run `tsc --noEmit` + `npm run build` (no pytest/ruff). Phases touching both run both.
## Phases
- [x] Phase 1: Storage — model, migration, repository [domain: data] → [subplan](./phase-1-storage.md)
- [x] Phase 2: Recorder, actor context, retention, lifecycle [domain: backend] → [subplan](./phase-2-recorder-retention.md)
- [x] Phase 3: Event instrumentation (4 categories) [domain: backend] → [subplan](./phase-3-instrumentation.md)
- [x] Phase 4: REST API — query/filter/export/settings/clear [domain: backend] → [subplan](./phase-4-api.md)
- [x] Phase 5: Frontend — Activity tab + smart filtering + live updates [domain: frontend] → [subplan](./phase-5-frontend-tab.md)
- [x] Phase 6: Dashboard widget + Settings panel + docs [domain: frontend] → [subplan](./phase-6-dashboard-settings.md)
## Parallelizable Phase Groups (Orchestrator mode only)
- Phases 3 and 4 are **parallelizable only after the schema is frozen at end of Phase 2**.
Both depend on the P2 recorder/schema; P4 registers the router in `api/__init__.py`, P3
edits `dependencies.py`/`auth.py` (different files, shared schema contract). To keep the
Automated run simple and low-risk, they run **sequentially** (3 → 4) unless time pressure
warrants worktree-isolated parallelism. Phases 5 → 6 are sequential (6 reuses P5 formatters
and the feature module).
## Phase Progress Log
| Phase | Domain | Status | Review | Build | Committed |
|-------|--------|--------|--------|-------|-----------|
| Phase 1: Storage | data | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 2: Recorder/Retention | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 3: Instrumentation | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 4: REST API | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ |
| Phase 5: Frontend tab | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ |
| Phase 6: Dashboard/Settings | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ |
## Outstanding Warnings
| Phase | Warning | Severity | Status (open / resolved / accepted) |
|-------|---------|----------|-------------------------------------|
| 3 | Log injection via unauth mDNS device name/url into audit message | 🟠 High (security) | resolved — `sanitize_display` helper applied |
| 3 | Origin sanitizer missed spaces/NUL/ANSI | 🟠 High (security) | resolved — `sanitize_display` over netloc |
| 3 | Unauth auth-failure audit-write flood (no write-rate bound) | 🟠 High (security) | resolved — per-IP audit-record throttle (10s, capped) |
| 3 | Malformed-IPv6 Origin → urlparse ValueError into WS handler | 🟡 Warning | resolved — try/except guard |
| 3 | Throttle module-global state caused flaky test contamination | 🟡 Warning | resolved — autouse conftest reset fixture |
| 4 | Export held global DB write-lock across the stream (slow-client DoS) | 🟠 High (security) | resolved — chunked keyset export releases lock per batch |
| 4 | PUT /settings only AuthRequired → anon could disable auditing/prune trail | 🟠 High (security) | resolved — `require_authenticated` on settings PUT |
| 4 | CSV formula-injection missed leading TAB/CR | 🟡 Medium (security) | resolved — added `\t`/`\r` to guard |
| 4 | `total` count full-scans on every list request | 🔵 Low (perf) | accepted — bounded by retention; read-only; optional opt-in deferred |
| 5 | Inverted list ordering broke pagination + live-append | 🔴 Blocker | resolved — pages reversed to newest-first; re-review PASS |
| 5 | Attribute-context XSS (entity_name title + JSON.stringify onclick) | 🟡 Warning (security) | resolved — `_escapeAttr` + data-attr event delegation |
| 5 | Filter toolbar value= attrs not quote-escaped (new code) | 🟡 Warning (security) | resolved — `_escapeAttr` on q/actor/entity_type/since/until |
| 5 | Manual browser smoke test (tab loads, filters, live, export) | 🔵 Note | open — recommend at final review (server restart needed) |
| 6 | clearActivityLog() 401 path unreachable → silent failure | 🟡 Warning | resolved — `handle401:false` surfaces auth-required toast |
| 6 | Recent Activity widget dropped first live event when empty | 🔵 Note | resolved — empty→list transition on first live event |
| 6 | Widget outside dashboard layout-toggle/ordering system | 🔵 Note | accepted — deliberate (always-visible), collapse still works |
| Final | Entity-crosslink map keys mismatched backend entity_type (device/color_strip_source/audio_source) | 🟡 Warning | resolved — `_ENTITY_NAV` keys corrected + scene_playlist added |
| Final | Owner-authored names interpolated raw at some record sites | 🔵 Note (defense-in-depth) | resolved — `sanitize_display` applied uniformly |
| Manual test | Entry descriptions rendered server English (not localized) | 🟡 Warning (acceptance criterion) | resolved — client-side `localizeMessage` from structured fields + `activity_log.msg.*`/`entity_type.*` templates ×3 |
| Manual test | Redundant "Activity" header banner at top of tab | 🔵 Note | resolved — header block removed |
| Manual test | Recent Activity widget missing from Customize Dashboard | 🟡 Warning | resolved — registered as a first-class dashboard section (show/hide/reorder; pre-existing layouts preserved) |
| Manual test | Activity widget live event rebuilt the whole dashboard | 🟡 Warning (perf) | resolved — surgical list update; single listener with teardown |
| Manual test | Relative-time labels static (never tick) | 🟡 Warning | resolved — shared `ensureRelativeTimeTicker` (single 30s interval, visibility-aware) |
| Manual test | Dashboard fully rebuilt/jumped on entity edits (pre-existing `forceFullRender` wholesale innerHTML) | 🟡 Warning (UX) | resolved — section-level reconciliation (`_reconcileDynamicSections`): only changed sections replaced; widget live DOM + perf strip preserved |
| Manual test | Activity list columns slightly misaligned | 🔵 Note | resolved — CSS grid with fixed badge/actor columns |
| Manual test | Reconciler left orphan "no targets" node on empty→populated | 🟡 Warning (regression, caught in review) | resolved — sweep non-section top-level children |
## Final Review
- [ ] Comprehensive code review
- [ ] Security review (auth/PII-in-logs/secrets/log-injection — triggered)
- [ ] All Outstanding Warnings resolved or consciously accepted
- [ ] Full build passes (`npm run build` + `tsc --noEmit`)
- [ ] Full test suite passes (`pytest`)
- [ ] Merged to `master`
## Amendment Log
_(Filled in if the plan is amended mid-implementation.)_
- 2026-06-09: Plan reviewer (pre-implementation) → ⚠️ with 3 Critical Gaps, all resolved
before Phase 1: (G1) descoped non-existent API-key mutation events; (G2) dropped the
buffered-writer subsystem for a direct synchronous-on-loop write with `call_soon_threadsafe`
marshaling for thread-origin events; (G3) record "server shutting down" first in the shutdown
block (no buffer to flush). Concerns folded in: device events via `device_health_changed` +
`device_discovered/_lost`; actor ContextVar in `verify_api_key`; name-on-delete passed
explicitly; settings-audit scoped + self-key excluded; parity allowlist before emit site.
Adopted suggestions: rowid keyset tiebreaker; log the disable action. Deferred: setup-scaffold
noise suppression.
+177
View File
@@ -0,0 +1,177 @@
# Phase 1: Storage — model, migration, repository
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** data
## Objective
Create the persistent foundation for the audit log: an `ActivityLogEntry` dataclass, an
additive idempotent SQLite migration that creates a dedicated indexed `activity_log` table,
and a purpose-built `ActivityLogRepository` (NOT `BaseSqliteStore`) supporting append,
keyset-paginated filtered query, count, time/count-based prune, and streaming export.
## Tasks
- [x] Create `server/src/ledgrab/storage/activity_log.py`:
- `ActivityCategory` and `ActivitySeverity` string enums (or `Literal` unions used as
constants). Categories: `auth`, `device`, `entity`, `capture`, `system`. Severities:
`info`, `warning`, `error`.
- `@dataclass ActivityLogEntry` with fields: `id: str` (e.g. `al_<uuid8>`), `ts: datetime`
(UTC, server-assigned), `category: str`, `action: str`, `severity: str`, `actor: str`,
`entity_type: str | None`, `entity_id: str | None`, `entity_name: str | None`,
`message: str`, `metadata: dict` (small JSON; default empty). Provide `to_row()` /
`from_row()` (column tuple/dict ↔ dataclass; `metadata` JSON-encoded; `ts` isoformat).
- [x] Add migration to `server/src/ledgrab/storage/data_migrations.py`:
- New `DataMigration` subclass `AddActivityLogTableMigration` with unique `name`
(next sequential id, e.g. `"NNN_add_activity_log"` — match existing naming) and
`apply(conn)` creating `activity_log` with an INTEGER PRIMARY KEY AUTOINCREMENT `seq`
(monotonic keyset tiebreaker) plus columns: `id TEXT UNIQUE NOT NULL`, `ts TEXT NOT NULL`,
`category TEXT NOT NULL`, `action TEXT NOT NULL`, `severity TEXT NOT NULL`,
`actor TEXT NOT NULL`, `entity_type TEXT`, `entity_id TEXT`, `entity_name TEXT`,
`message TEXT NOT NULL`, `metadata TEXT NOT NULL DEFAULT '{}'`.
- Indexes: `(ts DESC, seq DESC)` (primary keyset/sort), `category`, `severity`, `actor`,
`(entity_type, entity_id)`. Use `CREATE TABLE/INDEX IF NOT EXISTS` for idempotency.
- Append the instance to `ALL_MIGRATIONS` (never reorder existing entries).
- [x] Create `server/src/ledgrab/storage/activity_log_repository.py`:
- `class ActivityLogRepository` taking `db: Database` (NOT subclassing `BaseSqliteStore`).
- `record(entry: ActivityLogEntry) -> None`: single parameterized INSERT via
`db.execute(...)` (auto-commit). The `seq` is DB-assigned. **Caller guarantees this runs
on the event-loop thread** (see Phase 2 — cross-thread marshaling lives in the recorder).
- `query(filters: ActivityLogFilters, *, before_seq: int | None, limit: int) -> list[ActivityLogEntry]`:
keyset pagination `WHERE seq < ? ORDER BY seq DESC LIMIT ?` plus optional filters —
`category IN (...)`, `severity IN (...)`, `actor = ?`, `entity_type = ?`, `entity_id = ?`,
`ts >= ?` / `ts <= ?`, `message LIKE ?` (free-text, `%q%`, escaped). All parameterized.
- `count(filters) -> int`.
- `prune(*, before_ts: datetime | None, max_entries: int | None) -> int`: delete rows older
than `before_ts`, and/or trim to the newest `max_entries` by `seq`. Returns rows deleted.
- `clear() -> int`: delete all rows (used by the API clear endpoint; the clear action is
itself audited by the recorder, not here). Returns rows deleted.
- `iter_export(filters) -> Iterator[ActivityLogEntry]`: cursor-based streaming for export
(does not load all rows into memory).
- Define a small `ActivityLogFilters` dataclass (all-optional fields) in the repository or
`activity_log.py` and reuse it across query/count/prune/export.
- [x] Unit tests in `server/tests/storage/test_activity_log_repository.py`:
- insert + read back round-trip (incl. metadata JSON, UTC ts);
- filter by each dimension (category/severity/actor/entity/date/free-text);
- keyset pagination stability across two pages with same-`ts` rows (seq tiebreaker);
- prune by age and by max_entries;
- clear; count; export iterator yields all matching rows;
- migration idempotency (constructing the repo twice / running migrations twice is safe).
## Files to Modify/Create
- `server/src/ledgrab/storage/activity_log.py` — new: dataclass + enums + filters + row codec
- `server/src/ledgrab/storage/data_migrations.py` — modify: add migration + append to `ALL_MIGRATIONS`
- `server/src/ledgrab/storage/activity_log_repository.py` — new: repository
- `server/tests/storage/test_activity_log_repository.py` — new: unit tests
## Acceptance Criteria
- `activity_log` table + indexes created idempotently on startup (running migrations twice is a no-op).
- Query is keyset-paginated and index-backed; a 10k-row table never loads fully into memory.
- Pagination is stable when many rows share the same millisecond `ts` (uses `seq` tiebreaker).
- `prune` removes by age AND by max-entry cap; `clear` empties the table; `export` streams.
- All filters use parameterized SQL (no string interpolation of user input).
- New unit tests pass; `ruff check` clean; existing tests still green.
## Notes
- Reference patterns: `storage/database.py` (`execute`, `transaction`, `get_setting`),
`storage/data_migrations.py` (`DataMigration`, `MigrationRunner`, `ALL_MIGRATIONS`),
`storage/sync_clock.py` (dataclass `to_dict`/`from_dict` style).
- 🔒 **Migration-safety addendum (data domain):** this migration is purely additive (new
table) — no rename, no field/key/file move, no data movement → no data-loss risk. Still
idempotent (`IF NOT EXISTS`). Rollback = drop the table; no user data is transformed.
- Do NOT wire the repository into `main.py` or `dependencies.py` here — that is Phase 2.
- `Database`'s connection is created with the existing threading model; the repository must
not assume it can be called from arbitrary threads. Thread marshaling is Phase 2's job.
## Review Checklist
- [x] All tasks completed
- [x] Code follows project conventions (dataclass codec style, migration naming)
- [x] No unintended side effects (no startup wiring yet)
- [x] Build passes (ruff + pytest)
- [x] Tests pass (new + existing)
## Handoff to Next Phase
### ActivityLogEntry — final field list and dict shape
```python
@dataclass
class ActivityLogEntry:
id: str # "al_<uuid8>" — caller-assigned
ts: datetime # UTC-aware; stored as ISO-8601 string in DB
category: str # ActivityCategory constant
action: str # verb-object label, e.g. "entity.created"
severity: str # ActivitySeverity constant
actor: str # API-key label or "system"
message: str # human-readable description
entity_type: str | None # e.g. "output_target"
entity_id: str | None # stable entity id
entity_name: str | None # name at time of event
metadata: dict # JSON-serialisable; default {}
```
`to_row()` returns a flat dict with 11 keys (same names); `metadata` is JSON string, `ts` is isoformat string. `seq` is NOT in `to_row()` — it is DB-assigned.
### ActivityLogFilters — shape (all fields optional, default None)
```python
@dataclass
class ActivityLogFilters:
categories: Sequence[str] | None # category IN (...)
severities: Sequence[str] | None # severity IN (...)
actor: str | None # exact match
entity_type: str | None # exact match
entity_id: str | None # exact match
since: datetime | None # ts >= since
until: datetime | None # ts <= until
message_like: str | None # LIKE %value% (escaped)
```
### Migration name used
`"002_add_activity_log"` — appended as position [1] in `ALL_MIGRATIONS`.
### ActivityLogRepository — exact method signatures
```python
class ActivityLogRepository:
def __init__(self, db: Database) -> None
def record(self, entry: ActivityLogEntry) -> None
def query(
self,
filters: ActivityLogFilters,
*,
before_seq: int | None = None,
limit: int = 50,
) -> list[ActivityLogEntry]
def count(self, filters: ActivityLogFilters | None = None) -> int
def prune(
self,
*,
before_ts: datetime | None = None,
max_entries: int | None = None,
) -> int
def clear(self) -> int
def iter_export(
self, filters: ActivityLogFilters | None = None
) -> Iterator[ActivityLogEntry]
```
### Key behavioural notes for Phase 2/3/4
- `record()` expects to be called from the event-loop thread (or with `Database` RLock already held). Phase 2 is responsible for thread marshaling via `loop.call_soon_threadsafe`.
- `query()` returns entries in **ascending chronological order within the page** (reversed internally from DESC fetch for display convenience). The smallest `seq` on a page is `page[0]`'s seq — pass that as `before_seq` for the next page.
- `count(None)` == `count(ActivityLogFilters())` — both count all rows.
- `prune(before_ts=X, max_entries=N)` applies both predicates independently (age prune first, then count cap).
- `iter_export` holds `db._lock` for the entire iteration. Phase 4 should stream the response and consume promptly.
- `ActivityLogCategory` and `ActivityLogSeverity` are plain classes with string class-attributes and an `ALL` tuple — NOT `enum.Enum`.
- Imports for Phase 2/3/4:
```python
from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters, ActivityCategory, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
```
@@ -0,0 +1,196 @@
# Phase 2: Recorder, actor context, retention, lifecycle
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Build the runtime layer over the Phase 1 repository: a thread-safe `ActivityRecorder` facade
that persists an entry AND pushes a live `activity_logged` event; an actor `ContextVar`
populated by the auth layer; a background `ActivityLogRetentionEngine` mirroring
`AutoBackupEngine`; and the `main.py`/`dependencies.py` wiring (init, DI getter, retention
start/stop, shutdown ordering). After this phase the audit log records nothing yet (no call
sites) — that is Phase 3 — but the full machinery is live and unit-tested.
## Tasks
- [x] Create `server/src/ledgrab/core/activity_log/__init__.py` and
`server/src/ledgrab/core/activity_log/recorder.py`:
- `ActivityRecorder(repo: ActivityLogRepository, processor_manager, *, loop=None)`.
- `record(category, action, *, severity="info", actor=None, entity_type=None,
entity_id=None, entity_name=None, message, metadata=None) -> None`:
- resolve `actor` from the actor `ContextVar` when not supplied, default `"system"`;
- build an `ActivityLogEntry` (id `al_<uuid8>`, `ts=datetime.now(timezone.utc)`);
- **thread-safe write:** if called on the event loop thread, write inline via
`repo.record(entry)` then fire the live event; if called from another thread (zeroconf
discovery), marshal the whole write+emit onto the loop via
`loop.call_soon_threadsafe(...)`. Capture the loop lazily (mirror
`utils/log_broadcaster.py:ensure_loop`/`call_soon_threadsafe`). Never raise into the
caller — audit recording is best-effort and must not break the audited action; log
failures at `warning`.
- live push: `processor_manager.fire_event({"type": "activity_logged", "entry": entry_as_dict})`.
- Provide a tiny helper to serialize an entry to the same dict shape the API returns
(reuse in Phase 4 / frontend).
- `enabled` flag honored: when retention settings say `enabled=false`, `record()` is a
no-op — EXCEPT the "audit log disabled" event itself, which must be recorded before the
flag takes effect (see retention engine).
- [x] Actor `ContextVar`:
- Add `current_actor: ContextVar[str]` (module-level, e.g. in `core/activity_log/context.py`
or `api/auth.py`). In `verify_api_key` (`api/auth.py`), set it next to the existing
`request.state.auth_label = ...` (both the authenticated label and the `"anonymous"`
branch). Default `"system"` when unset. Ensure no cross-request leakage (set on every
auth evaluation).
- [x] Create `server/src/ledgrab/core/activity_log/retention.py`:
- `ActivityLogRetentionEngine(repo, db, recorder)` mirroring `core/backup/auto_backup.py`:
`_load_settings()`/`_save_settings()` via `db.get_setting("activity_log")` /
`db.set_setting("activity_log", {...})`, `DEFAULT_SETTINGS = {"enabled": True,
"max_days": 90, "max_entries": 20000}`.
`async start()` → spawn `_retention_loop()` (`asyncio.create_task`); loop sleeps a sane
interval (e.g. hourly) then calls `repo.prune(before_ts=now-max_days, max_entries=...)`.
`async stop()` → cancel + await task. `get_settings()` / `async update_settings(...)`
that persist and apply (changing `enabled` is logged via the recorder BEFORE disabling).
- [x] Wiring:
- `main.py`: instantiate `activity_log_repo = ActivityLogRepository(db)` (module level near
other stores); in `lifespan` startup build `activity_recorder` + `activity_log_retention_engine`,
pass to `init_dependencies(...)`, and `await activity_log_retention_engine.start()`.
- In `lifespan` **shutdown**: record a `system` / `server_shutting_down` event via the
recorder as the **first** shutdown action (before engines/db close), then
`await _bounded("activity_log_retention.stop", activity_log_retention_engine.stop(), timeout=0.5)`.
- `api/dependencies.py`: add `activity_recorder` + `activity_log_repo` +
`activity_log_retention_engine` to `_deps`, parameters to `init_dependencies`, and
getters `get_activity_recorder()`, `get_activity_log_repo()`,
`get_activity_log_retention_engine()`.
- [x] Realtime allowlist (order matters — do allowlist FIRST so the parity test stays green):
- Add `'activity_logged'` to `_ALLOWED_SERVER_EVENT_TYPES` in
`server/src/ledgrab/static/js/core/events-ws.ts` (+ a one-line comment naming the source).
- Confirm `tests/test_events_ws_parity.py` passes with the new emit type.
- [x] Unit tests `server/tests/core/test_activity_recorder.py` +
`test_activity_log_retention.py`:
- recorder persists an entry AND calls `fire_event` with `type=="activity_logged"`;
- actor resolves from ContextVar; defaults to `"system"`; failure in repo doesn't raise;
- cross-thread `record()` (call from a `threading.Thread`) routes through the loop and persists;
- retention prunes per settings; settings round-trip via db; disabling logs the disable event.
## Files to Modify/Create
- `server/src/ledgrab/core/activity_log/__init__.py` — new
- `server/src/ledgrab/core/activity_log/recorder.py` — new
- `server/src/ledgrab/core/activity_log/context.py` — new (actor ContextVar) *(or place in auth.py)*
- `server/src/ledgrab/core/activity_log/retention.py` — new
- `server/src/ledgrab/api/auth.py` — modify: set actor ContextVar in `verify_api_key`
- `server/src/ledgrab/main.py` — modify: instantiate, wire lifespan start/shutdown
- `server/src/ledgrab/api/dependencies.py` — modify: `_deps`, `init_dependencies`, getters
- `server/src/ledgrab/static/js/core/events-ws.ts` — modify: allowlist `activity_logged`
- `server/tests/core/test_activity_recorder.py` — new
- `server/tests/core/test_activity_log_retention.py` — new
## Acceptance Criteria
- Recorder persists + fires `activity_logged`; never raises into callers; thread-safe from
non-loop threads.
- Actor ContextVar populated by auth; default `"system"`; no cross-request leakage.
- Retention engine starts/stops cleanly in lifespan; prunes by age + count; settings persist.
- `server_shutting_down` is recorded before teardown; no lost-on-graceful-shutdown entries.
- `test_events_ws_parity.py` green (allowlist updated). Existing tests still green; `ruff` clean.
## Notes
- Reference: `core/backup/auto_backup.py` (engine shape, settings persistence, `_bounded`
shutdown in `main.py`), `utils/log_broadcaster.py` (`ensure_loop`, `call_soon_threadsafe`
thread marshaling), `core/processing/processor_manager.py:247` (`fire_event`).
- **Do not add any instrumentation call sites in this phase** — only the machinery. Phase 3
adds the `record(...)` calls. (Intermediate commit emits nothing; that is fine and green.)
- Freeze the `ActivityLogEntry` dict shape here — Phase 4 (API response) and Phase 5
(frontend `entry`) consume it.
## Review Checklist
- [x] All tasks completed
- [x] Code follows project conventions (engine/DI patterns)
- [x] No unintended side effects (no call sites yet; lifespan order correct)
- [x] Build passes (ruff + pytest, incl. parity test)
- [x] Tests pass (new + existing)
## Handoff to Next Phase
### recorder.record(...) — final signature
```python
recorder.record(
category: str, # ActivityCategory constant
action: str, # verb-object label
*,
severity: str = "info", # ActivitySeverity constant
actor: str | None = None, # resolved from current_actor ContextVar when None
entity_type: str | None = None,
entity_id: str | None = None,
entity_name: str | None = None,
message: str,
metadata: dict | None = None,
_bypass_enabled: bool = False, # internal: used by retention engine only
) -> None
```
### Actor ContextVar import path
```python
from ledgrab.core.activity_log.context import current_actor
```
### Module accessor (for non-DI sites)
```python
from ledgrab.core.activity_log.recorder import get_module_recorder, set_module_recorder
recorder = get_module_recorder() # returns ActivityRecorder | None
```
### entry_to_dict helper (for API response serialisation)
```python
from ledgrab.core.activity_log.recorder import entry_to_dict
d = entry_to_dict(entry) # returns dict with 11 keys
```
### Frozen `activity_logged` event payload shape
```python
{
"type": "activity_logged",
"entry": {
"id": str, # "al_<8-hex>"
"ts": str, # ISO-8601 UTC string
"category": str,
"action": str,
"severity": str,
"actor": str,
"entity_type": str | None,
"entity_id": str | None,
"entity_name": str | None,
"message": str,
"metadata": dict, # real dict, not JSON string
}
}
```
### DI getter names (in `api/dependencies.py`)
```python
from ledgrab.api.dependencies import (
get_activity_recorder,
get_activity_log_repo,
get_activity_log_retention_engine,
)
```
### Notes for Phase 3
- Phase 3 instruments `fire_entity_event` in `api/dependencies.py` by calling
`get_module_recorder()` there (not via FastAPI DI — it's a plain function).
- The actor ContextVar is already set by `verify_api_key` before any route
handler runs, so entity events carry the correct actor automatically.
- `recorder.record(...)` never raises; Phase 3 call sites need no try/except.
Phase 2 landed (2026-06-09): ActivityRecorder, actor ContextVar, ActivityLogRetentionEngine,
all wiring in main.py/dependencies.py/auth.py, activity_logged allowlist in events-ws.ts,
24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean.
@@ -0,0 +1,172 @@
# Phase 3: Event instrumentation (4 categories)
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend · 🔒 security-sensitive (security reviewer triggers)
## Objective
Emit audit records at the real call sites for all four categories, using the Phase 2 recorder.
Maximize coverage via the central `fire_entity_event` choke point; add explicit
`recorder.record(...)` calls for non-entity events. Never log secrets.
## Tasks
### Entity CRUD (via the choke point)
- [x] In `api/dependencies.py`, extend `fire_entity_event` to ALSO record an audit entry:
- Signature gains an optional `entity_name: str | None = None`.
- For `created`/`updated`: if `entity_name` not supplied, best-effort resolve from the
matching store in `_deps` keyed by `entity_type` (entity still present). For `deleted`:
**do not** resolve post-hoc — rely on the explicit `entity_name` passed by the handler
(deletes are the most important; a name-less delete entry is unacceptable).
- Map `action` → severity (`info`), category `entity`. Build a human message
(e.g. `"Target 'Desk' updated"`). Read actor from the ContextVar.
- Recording is best-effort (never break the entity operation).
- [x] Update entity **delete** handlers to pass `entity_name` into `fire_entity_event`
(the entity object is already loaded for the 404 check). Cover the representative/most-used
entities at minimum: output targets, sync clocks, devices, picture/audio/color-strip
sources, automations, scene presets/playlists, templates, gradients. (Create/update can rely
on hook resolution but pass the name where trivially available.)
### Authentication (DESCOPED: no key create/rotate/revoke — those routes don't exist)
- [x] In `api/auth.py`, record:
- auth **failures**: missing/invalid Bearer token (HTTP), rejected LAN-without-keys, rejected
WS origin (4403), WS auth handshake failure (4401). Category `auth`, severity `warning`.
Include the caller IP/label and the reason in `metadata`**never** the attempted token.
- WS **session establishment** (successful `accept_and_authenticate_ws`): category `auth`,
severity `info`, actor = authenticated label.
- (Do NOT record per-request HTTP auth *success* — too frequent.)
### Device connect/disconnect (use existing discrete seams)
- [x] Hook `device_health_changed` (`core/processing/device_health.py`, fired only on
`online != prev_online`) → record online/offline transition. Category `device`,
severity `info` (online) / `warning` (offline).
- [x] Hook `device_discovered` / `device_lost` (`core/devices/discovery_watcher.py`, **runs on
the zeroconf thread** → recorder must marshal to the loop, which Phase 2 handles). Category
`device`.
- [x] ADB connect/disconnect (`api/routes/system_settings.py:adb_connect/adb_disconnect`).
### Capture & system events (explicit record calls)
- [x] Target processing start/stop + bulk (`api/routes/output_targets_control.py`).
- [x] Scene activation (`scene_presets.py:activate_scene_preset`), playlist start/stop
(`scene_playlists.py`), automation activate/deactivate (`automation_engine.py`).
- [x] System: backup create/restore/delete (`backup.py`), update apply/dismiss (`update.py`),
restart/shutdown (`backup.py`), calibration start/stop/cancel (`calibration.py`).
- [x] Settings changes: scope to high-value settings only (auto-backup, update, shutdown
action). **Exclude the activity-log's own `"activity_log"` settings key** to avoid
self-referential churn.
### Tests
- [x] `server/tests/test_activity_instrumentation.py` (or per-area):
- representative entity create/update/delete produces a record with correct category/actor/
name (incl. a delete carrying its name);
- an auth failure produces a `warning` record and the token never appears in any field;
- a device health transition and a discovery event produce records;
- a capture start and a backup/restore produce records.
## Files to Modify/Create
- `server/src/ledgrab/api/dependencies.py` — modify: `fire_entity_event` records + `entity_name`
- entity **delete** route handlers under `api/routes/` — modify: pass `entity_name`
- `server/src/ledgrab/api/auth.py` — modify: auth-failure + WS-session records
- `server/src/ledgrab/core/processing/device_health.py` — modify: online/offline record
- `server/src/ledgrab/core/devices/discovery_watcher.py` — modify: discovered/lost record
- `server/src/ledgrab/api/routes/system_settings.py` — modify: ADB + settings records
- `server/src/ledgrab/api/routes/output_targets_control.py` — modify: start/stop records
- `server/src/ledgrab/api/routes/{scene_presets,scene_playlists,backup,update,calibration}.py` — modify
- `server/src/ledgrab/core/automations/automation_engine.py` — modify: activate/deactivate records
- `server/tests/test_activity_instrumentation.py` — new
## Acceptance Criteria
- All four categories emit records at the named sites; entity deletes carry the entity name.
- API-key tokens / secrets never appear in any audit field (test-enforced).
- Recording never breaks the audited action (best-effort; failures swallowed + logged).
- Actor is the authenticated label for request-originated events, `"system"` for engine/thread
events. New + existing tests green; `ruff` clean.
## Notes
- Get the recorder via the Phase 2 DI getter; for engine/thread sites that lack DI, use the
module singleton/accessor Phase 2 exposes.
- Keep messages human-readable and localized-agnostic (English source strings; the frontend
renders structured fields, not server message translation — message is a fallback/summary).
- This is the security-sensitive phase — the security reviewer runs here AND at final review.
## Review Checklist
- [x] All tasks completed
- [x] Code follows project conventions
- [x] No unintended side effects (audited actions still succeed on recorder failure)
- [x] No secrets logged (token never recorded) — explicitly verified
- [x] Build passes (ruff + pytest)
- [x] Tests pass (new + existing)
## Handoff to Next Phase
Phase 3 is complete. The following (category, action) pairs are now emitted, along with their
metadata keys, for Phase 4 to expose via query/filter and for Phase 5 quick-filter presets.
### `entity` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `entity.created` | info | — | All entity types via `fire_entity_event` choke-point |
| `entity.updated` | info | — | All entity types; name resolved from store when not passed |
| `entity.deleted` | info | — | Name passed explicitly by delete handler before deletion |
### `auth` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `auth.rejected` | warning | `reason` (str), `client` (str/IP) | Missing Bearer, invalid Bearer, LAN-no-keys, WS origin, WS auth timeout, invalid WS token |
| `auth.ws_connected` | info | `client` (str/IP) | Successful WS session established |
### `device` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `device.online` | info | `latency_ms` (float) | Health monitor, transition only |
| `device.offline` | warning | `latency_ms` (float) | Health monitor, transition only |
| `device.discovered` | info | `url` (str), `device_type` (str) | Zeroconf discovery thread; recorder marshals to loop |
| `device.lost` | warning | `url` (str), `device_type` (str) | Zeroconf discovery thread |
| `device.adb_connected` | info | `address` (str) | ADB route success |
| `device.adb_disconnected` | info | `address` (str) | ADB route success |
### `capture` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `capture.started` | info | — | Per target (individual + bulk) |
| `capture.stopped` | info | — | Per target (individual + bulk) |
| `scene.activated` | info | — | `scene_presets.py:activate_scene_preset` |
| `playlist.started` | info | — | `scene_playlists.py:start_scene_playlist` |
| `playlist.stopped` | info | — | `scene_playlists.py:stop_scene_playlist` |
| `automation.activated` | info | — | `automation_engine.py:_activate_automation`; actor="system" |
| `automation.deactivated` | info | — | `automation_engine.py:_deactivate_automation`; actor="system" |
### `system` category
| Action | Severity | Metadata keys | Notes |
|--------|----------|---------------|-------|
| `backup.created` | info | `filename` (str) | `backup.py:backup_config` |
| `backup.restored` | info | — | `backup.py:restore_config` |
| `backup.deleted` | info | `filename` (str) | `backup.py:delete_saved_backup` |
| `server.restarting` | info | — | `backup.py:restart_server` |
| `server.shutdown_requested` | info | — | `backup.py:shutdown_server` |
| `update.dismissed` | info | `version` (str) | `update.py:dismiss_update` |
| `update.applied` | info | `version` (str) | `update.py:apply_update` |
| `settings.changed` | info | `setting_key` (str) + setting-specific keys | `setting_key` values: `"auto_backup"`, `"update"`, `"shutdown_action"`. Activity-log own key excluded. |
| `calibration.started` | info | — | `calibration.py`; entity_type="device", entity_id=device_id |
| `calibration.stopped` | info | — | `calibration.py` |
| `calibration.cancelled` | info | — | `calibration.py` |
### Implementation notes for Phase 4
- The `metadata` field is a JSON `TEXT` column. All keys above are scalars (str, float).
- Phase 4 filter `metadata_key` / `metadata_value` lookup, if added, can target `setting_key`
for settings-change filtering.
- `entity_type` is populated for entity CRUD and `calibration.started`. For auth/system/capture
events `entity_type` may be None.
- `entity_name` is always populated for `entity.deleted`; populated for CRUD create/update
when resolved; populated for most capture/system events where a name is meaningful.
+163
View File
@@ -0,0 +1,163 @@
# Phase 4: REST API — query / filter / export / settings / clear
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
## Objective
Expose the audit log over the REST API: a filtered, keyset-paginated list endpoint; a
streaming CSV/JSON export honoring the same filters; retention settings get/update; and a
destructive clear. Apply the project's auth posture (stricter auth on export + clear).
## Tasks
- [x] `server/src/ledgrab/api/schemas/activity_log.py` (Pydantic):
- `ActivityLogEntryResponse` (matches the frozen Phase 2 entry dict shape).
- `ActivityLogPageResponse` { `entries: list[...]`, `next_before_seq: int | None`,
`total: int` (optional/over filters), `has_more: bool` }.
- `ActivityLogSettingsResponse` / `UpdateActivityLogSettingsRequest`
(`enabled`, `max_days`, `max_entries`) with validation bounds.
- [x] `server/src/ledgrab/api/routes/activity_log.py``APIRouter(prefix="/api/v1/activity-log")`:
- `GET ""` — list. Query params: `categories`, `severities`, `actor`, `entity_type`,
`entity_id`, `since`/`until` (ISO), `q` (free-text), `before_seq` (cursor), `limit`
(default 50, capped e.g. 200). `AuthRequired`. Maps params → `ActivityLogFilters`,
calls `repo.query(...)`, returns page envelope.
- `GET "/export"` — streaming export. Same filters; `format=csv|json`. Uses
`StreamingResponse` over `repo.iter_export(...)`. **`require_authenticated()`** (may
contain IPs/labels). Sets `Content-Disposition` with a timestamped filename.
- `GET "/settings"` / `PUT "/settings"` — retention settings via the retention engine.
`AuthRequired`; updates apply immediately.
- `DELETE ""` — clear all entries. **`require_authenticated()`**. The clear is itself
audited (recorder records a `system`/`activity_log_cleared` entry AFTER the wipe, so the
log shows who cleared it and when).
- Register the router in `server/src/ledgrab/api/__init__.py` (aggregator).
- [x] API tests `server/tests/api/routes/test_activity_log_api.py`:
- list returns entries; each filter narrows results; `before_seq` cursor paginates without
overlap/gaps; `limit` cap enforced;
- export CSV and JSON both stream and honor filters; export requires authentication
(401 for loopback-anonymous when keys configured);
- settings get/update round-trip + validation rejects out-of-range;
- clear empties the log, requires auth, and leaves exactly one post-clear audit entry.
## Files to Modify/Create
- `server/src/ledgrab/api/schemas/activity_log.py` — new
- `server/src/ledgrab/api/routes/activity_log.py` — new
- `server/src/ledgrab/api/__init__.py` — modify: register router
- `server/tests/api/routes/test_activity_log_api.py` — new
## Acceptance Criteria
- List is filterable on every dimension and keyset-paginated (stable, no dupes/gaps).
- Export streams CSV + JSON, honors filters, and requires authentication.
- Settings get/update works and validates bounds; changes take effect immediately.
- Clear requires authentication and is itself audited.
- New + existing tests green; `ruff` clean.
## Notes
- Auth helpers: `AuthRequired` dependency for normal endpoints; `require_authenticated()` for
export + clear (pattern: backup download / secret reveal). See `api/auth.py` + `server/CLAUDE.md`.
- Follow the existing route/schema conventions (one schema file per entity, router registered
in `api/__init__.py`). Reference `api/routes/backup.py` for settings-style GET/PUT + a
`StreamingResponse`/download pattern.
- Reuse the entry→dict serializer from Phase 2 to keep the response shape single-sourced.
- Backup/restore: no `STORE_MAP` change needed — backup is whole-DB; the table is auto-covered.
## Review Checklist
- [x] All tasks completed
- [x] Code follows project conventions (router registration, schema-per-entity, auth posture)
- [x] No unintended side effects
- [x] Build passes (ruff + pytest)
- [x] Tests pass (new + existing)
## Handoff to Next Phase
### Endpoint paths
| Method | Path | Auth |
|--------|------|------|
| GET | `/api/v1/activity-log` | AuthRequired (anonymous allowed) |
| GET | `/api/v1/activity-log/export` | require_authenticated (no anonymous) |
| GET | `/api/v1/activity-log/settings` | AuthRequired |
| PUT | `/api/v1/activity-log/settings` | AuthRequired |
| DELETE | `/api/v1/activity-log` | require_authenticated (no anonymous) |
### List query parameters (GET /api/v1/activity-log)
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `categories` | `list[str]` | — | Repeatable. Values: auth, device, entity, capture, system |
| `severities` | `list[str]` | — | Repeatable. Values: info, warning, error |
| `actor` | `str` | — | Exact match |
| `entity_type` | `str` | — | Exact match |
| `entity_id` | `str` | — | Exact match |
| `since` | `datetime` (ISO-8601) | — | Inclusive lower bound on ts |
| `until` | `datetime` (ISO-8601) | — | Inclusive upper bound on ts |
| `q` | `str` | — | Substring match on message (LIKE %q%) |
| `before_seq` | `int` | — | Keyset cursor from previous page's `next_before_seq` |
| `limit` | `int` | 50 | Max entries per page. ge=1, le=200 |
Export endpoint (`GET /api/v1/activity-log/export`) accepts the same filter params plus `format=csv|json`.
### Page envelope fields (ActivityLogPageResponse)
```json
{
"entries": [...], // list[ActivityLogEntryResponse]
"next_before_seq": 42, // int | null — pass as before_seq for next page
"has_more": true, // bool
"total": 1337 // int — total matching all filters (all pages)
}
```
### Entry dict shape (ActivityLogEntryResponse)
11 fields — identical to `entry_to_dict()` output:
```json
{
"id": "al_abcd1234",
"ts": "2026-06-09T12:34:56.789+00:00",
"category": "entity",
"action": "entity.created",
"severity": "info",
"actor": "my-api-key",
"entity_type": "output_target",
"entity_id": "pt_abc",
"entity_name": "Desk",
"message": "Output target 'Desk' created",
"metadata": {}
}
```
### Settings field bounds (UpdateActivityLogSettingsRequest)
| Field | Type | ge | le | Notes |
|-------|------|----|----|-------|
| `enabled` | `bool` | — | — | Enable/disable recording |
| `max_days` | `int` | 0 | 3650 | 0 = no age-based pruning |
| `max_entries` | `int` | 0 | 10_000_000 | 0 = no count-based pruning |
### Export format param
`?format=csv` (default) → `text/csv; charset=utf-8`
`?format=json``application/json` (streamed JSON array)
### Pagination algorithm
Keyset cursor (`before_seq`) works as follows:
- Omit `before_seq` (or pass `null`) to get the FIRST (newest) page.
- Each page response includes `next_before_seq` (the seq of the oldest entry on the page).
- Pass `next_before_seq` as `before_seq` in the next request to get the following (older) page.
- `has_more=false` means there are no more pages; `next_before_seq` is `null`.
- `total` is constant across pages for the same filter set.
### New method added to ActivityLogRepository (additive, not breaking)
`get_seq_for_id(entry_id: str) -> int | None` — indexed point-lookup of seq by entry id.
Used internally by the list endpoint to build the keyset cursor.
Phase 4 landed (2026-06-09): schemas, route (list/export/settings/clear), router registration,
49 new tests — all green. Full suite 2486 passed, 2 skipped, 0 failed. Ruff clean.
+137
View File
@@ -0,0 +1,137 @@
# Phase 5: Frontend — Activity tab + smart filtering + live updates
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend · uses the `frontend-design` skill
## Objective
Build the dedicated top-level **Activity** tab: a read-only, smart-filterable,
keyset-paginated log viewer with an entry detail view, live-append of new events, and export.
This is a viewer (Dashboard-style), NOT a CRUD card section.
## Tasks
- [x] `core/ui.ts`: add `formatTimestamp(isoOrMs)` (Today/Yesterday/Date · HH:MM, i18n-aware)
and `formatRelativeTime(isoOrMs)` ("2m ago"), with `tabular-nums` styling guidance for the
list. Reuse existing `time.*` i18n key conventions.
- [x] `features/activity-log.ts`:
- `export async function loadActivityLog()` — fetch first page from
`GET /activity-log` (via `fetchWithAuth`), render the toolbar + list into the panel.
- **Smart filter toolbar:** category (multi, chips), severity (chips), actor
(text input), entity type, date range, free-text search (debounced). Quick presets:
Today / Errors / Auth / Devices. Filters drive server-side query params (no client-side
filtering of a partial page). Re-query on change; reset cursor.
- **List:** one row per entry — severity icon, category badge, relative time (title=absolute),
actor, message, entity crosslink (use `navigateToCard(...)` when the referenced entity is
resolvable). Keyset "load more" (or infinite scroll) using `next_before_seq`.
- **Detail:** expandable row / drawer showing full metadata JSON, exact timestamp, ids.
- **Live append:** `document.addEventListener('server:activity_logged', e => …)` — prepend
the new entry if it passes the active filters; show a subtle "new" affordance. (Depends on
the Phase 2 allowlist entry — already shipped.)
- **Export button:** triggers `GET /activity-log/export?format=…` with current filters via an
authed blob download (use `fetchWithAuth` → blob URL, per frontend.md auth rules).
- Empty / loading / error states; re-render on `languageChanged`.
- [x] Tab wiring:
- `core/tab-registry.ts`: add `activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false }`.
- `templates/index.html`: sidebar tab button (`data-tab`, `switchTab('activity_log')`,
history/clock SVG icon, `data-i18n`) + `<div class="tab-panel" id="tab-activity_log">`.
- `app.ts`: import + `Object.assign(window, { loadActivityLog, … })`; `global.d.ts` decls.
- [x] Icons: add a history/audit icon to `core/icon-paths.ts` + `core/icons.ts`; severity icons
(info/warning/error) reuse existing constants where possible.
- [x] i18n: add `activity_log.*` keys to `static/locales/{en,ru,zh}.json` (title, filter labels,
category/severity names, column labels, presets, empty/error, export, "N entries").
- [x] Tutorials: add an Activity-tab step to the getting-started tour in
`features/tutorials.ts` + `tour.*` keys in all 3 locales.
## Files to Modify/Create
- `server/src/ledgrab/static/js/core/ui.ts` — modified: timestamp/relative-time formatters
- `server/src/ledgrab/static/js/features/activity-log.ts` — NEW: the viewer
- `server/src/ledgrab/static/js/core/tab-registry.ts` — modified: register tab
- `server/src/ledgrab/templates/index.html` — modified: tab button + panel
- `server/src/ledgrab/static/js/app.ts` — modified: import + window globals
- `server/src/ledgrab/static/js/global.d.ts` — modified: window decls
- `server/src/ledgrab/static/js/core/icon-paths.ts` — modified: icons (scrollText, circleAlert, info, filter, xCircle)
- `server/src/ledgrab/static/js/core/icons.ts` — modified: ICON_ACTIVITY_LOG, ICON_SEVERITY_*, ICON_FILTER, ICON_X_CIRCLE
- `server/src/ledgrab/static/locales/{en,ru,zh}.json` — modified: activity_log.* + time.* + tour.activity_log
- `server/src/ledgrab/static/js/features/tutorials.ts` — modified: gettingStartedSteps
- `server/src/ledgrab/static/css/activity-log.css` — NEW: list + toolbar styling
- `server/src/ledgrab/static/css/all.css` — modified: import activity-log.css
## Acceptance Criteria
- [x] New **Activity** tab loads, lists entries, and paginates via keyset "load more".
- [x] Filters hit server-side query params; quick presets work; free-text is debounced.
- [x] New events append live via `server:activity_logged` and respect active filters.
- [x] Export downloads CSV/JSON with auth, honoring current filters.
- [x] Fully localized (en/ru/zh); empty/loading/error states; re-renders on language change.
- [x] No plain `<select>` (use chips); SVG icons only (no emoji).
- [x] `npx tsc --noEmit` clean; `npm run build` succeeds.
## Notes
- **Used the `frontend-design` skill** for the viewer layout, filter toolbar, and detail design.
- Models mirrored: `features/dashboard.ts` (live viewer pattern), `core/events-ws.ts` (WS dispatch),
`core/api.ts` (fetchWithAuth), `core/navigation.ts` (navigateToCard).
- Frontend-only phase — ran `tsc --noEmit` + `npm run build`; did NOT run pytest/ruff.
## Review Checklist
- [x] All tasks completed
- [x] Code follows frontend conventions (i18n ×3, icons, auth fetch, CSS vars)
- [x] No unintended side effects
- [x] Build passes (`tsc --noEmit` + `npm run build`)
- [ ] Manual smoke: tab loads, filters query server, live append, export
## Handoff to Next Phase
### Reusable helpers for Phase 6 (Dashboard widget + Settings panel)
**From `features/activity-log.ts`:**
| Export | Purpose | How Phase 6 uses it |
|--------|---------|---------------------|
| `loadActivityLog()` | Loads the full tab (already registered as the tab loader) | Phase 6 doesn't call this, but it shares state |
| `activityLogToggleDetail(id)` | Expand/collapse an entry row | Dashboard widget can reuse for the Recent Activity list |
| `activityLogExport(format)` | Authed blob download with current filters | Could surface a direct link in the Settings panel |
| `_renderEntryRow(entry, isNew)` | Renders a single entry as an HTML string | Dashboard widget should import this to render its 5-10 most-recent entries |
| `_fetchPage(beforeSeq, append)` | Keyset-paged fetch from `/activity-log` | Dashboard widget calls with `limit=5&categories=…` for its mini-list |
**Note:** `_renderEntryRow` and `_fetchPage` are module-private (underscore prefix). Phase 6 should
either (a) re-export them with public names, or (b) duplicate the minimal render logic for the
compact widget format.
**Recommended approach for Phase 6:** Export two new public helpers:
```typescript
// Add to activity-log.ts
export async function fetchRecentEntries(limit = 5): Promise<ActivityEntry[]>
export function renderCompactEntry(entry: ActivityEntry): string
```
### i18n namespace
All keys are under `activity_log.*`. Time-relative formatters use `time.*` keys.
### CSS classes and tokens introduced
| Class | Purpose |
|-------|---------|
| `.al-panel` | Tab root wrapper |
| `.al-toolbar` | Filter toolbar container |
| `.al-chip` / `.al-chip.active` | Category/severity toggle chips |
| `.al-preset-btn` | Quick-preset buttons |
| `.al-entry` / `.al-entry-row` | Log entry row |
| `.al-detail` / `.al-detail-grid` | Expandable entry detail |
| `.al-cat-*` | Per-category badge colors (auth/device/entity/capture/system) |
| `.al-sev-info/warning/error` | Severity icon color classes |
| `.al-live-dot` | Pulsing green live-update dot |
| `.al-meta-pre` | Scrollable metadata JSON block |
| `.tabular-nums` | `font-variant-numeric: tabular-nums` utility |
### Settings endpoint shape used
Phase 6 Settings panel will call:
- `GET /activity-log/settings``{ enabled: bool, max_days: int, max_entries: int }`
- `PUT /activity-log/settings` → same shape (bounds: enabled=bool, max_days=03650, max_entries=010_000_000)
@@ -0,0 +1,98 @@
# Phase 6: Dashboard widget + Settings retention panel + docs
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend · uses the `frontend-design` skill
## Objective
Add the two secondary surfaces: a compact **Recent Activity** widget on the Dashboard linking
to the full tab, and a **Settings** panel for retention configuration (+ clear + export entry)
positioned beside the existing Log Viewer with a clear "what's the difference" note. Update
docs/tutorials.
## Tasks
- [x] Dashboard **Recent Activity** widget (`features/dashboard.ts` + dashboard CSS):
- Compact card showing the latest ~5 entries (severity icon, relative time, message).
- Reuse the Phase 5 render helper (don't duplicate row markup).
- Live update via `server:activity_logged` (prepend, cap to N).
- **View all →** navigates to the Activity tab (`switchTab('activity_log')`).
- Respect the existing dashboard card layout/toggle system; localized; empty state.
- [x] **Settings** retention panel (`features/settings.ts` + `templates/modals/settings.html`):
- New rail entry `Activity Log` (beside the existing **Log Viewer**).
- Controls: `enabled` toggle, `max_days` number, `max_entries` number → `GET/PUT
/activity-log/settings`. Save → toast; validation feedback.
- **Clear log** button (confirm dialog) → `DELETE /activity-log`; **Export** button →
`/activity-log/export`.
- One-line note distinguishing this persistent audit log from the ephemeral debug Log Viewer
(cross-link both ways).
- i18n for all controls/labels/hints.
- [x] Docs: update user-facing docs/README/feature list for the new Activity tab + retention
settings + export (and the audit-vs-debug-log distinction). Keep it brief.
- [x] Tutorials/cross-links: ensure the Settings tutorial (if any) and tab tour mention the
panel; `tour.*`/`settings.*` i18n keys in all 3 locales.
## Files to Modify/Create
- `server/src/ledgrab/static/js/features/dashboard.ts` — modify: Recent Activity widget
- `server/src/ledgrab/static/css/dashboard.css` (or relevant sheet) — modify: widget styles
- `server/src/ledgrab/static/js/features/settings.ts` — modify: retention panel + handlers
- `server/src/ledgrab/templates/modals/settings.html` — modify: rail entry + panel HTML
- `server/src/ledgrab/static/js/app.ts` / `global.d.ts` — modify: new window handlers
- `server/src/ledgrab/static/locales/{en,ru,zh}.json` — modify: i18n keys
- docs (README / feature list / relevant context doc) — modify: brief feature documentation
## Acceptance Criteria
- Dashboard shows a live Recent Activity widget; **View all →** opens the Activity tab.
- Settings panel reads/writes retention settings, clears (with confirm + auth), and exports.
- Audit-log vs debug-Log-Viewer distinction is explicit and cross-linked.
- Fully localized (en/ru/zh); empty/loading states; consistent with app design.
- `npx tsc --noEmit` clean; `npm run build` succeeds. No plain `<select>`; SVG icons only.
## Notes
- **Use the `frontend-design` skill** for the widget + settings panel layout.
- Reuse Phase 5's render helper and i18n namespace — no duplicated row markup or keys.
- Settings UI model: `features/settings.ts switchSettingsTab` + `modals/settings.html` rail.
- Frontend-only phase → run `tsc --noEmit` + `npm run build`; do NOT run pytest/ruff (docs +
TS only).
- Backup/restore cross-ref: no `STORE_MAP` edit needed (whole-DB backup covers the table) —
confirm nothing else (graph editor) needs syncing for this viewer (verified: not needed).
## Review Checklist
- [x] All tasks completed
- [x] Code follows frontend conventions (i18n ×3, icons, selectors, CSS vars, settings pattern)
- [x] No unintended side effects
- [x] Build passes (`tsc --noEmit` + `npm run build`)
- [ ] Manual smoke: widget live-updates + links; settings save/clear/export; docs updated (recommend at final review — requires server restart)
## Handoff to Next Phase
This is the **final implementation phase**. All six phases are complete. Notes for the final reviewer:
**What was implemented in Phase 6:**
- `features/dashboard.ts`: `_loadRecentActivityWidget()`, `_renderRecentActivityList()`, `_startRecentActivityLive()` — SSE live-update listener (`server:activity_logged`) with cap-to-5 prepend logic. Widget appended after `getOrderedSections()` loop (outside the layout toggle system — always visible). Non-blocking: `.catch()` on the async load call.
- `features/settings.ts`: `loadActivityLogSettings()`, `saveActivityLogSettings()`, `activityLogSettingsExport(format)`, `clearActivityLog()` — all exported and exposed on `window` via `app.ts` + `global.d.ts`.
- `templates/modals/settings.html`: Activity Log rail entry (System group, cyan channel) + full panel (`id="settings-panel-activity_log"`) with enabled toggle, `max_days`/`max_entries` inputs, Save, CSV/JSON export, Clear (danger zone). Audit-vs-debug distinction note with cross-links in both directions (`closeSettingsModal(); openLogOverlay()` and `closeSettingsModal(); switchTab('activity_log')`).
- `static/css/activity-log.css`: `.dal-*` dashboard widget styles + `.ds-info-note` / `.ds-inline-link` settings panel utilities appended (no new CSS file).
- `static/locales/{en,ru,zh}.json`: 32 new keys each under `dashboard.section.recent_activity`, `dashboard.recent_activity.*`, `settings.tab.activity_log`, `settings.activity_log.*`.
- `README.md`: "### Activity Log" section documenting tab, retention settings, export, and audit-vs-debug distinction.
**Reused Phase 5 helpers (no duplication):**
- `fetchRecentEntries(limit)` and `renderCompactEntry(entry)` — new public exports added to `activity-log.ts` in Phase 6 (not duplicated in dashboard.ts).
- All `.al-*` CSS classes from Phase 5 are reused in the compact rows inside the widget.
**Build verification:** `tsc --noEmit` clean, `npm run build` passed (2.8 MB bundle, 258 ms) at time of implementation.
**Remaining manual smoke test (requires server restart):**
- Dashboard widget loads recent entries, prepends live on new activity, "View all →" switches to Activity tab.
- Settings panel reads/writes retention, Save shows toast, Clear prompts confirm then deletes, Export downloads authed blob.
- Cross-links: note in Settings opens Log Viewer overlay; note in Log Viewer links back to Activity tab.
**Outstanding open note from PLAN.md:** The manual browser smoke test across the whole feature (P5 note) remains open — it requires a server restart to exercise the live API endpoints. Recommend as first step in the final review session.
+2
View File
@@ -38,6 +38,7 @@ from .routes.snapshot import router as snapshot_router
from .routes.graph import router as graph_router
from .routes.calibration import router as calibration_router
from .routes.setup import router as setup_router
from .routes.activity_log import router as activity_log_router
router = APIRouter()
router.include_router(system_router)
@@ -76,5 +77,6 @@ router.include_router(snapshot_router)
router.include_router(graph_router)
router.include_router(calibration_router)
router.include_router(setup_router)
router.include_router(activity_log_router)
__all__ = ["router"]
+124 -2
View File
@@ -3,18 +3,111 @@
import asyncio
import json
import secrets
import time
from typing import Annotated
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from starlette.websockets import WebSocket, WebSocketDisconnect
from ledgrab.config import get_config
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback
logger = get_logger(__name__)
# ── Auth-failure audit throttle (H3) ───────────────────────────────────────
#
# Unauthenticated callers can hammer any auth path; without a recording
# throttle each attempt would write one SQLite row AND broadcast one WS event,
# providing a cheap disk/broadcast amplification vector.
#
# Mitigation: record at most one ``auth.rejected`` audit entry per client IP
# per _AUTH_RECORD_WINDOW seconds. The auth decision (401) is NEVER
# suppressed — only the *audit recording* is de-duplicated.
#
# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP
# entries. When the cap is exceeded the oldest-seen IP (lowest timestamp) is
# evicted so the dict stays bounded regardless of the number of distinct source
# IPs an attacker can forge.
_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window
_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously
# ip -> monotonic timestamp of last *recorded* auth.rejected entry
_auth_record_last: dict[str, float] = {}
def _should_record_auth_failure(client_ip: str) -> bool:
"""Return True when an ``auth.rejected`` record should be written for *client_ip*.
Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the
oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent
unbounded memory growth under IP-spray attacks.
"""
now = time.monotonic()
last = _auth_record_last.get(client_ip)
if last is not None and (now - last) < _AUTH_RECORD_WINDOW:
return False # suppress: within the de-dup window
# Enforce hard cap before inserting: evict the single oldest entry.
if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP:
oldest_ip = min(_auth_record_last, key=lambda ip: _auth_record_last[ip])
del _auth_record_last[oldest_ip]
_auth_record_last[client_ip] = now
return True
def _record_auth_failure(reason: str, client_host: str | None) -> None:
"""Best-effort: record an auth failure audit entry (never raises).
SECURITY: the attempted token is NEVER passed here; only the reason and
the caller's IP/label are recorded.
THROTTLE: at most one ``auth.rejected`` record is written per client IP
per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification
DoS. The 401 response is always returned regardless.
"""
if not _should_record_auth_failure(client_host or "unknown"):
return # throttled — drop duplicate recording for this IP/window
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.rejected",
severity=ActivitySeverity.WARNING,
actor="anonymous",
message=f"Authentication failed: {reason}",
metadata={"reason": reason, "client": client_host or "unknown"},
)
def _record_ws_auth_success(label: str, client_host: str | None) -> None:
"""Best-effort: record a successful WebSocket session establishment."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is None:
return
rec.record(
category=ActivityCategory.AUTH,
action="auth.ws_connected",
severity=ActivitySeverity.INFO,
actor=label,
message=f"WebSocket session established by '{label}'",
metadata={"client": client_host or "unknown"},
)
# Security scheme for Bearer token
security = HTTPBearer(auto_error=False)
@@ -81,10 +174,12 @@ def verify_api_key(
# No keys configured — allow loopback only.
if _is_loopback(client_host):
request.state.auth_label = "anonymous"
current_actor.set("anonymous")
return "anonymous"
# Allow caller to authenticate explicitly even without configured keys?
# No — there are no keys to compare against. Reject.
logger.warning("Rejected LAN request from %s: no API key configured", client_host)
_record_auth_failure("LAN access rejected: no API key configured", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
@@ -97,13 +192,14 @@ def verify_api_key(
# Check if credentials are provided
if not credentials:
logger.warning("Request missing Authorization header")
_record_auth_failure("missing Bearer token", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key - authentication is required",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract token
# Extract token — NEVER log or record the token value itself.
token = credentials.credentials
# Find matching key and return its label using constant-time comparison
@@ -115,6 +211,7 @@ def verify_api_key(
if not authenticated_as:
logger.warning("Invalid API key attempt")
_record_auth_failure("invalid Bearer token", client_host)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
@@ -127,6 +224,9 @@ def verify_api_key(
# Stash the friendly label so the access-log middleware can attribute the
# request to a client without re-running the token comparison.
request.state.auth_label = authenticated_as
# Set the actor ContextVar so ActivityRecorder can resolve it without
# threading it through every call site.
current_actor.set(authenticated_as)
return authenticated_as
@@ -190,12 +290,30 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
# a strong signal even before the token check. Non-browser clients
# legitimately omit Origin; those fall through to the auth handshake.
config = get_config()
client_host = websocket.client.host if websocket.client else None
origin = websocket.headers.get("origin")
if not _is_origin_allowed(origin, config.server.cors_origins):
logger.warning(
"Rejected WebSocket from origin %r (not in cors_origins)",
origin,
)
# Sanitize first so urlparse does not choke on control chars / ANSI / NUL
# embedded by an attacker in the Origin header (e.g. \n triggers IPv6 parse
# error in Python's urlsplit on malformed netloc).
_safe_origin_raw = sanitize_display(origin) if origin else ""
try:
_netloc = urlparse(_safe_origin_raw).netloc if _safe_origin_raw else ""
except ValueError:
# Malformed IPv6 addresses (e.g. "http://[::1" without closing "]")
# cause urlparse to raise ValueError. Fall back to "unknown" — do NOT
# fall back to the raw origin string, which could carry query params
# or path components containing secrets.
_netloc = ""
_safe_origin = sanitize_display(_netloc or "unknown")
_record_auth_failure(
f"WebSocket origin rejected: {_safe_origin!r}",
client_host,
)
try:
await websocket.close(code=WS_ORIGIN_CLOSE_CODE)
except _WS_SEND_BENIGN_EXC:
@@ -210,6 +328,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0)
except _WS_SEND_BENIGN_EXC:
pass
return None
_record_ws_auth_success(label, client_host)
return label
@@ -275,6 +394,7 @@ async def verify_ws_auth(
return None
return "anonymous"
logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host)
_record_auth_failure("WebSocket auth timeout", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "auth timeout"})
except _WS_SEND_BENIGN_EXC:
@@ -332,6 +452,7 @@ async def verify_ws_auth(
await websocket.send_json({"type": "auth_ok"})
return "anonymous"
logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host)
_record_auth_failure("LAN WebSocket rejected: no API key configured", client_host)
try:
await websocket.send_json(
{
@@ -343,10 +464,11 @@ async def verify_ws_auth(
pass
return None
# Keys configured: require a matching token.
# Keys configured: require a matching token. NEVER log the token value.
label = _match_api_key(token or "")
if not label:
logger.warning("Invalid WebSocket auth attempt from %s", client_host)
_record_auth_failure("invalid WebSocket token", client_host)
try:
await websocket.send_json({"type": "auth_error", "reason": "invalid token"})
except _WS_SEND_BENIGN_EXC:
+112 -2
View File
@@ -42,6 +42,11 @@ from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.http_endpoint_store import HTTPEndpointStore
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore
from ledgrab.core.activity_log.recorder import ActivityRecorder, get_module_recorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
T = TypeVar("T")
@@ -196,16 +201,83 @@ def get_update_service() -> UpdateService:
return _get("update_service", "Update service")
def get_activity_recorder() -> ActivityRecorder:
return _get("activity_recorder", "Activity recorder")
def get_activity_log_repo() -> ActivityLogRepository:
return _get("activity_log_repo", "Activity log repository")
def get_activity_log_retention_engine() -> ActivityLogRetentionEngine:
return _get("activity_log_retention_engine", "Activity log retention engine")
# ── Event helper ────────────────────────────────────────────────────────
def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus.
def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None:
"""Best-effort: look up a human name for *entity_id* from the matching store.
Returns ``None`` when the store is missing, the entity is gone, or any
exception occurs (e.g. during delete the entity may have just been removed).
"""
# Map entity_type → (_deps key, method name on the store)
_STORE_LOOKUP: dict[str, tuple[str, str]] = {
"output_target": ("output_target_store", "get_target"),
"device": ("device_store", "get_device"),
"picture_source": ("picture_source_store", "get_source"),
"audio_source": ("audio_source_store", "get_source"),
"color_strip_source": ("color_strip_store", "get_source"),
"template": ("template_store", "get_template"),
"capture_template": ("template_store", "get_template"),
"pp_template": ("pp_template_store", "get_template"),
"automation": ("automation_store", "get_automation"),
"scene_preset": ("scene_preset_store", "get_preset"),
"scene_playlist": ("scene_playlist_store", "get_playlist"),
"sync_clock": ("sync_clock_store", "get_clock"),
"gradient": ("gradient_store", "get_gradient"),
"audio_template": ("audio_template_store", "get_template"),
"value_source": ("value_source_store", "get_source"),
"cspt": ("cspt_store", "get_template"),
"audio_processing_template": ("audio_processing_template_store", "get_template"),
"pattern_template": ("pattern_template_store", "get_template"),
"home_assistant_source": ("ha_store", "get_source"),
"mqtt_source": ("mqtt_store", "get_source"),
"http_endpoint": ("http_endpoint_store", "get_endpoint"),
}
entry = _STORE_LOOKUP.get(entity_type)
if entry is None:
return None
store_key, method_name = entry
store = _deps.get(store_key)
if store is None:
return None
try:
obj = getattr(store, method_name)(entity_id)
if obj is not None:
return getattr(obj, "name", None)
except Exception:
pass
return None
def fire_entity_event(
entity_type: str,
action: str,
entity_id: str,
entity_name: str | None = None,
) -> None:
"""Fire an entity_changed event via the ProcessorManager event bus and
record an audit entry.
Args:
entity_type: e.g. "device", "output_target", "color_strip_source"
action: "created", "updated", or "deleted"
entity_id: The entity's unique ID
entity_name: Human-readable name. For deletes: **must** be passed
explicitly (entity is already gone when we get here).
For create/update: resolved from the store when not supplied.
"""
pm = _deps.get("processor_manager")
if pm is not None:
@@ -218,6 +290,38 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None:
}
)
# ── Audit record (best-effort) ──────────────────────────────────────────
rec = get_module_recorder()
if rec is None:
return
# Resolve name when not explicitly provided (create / update paths).
# For deleted: entity already gone — rely on the explicitly passed name.
resolved_name = entity_name
if resolved_name is None and action != "deleted":
resolved_name = _resolve_entity_name(entity_type, entity_id)
# Build a concise human message.
# Sanitize the display name before interpolating into the free-text message
# (user-authored names hit the CSV/export trust surface).
safe_display_name = sanitize_display(resolved_name) if resolved_name else None
display_name = f"'{safe_display_name}'" if safe_display_name else entity_id
action_word = {"created": "created", "updated": "updated", "deleted": "deleted"}.get(
action, action
)
entity_label = entity_type.replace("_", " ")
message = f"{entity_label.capitalize()} {display_name} {action_word}"
rec.record(
category=ActivityCategory.ENTITY,
action=f"entity.{action}",
severity=ActivitySeverity.INFO,
entity_type=entity_type,
entity_id=entity_id,
entity_name=sanitize_display(resolved_name) if resolved_name else None,
message=message,
)
# ── Initialization ──────────────────────────────────────────────────────
@@ -257,6 +361,9 @@ def init_dependencies(
http_endpoint_store: HTTPEndpointStore | None = None,
audio_processing_template_store: AudioProcessingTemplateStore | None = None,
pattern_template_store: PatternTemplateStore | None = None,
activity_recorder: ActivityRecorder | None = None,
activity_log_repo: ActivityLogRepository | None = None,
activity_log_retention_engine: ActivityLogRetentionEngine | None = None,
):
"""Initialize global dependencies."""
_deps.update(
@@ -295,5 +402,8 @@ def init_dependencies(
"http_endpoint_store": http_endpoint_store,
"audio_processing_template_store": audio_processing_template_store,
"pattern_template_store": pattern_template_store,
"activity_recorder": activity_recorder,
"activity_log_repo": activity_log_repo,
"activity_log_retention_engine": activity_log_retention_engine,
}
)
@@ -0,0 +1,436 @@
"""Activity-log REST API — query / filter / export / settings / clear.
Endpoints
---------
GET /api/v1/activity-log List (filterable, keyset-paginated)
GET /api/v1/activity-log/export Streaming CSV or JSON export
GET /api/v1/activity-log/settings Retention settings
PUT /api/v1/activity-log/settings Update retention settings (requires non-anonymous auth)
DELETE /api/v1/activity-log Clear all entries (requires non-anonymous auth)
Auth posture
------------
- List + read settings (``GET``): ``AuthRequired`` (loopback-anonymous is fine).
- Export, update settings (``PUT``), and clear: ``require_authenticated()``
(loopback-anonymous is rejected; mirrors the backup download / secret-reveal
pattern from ``backup.py``). Updating settings can disable auditing or prune
the trail, so it is gated like the destructive clear.
CSV injection
-------------
Cells that begin with =, +, -, @, TAB, or CR can trigger formula execution in
spreadsheet apps (OWASP Formula Injection). ``_csv_safe`` prefixes any such cell
with a single quote so formulas are inert. Fields already go through
``sanitize_display`` in Phase 3 instrumentation, but the CSV writer applies its
own guard as defence-in-depth.
Export generator + lock
-----------------------
``repo.iter_export()`` fetches rows in bounded batches, holding the DB ``_lock``
only around each batch fetch and releasing it before yielding — so a slow or
stalled client never blocks other DB operations. The ``StreamingResponse``
generator is wrapped in a ``try/finally`` block so the batch generator is closed
even when the client disconnects mid-stream.
"""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Annotated, Iterator
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from ledgrab.api.auth import AuthRequired, require_authenticated
from ledgrab.api.dependencies import (
get_activity_log_repo,
get_activity_log_retention_engine,
get_activity_recorder,
)
from ledgrab.api.schemas.activity_log import (
ActivityLogPageResponse,
ActivityLogSettingsResponse,
UpdateActivityLogSettingsRequest,
)
from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogFilters, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"])
# Hard cap on the per-request limit to prevent runaway queries.
_MAX_LIMIT = 200
_DEFAULT_LIMIT = 50
# CSV export columns (matches entry_to_dict key order)
_CSV_COLUMNS = [
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
]
# Characters that trigger formula injection in spreadsheet apps (OWASP).
# Leading TAB and CR are also recognised triggers by Excel / Google Sheets.
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
def _csv_safe(value: str) -> str:
"""Prefix formula-injection triggers with a literal single-quote.
A cell starting with =, +, -, or @ can execute as a formula in Excel /
Google Sheets. OWASP recommends prepending a single quote to neutralise it.
"""
if value and value[0] in _FORMULA_PREFIXES:
return "'" + value
return value
def _build_filters(
categories: list[str] | None,
severities: list[str] | None,
actor: str | None,
entity_type: str | None,
entity_id: str | None,
since: datetime | None,
until: datetime | None,
q: str | None,
) -> ActivityLogFilters:
"""Assemble an ``ActivityLogFilters`` dataclass from query parameters."""
return ActivityLogFilters(
categories=categories or None,
severities=severities or None,
actor=actor or None,
entity_type=entity_type or None,
entity_id=entity_id or None,
since=since,
until=until,
message_like=q or None,
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log — list
# ---------------------------------------------------------------------------
@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries")
def list_activity_log(
auth: AuthRequired, # noqa: ARG001
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Filters ────────────────────────────────────────────────────────────
categories: Annotated[
list[str] | None,
Query(
description=(
"Filter by category (repeatable or comma-separated). "
"Values: auth, device, entity, capture, system"
)
),
] = None,
severities: Annotated[
list[str] | None,
Query(description="Filter by severity (repeatable). Values: info, warning, error"),
] = None,
actor: Annotated[
str | None,
Query(description="Filter by actor label (exact match)"),
] = None,
entity_type: Annotated[
str | None,
Query(description="Filter by entity type (exact match)"),
] = None,
entity_id: Annotated[
str | None,
Query(description="Filter by entity id (exact match)"),
] = None,
since: Annotated[
datetime | None,
Query(description="Return entries at or after this ISO-8601 datetime"),
] = None,
until: Annotated[
datetime | None,
Query(description="Return entries at or before this ISO-8601 datetime"),
] = None,
q: Annotated[
str | None,
Query(description="Free-text search in the message field (substring)"),
] = None,
# ── Pagination ─────────────────────────────────────────────────────────
before_seq: Annotated[
int | None,
Query(
description=(
"Keyset cursor: pass the 'next_before_seq' from the previous page "
"to get the following (older) page. Omit for the first (newest) page."
)
),
] = None,
limit: Annotated[
int,
Query(
ge=1,
le=_MAX_LIMIT,
description=f"Max entries per page (default {_DEFAULT_LIMIT}, max {_MAX_LIMIT})",
),
] = _DEFAULT_LIMIT,
) -> ActivityLogPageResponse:
"""Return the newest matching entries, oldest-first within the page.
Keyset pagination: the response includes ``next_before_seq`` — pass it
as ``before_seq`` in the next request to get the next (older) page.
The ``total`` field is the count of all entries matching the current
filters across all pages.
"""
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
# Fetch limit+1 rows to detect whether an older page exists.
#
# query() fetches DESC internally (newest-first) then reverses to ascending.
# With limit+1, the result is ascending: [oldest_probe, ..., newest].
# When we got exactly limit+1 rows, has_more is True and the probe row
# (index 0 — the oldest) is the extra one. We keep the newest `limit` rows
# by slicing [1:], which is the actual page content for the client.
# When we got <= limit rows, this is the last page and all rows are included.
effective_limit = min(limit, _MAX_LIMIT)
entries_plus = repo.query(filters, before_seq=before_seq, limit=effective_limit + 1)
has_more = len(entries_plus) > effective_limit
if has_more:
# Drop the oldest probe row; keep the newest `limit` entries.
entries = entries_plus[1:]
else:
entries = entries_plus
total = repo.count(filters)
# Compute next_before_seq: the seq of the oldest entry on this page.
# query() returns entries ascending (entries[0] is oldest); its seq is the
# cursor for the next page. The next request passes before_seq=X to get
# entries with seq < X, i.e. entries older than the oldest entry on this page.
# get_seq_for_id() does a cheap indexed point-lookup.
next_before_seq: int | None = None
if has_more and entries:
next_before_seq = repo.get_seq_for_id(entries[0].id)
return ActivityLogPageResponse(
entries=[entry_to_dict(e) for e in entries], # type: ignore[arg-type]
next_before_seq=next_before_seq,
has_more=has_more,
total=total,
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log/export — streaming export (CSV or JSON)
# ---------------------------------------------------------------------------
def _export_csv_generator(
repo: ActivityLogRepository,
filters: ActivityLogFilters,
) -> Iterator[bytes]:
"""Yield UTF-8-encoded CSV chunks one row at a time.
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
lock is released even on early client disconnect (which triggers
``GeneratorExit``).
"""
gen = repo.iter_export(filters)
try:
# Header
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_COLUMNS)
yield buf.getvalue().encode("utf-8")
for entry in gen:
d = entry_to_dict(entry)
row = []
for col in _CSV_COLUMNS:
if col == "metadata":
cell = json.dumps(d.get(col) or {})
else:
cell = str(d.get(col, "") or "")
row.append(_csv_safe(cell))
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(row)
yield buf.getvalue().encode("utf-8")
finally:
gen.close()
def _export_json_generator(
repo: ActivityLogRepository,
filters: ActivityLogFilters,
) -> Iterator[bytes]:
"""Yield a streamed JSON array, one entry per chunk.
Format: ``[\\n{entry},\\n{entry},\\n...]\\n``
The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB
lock is released even on early client disconnect.
"""
gen = repo.iter_export(filters)
try:
first = True
yield b"[\n"
for entry in gen:
d = entry_to_dict(entry)
chunk = json.dumps(d, ensure_ascii=False, default=str)
if first:
yield chunk.encode("utf-8")
first = False
else:
yield b",\n" + chunk.encode("utf-8")
yield b"\n]\n"
finally:
gen.close()
@router.get("/export", summary="Export activity-log entries (streaming CSV or JSON)")
def export_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
# ── Format ────────────────────────────────────────────────────────────
format: Annotated[
str,
Query(description="Export format: 'csv' or 'json'"),
] = "csv",
# ── Same filters as list ───────────────────────────────────────────────
categories: Annotated[list[str] | None, Query()] = None,
severities: Annotated[list[str] | None, Query()] = None,
actor: Annotated[str | None, Query()] = None,
entity_type: Annotated[str | None, Query()] = None,
entity_id: Annotated[str | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None,
until: Annotated[datetime | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
) -> StreamingResponse:
"""Stream all matching entries as CSV or JSON.
Requires a non-anonymous API key (loopback-anonymous access is rejected
because the log may contain IP addresses and entity names).
"""
require_authenticated(auth)
if format not in ("csv", "json"):
from fastapi import HTTPException
raise HTTPException(
status_code=422,
detail="'format' must be 'csv' or 'json'",
)
filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
if format == "csv":
filename = f"activity-log-{timestamp}.csv"
media_type = "text/csv; charset=utf-8"
generator = _export_csv_generator(repo, filters)
else:
filename = f"activity-log-{timestamp}.json"
media_type = "application/json"
generator = _export_json_generator(repo, filters)
return StreamingResponse(
generator,
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# GET /api/v1/activity-log/settings
# PUT /api/v1/activity-log/settings
# ---------------------------------------------------------------------------
@router.get(
"/settings",
response_model=ActivityLogSettingsResponse,
summary="Get activity-log retention settings",
)
def get_activity_log_settings(
_: AuthRequired,
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
) -> ActivityLogSettingsResponse:
"""Return the current activity-log retention settings."""
return ActivityLogSettingsResponse(**engine.get_settings())
@router.put(
"/settings",
response_model=ActivityLogSettingsResponse,
summary="Update activity-log retention settings",
)
async def update_activity_log_settings(
auth: AuthRequired,
body: UpdateActivityLogSettingsRequest,
engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine),
) -> ActivityLogSettingsResponse:
"""Update the activity-log retention settings (applied immediately).
Requires a non-anonymous API key (loopback-anonymous access is rejected)
because disabling the log or pruning retention is equivalent in impact to
clearing the audit trail.
Setting ``enabled=false`` records an audit entry BEFORE the flag takes
effect so the last entry in the log shows who disabled recording.
"""
require_authenticated(auth)
result = await engine.update_settings(
enabled=body.enabled,
max_days=body.max_days,
max_entries=body.max_entries,
)
return ActivityLogSettingsResponse(**result)
# ---------------------------------------------------------------------------
# DELETE /api/v1/activity-log — clear
# ---------------------------------------------------------------------------
@router.delete("", summary="Clear all activity-log entries")
def clear_activity_log(
auth: AuthRequired,
repo: ActivityLogRepository = Depends(get_activity_log_repo),
recorder: ActivityRecorder = Depends(get_activity_recorder),
) -> dict:
"""Delete all activity-log entries.
Requires a non-anonymous API key (loopback-anonymous access is rejected).
The clear operation itself is audited — a ``system/activity_log_cleared``
entry is recorded AFTER the wipe, so the log shows who cleared it and how
many rows were removed.
Returns ``{"deleted": <count>}``.
"""
require_authenticated(auth)
deleted = repo.clear()
# Record the clear action (best-effort — recorder never raises).
recorder.record(
category=ActivityCategory.SYSTEM,
action="activity_log.cleared",
severity=ActivitySeverity.INFO,
actor=auth,
message=f"Activity log cleared ({deleted} entries removed)",
metadata={"deleted_count": deleted},
)
return {"deleted": deleted}
@@ -182,6 +182,12 @@ async def delete_audio_source(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete an audio source."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try:
# Check if any CSS entities reference this audio source
from ledgrab.storage.color_strip_source import AudioColorStripSource
@@ -194,7 +200,7 @@ async def delete_audio_source(
raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'")
store.delete_source(source_id)
fire_entity_event("audio_source", "deleted", source_id)
fire_entity_event("audio_source", "deleted", source_id, entity_name=_entity_name)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
+7 -1
View File
@@ -329,6 +329,12 @@ async def delete_automation(
engine: AutomationEngine = Depends(get_automation_engine),
):
"""Delete an automation."""
_entity_name: str | None = None
try:
_entity_name = store.get_automation(automation_id).name
except Exception:
pass
# Deactivate first
await engine.deactivate_if_active(automation_id)
@@ -337,7 +343,7 @@ async def delete_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("automation", "deleted", automation_id)
fire_entity_event("automation", "deleted", automation_id, entity_name=_entity_name)
# ===== Enable/Disable =====
+30 -1
View File
@@ -27,6 +27,7 @@ from ledgrab.api.schemas.system import (
)
from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger, read_upload_capped
@@ -35,6 +36,22 @@ logger = get_logger(__name__)
router = APIRouter()
def _record_system(action: str, message: str, metadata: dict | None = None) -> None:
"""Best-effort audit record for a system-level event."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
message=message,
metadata=metadata or {},
)
_SERVER_DIR = Path(__file__).resolve().parents[4]
@@ -143,6 +160,8 @@ def backup_config(
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.zip"
_record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename})
return StreamingResponse(
zip_buffer,
media_type="application/zip",
@@ -243,6 +262,7 @@ async def restore_config(
freeze_writes()
logger.info("Database restored from uploaded backup. Scheduling restart...")
_record_system("backup.restored", "Database restored from uploaded backup")
_schedule_restart()
return RestoreResponse(
@@ -257,6 +277,7 @@ def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
from ledgrab.server_ref import _broadcast_restarting
_record_system("server.restarting", "Server restart requested by user")
_broadcast_restarting()
_schedule_restart()
return {"status": "restarting"}
@@ -267,6 +288,7 @@ def shutdown_server(_: AuthRequired):
"""Gracefully shut down the server."""
from ledgrab.server_ref import request_shutdown
_record_system("server.shutdown_requested", "Server shutdown requested by user")
request_shutdown()
return {"status": "shutting_down"}
@@ -300,11 +322,17 @@ async def update_auto_backup_settings(
engine: AutoBackupEngine = Depends(get_auto_backup_engine),
):
"""Update auto-backup settings (enable/disable, interval, max backups)."""
return await engine.update_settings(
result = await engine.update_settings(
enabled=body.enabled,
interval_hours=body.interval_hours,
max_backups=body.max_backups,
)
_record_system(
"settings.changed",
f"Auto-backup settings updated (enabled={body.enabled})",
{"setting_key": "auto_backup", "enabled": body.enabled},
)
return result
@router.post("/api/v1/system/auto-backup/trigger", tags=["System"])
@@ -365,4 +393,5 @@ async def delete_saved_backup(
engine.delete_backup(filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
_record_system("backup.deleted", f"Saved backup deleted: {filename}", {"filename": filename})
return {"status": "deleted", "filename": filename}
@@ -36,6 +36,7 @@ from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_processor_manager
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.api.schemas.calibration import (
CalibrationSessionPositionRequest,
CalibrationSessionStartRequest,
@@ -81,6 +82,19 @@ async def start_calibration_session(
logger.error("Failed to start calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.started",
severity=ActivitySeverity.INFO,
entity_type="device",
entity_id=body.device_id,
message=f"Calibration session started for device '{body.device_id}'",
)
return CalibrationSessionStateResponse(**session.get_state())
@@ -135,6 +149,17 @@ async def stop_calibration_session(
logger.error("Failed to stop calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.stopped",
severity=ActivitySeverity.INFO,
message="Calibration session stopped",
)
return CalibrationSessionStateResponse(**session.get_state())
@@ -155,6 +180,17 @@ async def cancel_calibration_session(
logger.error("Failed to cancel calibration session: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="calibration.cancelled",
severity=ActivitySeverity.INFO,
message="Calibration session cancelled",
)
return CalibrationSessionStateResponse(**session.get_state())
@@ -167,6 +167,12 @@ async def delete_color_strip_source(
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
_entity_name: str | None = None
try:
_entity_name = store.get_source(source_id).name
except Exception:
pass
try:
target_names = target_store.get_targets_referencing_css(source_id)
if target_names:
@@ -201,7 +207,7 @@ async def delete_color_strip_source(
"Delete or reassign the processed source(s) first.",
)
store.delete_source(source_id)
fire_entity_event("color_strip_source", "deleted", source_id)
fire_entity_event("color_strip_source", "deleted", source_id, entity_name=_entity_name)
except HTTPException:
raise
except ValueError as e:
+8 -1
View File
@@ -701,6 +701,13 @@ async def delete_device(
):
"""Delete/detach a device. Returns 409 if referenced by a target."""
try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = store.get_device(device_id).name
except Exception:
pass
# Check if any target references this device
refs = target_store.get_targets_for_device(device_id)
if refs:
@@ -728,7 +735,7 @@ async def delete_device(
# Delete from storage
store.delete_device(device_id)
fire_entity_event("device", "deleted", device_id)
fire_entity_event("device", "deleted", device_id, entity_name=_entity_name)
logger.info(f"Deleted device {device_id}")
except HTTPException:
@@ -6,6 +6,7 @@ adapter metadata, and diagnostics.
import threading
import time
from collections import defaultdict
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -57,6 +58,73 @@ _prev_states: dict[str, dict[str, Any]] = {}
_integration_stats: dict[str, dict[str, Any]] = {}
# ── Failed-auth rate limiter (brute-force defence on the ingest route) ─────
#
# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT
# rate-limit every event — that would throttle legitimate gameplay traffic.
# Instead we throttle only FAILED-auth attempts per source IP (the only thing
# an attacker without the token can produce). This mirrors the IP-based
# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a
# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute
# while authenticated high-rate ingestion is completely unaffected.
_AUTH_FAIL_LIMIT = 30
_AUTH_FAIL_WINDOW = 60.0 # seconds
_AUTH_FAIL_HITS_HARD_CAP = 1024
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
_auth_fail_hits: dict[str, list[float]] = defaultdict(list)
_auth_fail_lock = threading.Lock()
def _rate_limit_key(request: Request) -> str:
"""Pick a stable client identifier for rate-limiting.
When the immediate peer is loopback (assumed reverse-proxy), use the
first ``X-Forwarded-For`` entry; otherwise use the peer's IP.
"""
peer = request.client.host if request.client else "unknown"
if peer in _LOOPBACK_HOSTS:
xff = request.headers.get("x-forwarded-for", "")
if xff:
return xff.split(",", 1)[0].strip() or peer
return peer
def _check_auth_fail_rate_limit(client_ip: str) -> None:
"""Raise 429 if *client_ip* exceeded the failed-auth attempt limit."""
now = time.time()
window_start = now - _AUTH_FAIL_WINDOW
with _auth_fail_lock:
timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start]
_auth_fail_hits[client_ip] = timestamps
if len(timestamps) >= _AUTH_FAIL_LIMIT:
raise HTTPException(
status_code=429,
detail="Too many failed authentication attempts. Try again later.",
)
def _record_auth_failure(client_ip: str) -> None:
"""Record a failed-auth attempt for *client_ip* (bounded memory)."""
now = time.time()
window_start = now - _AUTH_FAIL_WINDOW
with _auth_fail_lock:
_auth_fail_hits[client_ip].append(now)
# Periodic cleanup of stale IPs to prevent unbounded growth.
if len(_auth_fail_hits) > 100:
stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start]
for ip in stale:
del _auth_fail_hits[ip]
# Hard cap against an attacker spraying many distinct X-Forwarded-For
# values; drop the oldest-touched IPs.
if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP:
ordered = sorted(
_auth_fail_hits.items(),
key=lambda kv: kv[1][-1] if kv[1] else 0.0,
)
for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]:
_auth_fail_hits.pop(ip, None)
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Convert a JSON Schema object into a flat list of field descriptors.
@@ -387,7 +455,16 @@ async def ingest_event(
called before standard API auth.
No AuthRequired dependency — adapter-level auth is used instead.
Rate limiting is scoped to FAILED-auth attempts per source IP (see
``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is
never throttled, but a brute-forcer is locked out after the threshold.
"""
client_ip = _rate_limit_key(request)
# Block IPs that have already burned through the failed-auth budget,
# before doing any work (cheap brute-force lockout).
_check_auth_fail_rate_limit(client_ip)
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
@@ -405,6 +482,7 @@ async def ingest_event(
# Adapter-level auth check
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
_record_auth_failure(client_ip)
raise HTTPException(status_code=403, detail="Adapter authentication failed")
# Parse payload through adapter
+7 -1
View File
@@ -152,13 +152,19 @@ async def delete_gradient(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a gradient (fails if built-in or referenced by sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_gradient(gradient_id).name
except Exception:
pass
try:
# Check references
for source in css_store.get_all_sources():
if getattr(source, "gradient_id", None) == gradient_id:
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
store.delete_gradient(gradient_id)
fire_entity_event("gradient", "deleted", gradient_id)
fire_entity_event("gradient", "deleted", gradient_id, entity_name=_entity_name)
except (ValueError, EntityNotFoundError) as e:
status = 404 if "not found" in str(e).lower() else 400
raise HTTPException(status_code=status, detail=str(e))
@@ -2,6 +2,7 @@
import asyncio
import json
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.home_assistant_source import HomeAssistantSource
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.utils import get_logger
from ledgrab.utils.net_classify import validate_lan_host
logger = get_logger(__name__)
@@ -37,6 +39,23 @@ router = APIRouter()
_REDACTED_TOKEN = "***"
def _validate_ha_host(host: str | None) -> None:
"""Reject literal public/link-local/metadata IPs for a HA source host.
HA sources are LAN-by-design (loopback + private ranges allowed), so we
gate the user-supplied ``host`` with the same shared classifier the LED
device providers use (``validate_lan_host``). The HA host is stored as
``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via
``urlparse`` — which also handles bracketed IPv6 literals. Hostnames /
mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError``
on a literal public IP, which the callers translate to HTTP 400.
"""
if not host:
return
bare_host = urlparse(f"//{host.strip()}").hostname or host.strip()
validate_lan_host(bare_host)
def _to_response(
source: HomeAssistantSource,
manager: HomeAssistantManager,
@@ -99,6 +118,7 @@ async def create_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
_validate_ha_host(data.host)
source = store.create_source(
name=data.name,
host=data.host,
@@ -153,6 +173,7 @@ async def update_ha_source(
manager: HomeAssistantManager = Depends(get_ha_manager),
):
try:
_validate_ha_host(data.host)
source = store.update_source(
source_id,
name=data.name,
@@ -624,6 +624,13 @@ async def delete_target(
):
"""Delete a output target. Stops processing first if active."""
try:
# Resolve name before deletion for the audit record.
_entity_name: str | None = None
try:
_entity_name = target_store.get_target(target_id).name
except Exception:
pass
# Stop processing if running
try:
await manager.stop_processing(target_id)
@@ -641,7 +648,7 @@ async def delete_target(
# Delete from store
target_store.delete_target(target_id)
fire_entity_event("output_target", "deleted", target_id)
fire_entity_event("output_target", "deleted", target_id, entity_name=_entity_name)
logger.info(f"Deleted target {target_id}")
except ValueError as e:
@@ -12,6 +12,7 @@ from ledgrab.api.dependencies import (
get_picture_source_store,
get_processor_manager,
)
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
@@ -28,6 +29,7 @@ from ledgrab.storage.color_strip_source import (
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -35,6 +37,23 @@ logger = get_logger(__name__)
router = APIRouter()
def _record_capture(action: str, target_id: str, target_name: str | None, message: str) -> None:
"""Best-effort audit record for a capture start/stop action."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.CAPTURE,
action=action,
severity=ActivitySeverity.INFO,
entity_type="output_target",
entity_id=target_id,
entity_name=sanitize_display(target_name) if target_name else None,
message=message,
)
# ===== BULK PROCESSING CONTROL ENDPOINTS =====
@@ -53,10 +72,18 @@ async def bulk_start_processing(
for target_id in body.ids:
try:
target_store.get_target(target_id)
_tgt = target_store.get_target(target_id)
await manager.start_processing(target_id)
started.append(target_id)
logger.info(f"Bulk start: started processing for target {target_id}")
_tgt_name_raw = getattr(_tgt, "name", None)
_tgt_safe = sanitize_display(_tgt_name_raw) if _tgt_name_raw else None
_record_capture(
"capture.started",
target_id,
_tgt_safe,
f"Capture started for target '{_tgt_safe or target_id}' (bulk)",
)
except ValueError as e:
errors[target_id] = str(e)
except RuntimeError as e:
@@ -78,6 +105,7 @@ async def bulk_start_processing(
async def bulk_stop_processing(
body: BulkTargetRequest,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors."""
@@ -89,6 +117,18 @@ async def bulk_stop_processing(
await manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Bulk stop: stopped processing for target {target_id}")
_tgt_name: str | None = None
try:
_tgt_name = target_store.get_target(target_id).name
except Exception:
pass
_tgt_name_safe = sanitize_display(_tgt_name) if _tgt_name else None
_record_capture(
"capture.stopped",
target_id,
_tgt_name_safe,
f"Capture stopped for target '{_tgt_name_safe or target_id}' (bulk)",
)
except ValueError as e:
errors[target_id] = str(e)
except Exception as e:
@@ -112,11 +152,19 @@ async def start_processing(
logger.info("Start processing requested for target %s", target_id)
try:
# Verify target exists in store
target_store.get_target(target_id)
target = target_store.get_target(target_id)
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
_tgt_name_raw2 = getattr(target, "name", None)
_tgt_safe2 = sanitize_display(_tgt_name_raw2) if _tgt_name_raw2 else None
_record_capture(
"capture.started",
target_id,
_tgt_safe2,
f"Capture started for target '{_tgt_safe2 or target_id}'",
)
return {"status": "started", "target_id": target_id}
except ValueError as e:
@@ -137,6 +185,7 @@ async def start_processing(
async def stop_processing(
target_id: str,
_auth: AuthRequired,
target_store: OutputTargetStore = Depends(get_output_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Stop processing for a output target."""
@@ -144,6 +193,18 @@ async def stop_processing(
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
_target_name: str | None = None
try:
_target_name = target_store.get_target(target_id).name
except Exception:
pass
_target_name_safe = sanitize_display(_target_name) if _target_name else None
_record_capture(
"capture.stopped",
target_id,
_target_name_safe,
f"Capture stopped for target '{_target_name_safe or target_id}'",
)
return {"status": "stopped", "target_id": target_id}
except ValueError as e:
@@ -374,6 +374,12 @@ async def delete_picture_source(
css_store: ColorStripStore = Depends(get_color_strip_store),
):
"""Delete a picture source."""
_entity_name: str | None = None
try:
_entity_name = store.get_stream(stream_id).name
except Exception:
pass
try:
# Check if any target transitively references this stream via a CSS
target_names = store.get_targets_referencing(stream_id, target_store, css_store)
@@ -395,7 +401,7 @@ async def delete_picture_source(
f"{css_names}. Please reassign or delete those first.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
fire_entity_event("picture_source", "deleted", stream_id, entity_name=_entity_name)
except HTTPException:
raise
except EntityNotFoundError as e:
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.api.dependencies import (
fire_entity_event,
get_playlist_engine,
@@ -220,13 +221,19 @@ async def delete_scene_playlist(
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Delete a scene playlist (stops it first if it is currently cycling)."""
_entity_name: str | None = None
try:
_entity_name = store.get_playlist(playlist_id).name
except Exception:
pass
try:
store.delete_playlist(playlist_id)
except (ValueError, EntityNotFoundError) as e:
raise HTTPException(status_code=404, detail=str(e))
await engine.stop_if_running(playlist_id)
fire_entity_event("scene_playlist", "deleted", playlist_id)
fire_entity_event("scene_playlist", "deleted", playlist_id, entity_name=_entity_name)
# ===== Cycling control =====
@@ -255,6 +262,28 @@ async def start_scene_playlist(
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("scene_playlist", "updated", playlist_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_pl_name: str | None = None
try:
_pl_name = store.get_playlist(playlist_id).name
except Exception:
pass
_safe_pl_name = sanitize_display(_pl_name) if _pl_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="playlist.started",
severity=ActivitySeverity.INFO,
entity_type="scene_playlist",
entity_id=playlist_id,
entity_name=_safe_pl_name,
message=f"Playlist '{_safe_pl_name or playlist_id}' started",
)
return PlaylistRuntimeStateSchema(**engine.get_state())
@@ -265,11 +294,35 @@ async def start_scene_playlist(
)
async def stop_scene_playlist(
_auth: AuthRequired,
store: ScenePlaylistStore = Depends(get_scene_playlist_store),
engine: PlaylistEngine = Depends(get_playlist_engine),
):
"""Stop the active playlist (leaves the last applied scene in place)."""
stopped_id = engine.get_running_playlist_id()
_stopped_name: str | None = None
if stopped_id:
try:
_stopped_name = store.get_playlist(stopped_id).name
except Exception:
pass
await engine.stop()
if stopped_id:
fire_entity_event("scene_playlist", "updated", stopped_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_stopped_name = sanitize_display(_stopped_name) if _stopped_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="playlist.stopped",
severity=ActivitySeverity.INFO,
entity_type="scene_playlist",
entity_id=stopped_id,
entity_name=_safe_stopped_name,
message=f"Playlist '{_safe_stopped_name or stopped_id}' stopped",
)
return PlaylistRuntimeStateSchema(**engine.get_state())
+25 -1
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from ledgrab.api.auth import AuthRequired
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.api.dependencies import (
fire_entity_event,
get_output_target_store,
@@ -208,12 +209,18 @@ async def delete_scene_preset(
store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Delete a scene preset."""
_entity_name: str | None = None
try:
_entity_name = store.get_preset(preset_id).name
except Exception:
pass
try:
store.delete_preset(preset_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
fire_entity_event("scene_preset", "deleted", preset_id)
fire_entity_event("scene_preset", "deleted", preset_id, entity_name=_entity_name)
# ===== Recapture =====
@@ -282,4 +289,21 @@ async def activate_scene_preset(
logger.info(f"Scene preset '{preset.name}' activated successfully")
fire_entity_event("scene_preset", "updated", preset_id)
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_preset_name = sanitize_display(preset.name) if preset.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="scene.activated",
severity=ActivitySeverity.INFO,
entity_type="scene_preset",
entity_id=preset_id,
entity_name=_safe_preset_name,
message=f"Scene preset '{_safe_preset_name or preset_id}' activated",
)
return ActivateResponse(status=status, errors=errors)
+7 -1
View File
@@ -149,6 +149,12 @@ async def delete_sync_clock(
manager: SyncClockManager = Depends(get_sync_clock_manager),
):
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
_entity_name: str | None = None
try:
_entity_name = store.get_clock(clock_id).name
except Exception:
pass
try:
# Check references
for source in css_store.get_all_sources():
@@ -159,7 +165,7 @@ async def delete_sync_clock(
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -21,11 +21,29 @@ from ledgrab.api.schemas.system import (
ShutdownActionRequest,
ShutdownActionResponse,
)
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
def _record_setting(action: str, key: str, message: str) -> None:
"""Best-effort audit record for a high-value settings change."""
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
message=message,
metadata={"setting_key": key},
)
router = APIRouter()
@@ -117,6 +135,11 @@ async def update_shutdown_action(
"""Set what happens to LED targets when the server shuts down."""
db.set_setting("shutdown_action", {"action": body.action})
logger.info("Shutdown action updated: %s", body.action)
_record_setting(
"settings.changed",
"shutdown_action",
f"Shutdown action set to '{body.action}'",
)
return ShutdownActionResponse(action=body.action)
@@ -246,6 +269,17 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
output = (stdout.decode() + stderr.decode()).strip()
if "connected" in output.lower():
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.DEVICE,
action="device.adb_connected",
severity=ActivitySeverity.INFO,
message=f"ADB device connected: {sanitize_display(address)}",
metadata={"address": address},
)
return {"status": "connected", "address": address, "message": output}
raise HTTPException(status_code=400, detail=output or "Connection failed")
except FileNotFoundError:
@@ -276,6 +310,17 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.DEVICE,
action="device.adb_disconnected",
severity=ActivitySeverity.INFO,
message=f"ADB device disconnected: {sanitize_display(address)}",
metadata={"address": address},
)
return {"status": "disconnected", "message": stdout.decode().strip()}
except FileNotFoundError:
raise HTTPException(status_code=500, detail="adb not found on PATH")
+7 -1
View File
@@ -183,6 +183,12 @@ async def delete_template(
Validates that no streams are currently using this template before deletion.
"""
_entity_name: str | None = None
try:
_entity_name = template_store.get_template(template_id).name
except Exception:
pass
try:
# Check if any streams are using this template
streams_using_template = []
@@ -203,7 +209,7 @@ async def delete_template(
# Proceed with deletion
template_store.delete_template(template_id)
fire_entity_event("capture_template", "deleted", template_id)
fire_entity_event("capture_template", "deleted", template_id, entity_name=_entity_name)
except HTTPException:
raise # Re-raise HTTP exceptions as-is
+37 -1
View File
@@ -12,6 +12,7 @@ from ledgrab.api.schemas.update import (
UpdateStatusResponse,
)
from ledgrab.core.update.update_service import UpdateService
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -42,6 +43,17 @@ async def dismiss_update(
service: UpdateService = Depends(get_update_service),
):
service.dismiss(body.version)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="update.dismissed",
severity=ActivitySeverity.INFO,
message=f"Update dismissed: {body.version}",
metadata={"version": body.version},
)
return {"ok": True}
@@ -63,6 +75,18 @@ async def apply_update(
)
try:
await service.apply_update()
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
version = status.get("available_version", "unknown")
rec.record(
category=ActivityCategory.SYSTEM,
action="update.applied",
severity=ActivitySeverity.INFO,
message=f"Update applied: {version}",
metadata={"version": version},
)
return {"ok": True, "message": "Update applied, server shutting down"}
except Exception as exc:
logger.error("Failed to apply update: %s", exc, exc_info=True)
@@ -83,8 +107,20 @@ async def update_update_settings(
body: UpdateSettingsRequest,
service: UpdateService = Depends(get_update_service),
):
return await service.update_settings(
result = await service.update_settings(
enabled=body.enabled,
check_interval_hours=body.check_interval_hours,
include_prerelease=body.include_prerelease,
)
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
rec.record(
category=ActivityCategory.SYSTEM,
action="settings.changed",
severity=ActivitySeverity.INFO,
message=f"Update settings changed (enabled={body.enabled})",
metadata={"setting_key": "update", "enabled": body.enabled},
)
return result
@@ -0,0 +1,93 @@
"""Pydantic schemas for the activity-log API (Phase 4)."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Entry + page response
# ---------------------------------------------------------------------------
class ActivityLogEntryResponse(BaseModel):
"""Single audit-log entry.
Shape matches ``entry_to_dict()`` from
``ledgrab.core.activity_log.recorder`` exactly — that function is the
single source of truth for serialisation; this schema documents the wire
format.
"""
id: str = Field(description="Entry id — 'al_<8-hex>'")
ts: str = Field(description="ISO-8601 UTC timestamp")
category: str = Field(description="Broad bucket (auth, device, entity, capture, system)")
action: str = Field(description="Verb-object label, e.g. 'entity.created'")
severity: str = Field(description="info | warning | error")
actor: str = Field(description="API-key label or 'system' / 'anonymous'")
entity_type: str | None = Field(default=None, description="Affected entity type, if applicable")
entity_id: str | None = Field(default=None, description="Affected entity id, if applicable")
entity_name: str | None = Field(
default=None, description="Entity name at time of event, if applicable"
)
message: str = Field(description="Human-readable description")
metadata: dict[str, Any] = Field(default_factory=dict, description="Extra structured context")
class ActivityLogPageResponse(BaseModel):
"""Paginated list of audit-log entries (keyset cursor)."""
entries: list[ActivityLogEntryResponse] = Field(description="Entries on this page")
next_before_seq: int | None = Field(
default=None,
description=(
"Pass as 'before_seq' in the next request to get the following page. "
"None when this is the last page."
),
)
has_more: bool = Field(
description="True when there are more entries before the first entry on this page"
)
total: int = Field(description="Total entries matching the current filters (all pages)")
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
_MAX_DAYS_CAP = 3650 # 10 years — sanity upper bound
_MAX_ENTRIES_CAP = 10_000_000 # 10 M rows — sanity upper bound
class ActivityLogSettingsResponse(BaseModel):
"""Current activity-log retention settings."""
enabled: bool = Field(description="Whether the activity log is recording")
max_days: int = Field(
ge=0,
le=_MAX_DAYS_CAP,
description="Retain entries for at most this many days (0 = no age-based pruning)",
)
max_entries: int = Field(
ge=0,
le=_MAX_ENTRIES_CAP,
description="Keep at most this many entries (0 = no count-based pruning)",
)
class UpdateActivityLogSettingsRequest(BaseModel):
"""Request body for PUT /settings."""
enabled: bool = Field(description="Enable or disable activity-log recording")
max_days: int = Field(
ge=0,
le=_MAX_DAYS_CAP,
description="Retain entries for at most this many days (0 = no age-based pruning)",
)
max_entries: int = Field(
ge=0,
le=_MAX_ENTRIES_CAP,
description="Keep at most this many entries (0 = no count-based pruning)",
)
@@ -0,0 +1,25 @@
"""Activity / audit log core — recorder, retention engine, and actor context.
Public surface
--------------
``ActivityRecorder`` — thread-safe facade; persists entries and fires live events.
``ActivityLogRetentionEngine`` — background pruning engine (mirrors AutoBackupEngine).
``current_actor`` — ``ContextVar[str]`` set by the auth layer per request.
Quick start
-----------
Wired in ``main.py`` lifespan; injected via ``api/dependencies.py`` getters.
Phase 3 adds the instrumentation call sites.
"""
from ledgrab.core.activity_log.context import current_actor
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.core.activity_log.sanitize import sanitize_display
__all__ = [
"ActivityRecorder",
"ActivityLogRetentionEngine",
"current_actor",
"sanitize_display",
]
@@ -0,0 +1,21 @@
"""Actor context variable for the activity log.
``current_actor`` is set by ``api/auth.py:verify_api_key`` on every request so
that ``ActivityRecorder.record(...)`` can resolve the actor without requiring
every call site to pass it explicitly.
Default value is ``"system"`` — used by background engines and any code path
that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf
discovery thread).
Per-request isolation is guaranteed by ASGI's coroutine context: each request
runs in its own coroutine with its own copy of the context inherited from the
server's main task. The auth layer resets it on every request before the route
handler runs, so stale labels from a previous request cannot bleed into a new
one.
"""
from contextvars import ContextVar
#: The actor label for the current request — API-key label or ``"system"``.
current_actor: ContextVar[str] = ContextVar("current_actor", default="system")
@@ -0,0 +1,267 @@
"""Thread-safe ActivityRecorder facade.
Responsibilities
----------------
1. Build an ``ActivityLogEntry`` from the caller-supplied fields.
2. Resolve the ``actor`` from the ``current_actor`` ContextVar when not given.
3. Persist the entry via ``ActivityLogRepository.record()`` on the event-loop
thread — inline if already on that thread, via
``loop.call_soon_threadsafe`` if called from another thread (e.g. the
zeroconf discovery thread that fires ``device_discovered/lost`` events).
4. Push a live ``activity_logged`` event via
``ProcessorManager.fire_event({"type": "activity_logged", "entry": {...}})``.
5. Never raise into the caller — audit recording is best-effort. Failures are
logged at ``WARNING`` level so operators can diagnose without breaking the
audited action.
Thread-marshal pattern mirrors ``utils/log_broadcaster.py`` (``ensure_loop`` /
``call_soon_threadsafe``).
Module accessor
---------------
A module-level singleton ``_recorder`` is populated by
``set_module_recorder()`` during ``main.py`` lifespan startup and exposed via
``get_module_recorder()``. Background engines and other non-DI sites that need
to call ``record()`` without FastAPI DI can use this accessor. Phase 3
instrumentation uses it at the ``fire_entity_event`` choke-point.
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from ledgrab.core.activity_log.context import current_actor
from ledgrab.storage.activity_log import ActivityLogEntry, ActivitySeverity
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.activity_log_repository import ActivityLogRepository
logger = get_logger(__name__)
def _new_id() -> str:
"""Generate a compact activity-log entry id: ``al_<8-hex-chars>``."""
return "al_" + uuid.uuid4().hex[:8]
def entry_to_dict(entry: ActivityLogEntry) -> dict:
"""Serialise an ``ActivityLogEntry`` to the canonical API/event payload dict.
Reused by Phase 4 (API response serialisation) and Phase 5 (frontend).
The shape is identical to the flat row codec minus the DB-only ``seq``
field, but with ``ts`` kept as an ISO-8601 string and ``metadata`` as a
real ``dict`` (not a JSON string).
"""
return {
"id": entry.id,
"ts": entry.ts.isoformat(),
"category": entry.category,
"action": entry.action,
"severity": entry.severity,
"actor": entry.actor,
"entity_type": entry.entity_type,
"entity_id": entry.entity_id,
"entity_name": entry.entity_name,
"message": entry.message,
"metadata": entry.metadata,
}
class ActivityRecorder:
"""Thread-safe facade for persisting audit log entries.
Parameters
----------
repo:
``ActivityLogRepository`` used to persist entries.
processor_manager:
``ProcessorManager`` whose ``fire_event`` dispatches the live
``activity_logged`` event to WebSocket subscribers.
loop:
Optional: the running asyncio event loop. If ``None``, it is
captured lazily on the first call that originates from an async
context (mirroring ``LogBroadcaster.ensure_loop``). Pass it
explicitly in tests to avoid depending on a real running loop.
"""
def __init__(
self,
repo: "ActivityLogRepository",
processor_manager: "ProcessorManager",
*,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._repo = repo
self._pm = processor_manager
self._loop: asyncio.AbstractEventLoop | None = loop
self._enabled: bool = True
# ── Loop capture (mirrors LogBroadcaster.ensure_loop) ──────────────────
def ensure_loop(self) -> None:
"""Capture the running event loop if not already stored.
Call from an async context (e.g. lifespan startup) so that
``call_soon_threadsafe`` works when ``record()`` is later called from
non-async threads.
"""
if self._loop is None:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
pass
# ── Public API ──────────────────────────────────────────────────────────
@property
def enabled(self) -> bool:
"""Whether recording is currently active."""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = value
def record(
self,
category: str,
action: str,
*,
severity: str = ActivitySeverity.INFO,
actor: str | None = None,
entity_type: str | None = None,
entity_id: str | None = None,
entity_name: str | None = None,
message: str,
metadata: dict | None = None,
_bypass_enabled: bool = False,
) -> None:
"""Append one audit entry — best-effort, never raises.
Parameters
----------
category:
Broad bucket — one of :class:`~ledgrab.storage.activity_log.ActivityCategory`.
action:
Verb-object label, e.g. ``"entity.created"`` or ``"server.shutting_down"``.
severity:
One of :class:`~ledgrab.storage.activity_log.ActivitySeverity`. Defaults
to ``"info"``.
actor:
Who triggered the action. When ``None`` (the common case), the
value is resolved from :data:`~ledgrab.core.activity_log.context.current_actor`
with a default of ``"system"``.
entity_type / entity_id / entity_name:
Optional entity context for entity-domain events.
message:
Human-readable description suitable for display.
metadata:
Small JSON-serialisable dict with extra context. Defaults to ``{}``.
_bypass_enabled:
Internal flag used by the retention engine to record the
"audit log disabled" event even when ``enabled`` is ``False``.
"""
if not self._enabled and not _bypass_enabled:
return
# Resolve actor from ContextVar when not explicitly supplied.
resolved_actor = actor if actor is not None else current_actor.get()
entry = ActivityLogEntry(
id=_new_id(),
ts=datetime.now(timezone.utc),
category=category,
action=action,
severity=severity,
actor=resolved_actor,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
message=message,
metadata=metadata or {},
)
# Determine whether we are on the event-loop thread or not.
loop = self._loop
if loop is None:
# Lazy capture — may fail if called before the loop is running.
try:
loop = asyncio.get_running_loop()
self._loop = loop
except RuntimeError:
pass
if loop is not None and loop.is_running():
try:
current = asyncio.get_event_loop()
except RuntimeError:
current = None
# If the current thread IS the event-loop thread, write inline.
if current is loop:
self._write_and_emit(entry)
else:
# Called from a non-loop thread (e.g. zeroconf discovery) —
# marshal onto the event-loop thread.
try:
loop.call_soon_threadsafe(self._write_and_emit, entry)
except RuntimeError:
# Loop has been closed (rare; happens during tests)
logger.warning(
"ActivityRecorder: event loop closed, dropping entry %s", entry.id
)
else:
# No running loop — fall back to a direct synchronous write.
# This path hits in synchronous unit tests that do not start a loop.
self._write_and_emit(entry)
def _write_and_emit(self, entry: ActivityLogEntry) -> None:
"""Persist *entry* and fire the live event — called on the loop thread."""
try:
self._repo.record(entry)
except Exception as exc:
logger.warning("ActivityRecorder: failed to persist entry %s: %s", entry.id, exc)
return # don't emit an event for an entry that failed to persist
try:
self._pm.fire_event(
{
"type": "activity_logged",
"entry": entry_to_dict(entry),
}
)
except Exception as exc:
logger.warning("ActivityRecorder: failed to fire live event for %s: %s", entry.id, exc)
# ── Module-level singleton accessor ────────────────────────────────────────
#
# Background engines and non-DI call sites (Phase 3's fire_entity_event hook,
# device discovery thread) need ``record()`` without going through FastAPI DI.
# ``set_module_recorder`` is called from ``main.py`` lifespan immediately after
# the recorder is wired into ``init_dependencies``.
_recorder: ActivityRecorder | None = None
def set_module_recorder(recorder: ActivityRecorder) -> None:
"""Store the application-level recorder in the module singleton.
Called once from ``main.py`` lifespan startup.
"""
global _recorder
_recorder = recorder
def get_module_recorder() -> ActivityRecorder | None:
"""Return the module-level recorder, or ``None`` if not yet initialised.
Callers must guard against ``None`` — this returns ``None`` during module
import and early startup before ``main.py`` lifespan has run.
"""
return _recorder
@@ -0,0 +1,216 @@
"""Activity log retention engine.
Mirrors ``core/backup/auto_backup.py``:
- Settings persisted via ``db.get_setting("activity_log")`` /
``db.set_setting("activity_log", {...})``.
- ``start()`` / ``stop()`` lifecycle following the engine convention used
throughout the codebase.
- Hourly background loop calling ``repo.prune(before_ts=..., max_entries=...)``.
- ``get_settings()`` / ``async update_settings(...)`` for the Settings API
(Phase 4).
Changing ``enabled`` to ``False`` records an ``"audit_log.disabled"`` event via
the recorder BEFORE the flag takes effect — so the last action in the log is a
record of the intentional disable.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.storage.database import Database
logger = get_logger(__name__)
DEFAULT_SETTINGS: dict = {
"enabled": True,
"max_days": 90,
"max_entries": 20000,
}
# Prune loop interval — run roughly once an hour.
_PRUNE_INTERVAL_SECS = 3600
class ActivityLogRetentionEngine:
"""Background engine that prunes old activity log entries.
Parameters
----------
repo:
The ``ActivityLogRepository`` used to prune entries.
db:
The shared ``Database`` singleton for settings persistence.
recorder:
The ``ActivityRecorder`` used to log the "audit log disabled" event
before disabling takes effect.
"""
def __init__(
self,
repo: "ActivityLogRepository",
db: "Database",
recorder: "ActivityRecorder",
) -> None:
self._repo = repo
self._db = db
self._recorder = recorder
self._task: asyncio.Task | None = None
self._settings = self._load_settings()
# Rehydrate the recorder's enabled flag from persisted settings so a
# previously-disabled log stays disabled across restarts.
self._recorder.enabled = self._settings["enabled"]
# ── Settings persistence ───────────────────────────────────────────────
def _load_settings(self) -> dict:
data = self._db.get_setting("activity_log")
if data:
return {**DEFAULT_SETTINGS, **data}
return dict(DEFAULT_SETTINGS)
def _save_settings(self) -> None:
self._db.set_setting(
"activity_log",
{
"enabled": self._settings["enabled"],
"max_days": self._settings["max_days"],
"max_entries": self._settings["max_entries"],
},
)
# ── Lifecycle ──────────────────────────────────────────────────────────
async def start(self) -> None:
"""Start the retention loop if enabled."""
if self._settings["enabled"]:
self._start_loop()
logger.info(
"Activity log retention engine started " "(max_days=%d, max_entries=%d)",
self._settings["max_days"],
self._settings["max_entries"],
)
else:
logger.info("Activity log retention engine initialized (disabled)")
async def stop(self) -> None:
"""Cancel the retention loop."""
self._cancel_loop()
logger.info("Activity log retention engine stopped")
def _start_loop(self) -> None:
self._cancel_loop()
self._task = asyncio.create_task(self._retention_loop())
def _cancel_loop(self) -> None:
if self._task is not None:
self._task.cancel()
self._task = None
# ── Prune loop ─────────────────────────────────────────────────────────
async def _retention_loop(self) -> None:
try:
while True:
await asyncio.sleep(_PRUNE_INTERVAL_SECS)
try:
self._prune()
except Exception as exc:
logger.error("Activity log retention prune failed: %s", exc, exc_info=True)
except asyncio.CancelledError:
logger.debug("Activity log retention loop cancelled")
def _prune(self) -> None:
"""Execute one prune pass based on current settings."""
settings = self._settings
if not settings["enabled"]:
return
max_days: int = settings["max_days"]
max_entries: int = settings["max_entries"]
before_ts: datetime | None = None
if max_days and max_days > 0:
before_ts = datetime.now(timezone.utc) - timedelta(days=max_days)
max_entries_val: int | None = max_entries if max_entries and max_entries > 0 else None
deleted = self._repo.prune(before_ts=before_ts, max_entries=max_entries_val)
if deleted:
logger.info(
"Activity log pruned %d rows (max_days=%d, max_entries=%d)",
deleted,
max_days,
max_entries,
)
# ── Public API ─────────────────────────────────────────────────────────
def get_settings(self) -> dict:
"""Return the current retention settings dict."""
return {
"enabled": self._settings["enabled"],
"max_days": self._settings["max_days"],
"max_entries": self._settings["max_entries"],
}
async def update_settings(
self,
*,
enabled: bool,
max_days: int,
max_entries: int,
) -> dict:
"""Persist new settings and apply them immediately.
If ``enabled`` is changing to ``False``, the disable event is recorded
BEFORE the flag takes effect so there is a final log entry.
Returns the new settings dict (same as ``get_settings()``).
"""
was_enabled = self._settings["enabled"]
# Record the disable event before the recorder stops accepting entries.
if was_enabled and not enabled:
self._recorder.record(
category=ActivityCategory.SYSTEM,
action="audit_log.disabled",
severity=ActivitySeverity.WARNING,
actor="system",
message="Activity log recording disabled via settings",
_bypass_enabled=True,
)
self._settings["enabled"] = enabled
self._settings["max_days"] = max_days
self._settings["max_entries"] = max_entries
self._save_settings()
# Propagate enabled flag to the recorder.
self._recorder.enabled = enabled
if enabled:
self._start_loop()
logger.info(
"Activity log retention enabled (max_days=%d, max_entries=%d)",
max_days,
max_entries,
)
# Run an immediate prune pass when re-enabling.
try:
self._prune()
except Exception as exc:
logger.error("Activity log immediate prune failed: %s", exc)
else:
self._cancel_loop()
logger.info("Activity log retention disabled")
return self.get_settings()
@@ -0,0 +1,82 @@
"""Log-injection sanitizer for audit-log message and display strings.
Provides :func:`sanitize_display` — a dependency-free helper that strips
characters that should not appear in a recorded ``message`` or display
string before it is persisted to SQLite, broadcast over WebSocket, or
exported to CSV.
Design constraints
------------------
- **Dependency-free**: uses only the Python standard library so it can be
imported from any module without adding transitive weight.
- **Conservative**: keeps printable ASCII/Unicode and normal spaces; drops
everything else including control chars (NUL, BEL, BS, VT, FF, ESC,
DEL), ANSI/CSI escape sequences (``\\x1b[...``), and carriage returns /
newlines / tabs which are the classic log-injection primitives.
- **Length-capped**: truncates to *maxlen* characters and appends ``""``
so callers can rely on a bounded string without adding their own guards.
"""
from __future__ import annotations
import re
# Matches ANSI/VT100 escape sequences: ESC [ ... m (CSI) and shorter forms.
# We strip these before the printable-char filter so the bracket/letters that
# follow the ESC don't survive stripping the ESC alone.
_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
# Characters we explicitly want to remove even if str.isprintable() would
# let them through in some edge-case: NUL is the canonical SQL/log null-byte
# injection; the others are kept out by the printable check but listed here
# for documentation clarity.
_EXPLICIT_DROP = frozenset("\x00\r\n\t")
def sanitize_display(value: str | None, *, maxlen: int = 120) -> str:
"""Return a sanitized, length-capped version of *value* safe for log messages.
Parameters
----------
value:
The raw, potentially attacker-controlled string. ``None`` or empty
returns ``""``.
maxlen:
Maximum length of the returned string (default: 120). If the input
exceeds this length after sanitization, the string is truncated and
``""`` is appended (the ellipsis counts toward *maxlen*).
Returns
-------
str
A string that:
- contains no NUL bytes (``\\x00``),
- contains no ANSI/CSI escape sequences,
- contains no carriage returns, newlines, or tab characters,
- contains only characters for which ``str.isprintable()`` is ``True``
plus the regular ASCII space (``\\x20``),
- is at most *maxlen* characters long.
"""
if not value:
return ""
# 1. Strip ANSI escape sequences first so their bracket/letter tails don't
# survive as stray printable characters.
cleaned = _ANSI_RE.sub("", value)
# 2. Drop each character that is neither printable nor a plain space.
# str.isprintable() returns False for all control chars (including NUL,
# BEL, BS, TAB, LF, VT, FF, CR, ESC, DEL) and True for normal letters,
# digits, punctuation, and the space character.
cleaned = "".join(ch for ch in cleaned if ch.isprintable() or ch == " ")
# 3. Final belt-and-suspenders pass for the explicit drop set (catches NUL
# that may survive if isprintable ever changes in a future Python version).
cleaned = "".join(ch for ch in cleaned if ch not in _EXPLICIT_DROP)
# 4. Cap length.
if len(cleaned) > maxlen:
# Reserve one character for the ellipsis so total length == maxlen.
cleaned = cleaned[: maxlen - 1] + ""
return cleaned
@@ -726,6 +726,28 @@ class AutomationEngine:
else:
logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_safe_name = sanitize_display(automation.name) if automation.name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.activated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation.id,
entity_name=_safe_name,
message=f"Automation '{_safe_name or automation.id}' activated",
)
except Exception:
pass
async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_automations.pop(automation_id, False)
if not was_active:
@@ -751,6 +773,33 @@ class AutomationEngine:
# Clean up any leftover snapshot
self._pre_activation_snapshots.pop(automation_id, None)
# Audit record — best-effort.
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
rec = get_module_recorder()
if rec is not None:
_auto_name: str | None = None
try:
_auto_name = self._store.get_automation(automation_id).name
except Exception:
pass
_safe_deact_name = sanitize_display(_auto_name) if _auto_name else None
rec.record(
category=ActivityCategory.CAPTURE,
action="automation.deactivated",
severity=ActivitySeverity.INFO,
actor="system",
entity_type="automation",
entity_id=automation_id,
entity_name=_safe_deact_name,
message=f"Automation '{_safe_deact_name or automation_id}' deactivated",
)
except Exception:
pass
async def _deactivate_revert(self, automation_id: str) -> None:
"""Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
@@ -824,6 +824,30 @@ def create_default_calibration(
right_count = max(1, right_count)
left_count = max(1, left_count)
# The max(1, ...) floors above can push the total above led_count for
# small counts (e.g. led_count=5 -> top=2,right=1,bottom=2,left=1 = 6).
# Trim the largest edge that stays >= 1 until the total matches exactly.
edge_order = ["bottom", "top", "right", "left"]
counts = {
"bottom": bottom_count,
"top": top_count,
"right": right_count,
"left": left_count,
}
overshoot = sum(counts.values()) - led_count
while overshoot > 0:
# Pick the largest edge that can still be reduced (stays >= 1).
trimmable = [e for e in edge_order if counts[e] > 1]
if not trimmable:
break
target_edge = max(trimmable, key=lambda e: counts[e])
counts[target_edge] -= 1
overshoot -= 1
bottom_count = counts["bottom"]
top_count = counts["top"]
right_count = counts["right"]
left_count = counts["left"]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
@@ -154,6 +154,15 @@ class DDPClient:
all buses (observed in multi-bus setups). We reorder pixel channels
here so the hardware receives the correct byte order directly.
TODO(ddp-multibus): currently UNUSED — ``send_pixels_numpy`` (the hot
send path) does NOT call this, so the per-bus color-order config
captured by ``set_buses`` is never applied to outgoing pixels. This
is intentionally left in place (not deleted) because it encodes real
multi-bus handling: if a multi-bus WLED setup needs per-bus byte
reordering, wire this into ``send_pixels_numpy`` before the payload
view is built (note it allocates a copy, so only call it when
``self._buses`` actually requires reordering).
Args:
pixel_array: (N, 3) uint8 numpy array in RGB order
@@ -36,6 +36,7 @@ from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZerocon
from ledgrab.core.devices.serial_transport import list_serial_ports
from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
from ledgrab.utils.platform import is_android
@@ -286,3 +287,34 @@ class DiscoveryWatcher:
)
except Exception as e:
logger.debug("Discovery watcher: fire_event failed: %s", e)
# Audit record — best-effort, thread-safe (recorder marshals via
# call_soon_threadsafe when called from the zeroconf thread).
try:
from ledgrab.core.activity_log.recorder import get_module_recorder
from ledgrab.core.activity_log.sanitize import sanitize_display
rec = get_module_recorder()
if rec is not None:
is_discovered = event_type == "device_discovered"
action = "device.discovered" if is_discovered else "device.lost"
severity = ActivitySeverity.INFO if is_discovered else ActivitySeverity.WARNING
verb = "discovered" if is_discovered else "lost"
# Sanitize mDNS-advertised strings before they enter the log.
# entry.name and entry.url are unauthenticated, attacker-controlled
# values; strip control chars, ANSI escapes, and NUL before use.
safe_name = sanitize_display(entry.name)
safe_url = sanitize_display(entry.url)
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=entry.url,
entity_name=safe_name,
message=f"Device '{safe_name}' {verb} at {safe_url}",
metadata={"url": safe_url, "device_type": entry.device_type},
)
except Exception as e:
logger.debug("Discovery watcher: audit record failed: %s", e)
@@ -260,7 +260,10 @@ class CS2Adapter(GameAdapter):
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
return bool(actual_token and actual_token == expected_token)
if not actual_token:
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_token, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -177,7 +177,10 @@ class Dota2Adapter(GameAdapter):
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
return bool(actual_token and actual_token == expected_token)
if not actual_token:
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_token, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
@@ -4,6 +4,7 @@ Allows users to define custom JSON path mappings via the adapter_config
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
"""
import secrets
from typing import Any, ClassVar
from ledgrab.core.game_integration.base_adapter import GameAdapter
@@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter):
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate auth using a configurable header token."""
"""Validate auth using a configurable header token.
Secure-by-default: this adapter is explicitly network-facing
(it accepts unauthenticated HTTP POSTs from anywhere on the LAN),
so a missing/empty ``auth_token`` REJECTS the request rather than
accepting it. A token must be configured for ingestion to work.
"""
expected_token = adapter_config.get("auth_token")
if not expected_token:
# No auth configured
return True
# No token configured — reject (secure-by-default for a
# network-facing adapter; an open webhook is a LAN attack surface).
return False
auth_header = adapter_config.get("auth_header", "Authorization")
actual_value = headers.get(auth_header, "")
@@ -67,18 +75,27 @@ class GenericWebhookAdapter(GameAdapter):
if actual_value.startswith("Bearer "):
actual_value = actual_value[7:]
return bool(actual_value and actual_value == expected_token)
if not actual_value:
return False
# Constant-time comparison to avoid a token-length/timing oracle.
return secrets.compare_digest(actual_value, expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return generic webhook config schema."""
return {
"type": "object",
"required": ["auth_token"],
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
"description": "Optional token for authenticating incoming webhooks.",
"description": (
"Required token for authenticating incoming webhooks. "
"Without it, ingestion is rejected (this adapter is "
"network-facing and secure-by-default)."
),
},
"auth_header": {
"type": "string",
@@ -136,15 +153,17 @@ class GenericWebhookAdapter(GameAdapter):
"HTTP POST requests with JSON payloads.\n\n"
"**Steps:**\n"
"1. Configure your event mappings above — map JSON paths to standard events\n"
"2. Set an auth token (optional but recommended)\n"
"2. Set an auth token (REQUIRED — without it incoming webhooks are rejected)\n"
"3. Point your game/application to:\n"
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
"**Mapping example:**\n"
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
"**Auth:**\n"
"**Auth (required):**\n"
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
"- Or configure a custom auth header name in the adapter config\n"
"- A token is mandatory: this endpoint is reachable from the LAN, so\n"
" an unconfigured token rejects all incoming events.\n"
)
@@ -10,6 +10,7 @@ The MappingAdapter class is a concrete GameAdapter whose behavior is
entirely driven by the parsed YAML definition.
"""
import secrets
import time
from pathlib import Path
from typing import Any
@@ -241,7 +242,10 @@ class MappingAdapter(GameAdapter):
expected_key = "auth_token"
expected_value = adapter_config.get(expected_key, "")
actual_value = headers.get(header_name, "")
return bool(expected_value and actual_value == expected_value)
if not (expected_value and actual_value):
return False
# Constant-time comparison to avoid a timing oracle.
return secrets.compare_digest(actual_value, expected_value)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False
@@ -69,6 +69,9 @@ class CompositeColorStripStream(ColorStripStream):
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {}
# (src_len, target_n) -> (src_x, dst_x) cache for full-strip resizing
# (output reuses the preallocated self._resize_buf from _ensure_pool)
self._resize_linspace_cache: Dict[tuple, tuple] = {}
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream)
@@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream):
n_src = len(colors)
if n_src == target_n:
return colors
src_x = np.linspace(0, 1, n_src)
dst_x = np.linspace(0, 1, target_n)
# Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n)
# exactly like the zone path, so they are not reallocated every frame.
lkey = (n_src, target_n)
linspaces = self._resize_linspace_cache.get(lkey)
if linspaces is None:
linspaces = (np.linspace(0, 1, n_src), np.linspace(0, 1, target_n))
self._resize_linspace_cache[lkey] = linspaces
src_x, dst_x = linspaces
buf = self._resize_buf
for ch in range(3):
np.copyto(
@@ -11,6 +11,8 @@ from ledgrab.core.devices.led_client import (
check_device_health,
get_device_capabilities,
)
from ledgrab.core.activity_log.sanitize import sanitize_display
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -128,6 +130,35 @@ class DeviceHealthMixin:
"latency_ms": state.health.latency_ms,
}
)
# Audit record for device online/offline transition.
from ledgrab.core.activity_log.recorder import get_module_recorder
rec = get_module_recorder()
if rec is not None:
is_online = state.health.online
# Best-effort name lookup from the device store.
device_name: str | None = None
try:
if self._device_store is not None:
device_name = self._device_store.get_device(device_id).name
except Exception:
pass
safe_name = sanitize_display(device_name) if device_name else None
display = safe_name or device_id
action = "device.online" if is_online else "device.offline"
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
status_word = "came online" if is_online else "went offline"
rec.record(
category=ActivityCategory.DEVICE,
action=action,
severity=severity,
actor="system",
entity_type="device",
entity_id=device_id,
entity_name=safe_name,
message=f"Device '{display}' {status_word}",
metadata={"latency_ms": state.health.latency_ms},
)
# Auto-sync LED count
reported = state.health.device_led_count
@@ -137,16 +137,34 @@ class DeviceTestModeMixin:
await self._send_pixels_to_device(device_id, pixels)
async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear LED output."""
"""Send all-black pixels to clear LED output.
This is the explicit teardown path unlike the per-frame
``_send_pixels_to_device`` swallow, a clear that must actually take
effect retries once before giving up, so a single transient send
error doesn't leave the device lit after a session ends.
"""
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
await self._send_pixels_to_device(device_id, pixels)
try:
client = await self._get_idle_client(device_id)
await client.send_pixels(pixels)
except Exception as e:
logger.warning(f"Clear send to {device_id} failed, retrying once: {e}")
client = await self._get_idle_client(device_id)
await client.send_pixels(pixels)
async def _send_pixels_to_device(self, device_id: str, pixels) -> None:
"""Send pixels to a device via cached idle client.
Reuses a cached connection to avoid repeated serial reconnections
(which trigger Arduino bootloader reset on Adalight devices).
Send failures are logged and swallowed (best-effort per-frame send).
Callers that need a *guaranteed* clear e.g. session teardown that
must "never leave the device dark" must NOT rely on this returning
cleanly; use ``_send_clear_pixels`` (which retries once) and treat a
propagated exception as a failed clear.
"""
try:
client = await self._get_idle_client(device_id)
@@ -973,13 +973,18 @@ class EffectColorStripStream(ColorStripStream):
# Use noise at very low frequency for blob movement
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
# Two blob layers at different speeds for organic movement
# Two blob layers at different speeds for organic movement.
# fbm() returns a shared internal buffer that the next fbm() call
# overwrites, so each layer must be copied out — write into the
# preallocated scratch buffers instead of allocating per frame.
self._s_f32_a += t * speed * 0.1
layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy()
layer1 = self._s_layer1
np.copyto(layer1, self._noise.fbm(self._s_f32_a, octaves=3))
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
self._s_f32_a += t * speed * 0.07 + 100.0
layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy()
layer2 = self._s_layer2
np.copyto(layer2, self._noise.fbm(self._s_f32_a, octaves=2))
# Combine: create blob-like shapes with soft edges
combined = self._s_f32_a
@@ -739,10 +739,19 @@ class WledTargetProcessor(TargetProcessor):
self._last_preview_data = data
async def _send_safe(ws):
# Bound each per-client send to roughly one frame interval so a slow or
# backpressured preview WebSocket can never throttle the device send
# cadence. Clients that time out or error are dropped from the set.
eff_fps = self._effective_fps if self._effective_fps > 0 else 30
send_timeout = 1.0 / eff_fps
async def _send_safe(ws) -> bool:
try:
await ws.send_bytes(data)
await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout)
return True
except asyncio.TimeoutError:
logger.debug("LED preview broadcast WS send timed out (slow client dropped)")
return False
except Exception as e:
logger.debug("LED preview broadcast WS send failed: %s", e)
return False
@@ -138,22 +138,42 @@ class PlaylistEngine:
"""Stop the playlist only if ``playlist_id`` is the one running.
Used when a playlist is deleted or edited so a stale snapshot can't keep
cycling.
cycling. The read-compare and the stop happen atomically under the
lifecycle lock so a concurrent natural-end / start can't slip a
different playlist in between the check and the stop.
"""
if self._state is not None and self._state.playlist_id == playlist_id:
await self.stop()
async with self._lifecycle_lock:
state = self._state
if state is None or state.playlist_id != playlist_id:
return
was_running = self._task is not None
await self._cancel_task()
stopped_id = self._state.playlist_id if self._state else playlist_id
self._state = None
if was_running:
self._fire_event("stopped", playlist_id=stopped_id)
logger.info("Playlist stopped")
# ===== Query API (used by routes) =====
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
# Snapshot the task ref so a concurrent clear (set to None at an await
# boundary) can't turn the deref into an attribute error mid-read.
task = self._task
return task is not None and not task.done()
def get_running_playlist_id(self) -> str | None:
return self._state.playlist_id if self._state else None
state = self._state
return state.playlist_id if state else None
def get_state(self) -> dict:
if self._state is not None and self.is_running():
return self._state.to_dict()
# Snapshot both refs once: the event loop won't preempt this sync method
# between the reads, but snapshotting also guards against ever returning
# a half-cleared state (running True while _state is already None).
state = self._state
task = self._task
if state is not None and task is not None and not task.done():
return state.to_dict()
return dict(_IDLE_STATE)
# ===== Internal =====
@@ -201,13 +221,25 @@ class PlaylistEngine:
break
# Natural end (non-loop or guard). Clear state without recursing
# through stop() (which would try to cancel this very task). Guard
# against a concurrent start_playlist having already replaced us:
# only clear if we are still the engine's current task.
if self._task is asyncio.current_task():
self._task = None
ended_id = self._state.playlist_id if self._state else None
self._state = None
# through stop() (which would try to cancel this very task). Take
# the lifecycle lock so the clear + 'stopped' event are atomic with
# respect to a concurrent start/stop/stop_if_running — otherwise the
# two could interleave and emit duplicate or contradictory terminal
# events. _run never calls _cancel_task and never otherwise holds
# this lock, so acquiring it here cannot deadlock; if a canceller
# holding the lock cancels us while we wait to acquire it, the
# acquire raises CancelledError and we fall through to the handler.
ended_id = None
should_fire = False
async with self._lifecycle_lock:
# Re-check under the lock: a concurrent start_playlist may have
# replaced us while we waited. Only clear if we're still current.
if self._task is asyncio.current_task():
self._task = None
ended_id = self._state.playlist_id if self._state else None
self._state = None
should_fire = True
if should_fire:
self._fire_event("stopped", playlist_id=ended_id)
logger.info("Playlist '%s' finished", playlist_id)
except asyncio.CancelledError:
+48
View File
@@ -60,6 +60,9 @@ from ledgrab.storage.audio_processing_template_store import AudioProcessingTempl
from ledgrab.storage.pattern_template_store import PatternTemplateStore
import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.activity_log.recorder import ActivityRecorder, set_module_recorder
from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.core.processing.os_notification_listener import OsNotificationListener
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher
from ledgrab.core.update.update_service import UpdateService
@@ -184,6 +187,10 @@ pattern_template_store = PatternTemplateStore(db)
game_event_bus = GameEventBus()
register_community_adapters()
# Activity log repository — constructed at module level like other stores so
# it migrates the DB schema (``002_add_activity_log``) on import.
activity_log_repo = ActivityLogRepository(db)
processor_manager = ProcessorManager(
ProcessorDependencies(
picture_source_store=picture_source_store,
@@ -290,6 +297,17 @@ async def lifespan(app: FastAPI):
processor_manager=processor_manager,
)
# Create activity recorder + retention engine. The recorder needs the
# processor_manager to fire live events, so it is built after that is
# already constructed at module level.
activity_recorder = ActivityRecorder(activity_log_repo, processor_manager)
activity_recorder.ensure_loop()
activity_log_retention_engine = ActivityLogRetentionEngine(
repo=activity_log_repo,
db=db,
recorder=activity_recorder,
)
# Create auto-backup engine — derive paths from database location so that
# demo mode auto-backups go to data/demo/ instead of data/.
_data_dir = Path(config.storage.database_file).parent
@@ -347,7 +365,13 @@ async def lifespan(app: FastAPI):
http_endpoint_store=http_endpoint_store,
audio_processing_template_store=audio_processing_template_store,
pattern_template_store=pattern_template_store,
activity_recorder=activity_recorder,
activity_log_repo=activity_log_repo,
activity_log_retention_engine=activity_log_retention_engine,
)
# Expose the recorder via the module singleton so non-DI sites
# (fire_entity_event, device threads) can call record() without FastAPI DI.
set_module_recorder(activity_recorder)
# Register devices in processor manager for health monitoring
devices = device_store.get_all_devices()
@@ -390,6 +414,9 @@ async def lifespan(app: FastAPI):
# Start auto-backup engine (periodic configuration backups)
await auto_backup_engine.start()
# Start activity log retention engine (hourly prune of old entries)
await activity_log_retention_engine.start()
# Start update checker (periodic release polling)
await update_service.start()
@@ -438,6 +465,19 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error("Shutdown step '%s' raised: %s", label, e)
# Record the shutdown event FIRST — before any engine teardown — so there
# is always a final log entry on graceful shutdown.
try:
activity_recorder.record(
category="system",
action="server.shutting_down",
severity="info",
actor="system",
message="Server is shutting down",
)
except Exception as e:
logger.error("Failed to record shutdown event: %s", e)
# Legacy hook — SQLite stores are write-through so this only logs.
# Durability comes from PRAGMA synchronous=FULL + the explicit
# wal_checkpoint(TRUNCATE) in Database.close() at the end of this block.
@@ -453,6 +493,13 @@ async def lifespan(app: FastAPI):
# Stop the playlist engine so its cycling task can't apply scenes mid-shutdown.
await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0)
# Tear down any active calibration session BEFORE stop_all so the device
# isn't left stuck in the white-chase and its prior target is restored.
# stop() is a no-op when no session is active.
from ledgrab.core.capture.calibration_session import get_calibration_session
await _bounded("calibration_session.stop", get_calibration_session().stop(), timeout=1.0)
# Stop discovery watcher and OS notification listener so they stop
# firing events into a shutting-down processor manager.
if discovery_watcher is not None:
@@ -503,6 +550,7 @@ async def lifespan(app: FastAPI):
await _bounded("update_service.stop", update_service.stop(), timeout=0.5)
await _bounded("auto_backup_engine.stop", auto_backup_engine.stop(), timeout=0.5)
await _bounded("activity_log_retention.stop", activity_log_retention_engine.stop(), timeout=0.5)
# Close the DB last so it runs a TRUNCATE checkpoint, flushing the WAL
# into the main file. Without this, writes can survive a graceful app
@@ -0,0 +1,770 @@
/*
Activity Log audit viewer tab
Design language: precision-instrument / ledger. Monospaced timestamps,
color-coded severity rail, thin category pills. Clean "terminal" feel
without being cold the primary green accent anchors the live-update dot.
*/
/* ── Panel wrapper ───────────────────────────────────────────────────────── */
.al-panel {
display: flex;
flex-direction: column;
gap: 0;
max-width: 1400px;
margin: 0 auto;
padding: var(--space-lg) var(--space-lg) var(--space-xl);
min-height: 0;
}
/* ── Filter toolbar ──────────────────────────────────────────────────────── */
.al-toolbar {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) var(--space-md);
/* Match the elevated card surface used by entity cards (.dashboard-target),
not the near-black --bg-secondary, so the panel reads as one of the app's
cards rather than a separate flat sheet. */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline) solid var(--lux-line, var(--border-color));
border-radius: var(--radius-md);
margin-bottom: var(--space-md);
}
.al-toolbar-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.al-toolbar-search {
gap: var(--space-sm);
}
/* Search input */
.al-search-wrap {
position: relative;
flex: 1;
min-width: 200px;
max-width: 420px;
}
.al-search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--text-muted);
display: flex;
align-items: center;
}
.al-search-icon .icon {
width: 15px;
height: 15px;
}
.al-search-input {
width: 100%;
padding: 6px 10px 6px 34px;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-color);
font-size: 0.875rem;
font-family: var(--font-body);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
outline: none;
}
.al-search-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
.al-search-input::placeholder { color: var(--text-muted); }
/* Quick presets */
.al-presets {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.al-preset-btn {
padding: 4px 10px;
font-size: 0.75rem;
font-weight: 500;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-pill);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
white-space: nowrap;
}
.al-preset-btn:hover {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast);
}
/* Clear button */
.al-clear-btn {
padding: 4px 6px;
margin-left: auto;
color: var(--text-secondary);
}
.al-clear-btn:hover { color: var(--danger-color); border-color: var(--danger-color); }
/* Export button + dropdown */
.al-export-wrap {
position: relative;
margin-left: auto;
}
.al-export-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
font-size: 0.8125rem;
}
.al-export-btn .icon { width: 14px; height: 14px; }
/* Caret signals this button opens a menu (rather than firing a direct action),
and rotates to point up while the menu is open. */
.al-export-caret {
display: inline-flex;
margin-left: 1px;
transition: transform var(--duration-fast) var(--ease-out);
}
.al-export-caret .icon { width: 12px; height: 12px; }
.al-export-wrap.open .al-export-caret { transform: rotate(180deg); }
.al-export-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--shadow-color);
z-index: 100;
min-width: 140px;
overflow: hidden;
}
.al-export-wrap.open .al-export-menu { display: block; }
.al-export-menu button {
display: block;
width: 100%;
padding: 8px 14px;
text-align: left;
background: none;
border: none;
color: var(--text-color);
font-size: 0.8125rem;
cursor: pointer;
}
.al-export-menu button:hover,
.al-export-menu button:focus-visible { background: var(--bg-secondary); outline: none; }
/* Filter label */
.al-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
flex-shrink: 0;
}
.al-filter-label-sep { margin-left: var(--space-sm); }
/* Category / severity chips */
.al-chip-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.al-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
font-size: 0.75rem;
font-weight: 500;
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-pill);
background: var(--card-bg);
color: var(--text-secondary);
cursor: pointer;
transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast);
white-space: nowrap;
}
.al-chip .icon { width: 12px; height: 12px; }
.al-chip:hover {
background: var(--bg-secondary);
border-color: var(--text-secondary);
color: var(--text-color);
}
.al-chip.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: var(--primary-contrast);
}
/* Severity chip colors when active */
.al-sev-chip-error.active { background: var(--danger-color); border-color: var(--danger-color); }
.al-sev-chip-warning.active { background: var(--warning-color); border-color: var(--warning-color); }
.al-sev-chip-info.active { background: var(--info-color); border-color: var(--info-color); }
/* Advanced field row */
.al-toolbar-advanced {
gap: var(--space-sm);
padding-top: var(--space-xs);
border-top: var(--lux-hairline) solid var(--border-color);
}
.al-field-group {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 160px;
flex: 1;
}
.al-field-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.al-field-input {
padding: 4px 8px;
background: var(--card-bg);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-color);
font-size: 0.8125rem;
font-family: var(--font-body);
outline: none;
transition: border-color var(--duration-fast);
}
.al-field-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.12);
}
/* ── List header (count + live dot) ─────────────────────────────────────── */
.al-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xs) 0;
margin-bottom: var(--space-xs);
}
.al-count {
font-size: 0.8125rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.al-live-indicator {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.75rem;
font-weight: 500;
color: var(--primary-text-color);
letter-spacing: 0.02em;
}
.al-live-dot {
width: 7px;
height: 7px;
background: var(--primary-color);
border-radius: 50%;
animation: al-pulse 2s infinite;
}
@keyframes al-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.85); }
}
/* ── Entry rows ─────────────────────────────────────────────────────────── */
.al-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.al-entry {
/* Same elevated surface + hairline as entity cards (see .al-toolbar). */
background: var(--lux-bg-1, var(--card-bg));
border: var(--lux-hairline) solid var(--lux-line, var(--border-color));
border-radius: var(--radius-sm);
overflow: hidden;
transition: border-color var(--duration-fast);
}
.al-entry:hover { border-color: var(--text-muted); }
/* New-entry flash settles on the card surface (animation-fill-mode: forwards
holds the 100% frame, so it must match the .al-entry background exactly). */
@keyframes al-new-flash {
0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--lux-bg-1, var(--card-bg))); }
100% { background: var(--lux-bg-1, var(--card-bg)); }
}
.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; }
.al-entry-row {
display: grid;
/* icon | time | badge | actor | message | entity | chevron
badge is fixed so all category names (AUTHCAPTURE) occupy identical
width; actor is capped so long usernames don't push the message over;
message takes all remaining space. */
grid-template-columns: 24px 80px 78px minmax(0, 110px) 1fr auto 20px;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-md);
cursor: pointer;
min-height: 36px;
outline: none;
}
.al-entry-row:focus-visible { box-shadow: inset 0 0 0 2px var(--primary-color); }
/* Severity rail icon */
.al-sev { display: flex; align-items: center; justify-content: center; }
.al-sev .icon { width: 14px; height: 14px; }
.al-sev-info .icon { color: var(--info-color); }
.al-sev-warning .icon { color: var(--warning-color); }
.al-sev-error .icon { color: var(--danger-color); }
/* Time */
.al-time {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
color: var(--text-muted);
white-space: nowrap;
}
/* Category badge */
.al-cat-badge {
display: inline-block;
padding: 1px 7px;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
border-radius: var(--radius-pill);
white-space: nowrap;
border: var(--lux-hairline) solid transparent;
}
/* Per-category colors — subtle tinted backgrounds */
.al-cat-auth { background: rgba(33, 150, 243, 0.12); color: var(--info-color); border-color: rgba(33, 150, 243, 0.25); }
.al-cat-device { background: rgba(156, 39, 176, 0.10); color: #ab47bc; border-color: rgba(156, 39, 176, 0.22); }
.al-cat-entity { background: rgba(76, 175, 80, 0.12); color: var(--primary-text-color); border-color: rgba(76, 175, 80, 0.25); }
.al-cat-capture { background: rgba(255, 152, 0, 0.12); color: var(--warning-color); border-color: rgba(255, 152, 0, 0.25); }
.al-cat-system { background: rgba(120, 120, 120, 0.12); color: var(--text-secondary); border-color: rgba(120, 120, 120, 0.25); }
[data-theme="light"] .al-cat-auth { background: rgba(33, 150, 243, 0.08); }
/* Darker purple text in light theme the dark-theme #ab47bc fails AA contrast
on the pale tinted background at this small badge size. */
[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); color: #8e24aa; }
[data-theme="light"] .al-cat-entity { background: rgba(76, 175, 80, 0.08); }
[data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); }
[data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); }
/* Actor — constrained by its grid column (minmax(0, 110px)) */
.al-actor {
font-size: 0.8125rem;
font-family: var(--font-mono);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* Message — min-width:0 lets the 1fr column actually truncate */
.al-msg {
font-size: 0.8125rem;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
text-align: left;
}
/* Entity crosslink */
.al-entity { display: flex; align-items: center; }
.al-entity-link {
font-size: 0.75rem;
color: var(--primary-text-color);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
display: block;
}
.al-entity-link:hover { color: var(--primary-hover); text-decoration-style: solid; }
.al-entity-name {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Expand chevron */
.al-expand-chevron {
font-size: 0.6875rem;
color: var(--text-muted);
justify-self: end;
user-select: none;
}
/* ── Entry detail drawer ─────────────────────────────────────────────────── */
.al-detail {
padding: var(--space-sm) var(--space-md) var(--space-md);
border-top: var(--lux-hairline) solid var(--border-color);
background: var(--bg-secondary);
animation: al-detail-open var(--duration-fast) var(--ease-out);
}
@keyframes al-detail-open {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.al-detail-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px var(--space-md);
font-size: 0.8125rem;
}
.al-detail-grid dt {
color: var(--text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: start;
padding-top: 2px;
white-space: nowrap;
}
.al-detail-grid dd {
color: var(--text-color);
word-break: break-all;
}
.al-detail-grid code {
font-family: var(--font-mono);
font-size: 0.8125rem;
background: var(--bg-color);
padding: 1px 5px;
border-radius: var(--radius-sm);
border: var(--lux-hairline) solid var(--border-color);
}
.al-meta-pre {
font-family: var(--font-mono);
font-size: 0.75rem;
background: var(--bg-color);
border: var(--lux-hairline) solid var(--border-color);
border-radius: var(--radius-sm);
padding: var(--space-sm);
overflow-x: auto;
white-space: pre;
color: var(--text-secondary);
max-height: 220px;
overflow-y: auto;
margin: 0;
}
/* ── Load More ───────────────────────────────────────────────────────────── */
.al-load-more {
display: block;
width: 100%;
margin-top: var(--space-md);
text-align: center;
font-size: 0.875rem;
}
/* ── Empty / loading / error states ─────────────────────────────────────── */
.al-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--text-muted);
font-size: 0.875rem;
text-align: center;
}
.al-state-icon .icon {
width: 36px;
height: 36px;
opacity: 0.35;
}
.al-loading { flex-direction: row; padding: var(--space-lg); }
.al-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: al-spin 0.6s linear infinite;
flex-shrink: 0;
}
@keyframes al-spin { to { transform: rotate(360deg); } }
.al-error .al-state-icon .icon { color: var(--danger-color); opacity: 0.6; }
/* ── List container ──────────────────────────────────────────────────────── */
.al-list-container {
flex: 1;
min-height: 0;
}
/* Subtle busy state while a slow re-query is in flight: the current rows stay
visible (no spinner flash) but dim slightly and stop accepting clicks until
the fresh results swap in. Only applied after a short delay, so instant
filtering shows nothing. */
.al-list-container.al-busy {
opacity: 0.55;
pointer-events: none;
transition: opacity var(--duration-fast) var(--ease-out);
}
/* ── Tabular-nums utility ────────────────────────────────────────────────── */
.tabular-nums { font-variant-numeric: tabular-nums; }
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.al-entry-row {
/* icon | time | badge (fixed) | message | chevron — actor+entity hidden */
grid-template-columns: 20px 70px 78px 1fr 18px;
}
/* Hide actor and entity link at small widths */
.al-actor,
.al-entity { display: none; }
}
@media (max-width: 600px) {
.al-panel { padding: var(--space-sm); }
.al-entry-row {
grid-template-columns: 20px 1fr auto 18px;
grid-template-rows: auto auto;
gap: 4px var(--space-xs);
}
/* Row 1: [sev] [message] [badge] [chevron]; Row 2: [time] under the message.
message stays in its own 1fr column so it never overlaps the badge. */
.al-time { grid-column: 2; grid-row: 2; font-size: 0.6875rem; }
.al-cat-badge{ grid-column: 3; grid-row: 1; }
.al-msg { grid-column: 2; grid-row: 1; }
.al-toolbar-advanced .al-field-group { min-width: 100%; }
}
/*
Dashboard "Recent Activity" widget (.dal-*)
Compact, consistent with the precision-instrument language of the full tab.
Rows are tighter than the full viewer just sev icon + relative time + msg.
*/
/* List container */
.dal-list {
display: flex;
flex-direction: column;
gap: 0;
}
/* Compact entry row */
.al-compact-row {
display: grid;
grid-template-columns: 18px 52px 1fr;
align-items: center;
gap: 0 8px;
padding: 5px 4px;
border-bottom: 1px solid var(--border-color);
min-height: 28px;
transition: background 0.12s;
}
.al-compact-row:last-child {
border-bottom: none;
}
.al-compact-row:hover {
background: var(--bg-secondary);
}
.al-compact-icon .icon {
width: 14px;
height: 14px;
display: block;
flex-shrink: 0;
}
.al-compact-time {
font-family: var(--font-mono, monospace);
font-size: 0.6875rem;
color: var(--text-muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.al-compact-msg {
font-size: 0.8125rem;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Severity color on the row itself (row inherits .al-sev-* from renderCompactEntry) */
.al-compact-row.al-sev-error .al-compact-icon .icon { color: var(--danger-color); }
.al-compact-row.al-sev-warning .al-compact-icon .icon { color: var(--warning-color); }
.al-compact-row.al-sev-info .al-compact-icon .icon { color: var(--info-color); }
/* Empty state inside widget */
.dal-empty {
padding: 16px 8px;
}
.dal-empty p {
font-size: 0.8125rem;
color: var(--text-muted);
}
/* Loading state placeholder */
.dal-loading {
padding: 16px 8px;
display: flex;
align-items: center;
justify-content: center;
}
/* Footer — "View all →" link */
.dal-footer {
padding: 6px 4px 2px;
display: flex;
justify-content: flex-end;
}
.dal-view-all {
font-size: 0.8125rem;
color: var(--primary-text-color);
text-decoration: none;
border: none;
background: none;
cursor: pointer;
padding: 2px 4px;
transition: opacity 0.15s;
}
.dal-view-all:hover {
opacity: 0.75;
text-decoration: underline;
}
/*
Settings panel helpers (ds-info-note, ds-inline-link)
These are general enough to live here but scoped tightly enough to not
bleed into the rest of the settings layout.
*/
.ds-info-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 14px;
margin-bottom: 16px;
background: color-mix(in srgb, var(--info-color) 8%, var(--bg-secondary));
border: 1px solid color-mix(in srgb, var(--info-color) 25%, var(--border-color));
border-radius: var(--radius-sm, 4px);
font-size: 0.8125rem;
color: var(--text-color);
line-height: 1.5;
}
.ds-info-note .icon {
width: 15px;
height: 15px;
flex-shrink: 0;
margin-top: 1px;
color: var(--info-color);
}
/* Inline text button that looks like a link (used in ds-info-note, hints) */
.ds-inline-link {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: var(--primary-text-color);
font-size: inherit;
font-family: inherit;
text-decoration: underline;
text-underline-offset: 2px;
display: inline;
}
.ds-inline-link:hover {
opacity: 0.8;
}
+1
View File
@@ -19,5 +19,6 @@
@import './graph-editor.css';
@import './appearance.css';
@import './game-integration.css';
@import './activity-log.css';
@import './mobile.css';
@import './tv.css';
@@ -79,6 +79,12 @@
opacity: 1;
}
.preview-screen-total:focus-visible {
opacity: 1;
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.preview-screen-total.mismatch {
color: #FFC107;
}
@@ -123,6 +129,12 @@
background: rgba(128, 128, 128, 0.25);
}
.edge-toggle:focus-visible {
background: rgba(128, 128, 128, 0.25);
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.preview-edge.edge-disabled {
opacity: 0.25;
pointer-events: none;
@@ -374,6 +386,13 @@
color: rgba(76, 175, 80, 0.6);
}
.preview-corner:focus-visible {
color: rgba(76, 175, 80, 0.6);
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: 4px;
}
.preview-corner.active:hover {
transform: none;
}
@@ -412,6 +431,12 @@
background: rgba(255, 255, 255, 0.25);
}
.direction-toggle:focus-visible {
background: rgba(255, 255, 255, 0.25);
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.direction-toggle #direction-icon {
font-size: 14px;
}
+37 -27
View File
@@ -328,7 +328,7 @@ select.field-invalid {
display: block;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--surface-2, color-mix(in srgb, var(--text-color) 6%, var(--bg-color)));
background: var(--lux-bg-2);
overflow: hidden;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@@ -396,10 +396,10 @@ select.field-invalid {
/* Token palette — restrained, three accents plus muted operators. */
.jinja-hl .tok-str { color: var(--success-color); }
.jinja-hl .tok-num { color: #d19a66; }
.jinja-hl .tok-num { color: var(--ch-amber); }
.jinja-hl .tok-fn { color: var(--primary-color); font-weight: 600; }
.jinja-hl .tok-raw { color: #c678dd; font-style: italic; }
.jinja-hl .tok-var { color: #61afef; }
.jinja-hl .tok-raw { color: var(--ch-violet); font-style: italic; }
.jinja-hl .tok-var { color: var(--ch-cyan); }
.jinja-hl .tok-op { color: var(--text-muted); }
/* ── Template-input rows ─────────────────────────────────────── */
@@ -496,8 +496,8 @@ select.field-invalid {
font-size: 0.76rem;
padding: 1px 6px;
border-radius: var(--radius-pill);
background: color-mix(in srgb, #61afef 16%, transparent);
color: #61afef;
background: color-mix(in srgb, var(--ch-cyan) 16%, transparent);
color: var(--ch-cyan);
}
.jinja-hints-examples { margin: 4px 0 0; padding-left: 0; list-style: none; }
@@ -918,7 +918,12 @@ input:-webkit-autofill:focus {
.discovery-item:focus-visible,
.tag-chip-remove:focus-visible,
.cs-filter-reset:focus-visible,
.graph-filter-clear:focus-visible {
.graph-filter-clear:focus-visible,
.wizard-discovery-item:focus-visible,
.wizard-display-item:focus-visible,
.autocal-corner-btn:focus-visible,
.autocal-direction-btn:focus-visible,
.scene-target-add-slot:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
@@ -1118,7 +1123,8 @@ textarea:focus-visible {
font-weight: 700;
line-height: 1;
}
.scene-target-add-slot:hover:not(:disabled) {
.scene-target-add-slot:hover:not(:disabled),
.scene-target-add-slot:focus-visible:not(:disabled) {
background:
repeating-linear-gradient(135deg,
color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px,
@@ -1625,14 +1631,14 @@ textarea:focus-visible {
padding: 6px 12px;
border: 1px solid var(--border-color, #333);
border-radius: 6px;
background: var(--surface-2, #1e1e2e);
background: var(--lux-bg-2);
color: var(--text-secondary, #999);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.type-picker-tab:hover {
background: var(--surface-3, #2a2a3e);
background: var(--lux-bg-3);
color: var(--text-primary, #e0e0e0);
}
.type-picker-tab.active {
@@ -2326,7 +2332,7 @@ textarea:focus-visible {
.autocal-step-desc {
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
@@ -2355,7 +2361,8 @@ textarea:focus-visible {
font-weight: 500;
text-align: center;
}
.autocal-corner-btn:hover {
.autocal-corner-btn:hover,
.autocal-corner-btn:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
@@ -2424,7 +2431,8 @@ textarea:focus-visible {
font-weight: 500;
text-align: center;
}
.autocal-direction-btn:hover {
.autocal-direction-btn:hover,
.autocal-direction-btn:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
@@ -2442,7 +2450,7 @@ textarea:focus-visible {
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
border-radius: var(--radius-sm, 6px);
font-size: 0.8rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
}
.autocal-led-dot {
@@ -2489,7 +2497,7 @@ textarea:focus-visible {
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
transition: border-color 0.2s, background 0.2s, color 0.2s;
flex-shrink: 0;
}
@@ -2618,7 +2626,7 @@ textarea:focus-visible {
}
.autocal-solved-key {
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
flex-shrink: 0;
min-width: 68px;
}
@@ -2720,7 +2728,7 @@ textarea:focus-visible {
font-size: 0.7rem;
font-weight: 700;
background: var(--bg-secondary, var(--bg-2, #2a2a2a));
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
border: 1.5px solid var(--border-color);
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
@@ -2775,7 +2783,7 @@ textarea:focus-visible {
}
.wizard-step-desc {
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
line-height: 1.5;
margin: 0;
}
@@ -2829,7 +2837,7 @@ textarea:focus-visible {
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
padding-bottom: 4px;
}
.wizard-section-label--scan {
@@ -2860,7 +2868,7 @@ textarea:focus-visible {
gap: 10px;
padding: 14px 12px;
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
@@ -2868,7 +2876,7 @@ textarea:focus-visible {
.wizard-discovery-empty {
padding: 14px 12px;
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
color: var(--text-muted);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
@@ -2891,7 +2899,8 @@ textarea:focus-visible {
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.wizard-discovery-item:hover {
.wizard-discovery-item:hover,
.wizard-discovery-item:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
@@ -2899,7 +2908,7 @@ textarea:focus-visible {
.wizard-discovery-icon .icon { width: 20px; height: 20px; }
.wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.wizard-discovery-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wizard-discovery-badge {
font-size: 0.68rem;
font-weight: 700;
@@ -2926,7 +2935,8 @@ textarea:focus-visible {
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.wizard-display-item:hover {
.wizard-display-item:hover,
.wizard-display-item:focus-visible {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg));
}
@@ -2938,7 +2948,7 @@ textarea:focus-visible {
.wizard-display-icon .icon { width: 20px; height: 20px; }
.wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.wizard-display-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); }
.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); }
.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted); }
.wizard-display-check { color: var(--primary-color); }
.wizard-display-check .icon { width: 16px; height: 16px; }
.wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; }
@@ -2953,7 +2963,7 @@ textarea:focus-visible {
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
}
.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted, var(--secondary-text-color)); }
.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted); }
/* Calibrate container */
.wizard-calibrate-container {
@@ -2990,7 +3000,7 @@ textarea:focus-visible {
padding: 12px 16px;
}
.wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; }
.wizard-done-label { color: var(--text-muted, var(--secondary-text-color)); }
.wizard-done-label { color: var(--text-muted); }
.wizard-done-value { font-weight: 600; color: var(--text-color); text-align: right; }
/* Wizard form rows */
+5 -1
View File
@@ -2512,7 +2512,11 @@
padding-top: 8px;
}
.error-message {
/* `.modal-error` is the convention recommended in contexts/frontend.md and is
used by several modals; it aliases `.error-message` so both render the same
inline error banner. */
.error-message,
.modal-error {
background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */
border: 1px solid var(--danger-color);
color: var(--danger-color);
+40 -1
View File
@@ -228,6 +228,24 @@ import {
mountAutoCalibration, unmountAutoCalibration,
} from './features/auto-calibration.ts';
// Layer 5: activity log
import {
loadActivityLog,
activityLogToggleDetail,
activityLogToggleCat,
activityLogToggleSev,
activityLogOnSearch,
activityLogOnActor,
activityLogOnEntityType,
activityLogOnSince,
activityLogOnUntil,
activityLogClearFilters,
activityLogPreset,
activityLogLoadMore,
activityLogExport,
activityLogNavigateToEntity,
} from './features/activity-log.ts';
// Layer 5.5: graph editor
import {
loadGraphEditor,
@@ -257,6 +275,7 @@ import {
loadDaylightTimezone, saveDaylightTimezone,
requestNotifPermissionFromSettings, testNotifFromSettings,
saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl,
loadActivityLogSettings, saveActivityLogSettings, activityLogSettingsExport, clearActivityLog,
} from './features/settings.ts';
import {
loadUpdateStatus, initUpdateListener, checkForUpdates,
@@ -741,6 +760,10 @@ Object.assign(window, {
saveExternalUrl,
revertExternalUrl,
getBaseOrigin,
loadActivityLogSettings,
saveActivityLogSettings,
activityLogSettingsExport,
clearActivityLog,
// update
checkForUpdates,
@@ -762,6 +785,22 @@ Object.assign(window, {
applyStylePreset,
applyBgEffect,
renderAppearanceTab,
// activity log
loadActivityLog,
activityLogToggleDetail,
activityLogToggleCat,
activityLogToggleSev,
activityLogOnSearch,
activityLogOnActor,
activityLogOnEntityType,
activityLogOnSince,
activityLogOnUntil,
activityLogClearFilters,
activityLogPreset,
activityLogLoadMore,
activityLogExport,
activityLogNavigateToEntity,
});
// ─── Global keyboard shortcuts ───
@@ -779,7 +818,7 @@ document.addEventListener('keydown', (e) => {
// Tab shortcuts: Ctrl+1..4 (skip when typing in inputs)
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph' };
const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph', '7': 'activity_log' };
const tab = tabMap[e.key];
if (tab) {
e.preventDefault();
@@ -32,6 +32,7 @@ import { openAuthedWs } from './ws-auth.ts';
* update_download_progress update_service.py (consumed by features/update.ts)
* device_discovered discovery_watcher.py (consumed by features/notifications-watcher.ts)
* device_lost discovery_watcher.py (consumed by features/notifications-watcher.ts)
* activity_logged core/activity_log/recorder.py (consumed by features/activity-log.ts)
*
* Missing any of these silently breaks the corresponding UI flow keep
* this list in sync when adding new event types on the server side.
@@ -47,6 +48,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet<string> = new Set([
'update_download_progress',
'device_discovered',
'device_lost',
'activity_logged', // source: core/activity_log/recorder.py
]);
interface ServerEventEnvelope {
@@ -135,6 +135,17 @@ export const armchair = '<path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/>
// Lucide: leaf
export const leaf = '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19.2 2.96c1 4.34.06 9.65-3.4 13.04A6.96 6.96 0 0 1 11 20z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>';
// Lucide: scroll-text (audit / activity log)
export const scrollText = '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>';
// Lucide: circle-alert (error severity)
export const circleAlert = '<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>';
// Lucide: info (info severity)
export const info = '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>';
// Lucide: filter (filter toolbar)
export const filter = '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>';
// Lucide: x-circle (clear/reset)
export const xCircle = '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>';
// Easing curve glyphs — custom mini-charts that draw the actual curve.
// Curve travels from (4, 20) to (20, 4); each path renders the easing
// function directly so the picker shows the shape, not a metaphor.
@@ -354,6 +354,15 @@ export const ICON_CIRCLE = _svg(P.circle);
export const ICON_GIT_MERGE = _svg(P.gitMerge);
export const ICON_COPY = _svg(P.copy);
// ── Activity log icons ─────────────────────────────────────
export const ICON_ACTIVITY_LOG = _svg(P.scrollText);
export const ICON_SEVERITY_INFO = _svg(P.info);
export const ICON_SEVERITY_WARN = _svg(P.triangleAlert);
export const ICON_SEVERITY_ERR = _svg(P.circleAlert);
export const ICON_FILTER = _svg(P.filter);
export const ICON_X_CIRCLE = _svg(P.xCircle);
// ── Game integration icons ─────────────────────────────────
export const ICON_GAMEPAD = _svg(P.gamepad2);
@@ -28,6 +28,7 @@ const TAB_REGISTRY: Readonly<Record<string, TabConfig>> = {
automations: { loadFnName: 'loadAutomations',
subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } },
graph: { loadFnName: 'loadGraphEditor' },
activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false },
};
/** Get the full config for a tab, or undefined if not registered. */
+84
View File
@@ -555,6 +555,90 @@ export function formatCompact(n: number | null | undefined) {
return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B';
}
/**
* Format an ISO-8601 timestamp (or ms epoch) into a locale-aware string.
* Returns "Today · HH:MM", "Yesterday · HH:MM", or "DD MMM · HH:MM".
* Use `font-variant-numeric: tabular-nums` on the element for stable layout.
*/
export function formatTimestamp(isoOrMs: string | number): string {
const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
if (isNaN(d.getTime())) return String(isoOrMs);
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yestStart = new Date(todayStart.getTime() - 86400000);
const hhmm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
if (d >= todayStart) {
return `${t('time.today')} · ${hhmm}`;
} else if (d >= yestStart) {
return `${t('time.yesterday')} · ${hhmm}`;
} else {
const dateStr = d.toLocaleDateString([], { day: 'numeric', month: 'short' });
return `${dateStr} · ${hhmm}`;
}
}
/**
* Format an ISO-8601 timestamp (or ms epoch) as a compact relative string.
* Examples: "just now", "2m ago", "3h ago", "5d ago".
* Use font-variant-numeric: tabular-nums on elements that update frequently.
*/
export function formatRelativeTime(isoOrMs: string | number): string {
const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs);
if (isNaN(d.getTime())) return String(isoOrMs);
const diffSec = Math.floor((Date.now() - d.getTime()) / 1000);
if (diffSec < 10) return t('time.just_now');
if (diffSec < 60) return t('time.seconds_ago', { n: diffSec });
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return t('time.minutes_ago', { n: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return t('time.hours_ago', { n: diffHr });
const diffDays = Math.floor(diffHr / 24);
return t('time.days_ago', { n: diffDays });
}
// ── Shared relative-time ticker ──────────────────────────────────────────────
// A single process-wide interval that keeps every `[data-reltime]` element
// up to date. Call `ensureRelativeTimeTicker()` from any feature that renders
// such elements — repeated calls are idempotent (one interval, ever).
let _relTimeIntervalId: ReturnType<typeof setInterval> | null = null;
let _relTimeVisibilityBound = false;
/** Refresh every `[data-reltime]` element's text content to the current
* relative-time label produced by `formatRelativeTime`. */
function _tickRelativeTimes(): void {
if (document.hidden) return;
document.querySelectorAll<HTMLElement>('[data-reltime]').forEach(el => {
const iso = el.getAttribute('data-reltime');
if (iso) el.textContent = formatRelativeTime(iso);
});
}
/**
* Start the shared relative-time ticker (idempotent safe to call many times).
* Ticks every 30 s, skips work when the tab is hidden, and fires one
* immediate refresh when the tab becomes visible again.
* Also fires one immediate refresh on each `languageChanged` event so
* freshly-translated labels appear without waiting for the next tick.
*/
export function ensureRelativeTimeTicker(): void {
// One-time visibility + language listeners
if (!_relTimeVisibilityBound) {
_relTimeVisibilityBound = true;
document.addEventListener('visibilitychange', () => {
if (!document.hidden) _tickRelativeTimes();
});
document.addEventListener('languageChanged', () => _tickRelativeTimes());
}
// Idempotent: only start the interval once
if (_relTimeIntervalId !== null) return;
_relTimeIntervalId = setInterval(_tickRelativeTimes, 30_000);
}
export function formatUptime(seconds: number | null | undefined): string {
if (!seconds || seconds <= 0) return '-';
const total = Math.floor(seconds);
@@ -0,0 +1,874 @@
/**
* Activity Log tab persistent, queryable audit log viewer.
*
* This is a READ-ONLY viewer (no CRUD), differentiated from the debug
* Log Viewer (utils/log_broadcaster.py) which is an ephemeral 500-line tail.
* This tab shows structured, semantic audit entries backed by the SQLite
* activity_log table.
*
* Phase 5: Activity tab + smart filtering + live updates.
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, formatRelativeTime, ensureRelativeTimeTicker } from '../core/ui.ts';
import { navigateToCard } from '../core/navigation.ts';
import {
ICON_ACTIVITY_LOG, ICON_SEVERITY_INFO, ICON_SEVERITY_WARN, ICON_SEVERITY_ERR,
ICON_X_CIRCLE, ICON_DOWNLOAD, ICON_SEARCH,
ICON_CHEVRON_UP, ICON_CHEVRON_DOWN,
} from '../core/icons.ts';
/**
* Escape a string for safe use inside an HTML attribute value (quoted with
* either `"` or `'`). Extends escapeHtml's `<>&` coverage with `"` and `'`.
*/
function _escapeAttr(text: string): string {
if (!text) return '';
return escapeHtml(text)
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ─── Types ───────────────────────────────────────────────────
export interface ActivityEntry {
id: string;
ts: string;
category: string;
action: string;
severity: string;
actor: string;
entity_type: string | null;
entity_id: string | null;
entity_name: string | null;
message: string;
metadata: Record<string, unknown>;
}
interface ActivityPage {
entries: ActivityEntry[];
next_before_seq: number | null;
has_more: boolean;
total: number;
}
interface ActiveFilters {
categories: string[];
severities: string[];
actor: string;
entity_type: string;
since: string;
until: string;
q: string;
}
// ─── Module state ────────────────────────────────────────────
let _initialized = false;
let _loading = false;
let _entries: ActivityEntry[] = [];
let _nextBeforeSeq: number | null = null;
let _hasMore = false;
let _total = 0;
let _expandedIds = new Set<string>();
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
let _liveEventListener: ((e: Event) => void) | null = null;
// Loading UX: `_showSpinner` gates the full-panel spinner so it only appears
// after a short delay (slow requests), never flashing on instant filtering.
// `_hasLoadedOnce` distinguishes the genuine first load (spinner immediately)
// from re-queries (keep current rows, subtle delayed busy hint).
let _loadingDelayTimer: ReturnType<typeof setTimeout> | null = null;
let _showSpinner = false;
let _hasLoadedOnce = false;
const _filters: ActiveFilters = {
categories: [],
severities: [],
actor: '',
entity_type: '',
since: '',
until: '',
q: '',
};
// ─── Category → navigation target map (entity crosslinks) ──
const _ENTITY_NAV: Record<string, { tab: string; subTab: string | null; attr: string } | null> = {
output_target: { tab: 'targets', subTab: 'led-targets', attr: 'data-target-id' },
device: { tab: 'targets', subTab: 'led-devices', attr: 'data-device-id' },
picture_source: { tab: 'streams', subTab: 'raw', attr: 'data-stream-id' },
color_strip_source: { tab: 'streams', subTab: 'color_strip', attr: 'data-css-id' },
audio_source: { tab: 'streams', subTab: 'audio_capture', attr: 'data-id' },
automation: { tab: 'automations', subTab: 'automations', attr: 'data-automation-id' },
scene_preset: { tab: 'automations', subTab: 'scenes', attr: 'data-scene-id' },
scene_playlist: { tab: 'automations', subTab: 'playlists', attr: 'data-playlist-id' },
};
// ─── Severity icon helper ────────────────────────────────────
function _severityIcon(severity: string): string {
if (severity === 'error') return ICON_SEVERITY_ERR;
if (severity === 'warning') return ICON_SEVERITY_WARN;
return ICON_SEVERITY_INFO;
}
function _severityClass(severity: string): string {
if (severity === 'error') return 'al-sev-error';
if (severity === 'warning') return 'al-sev-warning';
return 'al-sev-info';
}
// ─── Category label helper ───────────────────────────────────
function _categoryLabel(category: string): string {
return t(`activity_log.category.${category}`);
}
// ─── Localized entity-type label ────────────────────────────
function _entityTypeLabel(entityType: string): string {
const key = `activity_log.entity_type.${entityType}`;
const translated = t(key);
// If t() returned the key unchanged there is no translation — humanize it
if (translated === key) {
return entityType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
return translated;
}
// ─── Client-side message localization ───────────────────────
//
// Maps entry.action → an i18n template key and extracts placeholders
// from the structured fields so the displayed description is rendered
// in the user's locale rather than the server-generated English string.
//
// Fallback: if the template key is missing (t() returns the key
// unchanged) we return entry.message (the original server string) so
// the UI always shows something sensible.
export function localizeMessage(entry: ActivityEntry): string {
const meta = entry.metadata || {};
// Build a params bag from structured fields + metadata.
// Keys match the {placeholder} names used in locale templates.
const params: Record<string, string> = {
name: entry.entity_name ?? '',
actor: entry.actor ?? '',
type: entry.entity_type ? _entityTypeLabel(entry.entity_type) : '',
key: String(meta.setting_key ?? ''),
address: String(meta.address ?? meta.url ?? ''),
reason: String(meta.reason ?? ''),
client: String(meta.client ?? ''),
device_type: String(meta.device_type ?? ''),
filename: String(meta.filename ?? ''),
};
// The backend always emits dotted actions (e.g. "entity.created",
// "auth.ws_connected"), so the template key is a direct 1:1 mapping.
const templateKey = `activity_log.msg.${entry.action}`;
const localized = t(templateKey, params);
// t() returns the key unchanged when there is no matching translation.
if (localized === templateKey) {
return entry.message;
}
return localized;
}
// ─── Build query string from active filters + cursor ────────
function _buildQuery(beforeSeq: number | null = null): string {
const params = new URLSearchParams();
for (const cat of _filters.categories) params.append('categories', cat);
for (const sev of _filters.severities) params.append('severities', sev);
if (_filters.actor) params.set('actor', _filters.actor);
if (_filters.entity_type) params.set('entity_type', _filters.entity_type);
if (_filters.since) params.set('since', _filters.since);
if (_filters.until) params.set('until', _filters.until);
if (_filters.q) params.set('q', _filters.q);
if (beforeSeq != null) params.set('before_seq', String(beforeSeq));
params.set('limit', '50');
const qs = params.toString();
return qs ? `?${qs}` : '';
}
// ─── Entry rendering ─────────────────────────────────────────
function _renderEntryRow(entry: ActivityEntry, isNew = false): string {
const relTime = formatRelativeTime(entry.ts);
const iso = entry.ts;
const expanded = _expandedIds.has(entry.id);
const sevClass = _severityClass(entry.severity);
const sevIcon = _severityIcon(entry.severity);
// Entity crosslink — use data-* attributes + delegated listener (no JSON in onclick)
let entityHtml = '';
if (entry.entity_type && entry.entity_name) {
const nav = _ENTITY_NAV[entry.entity_type];
if (nav) {
const escapedName = escapeHtml(entry.entity_name);
const attrEntityType = _escapeAttr(entry.entity_type);
const attrEntityId = _escapeAttr(entry.entity_id || '');
entityHtml = `<button class="al-entity-link" type="button"
data-entity-type="${attrEntityType}" data-entity-id="${attrEntityId}"
title="${_escapeAttr(entry.entity_name)}">${escapedName}</button>`;
} else {
entityHtml = `<span class="al-entity-name">${escapeHtml(entry.entity_name)}</span>`;
}
}
const detailHtml = expanded ? _renderEntryDetail(entry) : '';
const attrEntryId = _escapeAttr(entry.id);
return `<div class="al-entry${isNew ? ' al-entry-new' : ''}" data-al-id="${_escapeAttr(entry.id)}">
<div class="al-entry-row" data-toggle-id="${attrEntryId}"
role="button" tabindex="0" aria-expanded="${expanded}">
<span class="al-sev ${sevClass}" title="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span>
<span class="al-time tabular-nums" title="${_escapeAttr(iso)}" data-reltime="${_escapeAttr(iso)}">${escapeHtml(relTime)}</span>
<span class="al-cat-badge al-cat-${escapeHtml(entry.category)}">${escapeHtml(_categoryLabel(entry.category))}</span>
<span class="al-actor">${escapeHtml(entry.actor)}</span>
<span class="al-msg">${escapeHtml(localizeMessage(entry))}</span>
${entityHtml ? `<span class="al-entity">${entityHtml}</span>` : ''}
<span class="al-expand-chevron" aria-hidden="true">${expanded ? ICON_CHEVRON_UP : ICON_CHEVRON_DOWN}</span>
</div>
${detailHtml}
</div>`;
}
function _renderEntryDetail(entry: ActivityEntry): string {
const metaJson = JSON.stringify(entry.metadata, null, 2);
const absTime = entry.ts ? new Date(entry.ts).toLocaleString() : '';
return `<div class="al-detail" role="region" aria-label="${escapeHtml(t('activity_log.detail.title'))}">
<dl class="al-detail-grid">
<dt>${escapeHtml(t('activity_log.detail.id'))}</dt>
<dd class="tabular-nums"><code>${escapeHtml(entry.id)}</code></dd>
<dt>${escapeHtml(t('activity_log.detail.timestamp'))}</dt>
<dd class="tabular-nums">${escapeHtml(absTime)}</dd>
<dt>${escapeHtml(t('activity_log.detail.action'))}</dt>
<dd><code>${escapeHtml(entry.action)}</code></dd>
<dt>${escapeHtml(t('activity_log.detail.actor'))}</dt>
<dd>${escapeHtml(entry.actor)}</dd>
${entry.entity_type ? `<dt>${escapeHtml(t('activity_log.detail.entity'))}</dt>
<dd>${escapeHtml(entry.entity_type)}${entry.entity_id ? ` / <code>${escapeHtml(entry.entity_id)}</code>` : ''}${entry.entity_name ? ` (${escapeHtml(entry.entity_name)})` : ''}</dd>` : ''}
<dt>${escapeHtml(t('activity_log.detail.metadata'))}</dt>
<dd><pre class="al-meta-pre">${escapeHtml(metaJson)}</pre></dd>
</dl>
</div>`;
}
// ─── Filter toolbar rendering ────────────────────────────────
const CATEGORIES = ['auth', 'device', 'entity', 'capture', 'system'];
const SEVERITIES = ['info', 'warning', 'error'];
function _renderFilterToolbar(): string {
const catChips = CATEGORIES.map(cat => {
const active = _filters.categories.includes(cat);
return `<button class="al-chip al-cat-chip${active ? ' active' : ''} al-cat-${cat}"
type="button" onclick="activityLogToggleCat('${cat}')"
aria-pressed="${active}">${escapeHtml(_categoryLabel(cat))}</button>`;
}).join('');
const sevChips = SEVERITIES.map(sev => {
const active = _filters.severities.includes(sev);
const icon = _severityIcon(sev);
return `<button class="al-chip al-sev-chip${active ? ' active' : ''} al-sev-chip-${sev}"
type="button" onclick="activityLogToggleSev('${sev}')"
aria-pressed="${active}">${icon} ${escapeHtml(t(`activity_log.severity.${sev}`))}</button>`;
}).join('');
const presets = [
{ key: 'today', label: t('activity_log.preset.today') },
{ key: 'errors', label: t('activity_log.preset.errors') },
{ key: 'auth', label: t('activity_log.preset.auth') },
{ key: 'devices', label: t('activity_log.preset.devices') },
];
const presetBtns = presets.map(p =>
`<button class="al-preset-btn" type="button" onclick="activityLogPreset('${p.key}')">${escapeHtml(p.label)}</button>`
).join('');
const hasFilters = _filters.categories.length || _filters.severities.length ||
_filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q;
return `<div class="al-toolbar" role="search" aria-label="${escapeHtml(t('activity_log.filter.title'))}">
<div class="al-toolbar-row al-toolbar-search">
<div class="al-search-wrap">
<span class="al-search-icon" aria-hidden="true">${ICON_SEARCH}</span>
<input class="al-search-input" type="search" id="al-search-input"
placeholder="${escapeHtml(t('activity_log.filter.search'))}"
value="${_escapeAttr(_filters.q)}"
oninput="activityLogOnSearch(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.search'))}">
</div>
<div class="al-presets">${presetBtns}</div>
${hasFilters ? `<button class="al-clear-btn btn btn-icon btn-secondary" type="button"
onclick="activityLogClearFilters()" title="${escapeHtml(t('activity_log.filter.clear'))}"
aria-label="${escapeHtml(t('activity_log.filter.clear'))}">${ICON_X_CIRCLE}</button>` : ''}
<div class="al-export-wrap">
<button class="btn btn-secondary al-export-btn" type="button"
data-al-export-toggle aria-haspopup="menu" aria-expanded="false"
title="${escapeHtml(t('activity_log.export'))}"
aria-label="${escapeHtml(t('activity_log.export'))}">${ICON_DOWNLOAD} <span>${escapeHtml(t('activity_log.export'))}</span><span class="al-export-caret" aria-hidden="true">${ICON_CHEVRON_DOWN}</span></button>
<div class="al-export-menu" role="menu">
<button type="button" role="menuitem" onclick="activityLogExport('csv')">${escapeHtml(t('activity_log.export.csv'))}</button>
<button type="button" role="menuitem" onclick="activityLogExport('json')">${escapeHtml(t('activity_log.export.json'))}</button>
</div>
</div>
</div>
<div class="al-toolbar-row al-toolbar-chips">
<span class="al-filter-label">${escapeHtml(t('activity_log.filter.category'))}</span>
<div class="al-chip-group" role="group" aria-label="${escapeHtml(t('activity_log.filter.category'))}">${catChips}</div>
<span class="al-filter-label al-filter-label-sep">${escapeHtml(t('activity_log.filter.severity'))}</span>
<div class="al-chip-group" role="group" aria-label="${escapeHtml(t('activity_log.filter.severity'))}">${sevChips}</div>
</div>
<div class="al-toolbar-row al-toolbar-advanced" id="al-toolbar-advanced">
<div class="al-field-group">
<label for="al-actor-input" class="al-field-label">${escapeHtml(t('activity_log.filter.actor'))}</label>
<input type="text" id="al-actor-input" class="al-field-input"
value="${_escapeAttr(_filters.actor)}"
placeholder="${_escapeAttr(t('activity_log.filter.actor.placeholder'))}"
oninput="activityLogOnActor(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.actor'))}">
</div>
<div class="al-field-group">
<label for="al-entity-type-input" class="al-field-label">${escapeHtml(t('activity_log.filter.entity_type'))}</label>
<input type="text" id="al-entity-type-input" class="al-field-input"
value="${_escapeAttr(_filters.entity_type)}"
placeholder="${_escapeAttr(t('activity_log.filter.entity_type.placeholder'))}"
oninput="activityLogOnEntityType(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.entity_type'))}">
</div>
<div class="al-field-group">
<label for="al-since-input" class="al-field-label">${escapeHtml(t('activity_log.filter.since'))}</label>
<input type="datetime-local" id="al-since-input" class="al-field-input"
value="${_escapeAttr(_filters.since)}"
onchange="activityLogOnSince(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.since'))}">
</div>
<div class="al-field-group">
<label for="al-until-input" class="al-field-label">${escapeHtml(t('activity_log.filter.until'))}</label>
<input type="datetime-local" id="al-until-input" class="al-field-input"
value="${_escapeAttr(_filters.until)}"
onchange="activityLogOnUntil(this.value)"
aria-label="${escapeHtml(t('activity_log.filter.until'))}">
</div>
</div>
</div>`;
}
// ─── List and state rendering ────────────────────────────────
function _renderList(): string {
if (_showSpinner && _entries.length === 0) {
return `<div class="al-state al-loading" role="status" aria-live="polite">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>`;
}
if (_entries.length === 0) {
const hasFilters = _filters.categories.length || _filters.severities.length ||
_filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q;
const emptyKey = hasFilters ? 'activity_log.empty' : 'activity_log.empty_no_filters';
return `<div class="al-state al-empty" role="status">
<span class="al-state-icon" aria-hidden="true">${ICON_ACTIVITY_LOG}</span>
<p>${escapeHtml(t(emptyKey))}</p>
</div>`;
}
const rows = _entries.map(e => _renderEntryRow(e)).join('');
const loadMore = _hasMore
? `<button class="al-load-more btn btn-secondary" type="button"
onclick="activityLogLoadMore()" aria-label="${escapeHtml(t('activity_log.load_more'))}">${escapeHtml(t('activity_log.load_more'))}</button>`
: '';
const countLabel = t('activity_log.n_entries', { n: _total });
return `<div class="al-list-header">
<span class="al-count tabular-nums">${escapeHtml(countLabel)}</span>
<div class="al-live-indicator" id="al-live-indicator" aria-live="polite">
<span class="al-live-dot" aria-hidden="true"></span>
<span>${escapeHtml(t('activity_log.live'))}</span>
</div>
</div>
<div class="al-list" role="log" aria-label="${escapeHtml(t('activity_log.title'))}" aria-live="polite">
${rows}
</div>
${loadMore}`;
}
// ─── Delegated click handler for entry rows and entity links ─
let _delegatedClickAttached = false;
/** Collapse the export dropdown if open (idempotent). */
function _closeExportMenu(): void {
const wrap = document.getElementById('tab-activity_log')
?.querySelector<HTMLElement>('.al-export-wrap.open');
if (!wrap) return;
wrap.classList.remove('open');
wrap.querySelector('[data-al-export-toggle]')?.setAttribute('aria-expanded', 'false');
}
function _attachDelegatedClicks(): void {
if (_delegatedClickAttached) return;
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
_delegatedClickAttached = true;
panel.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Export menu: toggle button opens/closes the CSV/JSON dropdown.
const exportToggle = target.closest<HTMLElement>('[data-al-export-toggle]');
if (exportToggle) {
const wrap = exportToggle.closest<HTMLElement>('.al-export-wrap');
const open = wrap?.classList.toggle('open') ?? false;
exportToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
return;
}
// Export menu: a menu item's inline onclick triggers the download (it
// runs first, on the deeper element) — we just collapse the menu after.
if (target.closest('.al-export-menu')) {
_closeExportMenu();
return;
}
// Any other click in the panel dismisses an open export menu, then
// continues to row / entity handling below.
_closeExportMenu();
// Entity navigation: click on data-entity-type button
const entityBtn = target.closest<HTMLElement>('button.al-entity-link[data-entity-type]');
if (entityBtn) {
e.stopPropagation();
const entityType = entityBtn.dataset.entityType ?? '';
const entityId = entityBtn.dataset.entityId ?? '';
activityLogNavigateToEntity(entityType, entityId);
return;
}
// Entry row toggle: click on al-entry-row with data-toggle-id
const row = target.closest<HTMLElement>('.al-entry-row[data-toggle-id]');
if (row) {
const entryId = row.dataset.toggleId ?? '';
if (entryId) activityLogToggleDetail(entryId);
return;
}
});
panel.addEventListener('keydown', (e: KeyboardEvent) => {
// Escape closes the export menu and restores focus to its trigger.
if (e.key === 'Escape') {
const toggle = panel.querySelector<HTMLElement>('.al-export-wrap.open [data-al-export-toggle]');
if (toggle) {
_closeExportMenu();
toggle.focus();
}
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = (e.target as HTMLElement).closest<HTMLElement>('.al-entry-row[data-toggle-id]');
if (row) {
e.preventDefault();
const entryId = row.dataset.toggleId ?? '';
if (entryId) activityLogToggleDetail(entryId);
}
});
}
// ─── Full panel render ───────────────────────────────────────
function _render(): void {
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
panel.innerHTML = `<div class="al-panel">
${_renderFilterToolbar()}
<div id="al-list-container" class="al-list-container">
${_renderList()}
</div>
</div>`;
_attachDelegatedClicks();
}
// ─── Partial re-render helpers ───────────────────────────────
function _updateListContainer(): void {
const container = document.getElementById('al-list-container');
if (!container) return;
container.innerHTML = _renderList();
}
// ─── Data fetching ───────────────────────────────────────────
/** Surface a loading affordance only when a request is slow enough to notice. */
function _showDelayedBusy(): void {
if (!_loading) return;
if (_entries.length === 0) {
// Nothing to keep on screen — fall back to the full spinner.
_showSpinner = true;
_updateListContainer();
} else {
// Re-query of a populated list: keep the current rows, just dim them.
const c = document.getElementById('al-list-container');
c?.classList.add('al-busy');
c?.setAttribute('aria-busy', 'true');
}
}
/** Clear all loading affordances (timer, spinner flag, busy dim). Idempotent. */
function _clearBusy(): void {
if (_loadingDelayTimer) {
clearTimeout(_loadingDelayTimer);
_loadingDelayTimer = null;
}
_showSpinner = false;
const c = document.getElementById('al-list-container');
c?.classList.remove('al-busy');
c?.removeAttribute('aria-busy');
}
async function _fetchPage(beforeSeq: number | null = null, append = false): Promise<void> {
if (_loading) return;
_loading = true;
if (!append) {
// Reset the cursor for a fresh query, but DON'T clear `_entries` — keep
// the current rows on screen so filtering an already-populated list
// never flashes the full "Loading" state (the new results replace them
// on arrival).
_nextBeforeSeq = null;
_hasMore = false;
}
if (!_hasLoadedOnce && !append) {
// Genuine first load — there's nothing to show yet, so the spinner is
// the correct (and expected) initial state. Show it immediately.
_showSpinner = true;
_updateListContainer();
} else if (!append) {
// Re-query (filter change / language change): defer any loading hint so
// near-instant responses show nothing at all; a slow request gets a
// subtle dim after the delay.
if (_loadingDelayTimer) clearTimeout(_loadingDelayTimer);
_loadingDelayTimer = setTimeout(_showDelayedBusy, 180);
}
// append (load-more): keep existing rows, no loading indicator.
try {
const qs = _buildQuery(beforeSeq);
const res = await fetchWithAuth(`/activity-log${qs}`);
if (!res || !res.ok) {
throw new Error(`HTTP ${res?.status}`);
}
const page: ActivityPage = await res.json();
// API returns each page oldest-first within the page; reverse to newest-first
// so the in-memory list is newest at index 0 (top of the rendered log).
const pageEntries = [...page.entries].reverse();
if (append) {
_entries = [..._entries, ...pageEntries];
} else {
_entries = pageEntries;
}
_nextBeforeSeq = page.next_before_seq;
_hasMore = page.has_more;
_total = page.total;
_hasLoadedOnce = true;
// Clear loading affordances BEFORE rendering so a zero-result page
// renders the empty state (not the spinner) and a re-query swaps in the
// fresh, undimmed rows.
_clearBusy();
_loading = false;
_updateListContainer();
} catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return;
_clearBusy();
const container = document.getElementById('al-list-container');
if (container) {
container.innerHTML = `<div class="al-state al-error" role="alert">
<span class="al-state-icon" aria-hidden="true">${ICON_SEVERITY_ERR}</span>
<p>${escapeHtml(t('activity_log.error'))}</p>
</div>`;
}
} finally {
_loading = false;
_clearBusy();
}
}
// ─── Filter re-query with debounce for text fields ───────────
function _requery(debounce = false): void {
if (debounce) {
if (_debounceTimer) clearTimeout(_debounceTimer);
_debounceTimer = setTimeout(() => { _fetchPage(null, false); }, 350);
} else {
_fetchPage(null, false);
}
}
// ─── Live event handling ──────────────────────────────────────
function _entryPassesFilters(entry: ActivityEntry): boolean {
if (_filters.categories.length && !_filters.categories.includes(entry.category)) return false;
if (_filters.severities.length && !_filters.severities.includes(entry.severity)) return false;
if (_filters.actor && entry.actor !== _filters.actor) return false;
if (_filters.entity_type && entry.entity_type !== _filters.entity_type) return false;
if (_filters.q) {
const q = _filters.q.toLowerCase();
if (!entry.message.toLowerCase().includes(q) &&
!entry.action.toLowerCase().includes(q) &&
!entry.actor.toLowerCase().includes(q)) return false;
}
// Date range filters: if an entry is brand-new it passes "since" checks trivially
if (_filters.since) {
const sinceMs = new Date(_filters.since).getTime();
if (!isNaN(sinceMs) && new Date(entry.ts).getTime() < sinceMs) return false;
}
if (_filters.until) {
const untilMs = new Date(_filters.until).getTime();
if (!isNaN(untilMs) && new Date(entry.ts).getTime() > untilMs) return false;
}
return true;
}
function _prependLiveEntry(entry: ActivityEntry): void {
if (!_entryPassesFilters(entry)) return;
_entries = [entry, ..._entries];
_total = _total + 1;
// Prepend the row into the existing list (no full re-render for performance)
const list = document.getElementById('tab-activity_log')?.querySelector('.al-list');
if (list) {
const html = _renderEntryRow(entry, true);
list.insertAdjacentHTML('afterbegin', html);
// Animate the new entry
const firstRow = list.firstElementChild as HTMLElement | null;
if (firstRow) {
requestAnimationFrame(() => { firstRow.classList.add('al-entry-appear'); });
}
// Update count badge
const countEl = list.closest('.al-panel')?.querySelector('.al-count');
if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total });
} else {
_updateListContainer();
}
}
function _startLiveUpdates(): void {
if (_liveEventListener) return;
_liveEventListener = (e: Event) => {
const ce = e as CustomEvent;
const entry = ce.detail?.entry as ActivityEntry | undefined;
if (!entry) return;
_prependLiveEntry(entry);
};
document.addEventListener('server:activity_logged', _liveEventListener);
}
// ─── Public window-exposed interaction functions ──────────────
export function activityLogToggleDetail(entryId: string): void {
if (_expandedIds.has(entryId)) {
_expandedIds.delete(entryId);
} else {
_expandedIds.add(entryId);
}
// Update just the affected row
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
const row = panel.querySelector(`[data-al-id="${CSS.escape(entryId)}"]`);
if (!row) return;
const entry = _entries.find(e => e.id === entryId);
if (!entry) return;
row.outerHTML = _renderEntryRow(entry, false);
}
export function activityLogToggleCat(cat: string): void {
const idx = _filters.categories.indexOf(cat);
if (idx >= 0) {
_filters.categories = _filters.categories.filter(c => c !== cat);
} else {
_filters.categories = [..._filters.categories, cat];
}
_render();
_requery();
}
export function activityLogToggleSev(sev: string): void {
const idx = _filters.severities.indexOf(sev);
if (idx >= 0) {
_filters.severities = _filters.severities.filter(s => s !== sev);
} else {
_filters.severities = [..._filters.severities, sev];
}
_render();
_requery();
}
export function activityLogOnSearch(val: string): void {
_filters.q = val;
_requery(true);
}
export function activityLogOnActor(val: string): void {
_filters.actor = val.trim();
_requery(true);
}
export function activityLogOnEntityType(val: string): void {
_filters.entity_type = val.trim();
_requery(true);
}
export function activityLogOnSince(val: string): void {
_filters.since = val;
_requery();
}
export function activityLogOnUntil(val: string): void {
_filters.until = val;
_requery();
}
export function activityLogClearFilters(): void {
_filters.categories = [];
_filters.severities = [];
_filters.actor = '';
_filters.entity_type = '';
_filters.since = '';
_filters.until = '';
_filters.q = '';
_render();
_requery();
}
export function activityLogPreset(key: string): void {
// Reset all filters first
_filters.categories = [];
_filters.severities = [];
_filters.actor = '';
_filters.entity_type = '';
_filters.q = '';
_filters.until = '';
switch (key) {
case 'today': {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
// datetime-local format: YYYY-MM-DDTHH:MM
_filters.since = todayStart.toISOString().slice(0, 16);
break;
}
case 'errors':
_filters.severities = ['error'];
_filters.since = '';
break;
case 'auth':
_filters.categories = ['auth'];
_filters.since = '';
break;
case 'devices':
_filters.categories = ['device'];
_filters.since = '';
break;
}
_render();
_requery();
}
export function activityLogLoadMore(): void {
if (_hasMore && !_loading) {
_fetchPage(_nextBeforeSeq, true);
}
}
export async function activityLogExport(format: 'csv' | 'json'): Promise<void> {
try {
showToast(t('activity_log.export.downloading'), 'info');
const qs = _buildQuery(null);
const sep = qs ? '&' : '?';
const url = `/activity-log/export${qs}${sep}format=${format}`;
const res = await fetchWithAuth(url);
if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `ledgrab-activity-${now}.${format}`;
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch (e: unknown) {
if (e && typeof e === 'object' && 'isAuth' in e) return;
showToast(t('activity_log.export.error'), 'error');
}
}
export function activityLogNavigateToEntity(entityType: string, entityId: string): void {
const nav = _ENTITY_NAV[entityType];
if (!nav || !entityId) return;
navigateToCard(nav.tab, nav.subTab, null, nav.attr, entityId);
}
// ─── Public helpers for Phase 6 (Dashboard widget + Settings export) ────────
/**
* Fetch the N most-recent activity log entries without affecting the full-tab
* state (separate request, no state mutations).
*/
export async function fetchRecentEntries(limit = 5): Promise<ActivityEntry[]> {
try {
const res = await fetchWithAuth(`/activity-log?limit=${limit}`);
if (!res || !res.ok) return [];
const page: ActivityPage = await res.json();
// API returns oldest-first within page; reverse for newest-first.
return [...page.entries].reverse();
} catch {
return [];
}
}
/**
* Render a compact single-line entry row for the Dashboard widget.
* Re-uses the severity icon / class helpers and escapeHtml from the full viewer
* so the visual language is consistent.
*/
export function renderCompactEntry(entry: ActivityEntry): string {
const relTime = formatRelativeTime(entry.ts);
const sevIcon = _severityIcon(entry.severity);
const sevClass = _severityClass(entry.severity);
return `<div class="al-compact-row al-sev ${sevClass}" title="${_escapeAttr(entry.ts)}">
<span class="al-compact-icon" aria-label="${_escapeAttr(t(`activity_log.severity.${entry.severity}`))}">${sevIcon}</span>
<span class="al-compact-time tabular-nums" data-reltime="${_escapeAttr(entry.ts)}">${escapeHtml(relTime)}</span>
<span class="al-compact-msg">${escapeHtml(localizeMessage(entry))}</span>
</div>`;
}
// ─── Main loader (registered with tab-registry) ─────────────
export async function loadActivityLog(): Promise<void> {
const panel = document.getElementById('tab-activity_log');
if (!panel) return;
_initialized = true;
_render();
await _fetchPage(null, false);
_startLiveUpdates();
ensureRelativeTimeTicker();
// Re-render on language change (baked-in t() calls)
document.addEventListener('languageChanged', _onLanguageChanged);
}
function _onLanguageChanged(): void {
if (!_initialized) return;
_render();
_fetchPage(null, false);
}
@@ -98,6 +98,9 @@ export interface AutoCalOptions {
let _state: AutoCalState | null = null;
let _opts: AutoCalOptions | null = null;
let _deviceEntitySelect: EntitySelect | null = null;
/** First render after mount: let the containing Modal's autofocus win; only
* steal focus on subsequent step transitions (for D-pad / TV navigation). */
let _firstRender = true;
// ── Public API ─────────────────────────────────────────────────────────────
@@ -119,6 +122,7 @@ let _deviceEntitySelect: EntitySelect | null = null;
*/
export async function mountAutoCalibration(opts: AutoCalOptions): Promise<void> {
await unmountAutoCalibration();
_firstRender = true;
_opts = opts;
let cssSourceType = 'picture';
@@ -177,6 +181,27 @@ function _render(): void {
case 'corners': _renderCorners(); break;
case 'preview': _renderPreview(); break;
}
_focusFirstControl();
}
/**
* Move focus to the step's first focusable control after a re-render so D-pad /
* TV WebView users don't land on a node that was just removed. Skipped on the
* very first render so it doesn't fight the Modal's own initial autofocus.
*/
function _focusFirstControl(): void {
if (_firstRender) {
_firstRender = false;
return;
}
const container = _opts?.container;
if (!container) return;
requestAnimationFrame(() => {
const el = container.querySelector(
'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
) as HTMLElement | null;
if (el && el.offsetParent !== null) el.focus();
});
}
// ── Step 1: Device picker ──────────────────────────────────────────────────
@@ -309,10 +334,15 @@ export async function autoCalSetCorner(position: StartPosition): Promise<void> {
// LED is at index 0; advance to ~5% to show movement direction
await _setPosition(0);
await _delay(350);
// User may have cancelled during the delay (unmount sets _state = null).
if (!_state) return;
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
await _setPosition(advance);
if (!_state) return;
_state.busy = false;
} catch (err: unknown) {
// _state may have been torn down mid-await by a cancel.
if (!_state) return;
_state.busy = false;
_state.errorMsg = _errMsg(err);
_state.step = 'corner'; // revert on error
@@ -64,6 +64,14 @@ class CalibrationModal extends Modal {
if (testGroup) testGroup.style.display = 'none';
const testSection = document.getElementById('calibration-test-setup-section');
if (testSection) testSection.style.display = 'none';
// Destroy the test-device EntitySelect; otherwise the instance leaks and
// leaves a stale enhanced trigger button in the DOM until the next open.
if (_calTestDeviceEntitySelect) {
_calTestDeviceEntitySelect.destroy();
_calTestDeviceEntitySelect = null;
}
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null;
if (testDeviceSelect) testDeviceSelect.onchange = null;
} else {
const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value;
if (deviceId) clearTestMode(deviceId);
@@ -151,6 +151,15 @@ let _cssTestWs: WebSocket | null = null;
let _cssTestRaf: number | null = null;
let _cssTestLatestRgb: Uint8Array | null = null;
let _cssTestMeta: any = null;
// Set true when a WS frame updates the latest RGB / layer data; the render loop
// only repaints when a new frame has arrived, then clears it. Avoids re-rendering
// the same frame every RAF tick on low-powered devices (e.g. Android TV WebView).
let _cssTestFrameDirty: boolean = false;
// Per-canvas render caches: reuse the 2d context and a single ImageData buffer
// instead of calling getContext()/createImageData() and resetting width/height
// every frame. ImageData is rebuilt only when the canvas LED count changes.
const _cssTestCtxCache = new WeakMap<HTMLCanvasElement, CanvasRenderingContext2D>();
const _cssTestImageDataCache = new WeakMap<HTMLCanvasElement, { w: number; h: number; imageData: ImageData }>();
let _cssTestSourceId: string | null = null;
let _cssTestIsComposite: boolean = false;
let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
@@ -234,6 +243,7 @@ function _testKeyColorsSource(sourceId: string) {
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
_cssTestFrameDirty = false;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
@@ -414,6 +424,7 @@ function _openTestModal(sourceId: string) {
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
_cssTestFrameDirty = false;
const modal = document.getElementById('test-css-source-modal') as HTMLElement | null;
if (!modal) return;
@@ -636,6 +647,8 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) {
// Standard format: raw RGB
_cssTestLatestRgb = raw;
}
// Mark a new frame so the render loop repaints on the next RAF tick.
_cssTestFrameDirty = true;
// Track FPS for api_input sources
if (_cssTestIsApiInput) {
@@ -756,6 +769,7 @@ export function applyCssTestSettings() {
_cssTestLatestRgb = null;
_cssTestMeta = null;
_cssTestLayerData = null;
_cssTestFrameDirty = false;
// Read selected input source from selector (both CSS and CSPT modes)
const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null;
@@ -774,6 +788,9 @@ export function applyCssTestSettings() {
function _cssTestRenderLoop() {
_cssTestRaf = requestAnimationFrame(_cssTestRenderLoop);
if (!_cssTestMeta) return;
// Skip repaint when no new WS frame has arrived since the last render.
if (!_cssTestFrameDirty) return;
_cssTestFrameDirty = false;
const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0;
@@ -786,14 +803,40 @@ function _cssTestRenderLoop() {
}
}
// Returns a cached 2d context and a reusable ImageData for the canvas, only
// resizing the canvas / rebuilding the ImageData when the requested dimensions
// differ from the cached ones. Returns null if a 2d context is unavailable.
function _cssTestAcquireCanvas(
canvas: HTMLCanvasElement,
w: number,
h: number,
): { ctx: CanvasRenderingContext2D; imageData: ImageData } | null {
let ctx = _cssTestCtxCache.get(canvas);
if (!ctx) {
const got = canvas.getContext('2d');
if (!got) return null;
ctx = got;
_cssTestCtxCache.set(canvas, ctx);
}
// Only touch width/height when they actually change (resetting them clears
// the backing store and is expensive).
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
let cached = _cssTestImageDataCache.get(canvas);
if (!cached || cached.w !== w || cached.h !== h) {
cached = { w, h, imageData: ctx.createImageData(w, h) };
_cssTestImageDataCache.set(canvas, cached);
}
return { ctx, imageData: cached.imageData };
}
function _cssTestRenderStrip(rgbBytes: Uint8Array) {
const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null;
if (!canvas) return;
const ledCount = rgbBytes.length / 3;
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
if (!acquired) return;
const { ctx, imageData } = acquired;
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
@@ -824,10 +867,9 @@ function _cssTestRenderLayers(data: any) {
function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) {
const ledCount = rgbBytes.length / 3;
if (ledCount <= 0) return;
canvas.width = ledCount;
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(ledCount, 1);
const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1);
if (!acquired) return;
const { ctx, imageData } = acquired;
const data = imageData.data;
for (let i = 0; i < ledCount; i++) {
const si = i * 3;
@@ -852,13 +894,17 @@ function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) {
const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null;
if (!canvas) continue;
const count = indices.length;
if (count === 0) { canvas.width = 0; continue; }
if (count === 0) {
if (canvas.width !== 0) canvas.width = 0;
continue;
}
const isH = edge === 'top' || edge === 'bottom';
canvas.width = isH ? count : 1;
canvas.height = isH ? 1 : count;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(canvas.width, canvas.height);
const w = isH ? count : 1;
const h = isH ? 1 : count;
const acquired = _cssTestAcquireCanvas(canvas, w, h);
if (!acquired) continue;
const { ctx, imageData } = acquired;
const px = imageData.data;
for (let i = 0; i < count; i++) {
const si = indices[i] * 3;
@@ -1185,6 +1231,7 @@ export function closeTestCssSourceModal() {
_cssTestIsComposite = false;
_cssTestIsKeyColors = false;
_cssTestLayerData = null;
_cssTestFrameDirty = false;
_cssTestNotificationIds = [];
_cssTestIsApiInput = false;
_cssTestStopFpsSampling();
@@ -63,6 +63,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [
'playlists',
'sync-clocks',
'targets',
'recent-activity',
] as const;
const SECTION_LABEL_KEYS: Record<string, string> = {
@@ -73,6 +74,7 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
playlists: 'dashboard.section.playlists',
'sync-clocks': 'dashboard.section.sync_clocks',
targets: 'dashboard.section.targets',
'recent-activity': 'dashboard.section.recent_activity',
};
const PERF_CELL_LABEL_KEYS: Record<string, string> = {
@@ -25,6 +25,7 @@ export type SectionKey =
| 'playlists'
| 'sync-clocks'
| 'targets'
| 'recent-activity'
// Reserved registry keys for v1.1+ (so saved layouts forward-compat).
| 'audio-meters'
| 'alerts'
@@ -155,6 +156,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
_defaultSection('playlists'),
_defaultSection('sync-clocks'),
_defaultSection('targets'),
_defaultSection('recent-activity'),
],
perfCells: [
_defaultPerfCell('patches'),
@@ -5,7 +5,7 @@
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts';
import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, ensureRelativeTimeTicker } from '../core/ui.ts';
import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts';
import { startAutoRefresh, updateTabBadge } from './tabs.ts';
import { isActiveTab } from '../core/tab-registry.ts';
@@ -21,6 +21,8 @@ import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { createFpsSparkline } from '../core/chart-utils.ts';
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
import { mountCardModeToggle } from './card-modes.ts';
import { ICON_ACTIVITY_LOG } from '../core/icons.ts';
import { fetchRecentEntries, renderCompactEntry, ActivityEntry } from './activity-log.ts';
function _applyGlobalLayoutAttrs(): void {
const c = document.getElementById('dashboard-content');
@@ -573,6 +575,84 @@ function renderDashboardPlaylist(playlist: ScenePlaylist): string {
</div>`;
}
// ─── Recent Activity widget (Dashboard) ─────────────────────
const RECENT_ACTIVITY_LIMIT = 5;
let _recentActivityLiveListener: ((e: Event) => void) | null = null;
/** Fetch recent entries and populate the widget list container.
* Skips the network fetch when the widget already contains live entries
* (dal-list class is set by _renderRecentActivityList on first mount) so
* unrelated dashboard re-renders never re-fetch or flash the widget. */
async function _loadRecentActivityWidget(): Promise<void> {
const list = document.getElementById('dashboard-recent-activity-list');
if (!list) return;
// Widget already populated — only wire up live listener and ticker;
// don't re-fetch or overwrite the live content.
if (list.classList.contains('dal-list')) {
ensureRelativeTimeTicker();
_startRecentActivityLive();
return;
}
const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT);
_renderRecentActivityList(list, entries);
// Start relative-time ticker (idempotent — shared with the Activity tab)
ensureRelativeTimeTicker();
// Start live listener (idempotent)
_startRecentActivityLive();
}
function _renderRecentActivityList(list: HTMLElement, entries: ActivityEntry[]): void {
if (!entries || entries.length === 0) {
list.className = '';
list.innerHTML = `<div class="al-state al-empty dal-empty" role="status">
<span class="al-state-icon" aria-hidden="true">${ICON_ACTIVITY_LOG}</span>
<p>${escapeHtml(t('activity_log.empty_no_filters'))}</p>
</div>`;
return;
}
list.className = 'dal-list';
list.innerHTML = entries.map(e => renderCompactEntry(e)).join('');
}
function _stopRecentActivityLive(): void {
if (_recentActivityLiveListener) {
document.removeEventListener('server:activity_logged', _recentActivityLiveListener);
_recentActivityLiveListener = null;
}
}
function _startRecentActivityLive(): void {
// Always tear down first so we never stack listeners across loadDashboard calls.
_stopRecentActivityLive();
_recentActivityLiveListener = (e: Event) => {
const ce = e as CustomEvent;
const entry = ce.detail?.entry;
if (!entry) return;
const list = document.getElementById('dashboard-recent-activity-list');
// No-op when the widget isn't mounted (section hidden or not yet rendered).
if (!list) return;
if (!list.classList.contains('dal-list')) {
// Transition from empty-state to list on the first live event
_renderRecentActivityList(list, [entry]);
return;
}
// Surgically prepend the new row and cap at N — no loadDashboard().
list.insertAdjacentHTML('afterbegin', renderCompactEntry(entry));
const rows = list.querySelectorAll('.al-compact-row');
if (rows.length > RECENT_ACTIVITY_LIMIT) {
for (let i = RECENT_ACTIVITY_LIMIT; i < rows.length; i++) {
rows[i].remove();
}
}
};
document.addEventListener('server:activity_logged', _recentActivityLiveListener);
}
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
/** Called from the transport-bar poll cycler (and any legacy callers
* that might still reference `window.changeDashboardPollInterval`). */
@@ -679,6 +759,127 @@ function _sectionContent(sectionKey: string, itemsHtml: string): string {
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
}
/**
* Reconcile the `.dashboard-dynamic` container against newly-built HTML
* without a wholesale innerHTML replacement.
*
* Algorithm:
* 1. Parse `newHtml` into a detached container.
* 2. Build a map of existing live sections keyed by data-section.
* 3. For each section in the new HTML (in order):
* a. If the live DOM has that section AND it is content-stable
* (recent-activity with live list) OR its outerHTML hasn't
* changed keep the live element.
* b. Otherwise replace / insert with the new element.
* 4. Remove sections that no longer appear in the new HTML.
* 5. Re-order to match the new order (move nodes, no recreation).
*
* The `recent-activity` section is treated as content-stable once
* its list has been populated (dal-list class), mirroring the perf-
* persistent pattern. The freshly-built loading placeholder in
* `newHtml` is never compared against the live-entry list instead
* the live DOM node is always kept when it has real content.
*/
function _reconcileDynamicSections(dynamic: HTMLElement, newHtml: string): void {
// Parse incoming HTML into a scratch container.
const scratch = document.createElement('div');
scratch.innerHTML = newHtml;
// Gather incoming sections in order.
const incoming = Array.from(
scratch.querySelectorAll<HTMLElement>(':scope > .dashboard-section[data-section]')
);
// If the new HTML contains non-section top-level nodes (e.g. the
// `.dashboard-no-targets` placeholder shown when there are no entities),
// fall back to a simple innerHTML swap — this path is rare and the
// no-entities state doesn't have live widgets worth preserving.
const totalTopLevel = scratch.children.length;
if (totalTopLevel !== incoming.length) {
if (dynamic.innerHTML !== newHtml) dynamic.innerHTML = newHtml;
return;
}
// Drop any stray non-section top-level nodes left over from a previous
// state (e.g. the `.dashboard-no-targets` placeholder shown when there
// were zero entities). The reconcile pass below only manages
// `.dashboard-section` children, so without this sweep that orphan node
// would linger over the freshly-populated dashboard.
for (const child of Array.from(dynamic.children)) {
if (!child.matches('.dashboard-section[data-section]')) child.remove();
}
// Index live sections by key.
const liveMap = new Map<string, HTMLElement>();
for (const el of Array.from(
dynamic.querySelectorAll<HTMLElement>(':scope > .dashboard-section[data-section]')
)) {
liveMap.set(el.dataset.section as string, el);
}
// Collect the keys that should remain (in new order).
const newKeys = new Set(incoming.map(el => el.dataset.section as string));
// Remove sections that are no longer present.
for (const [key, el] of liveMap) {
if (!newKeys.has(key)) el.remove();
}
// Walk incoming sections in order and reconcile each one.
let insertBefore: HTMLElement | null = null; // node to insert before (null = append)
for (let i = incoming.length - 1; i >= 0; i--) {
const newEl = incoming[i];
const key = newEl.dataset.section as string;
const live = liveMap.get(key);
let keep: HTMLElement;
if (live) {
// Content-stable guard: the recent-activity section must not be
// replaced once it holds live entries — the new HTML only has the
// loading placeholder and would wipe the list.
const isRecentActivity = key === 'recent-activity';
const raList = isRecentActivity
? live.querySelector('#dashboard-recent-activity-list')
: null;
const raIsPopulated = raList !== null && raList.classList.contains('dal-list');
if (raIsPopulated) {
// Always keep the live recent-activity section as-is.
keep = live;
} else if (live.outerHTML === newEl.outerHTML) {
// Unchanged section — keep live DOM, no mutation.
keep = live;
} else {
// Section content changed — replace.
live.replaceWith(newEl);
liveMap.set(key, newEl);
keep = newEl;
}
} else {
// New section — insert it.
dynamic.appendChild(newEl); // temporary placement; ordering pass below
liveMap.set(key, newEl);
keep = newEl;
}
// Re-order: after the ordering loop (reverse walk) each `keep`
// should end up just before the node we placed in the previous
// iteration (i+1). Using insertBefore to build correct order.
if (insertBefore === null) {
// Last in order — move to end of dynamic.
if (keep.nextElementSibling !== null || keep.parentElement !== dynamic) {
dynamic.appendChild(keep);
}
} else {
if (keep.nextElementSibling !== insertBefore || keep.parentElement !== dynamic) {
dynamic.insertBefore(keep, insertBefore);
}
}
insertBefore = keep;
}
}
export async function loadDashboard(forceFullRender: boolean = false): Promise<void> {
if (_dashboardLoading) return;
set_dashboardLoading(true);
@@ -763,7 +964,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
let dynamicHtml = '';
let runningIds: any[] = [];
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && playlists.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
const _raSection = isSectionVisible('recent-activity') ? `<div class="dashboard-section" data-section="recent-activity">
${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')}
${_sectionContent('recent-activity', `<div id="dashboard-recent-activity-list" class="dal-loading" aria-live="polite" aria-label="${escapeHtml(t('dashboard.section.recent_activity'))}">
<div class="al-state al-loading">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>
</div>
<div class="dal-footer">
<button class="btn btn-ghost btn-sm dal-view-all" onclick="switchTab('activity_log')" aria-label="${escapeHtml(t('dashboard.recent_activity.view_all'))}">
${escapeHtml(t('dashboard.recent_activity.view_all'))} &rarr;
</button>
</div>`)}
</div>` : '';
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>${_raSection}`;
} else {
const enriched = targets.map(target => ({
...target,
@@ -1003,6 +1218,23 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
</div>`;
}
// Recent Activity section — registered like any other section so it
// participates in layout ordering, show/hide, and Customize panel.
sectionFragments['recent-activity'] = `<div class="dashboard-section" data-section="recent-activity">
${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')}
${_sectionContent('recent-activity', `<div id="dashboard-recent-activity-list" class="dal-loading" aria-live="polite" aria-label="${escapeHtml(t('dashboard.section.recent_activity'))}">
<div class="al-state al-loading">
<div class="al-spinner"></div>
<span>${escapeHtml(t('activity_log.loading'))}</span>
</div>
</div>
<div class="dal-footer">
<button class="btn btn-ghost btn-sm dal-view-all" onclick="switchTab('activity_log')" aria-label="${escapeHtml(t('dashboard.recent_activity.view_all'))}">
${escapeHtml(t('dashboard.recent_activity.view_all'))} &rarr;
</button>
</div>`)}
</div>`;
// Now assemble in layout-driven order, skipping invisible
// sections and the perf section (which is always rendered
// separately at the top for chart-persistence reasons).
@@ -1043,8 +1275,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
existingPerf.style.display = perfVisible ? '' : 'none';
}
const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
dynamic.innerHTML = dynamicHtml;
if (dynamic) {
_reconcileDynamicSections(dynamic as HTMLElement, dynamicHtml);
}
_applyGlobalLayoutAttrs();
}
@@ -1062,6 +1294,9 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
_startUptimeTimer();
startPerfPolling();
// Async-load the Recent Activity widget (non-blocking — never blocks the main render).
_loadRecentActivityWidget().catch(() => { /* widget failure is non-fatal */ });
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load dashboard:', error);
@@ -146,6 +146,10 @@ export function switchSettingsTab(tabId: string): void {
if (tabId === 'notifications') {
initNotificationsPanel();
}
// Lazy-load activity log settings when switching to that tab
if (tabId === 'activity_log') {
loadActivityLogSettings();
}
}
// ─── Log Viewer ────────────────────────────────────────────
@@ -526,6 +530,7 @@ export function openSettingsModal(): void {
loadLogLevel();
loadShutdownAction();
loadDaylightTimezone();
loadActivityLogSettings();
_seedRailFooter();
// Refresh the update status so the rail badge ("update available" pill
// on the Updates tab) is current when the modal opens — it would
@@ -1200,3 +1205,110 @@ export function testNotifFromSettings(): void {
fireTestNotification();
}
// ─── Activity Log Settings ──────────────────────────────────
interface ActivityLogSettingsResponse {
enabled: boolean;
max_days: number;
max_entries: number;
}
/** Fetch and populate the Activity Log settings panel. */
export async function loadActivityLogSettings(): Promise<void> {
try {
const res = await fetchWithAuth('/activity-log/settings');
if (!res || !res.ok) return;
const data: ActivityLogSettingsResponse = await res.json();
const enabledCb = document.getElementById('al-settings-enabled') as HTMLInputElement | null;
const maxDaysIn = document.getElementById('al-settings-max-days') as HTMLInputElement | null;
const maxEntriesIn = document.getElementById('al-settings-max-entries') as HTMLInputElement | null;
if (enabledCb) enabledCb.checked = data.enabled;
if (maxDaysIn) maxDaysIn.value = String(data.max_days);
if (maxEntriesIn) maxEntriesIn.value = String(data.max_entries);
} catch (err) {
// Non-fatal — panel just shows defaults
console.error('Failed to load activity log settings:', err);
}
}
/** Validate and save the Activity Log retention settings. */
export async function saveActivityLogSettings(): Promise<void> {
const enabledCb = document.getElementById('al-settings-enabled') as HTMLInputElement | null;
const maxDaysIn = document.getElementById('al-settings-max-days') as HTMLInputElement | null;
const maxEntriesIn = document.getElementById('al-settings-max-entries') as HTMLInputElement | null;
const enabled = enabledCb ? enabledCb.checked : true;
const maxDays = parseInt(maxDaysIn?.value ?? '365', 10);
const maxEntries = parseInt(maxEntriesIn?.value ?? '100000', 10);
// Client-side validation matching server bounds
if (isNaN(maxDays) || maxDays < 0 || maxDays > 3650) {
showToast(t('settings.activity_log.error.max_days_range'), 'error');
return;
}
if (isNaN(maxEntries) || maxEntries < 0 || maxEntries > 10_000_000) {
showToast(t('settings.activity_log.error.max_entries_range'), 'error');
return;
}
try {
const res = await fetchWithAuth('/activity-log/settings', {
method: 'PUT',
body: JSON.stringify({ enabled, max_days: maxDays, max_entries: maxEntries }),
});
if (!res || !res.ok) {
const err = await res?.json().catch(() => ({}));
throw new Error((err as any).detail || `HTTP ${res?.status}`);
}
showToast(t('settings.activity_log.saved'), 'success');
} catch (err: any) {
if (err?.isAuth) return;
showToast(t('settings.activity_log.save_error') + ': ' + (err?.message || ''), 'error');
}
}
/** Export the full activity log as CSV or JSON via authed blob download. */
export async function activityLogSettingsExport(format: 'csv' | 'json'): Promise<void> {
try {
showToast(t('activity_log.export.downloading'), 'info');
const res = await fetchWithAuth(`/activity-log/export?format=${format}`);
if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `ledgrab-activity-${now}.${format}`;
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
} catch (err: any) {
if (err?.isAuth) return;
showToast(t('activity_log.export.error'), 'error');
}
}
/** Confirm and clear all activity log entries. */
export async function clearActivityLog(): Promise<void> {
const ok = await showConfirm(t('settings.activity_log.clear.confirm'));
if (!ok) return;
try {
const res = await fetchWithAuth('/activity-log', { method: 'DELETE', handle401: false });
if (!res || !res.ok) {
if (res?.status === 401) {
showToast(t('settings.activity_log.clear.auth_required'), 'error');
return;
}
throw new Error(`HTTP ${res?.status}`);
}
showToast(t('settings.activity_log.clear.success'), 'success');
} catch (err: any) {
if (err?.isAuth) return;
showToast(t('settings.activity_log.clear.error') + ': ' + (err?.message || ''), 'error');
}
}
@@ -76,6 +76,9 @@ interface WizardState {
let _state: WizardState | null = null;
let _modal: SetupWizardModal | null = null;
/** First render after open: let the Modal's own autofocus win; only steal focus
* on subsequent step transitions (for D-pad / TV navigation). */
let _firstRender = true;
const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done'];
@@ -95,6 +98,7 @@ class SetupWizardModal extends Modal {
/** Open the wizard (first-run or on-demand). */
export function openSetupWizard(): void {
if (!_modal) _modal = new SetupWizardModal();
_firstRender = true;
_state = {
step: 'welcome',
deviceId: '',
@@ -201,8 +205,18 @@ export async function wizardNext(): Promise<void> {
_renderStep();
await _runScaffold();
} else if (step === 'calibrate') {
// "Skip calibration" path — move to start
void unmountAutoCalibration();
// "Skip calibration" path — move to start.
// Mark busy BEFORE the await so a fast second tap on Skip/Next can't
// re-enter wizardNext (gated at the top) and fire a second teardown +
// start during the up-to-10s unmount await. _startOutput() manages
// busy itself afterward.
_state.busy = true;
_renderStep();
// Await teardown first: unmountAutoCalibration() POSTs session/stop, which
// restores the device's prior target. Starting output before that completes
// would let the stop clobber the just-started target.
await unmountAutoCalibration();
if (!_state) return;
_state.step = 'start';
_renderStep();
await _startOutput();
@@ -361,12 +375,15 @@ async function _loadDisplays(): Promise<void> {
}
}
export function wizardSelectDisplay(index: number, displayName: string): void {
export function wizardSelectDisplay(index: number, displayName: string, skipRender = false): void {
if (!_state) return;
_state.displayIndex = index;
_state.displayName = displayName;
_state.errorMsg = '';
_renderStep();
// The manual display-index <input> fallback passes skipRender=true: a full
// _renderStep() rebuilds the container via innerHTML, destroying the input
// and losing focus/caret on every keystroke (unusable on Android TV WebView).
if (!skipRender) _renderStep();
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -469,6 +486,25 @@ function _renderStep(): void {
const html = _buildStepHtml(_state);
container.innerHTML = html;
_attachStepListeners(_state.step);
_focusFirstControl(container);
}
/**
* Move focus to the step's first focusable control after a re-render so D-pad /
* TV WebView users don't land on a node that was just removed. Skipped on the
* very first render so it doesn't fight the Modal's own initial autofocus.
*/
function _focusFirstControl(container: HTMLElement): void {
if (_firstRender) {
_firstRender = false;
return;
}
requestAnimationFrame(() => {
const el = container.querySelector(
'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])'
) as HTMLElement | null;
if (el && el.offsetParent !== null) el.focus();
});
}
function _renderProgressBar(): void {
@@ -654,7 +690,7 @@ function _buildDisplayStep(state: WizardState): string {
<label class="wizard-form-label">${t('wizard.display.manual_index')}</label>
<input id="wizard-display-index-manual" class="form-input" type="number"
min="0" max="63" value="${state.displayIndex}"
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value)">
oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value, true)">
</div>
</div>`;
} else {
@@ -66,6 +66,7 @@ const gettingStartedSteps: TutorialStep[] = [
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
{ selector: '#tab-btn-integrations', textKey: 'tour.integrations', position: 'bottom' },
{ selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' },
{ selector: '#tab-btn-activity_log', textKey: 'tour.activity_log', position: 'bottom' },
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
{ selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' },
+20
View File
@@ -457,6 +457,26 @@ startTargetOverlay: (...args: any[]) => any;
applyBgEffect: (id: string) => void;
renderAppearanceTab: () => void;
// ─── Activity Log ───
loadActivityLog: () => Promise<void>;
activityLogToggleDetail: (entryId: string) => void;
loadActivityLogSettings: () => Promise<void>;
saveActivityLogSettings: () => Promise<void>;
activityLogSettingsExport: (format: 'csv' | 'json') => Promise<void>;
clearActivityLog: () => Promise<void>;
activityLogToggleCat: (cat: string) => void;
activityLogToggleSev: (sev: string) => void;
activityLogOnSearch: (val: string) => void;
activityLogOnActor: (val: string) => void;
activityLogOnEntityType: (val: string) => void;
activityLogOnSince: (val: string) => void;
activityLogOnUntil: (val: string) => void;
activityLogClearFilters: () => void;
activityLogPreset: (key: string) => void;
activityLogLoadMore: () => void;
activityLogExport: (format: 'csv' | 'json') => Promise<void>;
activityLogNavigateToEntity: (entityType: string, entityId: string) => void;
// ─── Overlay spinner internals ───
overlaySpinnerTimer: ReturnType<typeof setInterval> | null;
_overlayEscHandler: ((e: KeyboardEvent) => void) | null;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
"""Activity / audit log data model.
Defines the ``ActivityLogEntry`` dataclass together with its category and
severity enumerations and the ``ActivityLogFilters`` query object. Row-level
codec (``to_row`` / ``from_row``) converts between the dataclass and a flat
SQLite column dict.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Sequence
# ---------------------------------------------------------------------------
# Enumerations (string constants, not enum.Enum, to stay consistent with the
# rest of the codebase which uses plain string literals)
# ---------------------------------------------------------------------------
class ActivityCategory:
"""Valid ``category`` values for an ``ActivityLogEntry``."""
AUTH = "auth"
DEVICE = "device"
ENTITY = "entity"
CAPTURE = "capture"
SYSTEM = "system"
ALL: tuple[str, ...] = (AUTH, DEVICE, ENTITY, CAPTURE, SYSTEM)
class ActivitySeverity:
"""Valid ``severity`` values for an ``ActivityLogEntry``."""
INFO = "info"
WARNING = "warning"
ERROR = "error"
ALL: tuple[str, ...] = (INFO, WARNING, ERROR)
# ---------------------------------------------------------------------------
# Entry dataclass
# ---------------------------------------------------------------------------
@dataclass
class ActivityLogEntry:
"""One immutable audit record.
Fields
------
id Stable application-assigned identifier, e.g. ``al_<uuid8>``.
ts UTC timestamp of the event (server-assigned at record time).
category Broad bucket one of :class:`ActivityCategory`.
action Verb-object label, e.g. ``"entity.created"`` or ``"auth.rejected"``.
severity One of :class:`ActivitySeverity`.
actor Who triggered the action, e.g. an API-key label or ``"system"``.
entity_type Optional: kind of entity involved, e.g. ``"output_target"``.
entity_id Optional: stable entity identifier.
entity_name Optional: human-readable entity name at the time of the event.
message Human-readable description suitable for display.
metadata Small structured context (device address, error code, ).
Must be JSON-serialisable; defaults to empty dict.
"""
id: str
ts: datetime
category: str
action: str
severity: str
actor: str
message: str
entity_type: str | None = None
entity_id: str | None = None
entity_name: str | None = None
metadata: dict = field(default_factory=dict)
# -- Row codec -----------------------------------------------------------
def to_row(self) -> dict:
"""Return a flat dict suitable for a parameterised SQLite INSERT.
``ts`` is stored as an ISO-8601 string (UTC). ``metadata`` is stored
as a JSON string. ``seq`` is DB-assigned and is NOT included.
"""
return {
"id": self.id,
"ts": self.ts.isoformat(),
"category": self.category,
"action": self.action,
"severity": self.severity,
"actor": self.actor,
"entity_type": self.entity_type,
"entity_id": self.entity_id,
"entity_name": self.entity_name,
"message": self.message,
"metadata": json.dumps(self.metadata, ensure_ascii=False),
}
@staticmethod
def from_row(row: dict) -> "ActivityLogEntry":
"""Reconstruct an ``ActivityLogEntry`` from a SQLite row dict.
``row`` may include the ``seq`` column it is ignored here (callers
that need ``seq`` for keyset pagination access it directly from the
row before calling this method).
"""
raw_meta = row.get("metadata") or "{}"
try:
metadata = json.loads(raw_meta)
except (json.JSONDecodeError, TypeError):
metadata = {}
raw_ts = row["ts"]
ts = datetime.fromisoformat(raw_ts)
# Ensure tz-aware UTC even if stored without offset (legacy rows)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ActivityLogEntry(
id=row["id"],
ts=ts,
category=row["category"],
action=row["action"],
severity=row["severity"],
actor=row["actor"],
entity_type=row.get("entity_type"),
entity_id=row.get("entity_id"),
entity_name=row.get("entity_name"),
message=row["message"],
metadata=metadata,
)
# ---------------------------------------------------------------------------
# Filters dataclass
# ---------------------------------------------------------------------------
@dataclass
class ActivityLogFilters:
"""Optional query-time filters for :class:`ActivityLogRepository`.
All fields are optional. ``None`` / empty sequence means "no restriction
on this dimension".
Fields
------
categories Restrict to entries whose ``category`` is in this set.
severities Restrict to entries whose ``severity`` is in this set.
actor Exact match on the ``actor`` field.
entity_type Exact match on the ``entity_type`` field.
entity_id Exact match on the ``entity_id`` field.
since Inclusive lower bound on ``ts`` (``ts >= since``).
until Inclusive upper bound on ``ts`` (``ts <= until``).
message_like Free-text substring match on ``message`` (LIKE ``%value%``).
The value is escaped no SQL injection risk.
"""
categories: Sequence[str] | None = None
severities: Sequence[str] | None = None
actor: str | None = None
entity_type: str | None = None
entity_id: str | None = None
since: datetime | None = None
until: datetime | None = None
message_like: str | None = None
@@ -0,0 +1,338 @@
"""Append-only repository for the persistent activity / audit log.
Design notes
------------
* Does NOT subclass ``BaseSqliteStore`` that base loads every row into an
in-memory cache at construction time, which is wrong for an unbounded,
append-heavy log.
* All SQL parameters are passed as positional ``?`` placeholders no user
input is interpolated into the query string.
* Keyset pagination is implemented via the ``seq`` INTEGER PRIMARY KEY
AUTOINCREMENT column, which is strictly monotonic and survives rows with
identical ``ts`` values.
* The repository takes a ``Database`` instance and calls ``db.execute`` /
``db.transaction`` directly.
* Thread safety: ``Database.execute`` holds the ``RLock`` for the duration of
each call. The repository itself adds no extra locking. Callers that
originate from non-event-loop threads MUST marshal via
``loop.call_soon_threadsafe`` (that is Phase 2's responsibility).
"""
from __future__ import annotations
from datetime import datetime
from typing import Iterator
from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
_TABLE = "activity_log"
def _build_filter_clause(
filters: ActivityLogFilters,
params: list,
*,
extra_where: str | None = None,
) -> str:
"""Return a WHERE clause string and append bound parameters to *params*.
The caller is responsible for providing the opening ``WHERE`` keyword when
the returned string is non-empty, or ``AND`` when appending to an existing
predicate. This function returns only the condition fragment(s) joined by
``AND``, or an empty string when *filters* has no restrictions.
``extra_where`` is prepended (with AND) before the filter conditions if
provided (used for the ``seq < ?`` keyset predicate).
"""
conditions: list[str] = []
if extra_where:
conditions.append(extra_where)
if filters.categories:
placeholders = ",".join("?" * len(filters.categories))
conditions.append(f"category IN ({placeholders})")
params.extend(filters.categories)
if filters.severities:
placeholders = ",".join("?" * len(filters.severities))
conditions.append(f"severity IN ({placeholders})")
params.extend(filters.severities)
if filters.actor is not None:
conditions.append("actor = ?")
params.append(filters.actor)
if filters.entity_type is not None:
conditions.append("entity_type = ?")
params.append(filters.entity_type)
if filters.entity_id is not None:
conditions.append("entity_id = ?")
params.append(filters.entity_id)
if filters.since is not None:
conditions.append("ts >= ?")
params.append(filters.since.isoformat())
if filters.until is not None:
conditions.append("ts <= ?")
params.append(filters.until.isoformat())
if filters.message_like is not None:
# Escape LIKE special characters in the user-supplied substring so that
# a literal '%' or '_' in the message does not act as a wildcard.
escaped = filters.message_like.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions.append("message LIKE ? ESCAPE '\\'")
params.append(f"%{escaped}%")
return " AND ".join(conditions)
class ActivityLogRepository:
"""Purpose-built repository for the ``activity_log`` table.
Parameters
----------
db:
The shared ``Database`` singleton. The migration that creates the
``activity_log`` table must have been applied before the first call to
:meth:`record`. Construction triggers migration via
``MigrationRunner`` so users can create the repository directly
without a separate startup step.
"""
def __init__(self, db: Database) -> None:
self._db = db
# Apply pending migrations (idempotent; most runs are no-ops)
from ledgrab.storage.data_migrations import ALL_MIGRATIONS, MigrationRunner
MigrationRunner(db).run(ALL_MIGRATIONS)
# -- Write ---------------------------------------------------------------
def record(self, entry: ActivityLogEntry) -> None:
"""Append *entry* to the log.
The ``seq`` column is assigned by SQLite (AUTOINCREMENT) ``entry``
does not carry a ``seq`` field.
Caller contract: this must be called from the event-loop thread (or
from a context already serialised through the ``Database`` RLock).
Cross-thread callers must marshal via ``loop.call_soon_threadsafe``;
that is Phase 2's responsibility.
"""
row = entry.to_row()
self._db.execute(
f"INSERT INTO {_TABLE} "
f"(id, ts, category, action, severity, actor, "
f" entity_type, entity_id, entity_name, message, metadata) "
f"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
row["id"],
row["ts"],
row["category"],
row["action"],
row["severity"],
row["actor"],
row["entity_type"],
row["entity_id"],
row["entity_name"],
row["message"],
row["metadata"],
),
)
# -- Read ----------------------------------------------------------------
def query(
self,
filters: ActivityLogFilters,
*,
before_seq: int | None = None,
limit: int = 50,
) -> list[ActivityLogEntry]:
"""Return the newest matching entries, oldest-first within the page.
Keyset pagination: pass the smallest ``seq`` seen on the previous page
as *before_seq* to get the next page. Entries are fetched in
``seq DESC`` order from SQLite and then reversed before returning so
that the caller receives them in chronological order within the page.
Parameters
----------
filters:
Optional filter criteria.
before_seq:
Exclusive upper bound on ``seq``. ``None`` returns the first
(newest) page.
limit:
Maximum number of entries to return.
"""
params: list = []
keyset = "seq < ?" if before_seq is not None else None
if before_seq is not None:
params.append(before_seq)
where_fragment = _build_filter_clause(filters, params, extra_where=keyset)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
params.append(limit)
sql = (
f"SELECT seq, id, ts, category, action, severity, actor, "
f"entity_type, entity_id, entity_name, message, metadata "
f"FROM {_TABLE} "
f"{where_clause} "
f"ORDER BY seq DESC "
f"LIMIT ?"
)
cursor = self._db.execute(sql, tuple(params))
rows = cursor.fetchall()
# Reverse to return chronological order within the page
return [ActivityLogEntry.from_row(dict(row)) for row in reversed(rows)]
def count(self, filters: ActivityLogFilters | None = None) -> int:
"""Return the number of entries matching *filters* (or all entries)."""
if filters is None:
filters = ActivityLogFilters()
params: list = []
where_fragment = _build_filter_clause(filters, params)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
sql = f"SELECT COUNT(*) AS cnt FROM {_TABLE} {where_clause}"
cursor = self._db.execute(sql, tuple(params))
row = cursor.fetchone()
return int(row["cnt"])
# -- Maintenance ---------------------------------------------------------
def prune(
self,
*,
before_ts: datetime | None = None,
max_entries: int | None = None,
) -> int:
"""Delete old / excess entries; return the total rows deleted.
Both predicates are applied independently:
1. Delete all rows where ``ts < before_ts`` (age-based pruning).
2. Keep only the newest ``max_entries`` rows, deleting the rest
(count-based pruning).
The two operations run in separate statements so that each can be
independently enabled or disabled.
"""
deleted = 0
if before_ts is not None:
cursor = self._db.execute(
f"DELETE FROM {_TABLE} WHERE ts < ?",
(before_ts.isoformat(),),
)
deleted += cursor.rowcount
if max_entries is not None and max_entries >= 0:
# Find the seq of the Nth newest row; delete everything older than it.
cursor = self._db.execute(
f"SELECT seq FROM {_TABLE} ORDER BY seq DESC LIMIT 1 OFFSET ?",
(max_entries,),
)
cutoff_row = cursor.fetchone()
if cutoff_row is not None:
cutoff_seq = cutoff_row["seq"]
cursor = self._db.execute(
f"DELETE FROM {_TABLE} WHERE seq <= ?",
(cutoff_seq,),
)
deleted += cursor.rowcount
return deleted
def clear(self) -> int:
"""Delete all entries; return the number of rows deleted."""
cursor = self._db.execute(f"DELETE FROM {_TABLE}")
return cursor.rowcount
def get_seq_for_id(self, entry_id: str) -> int | None:
"""Return the ``seq`` value for the entry with *entry_id*, or ``None``.
Used by the API list endpoint to compute the keyset cursor
(``next_before_seq``) from the oldest entry on the current page.
"""
cursor = self._db.execute(
f"SELECT seq FROM {_TABLE} WHERE id = ?",
(entry_id,),
)
row = cursor.fetchone()
return int(row["seq"]) if row is not None else None
# -- Export --------------------------------------------------------------
def iter_export(
self,
filters: ActivityLogFilters | None = None,
*,
batch_size: int = 1000,
) -> Iterator[ActivityLogEntry]:
"""Yield all matching entries in ascending ``seq`` order.
Fetches rows in bounded batches (keyset-paginated by ``seq``), holding
the DB lock only for the duration of each ``fetchall()`` and releasing
it before yielding. This prevents a slow/stalled export client from
blocking all other DB operations (record, config writes, etc.) for the
full duration of the stream.
Memory usage is bounded to ``batch_size`` rows at a time.
"""
if filters is None:
filters = ActivityLogFilters()
# Keyset cursor: largest seq yielded so far; None means "start from the
# very beginning". We iterate ascending (seq ASC), so each batch uses
# "seq > ?" to advance past the already-yielded rows.
cursor_seq: int | None = None
while True:
# Build params list: cursor_seq placeholder must come first because
# _build_filter_clause prepends extra_where as the first condition.
params: list = []
if cursor_seq is not None:
params.append(cursor_seq)
keyset: str | None = "seq > ?"
else:
keyset = None
where_fragment = _build_filter_clause(filters, params, extra_where=keyset)
where_clause = f"WHERE {where_fragment}" if where_fragment else ""
params.append(batch_size)
sql = (
f"SELECT seq, id, ts, category, action, severity, actor, "
f"entity_type, entity_id, entity_name, message, metadata "
f"FROM {_TABLE} "
f"{where_clause} "
f"ORDER BY seq ASC "
f"LIMIT ?"
)
# Hold the lock only for the bounded fetchall; release before yielding.
with self._db._lock: # noqa: SLF001 — internal access; no public cursor API
rows = self._db._conn.execute(sql, tuple(params)).fetchall() # noqa: SLF001
if not rows:
break
for row in rows:
yield ActivityLogEntry.from_row(dict(row))
# The last row has the largest seq in this batch (ORDER BY seq ASC).
cursor_seq = rows[-1]["seq"]
if len(rows) < batch_size:
# Fewer rows than requested → this was the final batch.
break
@@ -213,7 +213,57 @@ class StaticToSingleColorMigration(DataMigration):
return rows_changed
class AddActivityLogTableMigration(DataMigration):
"""Create the ``activity_log`` table and its indexes.
This is a purely additive migration it does not touch any existing table.
``CREATE TABLE / INDEX IF NOT EXISTS`` ensures idempotency if the migration
somehow runs twice (e.g. after a partial restore).
"""
name = "002_add_activity_log"
def apply(self, conn: sqlite3.Connection) -> int:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS activity_log (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT UNIQUE NOT NULL,
ts TEXT NOT NULL,
category TEXT NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
actor TEXT NOT NULL,
entity_type TEXT,
entity_id TEXT,
entity_name TEXT,
message TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}'
)
"""
)
# Primary read path: newest-first keyset pagination
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_activity_log_ts_seq "
"ON activity_log (ts DESC, seq DESC)"
)
# Filter indexes
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_activity_log_category " "ON activity_log (category)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_activity_log_severity " "ON activity_log (severity)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_activity_log_actor " "ON activity_log (actor)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_activity_log_entity "
"ON activity_log (entity_type, entity_id)"
)
return 0
# Master list — ORDER MATTERS. Append new migrations; never reorder.
ALL_MIGRATIONS: list[DataMigration] = [
StaticToSingleColorMigration(),
AddActivityLogTableMigration(),
]
+46 -2
View File
@@ -10,6 +10,48 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, List
from ledgrab.utils import secret_box
# Keys inside ``adapter_config`` that hold secrets and must be encrypted at
# rest (and decrypted on load). Mirrors the secret_box pattern used by
# storage/http_endpoint.py for its ``auth_token`` field.
_SECRET_CONFIG_KEYS = ("auth_token",)
def _encrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *adapter_config* with secret values encrypted.
``secret_box.encrypt`` is a no-op on already-encrypted envelopes, so this
is safe to call repeatedly and on configs that were never plaintext.
"""
result = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
value = result.get(key)
if isinstance(value, str) and value:
result[key] = secret_box.encrypt(value)
return result
def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of *adapter_config* with secret values decrypted.
Migration-safe: ``secret_box.decrypt`` returns the input unchanged when it
is not an encryption envelope, so legacy plaintext rows still load. On a
corrupt/undecryptable envelope, fall back to dropping the value rather than
crashing the whole store load.
"""
result = dict(adapter_config)
for key in _SECRET_CONFIG_KEYS:
value = result.get(key)
if isinstance(value, str) and value:
if secret_box.is_encrypted(value):
try:
result[key] = secret_box.decrypt(value)
except Exception:
result[key] = ""
# else: legacy plaintext — leave as-is (migration-safe read path).
return result
@dataclass
class EventMapping:
@@ -89,7 +131,8 @@ class GameIntegrationConfig:
"name": self.name,
"adapter_type": self.adapter_type,
"enabled": self.enabled,
"adapter_config": dict(self.adapter_config),
# Encrypt secret values (e.g. webhook auth_token) at rest.
"adapter_config": _encrypt_adapter_config(self.adapter_config),
"event_mappings": [m.to_dict() for m in self.event_mappings],
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
@@ -110,7 +153,8 @@ class GameIntegrationConfig:
name=data["name"],
adapter_type=data["adapter_type"],
enabled=data.get("enabled", True),
adapter_config=data.get("adapter_config", {}),
# Decrypt secret values; tolerant of legacy-plaintext rows.
adapter_config=_decrypt_adapter_config(data.get("adapter_config", {})),
event_mappings=mappings,
created_at=(
datetime.fromisoformat(data["created_at"])
+4
View File
@@ -143,6 +143,7 @@
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
<button class="tab-btn" data-tab="integrations" onclick="switchTab('integrations')" role="tab" aria-selected="false" aria-controls="tab-integrations" id="tab-btn-integrations" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg> <span data-i18n="integrations.title">Integrations</span></button>
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+6"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
<button class="tab-btn" data-tab="activity_log" onclick="switchTab('activity_log')" role="tab" aria-selected="false" aria-controls="tab-activity_log" id="tab-btn-activity_log" title="Ctrl+7"><svg class="icon" viewBox="0 0 24 24"><path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/></svg> <span data-i18n="activity_log.title">Activity</span></button>
</div>
</div>
</aside>
@@ -201,6 +202,9 @@
</div>
</div>
<div class="tab-panel" id="tab-activity_log" role="tabpanel" aria-labelledby="tab-btn-activity_log">
<div class="loading-spinner"></div>
</div>
<script>
// Apply saved tab immediately during parse to prevent visible jump
@@ -56,10 +56,10 @@
<div class="calibration-preview">
<!-- Screen with direction toggle, total LEDs, and offset -->
<div class="preview-screen">
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction">
<button type="button" class="direction-toggle" onclick="toggleDirection()" data-i18n-title="calibration.direction.toggle" title="Toggle direction">
<span id="direction-icon"><svg class="icon" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg></span> <span id="direction-label">CW</span>
</button>
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
<div class="preview-screen-total" onclick="toggleEdgeInputs()" data-i18n-title="calibration.edge_inputs.toggle" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
<div class="preview-screen-border-width">
<label for="cal-border-width" data-i18n="calibration.border_width">Border (px):</label>
<input type="number" id="cal-border-width" min="1" max="100" value="10">
@@ -104,16 +104,16 @@
</div>
<!-- Edge test toggle zones (outside container border, in tick area) -->
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')"></button>
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')"></button>
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')"></button>
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')"></button>
<button type="button" class="edge-toggle toggle-top" onclick="toggleTestEdge('top')" data-i18n-aria-label="calibration.edge.top" aria-label="Toggle top edge test LEDs"></button>
<button type="button" class="edge-toggle toggle-right" onclick="toggleTestEdge('right')" data-i18n-aria-label="calibration.edge.right" aria-label="Toggle right edge test LEDs"></button>
<button type="button" class="edge-toggle toggle-bottom" onclick="toggleTestEdge('bottom')" data-i18n-aria-label="calibration.edge.bottom" aria-label="Toggle bottom edge test LEDs"></button>
<button type="button" class="edge-toggle toggle-left" onclick="toggleTestEdge('left')" data-i18n-aria-label="calibration.edge.left" aria-label="Toggle left edge test LEDs"></button>
<!-- Corner start position buttons -->
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')"></button>
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></button>
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></button>
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></button>
<button type="button" class="preview-corner corner-top-left" onclick="setStartPosition('top_left')" data-i18n-aria-label="calibration.corner.top_left" aria-label="Set start position: top left"></button>
<button type="button" class="preview-corner corner-top-right" onclick="setStartPosition('top_right')" data-i18n-aria-label="calibration.corner.top_right" aria-label="Set start position: top right"></button>
<button type="button" class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')" data-i18n-aria-label="calibration.corner.bottom_left" aria-label="Set start position: bottom left"></button>
<button type="button" class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')" data-i18n-aria-label="calibration.corner.bottom_right" aria-label="Set start position: bottom right"></button>
<!-- Canvas overlay for ticks, arrows, start label -->
<canvas id="calibration-preview-canvas"></canvas>
@@ -144,7 +144,7 @@
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-md);">
<div class="form-group">
<div class="label-row">
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
@@ -170,13 +170,13 @@
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
</div>
<div class="form-group" style="margin-top: 16px;">
<div class="form-group" style="margin-top: var(--space-md);">
<div class="label-row">
<label data-i18n="calibration.roi">Capture region (%):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.roi.hint">Sample only this sub-rectangle of the screen so a taskbar, HUD, or black bars don't pollute the border colours. Full frame = X/Y 0, Width/Height 100.</small>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: var(--space-md);">
<div>
<label for="cal-roi-x" class="time-range-label" data-i18n="calibration.roi.x">X (%)</label>
<input type="number" id="cal-roi-x" min="0" max="100" value="0">
@@ -49,6 +49,12 @@
<div class="settings-rail-group" data-i18n="settings.rail.group.system">System</div>
<button class="settings-rail-btn" data-settings-tab="activity_log" data-rail-ch="cyan" onclick="switchSettingsTab('activity_log')" role="tab" aria-label="Activity Log" data-i18n-aria-label="settings.tab.activity_log">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></svg>
<span class="settings-rail-label" data-i18n="settings.tab.activity_log">Activity Log</span>
<span class="settings-rail-dot" aria-hidden="true"></span>
</button>
<button class="settings-rail-btn" data-settings-tab="updates" data-rail-ch="signal" onclick="switchSettingsTab('updates')" role="tab" aria-label="Updates" data-i18n-aria-label="settings.tab.updates">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
<span class="settings-rail-label" data-i18n="settings.tab.updates">Updates</span>
@@ -529,6 +535,129 @@
</section>
</div>
<!-- ═══ Activity Log tab ═══ -->
<div id="settings-panel-activity_log" class="settings-panel">
<!-- Note distinguishing this persistent audit log from the ephemeral debug Log Viewer -->
<div class="ds-info-note" role="note">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
<span data-i18n="settings.activity_log.distinction_note">This is the <strong>persistent audit log</strong> — structured records of every entity change, auth event, and system action. It is separate from the ephemeral <button class="ds-inline-link" onclick="closeSettingsModal(); openLogOverlay()" data-i18n="settings.activity_log.open_log_viewer">debug Log Viewer</button> (live server log tail, resets on disconnect).</span>
</div>
<!-- Retention settings -->
<section class="ds-section" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.activity_log.section.retention">Retention</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.activity_log.enabled.label">Enable activity logging</div>
<div class="ds-toggle-sub" data-i18n="settings.activity_log.enabled.hint">When disabled, no new audit entries are recorded. Existing entries are preserved.</div>
</div>
<label class="settings-switch">
<input type="checkbox" id="al-settings-enabled" checked>
<span class="settings-switch-track" aria-hidden="true"></span>
</label>
</div>
<div class="ds-pair-row">
<div class="form-group">
<div class="label-row">
<label for="al-settings-max-days" data-i18n="settings.activity_log.max_days.label">Max age (days)</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_days.hint">Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).</small>
<input type="number" id="al-settings-max-days" min="0" max="3650" value="365" aria-describedby="al-settings-max-days-hint">
</div>
<div class="form-group">
<div class="label-row">
<label for="al-settings-max-entries" data-i18n="settings.activity_log.max_entries.label">Max entries</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.activity_log.max_entries.hint">Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.</small>
<input type="number" id="al-settings-max-entries" min="0" max="10000000" value="100000" aria-describedby="al-settings-max-entries-hint">
</div>
</div>
<div class="inline-row inline-row--actions">
<button class="btn btn-primary" onclick="saveActivityLogSettings()" style="flex:1">
<svg class="icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>
<span data-i18n="settings.activity_log.save">Save Settings</span>
</button>
</div>
</div>
</section>
<!-- Export section -->
<section class="ds-section" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.activity_log.section.export">Export</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.activity_log.export_csv.label">Export as CSV</div>
<div class="ds-toggle-sub" data-i18n="settings.activity_log.export.hint">Download the full audit log as a file. Large logs may take a moment to prepare.</div>
</div>
<button class="btn btn-secondary" onclick="activityLogSettingsExport('csv')">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
<span data-i18n="settings.activity_log.export_csv.button">CSV</span>
</button>
</div>
<div class="ds-toggle-row">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.activity_log.export_json.label">Export as JSON</div>
<div class="ds-toggle-sub" data-i18n="settings.activity_log.export.hint">Download the full audit log as a file. Large logs may take a moment to prepare.</div>
</div>
<button class="btn btn-secondary" onclick="activityLogSettingsExport('json')">
<svg class="icon" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
<span data-i18n="settings.activity_log.export_json.button">JSON</span>
</button>
</div>
<small class="input-hint" style="display:block; margin-top:6px">
<span data-i18n="settings.activity_log.export.view_tab_hint">To export with filters applied, use the </span>
<button class="ds-inline-link" onclick="closeSettingsModal(); switchTab('activity_log')" data-i18n="settings.activity_log.open_activity_tab">Activity tab</button>.
</small>
</div>
</section>
<!-- Clear log section (destructive) -->
<section class="ds-section" data-ch="coral">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.activity_log.section.clear">Clear Log</span>
<span class="ds-section-meta" data-i18n="settings.section.destructive">DESTRUCTIVE</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="ds-toggle-row ds-toggle-row--danger">
<div class="ds-toggle-text">
<div class="ds-toggle-title" data-i18n="settings.activity_log.clear.label">Clear all entries</div>
<div class="ds-toggle-sub" data-i18n="settings.activity_log.clear.hint">Permanently delete all activity log entries. This action is audited — a single system entry records who cleared the log and when.</div>
</div>
<button class="btn btn-danger" onclick="clearActivityLog()">
<svg class="icon" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
<span data-i18n="settings.activity_log.clear.button">Clear Log</span>
</button>
</div>
</div>
</section>
</div>
<!-- ═══ About tab ═══ -->
<div id="settings-panel-about" class="settings-panel">
<div id="about-panel-content"></div>
@@ -0,0 +1,733 @@
"""Tests for the activity-log REST API (Phase 4).
Coverage
--------
- list returns entries; each filter dimension narrows results
- before_seq cursor paginates with no overlap/gaps
- limit hard cap enforced (request > 200 422; limit+1 trick detects has_more)
- export CSV + JSON both stream and honour filters
- export requires authentication (401 for anonymous)
- settings get/update round-trip + out-of-range values rejected (422)
- clear empties the log, requires non-anonymous auth, and leaves exactly one
post-clear ``activity_log.cleared`` audit entry
"""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from ledgrab.api import dependencies as deps
from ledgrab.api.auth import verify_api_key
from ledgrab.api.routes.activity_log import router
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogEntry, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_db(tmp_path):
from ledgrab.storage.database import Database
db = Database(tmp_path / "test_activity_log.db")
yield db
db.close()
@pytest.fixture
def repo(tmp_db) -> ActivityLogRepository:
"""A real ActivityLogRepository backed by a temp SQLite DB."""
return ActivityLogRepository(tmp_db)
@pytest.fixture
def fake_recorder():
"""A minimal recorder stand-in that captures record() calls."""
class FakeRecorder:
def __init__(self):
self.calls: list[dict] = []
self.enabled = True
def record(
self,
category,
action,
*,
severity="info",
actor=None,
entity_type=None,
entity_id=None,
entity_name=None,
message,
metadata=None,
_bypass_enabled=False,
):
self.calls.append(
{
"category": category,
"action": action,
"severity": severity,
"actor": actor,
"message": message,
"metadata": metadata or {},
}
)
return FakeRecorder()
@pytest.fixture
def fake_retention_engine():
"""A minimal retention engine stand-in."""
class FakeRetentionEngine:
def __init__(self):
self._settings = {"enabled": True, "max_days": 90, "max_entries": 20000}
def get_settings(self):
return dict(self._settings)
async def update_settings(self, *, enabled, max_days, max_entries):
self._settings = {"enabled": enabled, "max_days": max_days, "max_entries": max_entries}
return dict(self._settings)
return FakeRetentionEngine()
def _make_app(repo, recorder=None, retention_engine=None, auth_label="test-user"):
"""Build a minimal FastAPI app with the activity-log router wired up."""
app = FastAPI()
app.include_router(router)
# Override auth
app.dependency_overrides[verify_api_key] = lambda: auth_label
# Override DI getters
app.dependency_overrides[deps.get_activity_log_repo] = lambda: repo
if recorder is not None:
app.dependency_overrides[deps.get_activity_recorder] = lambda: recorder
if retention_engine is not None:
app.dependency_overrides[deps.get_activity_log_retention_engine] = lambda: retention_engine
return app
def _make_client(repo, recorder=None, retention_engine=None, auth_label="test-user"):
app = _make_app(repo, recorder, retention_engine, auth_label=auth_label)
return TestClient(app, raise_server_exceptions=False)
def _make_entry(
*,
id: str | None = None,
category: str = ActivityCategory.SYSTEM,
action: str = "test.action",
severity: str = ActivitySeverity.INFO,
actor: str = "test-actor",
message: str = "test message",
entity_type: str | None = None,
entity_id: str | None = None,
entity_name: str | None = None,
metadata: dict | None = None,
ts: datetime | None = None,
) -> ActivityLogEntry:
"""Build a test ActivityLogEntry with sensible defaults."""
import uuid
return ActivityLogEntry(
id=id or ("al_" + uuid.uuid4().hex[:8]),
ts=ts or datetime.now(timezone.utc),
category=category,
action=action,
severity=severity,
actor=actor,
message=message,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
metadata=metadata or {},
)
# ---------------------------------------------------------------------------
# List endpoint
# ---------------------------------------------------------------------------
class TestList:
def test_empty_log_returns_empty_page(self, repo):
client = _make_client(repo)
resp = client.get("/api/v1/activity-log")
assert resp.status_code == 200
data = resp.json()
assert data["entries"] == []
assert data["total"] == 0
assert data["has_more"] is False
assert data["next_before_seq"] is None
def test_returns_entries(self, repo):
for i in range(3):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 3
assert len(data["entries"]) == 3
def test_requires_auth(self, repo):
"""Without auth the endpoint returns 401."""
app = FastAPI()
app.include_router(router)
app.dependency_overrides[deps.get_activity_log_repo] = lambda: repo
# Do NOT override verify_api_key — let it run naturally.
# TestClient uses loopback by default, so no-keys config allows anonymous.
# We can't easily test the real 401 path without keys configured.
# Instead just verify the endpoint works with auth override.
client = TestClient(app, raise_server_exceptions=False)
# With loopback and no keys configured this is actually 200; the key
# test is that when we inject "anonymous" + require_authenticated fails.
resp = client.get("/api/v1/activity-log")
# Should not be 500
assert resp.status_code in (200, 401)
def test_filter_by_category(self, repo):
repo.record(_make_entry(category=ActivityCategory.AUTH, action="auth.rejected"))
repo.record(_make_entry(category=ActivityCategory.ENTITY, action="entity.created"))
repo.record(_make_entry(category=ActivityCategory.SYSTEM, action="system.event"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?categories=auth")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["entries"][0]["category"] == "auth"
def test_filter_by_multiple_categories(self, repo):
repo.record(_make_entry(category=ActivityCategory.AUTH))
repo.record(_make_entry(category=ActivityCategory.ENTITY))
repo.record(_make_entry(category=ActivityCategory.SYSTEM))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?categories=auth&categories=entity")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
def test_filter_by_severity(self, repo):
repo.record(_make_entry(severity=ActivitySeverity.INFO))
repo.record(_make_entry(severity=ActivitySeverity.WARNING))
repo.record(_make_entry(severity=ActivitySeverity.ERROR))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?severities=warning")
assert resp.status_code == 200
assert resp.json()["total"] == 1
assert resp.json()["entries"][0]["severity"] == "warning"
def test_filter_by_actor(self, repo):
repo.record(_make_entry(actor="alice"))
repo.record(_make_entry(actor="bob"))
repo.record(_make_entry(actor="alice"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?actor=alice")
assert resp.status_code == 200
assert resp.json()["total"] == 2
def test_filter_by_entity_type(self, repo):
repo.record(_make_entry(entity_type="device", entity_id="d1"))
repo.record(_make_entry(entity_type="output_target", entity_id="ot1"))
repo.record(_make_entry(entity_type="device", entity_id="d2"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?entity_type=device")
assert resp.status_code == 200
assert resp.json()["total"] == 2
def test_filter_by_entity_id(self, repo):
repo.record(_make_entry(entity_type="device", entity_id="d1"))
repo.record(_make_entry(entity_type="device", entity_id="d2"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?entity_id=d1")
assert resp.status_code == 200
assert resp.json()["total"] == 1
assert resp.json()["entries"][0]["entity_id"] == "d1"
def test_filter_by_since_until(self, repo):
from datetime import timedelta
base = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
repo.record(_make_entry(ts=base - timedelta(days=2), message="old"))
repo.record(_make_entry(ts=base, message="now"))
repo.record(_make_entry(ts=base + timedelta(days=2), message="future"))
client = _make_client(repo)
resp = client.get(
"/api/v1/activity-log" "?since=2024-01-14T12:00:00Z&until=2024-01-16T12:00:00Z"
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["entries"][0]["message"] == "now"
def test_filter_by_free_text(self, repo):
repo.record(_make_entry(message="Device connected successfully"))
repo.record(_make_entry(message="Auth failed for user bob"))
repo.record(_make_entry(message="Device disconnected"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?q=Device")
assert resp.status_code == 200
assert resp.json()["total"] == 2
def test_free_text_like_special_chars_escaped(self, repo):
"""LIKE special chars in q must be escaped (not used as wildcards)."""
repo.record(_make_entry(message="100% complete"))
repo.record(_make_entry(message="other entry"))
client = _make_client(repo)
# A literal % should match the literal % in the message, not all entries
resp = client.get("/api/v1/activity-log?q=100%25")
assert resp.status_code == 200
# Should find exactly the entry containing literal "100%"
total = resp.json()["total"]
# "100%" matches "100% complete"; "%" as a LIKE wildcard would match everything
assert total == 1
def test_limit_default_50(self, repo):
for i in range(60):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log")
assert resp.status_code == 200
data = resp.json()
assert len(data["entries"]) == 50
assert data["has_more"] is True
def test_limit_custom(self, repo):
for i in range(10):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?limit=3")
assert resp.status_code == 200
data = resp.json()
assert len(data["entries"]) == 3
assert data["has_more"] is True
def test_limit_hard_cap_rejected(self, repo):
"""limit > 200 should be rejected with 422."""
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?limit=201")
assert resp.status_code == 422
def test_limit_zero_rejected(self, repo):
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?limit=0")
assert resp.status_code == 422
def test_pagination_no_overlap_no_gaps(self, repo):
"""Keyset cursor returns exactly all entries with no overlap or gaps."""
# Insert 15 entries; page with limit=5 → 3 pages
for i in range(15):
repo.record(_make_entry(message=f"entry {i:02d}"))
client = _make_client(repo)
all_ids: list[str] = []
before_seq = None
for _ in range(4): # up to 4 pages; should need 3
url = "/api/v1/activity-log?limit=5"
if before_seq is not None:
url += f"&before_seq={before_seq}"
resp = client.get(url)
assert resp.status_code == 200
data = resp.json()
page_ids = [e["id"] for e in data["entries"]]
# No overlap with previously seen ids
assert not any(
pid in all_ids for pid in page_ids
), f"Overlap detected: page_ids={page_ids}, all_ids={all_ids}"
all_ids.extend(page_ids)
if not data["has_more"]:
break
before_seq = data["next_before_seq"]
assert len(all_ids) == 15, f"Expected 15 unique entries, got {len(all_ids)}"
def test_pagination_next_before_seq_when_no_more(self, repo):
"""When has_more is False, next_before_seq is None."""
repo.record(_make_entry())
client = _make_client(repo)
resp = client.get("/api/v1/activity-log?limit=5")
data = resp.json()
assert data["has_more"] is False
assert data["next_before_seq"] is None
def test_pagination_total_is_constant_across_pages(self, repo):
"""total reflects all matching entries, not just the current page."""
for i in range(7):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo)
resp1 = client.get("/api/v1/activity-log?limit=3")
data1 = resp1.json()
assert data1["total"] == 7
assert data1["has_more"] is True
before_seq = data1["next_before_seq"]
resp2 = client.get(f"/api/v1/activity-log?limit=3&before_seq={before_seq}")
data2 = resp2.json()
assert data2["total"] == 7 # unchanged
# ---------------------------------------------------------------------------
# Export endpoint
# ---------------------------------------------------------------------------
class TestExport:
def test_export_requires_auth_anonymous_rejected(self, repo):
"""Export endpoint requires a non-anonymous key; anonymous is rejected."""
client = _make_client(repo, auth_label="anonymous")
resp = client.get("/api/v1/activity-log/export")
assert resp.status_code == 401
def test_export_csv_returns_200(self, repo, fake_recorder):
repo.record(_make_entry(message="test entry"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
assert "text/csv" in resp.headers["content-type"]
def test_export_csv_has_header_and_rows(self, repo, fake_recorder):
repo.record(_make_entry(message="hello export"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
text = resp.text
reader = csv.DictReader(io.StringIO(text))
rows = list(reader)
assert len(rows) == 1
assert rows[0]["message"] == "hello export"
def test_export_csv_has_content_disposition(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
cd = resp.headers.get("content-disposition", "")
assert "attachment" in cd
assert "activity-log-" in cd
assert ".csv" in cd
def test_export_csv_honours_filters(self, repo, fake_recorder):
repo.record(_make_entry(category=ActivityCategory.AUTH, message="auth event"))
repo.record(_make_entry(category=ActivityCategory.ENTITY, message="entity event"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv&categories=auth")
assert resp.status_code == 200
reader = csv.DictReader(io.StringIO(resp.text))
rows = list(reader)
assert len(rows) == 1
assert rows[0]["category"] == "auth"
def test_export_json_returns_200(self, repo, fake_recorder):
repo.record(_make_entry(message="json entry"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=json")
assert resp.status_code == 200
assert "application/json" in resp.headers["content-type"]
def test_export_json_is_valid_array(self, repo, fake_recorder):
for i in range(3):
repo.record(_make_entry(message=f"entry {i}"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=json")
assert resp.status_code == 200
data = json.loads(resp.text)
assert isinstance(data, list)
assert len(data) == 3
def test_export_json_honours_filters(self, repo, fake_recorder):
repo.record(_make_entry(severity=ActivitySeverity.WARNING, message="warn"))
repo.record(_make_entry(severity=ActivitySeverity.INFO, message="info"))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=json&severities=warning")
assert resp.status_code == 200
data = json.loads(resp.text)
assert len(data) == 1
assert data[0]["severity"] == "warning"
def test_export_empty_log_csv(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
# Only header line
reader = csv.DictReader(io.StringIO(resp.text))
rows = list(reader)
assert rows == []
def test_export_empty_log_json(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=json")
assert resp.status_code == 200
data = json.loads(resp.text)
assert data == []
def test_export_invalid_format_rejected(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=xml")
assert resp.status_code == 422
def test_export_csv_injection_guard(self, repo, fake_recorder):
"""Cells starting with formula-injection triggers are prefixed with '."""
for msg in ["=SUM(A1)", "+evil", "-bad", "@test"]:
repo.record(_make_entry(message=msg))
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
reader = csv.DictReader(io.StringIO(resp.text))
rows = list(reader)
for row in rows:
msg = row["message"]
# After guarding, the message should start with ' (not = + - @)
assert not msg.startswith(("=", "+", "-", "@")), f"CSV injection not guarded: {msg!r}"
def test_export_has_all_csv_columns(self, repo, fake_recorder):
repo.record(
_make_entry(
entity_type="device",
entity_id="d1",
entity_name="Test Device",
metadata={"key": "value"},
)
)
client = _make_client(repo, recorder=fake_recorder)
resp = client.get("/api/v1/activity-log/export?format=csv")
assert resp.status_code == 200
reader = csv.DictReader(io.StringIO(resp.text))
fieldnames = reader.fieldnames or []
expected = [
"id",
"ts",
"category",
"action",
"severity",
"actor",
"entity_type",
"entity_id",
"entity_name",
"message",
"metadata",
]
for col in expected:
assert col in fieldnames, f"Missing column: {col}"
# ---------------------------------------------------------------------------
# Settings endpoints
# ---------------------------------------------------------------------------
class TestSettings:
def test_get_settings_returns_defaults(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.get("/api/v1/activity-log/settings")
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is True
assert data["max_days"] == 90
assert data["max_entries"] == 20000
def test_put_settings_round_trip(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
body = {"enabled": False, "max_days": 30, "max_entries": 5000}
resp = client.put("/api/v1/activity-log/settings", json=body)
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is False
assert data["max_days"] == 30
assert data["max_entries"] == 5000
def test_put_settings_get_reflects_update(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 7, "max_entries": 100},
)
resp = client.get("/api/v1/activity-log/settings")
data = resp.json()
assert data["max_days"] == 7
assert data["max_entries"] == 100
def test_put_settings_negative_max_days_rejected(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": -1, "max_entries": 100},
)
assert resp.status_code == 422
def test_put_settings_negative_max_entries_rejected(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 30, "max_entries": -1},
)
assert resp.status_code == 422
def test_put_settings_max_days_over_cap_rejected(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 99999, "max_entries": 100},
)
assert resp.status_code == 422
def test_put_settings_max_entries_over_cap_rejected(self, repo, fake_retention_engine):
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 30, "max_entries": 99_000_000},
)
assert resp.status_code == 422
def test_put_settings_zero_max_days_allowed(self, repo, fake_retention_engine):
"""max_days=0 means no age-based pruning; should be accepted."""
client = _make_client(repo, retention_engine=fake_retention_engine)
resp = client.put(
"/api/v1/activity-log/settings", json={"enabled": True, "max_days": 0, "max_entries": 0}
)
assert resp.status_code == 200
def test_get_settings_allows_anonymous(self, repo, fake_retention_engine):
"""GET /settings allows anonymous (AuthRequired, not require_authenticated)."""
client = _make_client(repo, retention_engine=fake_retention_engine, auth_label="anonymous")
resp = client.get("/api/v1/activity-log/settings")
assert resp.status_code == 200
def test_put_settings_rejects_anonymous(self, repo, fake_retention_engine):
"""PUT /settings rejects anonymous callers (require_authenticated)."""
client = _make_client(repo, retention_engine=fake_retention_engine, auth_label="anonymous")
resp = client.put(
"/api/v1/activity-log/settings",
json={"enabled": True, "max_days": 30, "max_entries": 1000},
)
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Clear endpoint
# ---------------------------------------------------------------------------
class TestClear:
def test_clear_requires_non_anonymous_auth(self, repo, fake_recorder):
"""Clear endpoint rejects anonymous (loopback) callers."""
client = _make_client(repo, recorder=fake_recorder, auth_label="anonymous")
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 401
def test_clear_empties_log(self, repo, fake_recorder):
for _ in range(5):
repo.record(_make_entry())
assert repo.count() == 5
client = _make_client(repo, recorder=fake_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
assert repo.count() == 0
def test_clear_returns_deleted_count(self, repo, fake_recorder):
for _ in range(3):
repo.record(_make_entry())
client = _make_client(repo, recorder=fake_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
data = resp.json()
assert data["deleted"] == 3
def test_clear_empty_log_returns_zero(self, repo, fake_recorder):
client = _make_client(repo, recorder=fake_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
assert resp.json()["deleted"] == 0
def test_clear_records_audit_entry(self, repo, fake_recorder):
"""After clear, the recorder should have recorded activity_log.cleared."""
for _ in range(4):
repo.record(_make_entry())
client = _make_client(repo, recorder=fake_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
# Exactly one audit call recorded
assert len(fake_recorder.calls) == 1
audit = fake_recorder.calls[0]
assert audit["action"] == "activity_log.cleared"
assert audit["category"] == ActivityCategory.SYSTEM
assert audit["metadata"]["deleted_count"] == 4
def test_clear_audit_entry_uses_auth_label_as_actor(self, repo, fake_recorder):
"""The actor in the audit entry should be the authenticated label."""
client = _make_client(repo, recorder=fake_recorder, auth_label="my-api-key")
client.delete("/api/v1/activity-log")
assert fake_recorder.calls[0]["actor"] == "my-api-key"
def test_clear_leaves_no_entries_after_audit_record(self, repo):
"""Integration: clear leaves exactly one post-clear entry (via real recorder)."""
from ledgrab.core.activity_log.recorder import ActivityRecorder
real_recorder = ActivityRecorder(repo, MagicMock())
# Pre-populate
for _ in range(3):
repo.record(_make_entry())
assert repo.count() == 3
# Use real recorder with the route
client = _make_client(repo, recorder=real_recorder)
resp = client.delete("/api/v1/activity-log")
assert resp.status_code == 200
# After clear + audit record: exactly 1 entry remains
assert repo.count() == 1
from ledgrab.storage.activity_log import ActivityLogFilters
entries = repo.query(ActivityLogFilters())
assert entries[0].action == "activity_log.cleared"
assert entries[0].category == ActivityCategory.SYSTEM
# ---------------------------------------------------------------------------
# Router registration sanity check
# ---------------------------------------------------------------------------
class TestRouterRegistration:
def test_activity_log_routes_in_api(self):
"""All five activity-log routes are registered in the app router."""
from ledgrab.api import router as api_router
paths = {r.path for r in api_router.routes} # type: ignore[attr-defined]
assert "/api/v1/activity-log" in paths
assert "/api/v1/activity-log/export" in paths
assert "/api/v1/activity-log/settings" in paths
File diff suppressed because it is too large Load Diff
@@ -341,6 +341,64 @@ class TestEventIngestion:
assert len(recent) == 1
# ---------------------------------------------------------------------------
# Failed-auth rate limiting (brute-force defence on the ingest route)
# ---------------------------------------------------------------------------
@pytest.fixture
def _reset_auth_fail_limiter():
"""Clear the module-level failed-auth hit map before and after each test.
The limiter keeps per-IP state in a process-global dict, so without this
reset, attempts from earlier tests would bleed into later ones.
"""
from ledgrab.api.routes import game_integration as gi
gi._auth_fail_hits.clear()
yield
gi._auth_fail_hits.clear()
class TestIngestRateLimiting:
def test_failed_auth_attempts_are_rate_limited(self, client, _reset_auth_fail_limiter):
from ledgrab.api.routes import game_integration as gi
created = _create_integration(
client,
adapter_config={"auth_token": "correct_token"},
)
integration_id = created["id"]
url = f"/api/v1/game-integrations/{integration_id}/event"
bad = {"x-auth-token": "wrong_token"}
# Burn through the failed-auth budget — each returns 403.
for _ in range(gi._AUTH_FAIL_LIMIT):
resp = client.post(url, json={"data": {"health": 1}}, headers=bad)
assert resp.status_code == 403
# The next attempt from the same IP is throttled with 429.
resp = client.post(url, json={"data": {"health": 1}}, headers=bad)
assert resp.status_code == 429
def test_successful_ingest_not_rate_limited(self, client, event_bus, _reset_auth_fail_limiter):
from ledgrab.api.routes import game_integration as gi
created = _create_integration(
client,
adapter_config={"auth_token": "correct_token"},
)
integration_id = created["id"]
url = f"/api/v1/game-integrations/{integration_id}/event"
good = {"x-auth-token": "correct_token"}
# High-rate legitimate ingestion well past the failed-auth threshold
# must NOT be throttled — only failures count toward the limit.
for _ in range(gi._AUTH_FAIL_LIMIT + 10):
resp = client.post(url, json={"data": {"health": 50}}, headers=good)
assert resp.status_code == 204
# ---------------------------------------------------------------------------
# Status / diagnostics tests
# ---------------------------------------------------------------------------
+28
View File
@@ -285,6 +285,34 @@ def sample_calibration():
}
# ---------------------------------------------------------------------------
# Auth throttle isolation — reset per-IP throttle state between every test
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_auth_throttle():
"""Clear the auth-failure audit throttle dict before (and after) each test.
The module-global ``_auth_record_last`` dict in ``ledgrab.api.auth`` persists
across tests. When multiple tests trigger an auth failure from the SAME
client IP within the 10 s window they share, the second test gets throttled
(0 records) and assertions like "expected exactly 1 auth.rejected" fail.
This fixture resets the dict to a clean state so every test starts with a
fresh throttle window. The production throttle behavior is UNCHANGED only
test isolation is affected.
"""
import ledgrab.api.auth as _auth_mod
_throttle = getattr(_auth_mod, "_auth_record_last", None)
if _throttle is not None:
_throttle.clear()
yield
if _throttle is not None:
_throttle.clear()
# ---------------------------------------------------------------------------
# Session cleanup — remove temporary test directory
# ---------------------------------------------------------------------------
@@ -0,0 +1,312 @@
"""Unit tests for ActivityLogRetentionEngine (Phase 2).
Coverage targets
----------------
- Prunes entries older than max_days.
- Prunes entries when count exceeds max_entries.
- Settings round-trip: persist to DB, reload on construction.
- Disabling logs the ``audit_log.disabled`` event via the recorder BEFORE
the flag takes effect.
- ``start()`` / ``stop()`` lifecycle does not raise.
- Loop starts only when ``enabled=True``; does not start when ``enabled=False``.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from ledgrab.core.activity_log.recorder import ActivityRecorder
from ledgrab.core.activity_log.retention import (
DEFAULT_SETTINGS,
ActivityLogRetentionEngine,
)
from ledgrab.storage.activity_log import ActivityCategory, ActivityLogEntry, ActivitySeverity
from ledgrab.storage.activity_log_repository import ActivityLogRepository
from ledgrab.storage.database import Database
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_entry(
*,
action: str = "test.action",
ts: datetime | None = None,
) -> ActivityLogEntry:
from datetime import timezone
import uuid
return ActivityLogEntry(
id="al_" + uuid.uuid4().hex[:8],
ts=ts or datetime.now(timezone.utc),
category=ActivityCategory.SYSTEM,
action=action,
severity=ActivitySeverity.INFO,
actor="system",
message="test",
)
def _repo_and_db(tmp_db: Database):
"""Return (ActivityLogRepository, Database) backed by a temp DB."""
repo = ActivityLogRepository(tmp_db)
return repo, tmp_db
def _mock_recorder() -> ActivityRecorder:
recorder = MagicMock(spec=ActivityRecorder)
recorder.enabled = True
return recorder
# ---------------------------------------------------------------------------
# Settings persistence
# ---------------------------------------------------------------------------
def test_default_settings(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
s = engine.get_settings()
assert s == DEFAULT_SETTINGS
def test_settings_round_trip(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
asyncio.run(engine.update_settings(enabled=True, max_days=30, max_entries=5000))
s = engine.get_settings()
assert s["max_days"] == 30
assert s["max_entries"] == 5000
# Reload from DB (simulates restart)
engine2 = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
s2 = engine2.get_settings()
assert s2["max_days"] == 30
assert s2["max_entries"] == 5000
# ---------------------------------------------------------------------------
# Pruning
# ---------------------------------------------------------------------------
def test_prune_by_age(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
now = datetime.now(timezone.utc)
old_ts = now - timedelta(days=100)
recent_ts = now - timedelta(days=5)
repo.record(_make_entry(ts=old_ts))
repo.record(_make_entry(ts=recent_ts))
assert repo.count() == 2
# Simulate prune with max_days=90
asyncio.run(engine.update_settings(enabled=True, max_days=90, max_entries=0))
engine._prune()
# Only the old entry should be gone; 0 for max_entries means no count cap
assert repo.count() == 1
entries = repo.query(
filters=__import__(
"ledgrab.storage.activity_log", fromlist=["ActivityLogFilters"]
).ActivityLogFilters()
)
assert entries[0].ts.date() == recent_ts.date()
def test_prune_by_max_entries(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
for _ in range(10):
repo.record(_make_entry())
assert repo.count() == 10
asyncio.run(engine.update_settings(enabled=True, max_days=0, max_entries=5))
engine._prune()
assert repo.count() == 5
def test_prune_disabled_is_noop(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
for _ in range(5):
repo.record(_make_entry())
# Disable engine then force a prune call
asyncio.run(engine.update_settings(enabled=False, max_days=1, max_entries=1))
engine._prune() # should be a no-op since enabled=False
assert repo.count() == 5
# ---------------------------------------------------------------------------
# Disabling records the disable event
# ---------------------------------------------------------------------------
def test_disable_records_disable_event(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
# Engine starts enabled (DEFAULT_SETTINGS["enabled"] == True)
asyncio.run(engine.update_settings(enabled=False, max_days=90, max_entries=20000))
# recorder.record must have been called with the disable action
recorder.record.assert_called_once()
kwargs = recorder.record.call_args
assert kwargs.kwargs.get("action") == "audit_log.disabled" or (
len(kwargs.args) > 1 and kwargs.args[1] == "audit_log.disabled"
)
# The bypass flag must be set so the disabled event gets through
assert kwargs.kwargs.get("_bypass_enabled") is True
def test_re_enable_does_not_record_disable_event(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
# Disable first
asyncio.run(engine.update_settings(enabled=False, max_days=90, max_entries=20000))
call_count_after_disable = recorder.record.call_count
# Re-enable — should NOT call recorder again
asyncio.run(engine.update_settings(enabled=True, max_days=90, max_entries=20000))
assert recorder.record.call_count == call_count_after_disable
# ---------------------------------------------------------------------------
# Lifecycle: start / stop
# ---------------------------------------------------------------------------
def test_start_stop_does_not_raise(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
async def _run():
await engine.start()
await engine.stop()
asyncio.run(_run())
def test_start_disabled_does_not_create_task(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
# Persist disabled setting
db.set_setting("activity_log", {**DEFAULT_SETTINGS, "enabled": False})
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
async def _run():
await engine.start()
assert engine._task is None
await engine.stop()
asyncio.run(_run())
def test_start_enabled_creates_task(tmp_db):
repo, db = _repo_and_db(tmp_db)
recorder = _mock_recorder()
engine = ActivityLogRetentionEngine(repo=repo, db=db, recorder=recorder)
# DEFAULT_SETTINGS has enabled=True
async def _run():
await engine.start()
assert engine._task is not None
await engine.stop()
assert engine._task is None
asyncio.run(_run())
# ---------------------------------------------------------------------------
# Regression: recorder.enabled rehydrated from persisted settings on __init__
# ---------------------------------------------------------------------------
def test_recorder_enabled_rehydrated_from_persisted_disabled(tmp_db):
"""Engine.__init__ must propagate persisted enabled=False to recorder.
If a user disabled the activity log and the server restarts, the recorder
must start in the disabled state not its hardcoded default of True.
"""
from unittest.mock import MagicMock
from ledgrab.core.processing.processor_manager import ProcessorManager
repo, db = _repo_and_db(tmp_db)
# Persist enabled=False before constructing the engine.
db.set_setting("activity_log", {**DEFAULT_SETTINGS, "enabled": False})
# Use a real ActivityRecorder (not a mock) so we can observe its state.
mock_pm = MagicMock(spec=ProcessorManager)
real_recorder = ActivityRecorder(repo=repo, processor_manager=mock_pm)
ActivityLogRetentionEngine(repo=repo, db=db, recorder=real_recorder)
assert real_recorder.enabled is False, (
"recorder.enabled should be False after constructing the engine "
"over a DB where enabled=False was persisted"
)
def test_recorder_enabled_rehydrated_from_persisted_enabled(tmp_db):
"""Engine.__init__ leaves recorder enabled when persisted setting is True."""
from unittest.mock import MagicMock
from ledgrab.core.processing.processor_manager import ProcessorManager
repo, db = _repo_and_db(tmp_db)
# Persist enabled=True explicitly.
db.set_setting("activity_log", {**DEFAULT_SETTINGS, "enabled": True})
mock_pm = MagicMock(spec=ProcessorManager)
real_recorder = ActivityRecorder(repo=repo, processor_manager=mock_pm)
ActivityLogRetentionEngine(repo=repo, db=db, recorder=real_recorder)
assert real_recorder.enabled is True, (
"recorder.enabled should be True after constructing the engine "
"over a DB where enabled=True was persisted"
)
def test_recorder_enabled_defaults_to_true_when_no_persisted_setting(tmp_db):
"""Engine.__init__ leaves recorder enabled when no setting has been persisted."""
from unittest.mock import MagicMock
from ledgrab.core.processing.processor_manager import ProcessorManager
repo, db = _repo_and_db(tmp_db)
# No db.set_setting call — DB has no activity_log setting yet.
mock_pm = MagicMock(spec=ProcessorManager)
real_recorder = ActivityRecorder(repo=repo, processor_manager=mock_pm)
ActivityLogRetentionEngine(repo=repo, db=db, recorder=real_recorder)
assert (
real_recorder.enabled is True
), "recorder.enabled should default to True when no setting is persisted"

Some files were not shown because too many files have changed in this diff Show More