diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 46b962b..231308b 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -5,7 +5,7 @@ dc8d8412b74e19f2d32570ca0ef176c0036a140e47e41d020cb4d140ad9bfe39 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 079ddac7ba983a3d679b8c748bd43c731eb078e91a40c5d8a683bd76e14f06c5 ./README.md d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs -514065eefa7428afbcacaca7095605495f95339a2ae8b23707315dc434f9c4c5 ./docs/API.md +ed543caace807645561db525c19ae2c3745600af146e1c1ea9b89a39a7f597a3 ./docs/API.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md cb62e2ea377bdf4785c139ee169eec4914e04ceac6883c0afcb4c96fe60427b0 ./docs/openapi.json b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md @@ -27,8 +27,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -00cd78873d3a3290419a83ea736a9ec5f0d93f590159cbf40c54d5ea6c3c89fb ./lang/en.json -a088acc585a863e204100af7f5465a5d761ba94c0afff66b5ed329e7d204ec35 ./lang/pl.json +a5412c879ac0896ae6f9c2d66dd861789897b8c8610098e3fb9b5462068acd74 ./lang/en.json +615d7297c5fa6228c017a7814aa17898aa990bfbde649d9c7c43b7b49d046ebe ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json @@ -109,11 +109,11 @@ e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs be4082b142fe16ba40c5978b287b7ef808e67c13edbd8f2d6637ccad974b8847 ./src/db/configuration.rs bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_devices.rs 4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs -eb07d94b58bcc8ea17b5b086cc88c165a35666f84c3d9a1d74f72dc30b979829 ./src/db/events_tokens.rs +88cb4b7ff61a8a558949faa7157d4bc2c45facb951c57240412c4c9d3befdb86 ./src/db/events_tokens.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs -1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs +12c4f69011b22ec0af65121100e5cc717fe7bd5a139f4180d1982a9045c6918e ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs 4d2c6053586558afbca6b60fbbc9b1cd397f74f8284d7c01f3337eb42bf6d4f7 ./src/engine.rs 6046bdbc9c02ac74f47068229c59af69abc8bfa46cfeff6fe7ba573b493e128c ./src/engine/automations.rs @@ -152,7 +152,7 @@ e1621255217ee6128151cc898373dd404123cb6d54017beec6c7e3e5c3030486 ./src/models/i 887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs 7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs -48e5d0e0c8b2863daf35c64ad49bf5333fb3b807568323cf0958efbc3aee7433 ./src/notifications.rs +62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs 28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs 7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs @@ -169,14 +169,14 @@ c868422c02ba457df21ae92acbd00698c243855ddd1d4e95ac2166796eba9a44 ./src/protocol 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs 3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs 2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs -c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs +3112452367b68aeda0a5e3143b1a51ce62e3159859fb41e92c35642a3cd72322 ./src/queries/maintenance.rs 3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs -008be170ed418f8701c512f521828b8e6c0071ff195cd6f7aa39fade6c7c3fa9 ./src/state.rs +7af6db4d826f8cb5f2305f743bffe4e112d4e1c1772366a83ba75953e60db1c3 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -62e0a01a47a2d30e5265010aef5a089b0055d824fede940b7665d76f92018522 ./web/index.html +9e98df7a759bd76bc7682b23c3c4b7e29f7bde972f03ae316ba7f8159301e2c6 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 45fb54541a117a8ee0dc484cd263b6af4aafab75d94d269b126bb603b5f0392c ./web/js/bootstrap.js badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js @@ -189,11 +189,11 @@ cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js -e3e01511a34175b88eb01dac2c557e8b112fcad5826f02a0bc3cd1e8dfb7ff91 ./web/js/realtime.js +0f0ecb9fcc2b47aece715de4ededdc1cf512daf404c37efe33bf9069027221e3 ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js 25f5c9a0aa8622f419b05f64712c9eca4896f0e79818616904b83051fd77b07b ./web/js/settings-ui.js 7399a951ae585d7c9ee55cc57064c64a2806ec725e2456cfe6b09bc61f8f4166 ./web/js/settings.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -801bb151f7b6220587e22c3114fb224a540dcfbddd1c09870007b7b893a01301 ./web/styles.css +0b9774bb388158e23f8c90297d70659a015e0db58d25eadcae316a49e3c9fcb7 ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/docs/API.md b/docs/API.md index c457797..c1e85e9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1012,6 +1012,8 @@ Response: } ``` +For events that are eligible for external notification, `metadata.notification` records the delivery result. `status` is `sent`, `silent` or `failed`. A `silent` event includes a `reason` such as `alert_type_disabled`, `notifications_disabled`, `mode_filtered` or `cooldown`; the Web UI shows these rows with a **SILENT** badge. Notification-status changes are also emitted as `log.updated` WebSocket events. + --- ## Runtime settings — 0.12.0 diff --git a/lang/en.json b/lang/en.json index 8fc4d92..384a30d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -192,6 +192,12 @@ "logs.diagnostics": "Diagnostics", "logs.emptyTitle": "No events", "logs.emptyText": "The event log is empty.", + "logs.silent": "SILENT", + "logs.silentHint": "No notification was sent.", + "logs.silentAlertTypeDisabled": "No notification was sent: this alert type is disabled.", + "logs.silentNotificationsDisabled": "No notification was sent: notifications are disabled.", + "logs.silentModeFiltered": "No notification was sent: the notification mode filtered this event.", + "logs.silentCooldown": "No notification was sent: notification cooldown is active.", "common.name": "Name", "common.zone": "Zone", "common.device": "Device", @@ -877,6 +883,7 @@ "flow.emptyCanvas": "Start by adding blocks", "flow.emptyCanvasHint": "Put conditions on the left and actions on the right.", "flow.interpretation": "Interpretation", + "flow.previewDetails": "Interpretation and Flow cycle", "flow.selectBlock": "Select a block", "flow.selectBlockHint": "Its settings will appear here.", "flow.newDefaultName": "New logic", diff --git a/lang/pl.json b/lang/pl.json index a978c6c..fc49773 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -192,6 +192,12 @@ "logs.diagnostics": "Diagnostyka", "logs.emptyTitle": "Brak zdarzeń", "logs.emptyText": "Log jest pusty.", + "logs.silent": "SILENT", + "logs.silentHint": "Powiadomienie nie zostało wysłane.", + "logs.silentAlertTypeDisabled": "Powiadomienie nie zostało wysłane: ten typ alertu jest wyłączony.", + "logs.silentNotificationsDisabled": "Powiadomienie nie zostało wysłane: powiadomienia są wyłączone.", + "logs.silentModeFiltered": "Powiadomienie nie zostało wysłane: zdarzenie odfiltrował tryb powiadomień.", + "logs.silentCooldown": "Powiadomienie nie zostało wysłane: aktywny jest cooldown.", "common.name": "Nazwa", "common.zone": "Strefa", "common.device": "Urządzenie", @@ -877,6 +883,7 @@ "flow.emptyCanvas": "Zacznij od dodania bloków", "flow.emptyCanvasHint": "Warunki układaj po lewej, akcje po prawej.", "flow.interpretation": "Interpretacja", + "flow.previewDetails": "Interpretacja i cykl Flow", "flow.selectBlock": "Wybierz blok", "flow.selectBlockHint": "Tutaj pojawią się jego ustawienia.", "flow.newDefaultName": "Nowa logika", diff --git a/src/db/events_tokens.rs b/src/db/events_tokens.rs index cc8b8c9..72251ce 100644 --- a/src/db/events_tokens.rs +++ b/src/db/events_tokens.rs @@ -29,6 +29,14 @@ impl Db { Ok(conn.last_insert_rowid()) } + pub fn update_event_metadata(&self, id: i64, metadata: &Value) -> Result { + let conn = self.lock()?; + Ok(conn.execute( + queries::UPDATE_EVENT_METADATA, + params![serde_json::to_string(metadata)?, id], + )? > 0) + } + pub fn list_events(&self, limit: u32) -> Result> { let conn = self.lock()?; let mut stmt = conn.prepare(queries::LIST_EVENTS)?; diff --git a/src/db/tests.rs b/src/db/tests.rs index cc4cf05..ed343a3 100644 --- a/src/db/tests.rs +++ b/src/db/tests.rs @@ -38,9 +38,19 @@ mod tests { let loaded = db.get_device(&device.id).unwrap().unwrap(); assert_eq!(loaded.mac, device.mac); assert_eq!(db.list_devices().unwrap().len(), 1); - db.log_event("info", "test", "ok", &serde_json::json!({"a":1})) + let event_id = db + .log_event("info", "test", "ok", &serde_json::json!({"a":1})) .unwrap(); assert_eq!(db.list_events(10).unwrap().len(), 1); + db.update_event_metadata( + event_id, + &serde_json::json!({"a":1,"notification":{"status":"silent"}}), + ) + .unwrap(); + assert_eq!( + db.list_events(10).unwrap()[0].metadata["notification"]["status"], + "silent" + ); { let conn = db.lock().unwrap(); conn.execute( diff --git a/src/notifications.rs b/src/notifications.rs index 70e0b2a..b5cdc97 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -64,14 +64,28 @@ fn alert_type_enabled(cfg: &NotificationSettings, kind: &str) -> bool { types.other } -fn should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool { - if !cfg.enabled || !alert_type_enabled(cfg, kind) { - return false; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeliveryDecision { + NotApplicable, + Silent(&'static str), + Send, +} + +fn delivery_decision(cfg: &NotificationSettings, level: &str, kind: &str) -> DeliveryDecision { + let candidate = level == "error" || level == "warn" || important_kind(kind); + if !candidate { + return DeliveryDecision::NotApplicable; } - if level == "error" || level == "warn" { - return true; + if !cfg.enabled { + return DeliveryDecision::Silent("notifications_disabled"); } - cfg.mode == "important" && important_kind(kind) + if !alert_type_enabled(cfg, kind) { + return DeliveryDecision::Silent("alert_type_disabled"); + } + if level != "error" && level != "warn" && cfg.mode != "important" { + return DeliveryDecision::Silent("mode_filtered"); + } + DeliveryDecision::Send } fn cooldown_key(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> String { @@ -100,15 +114,85 @@ fn take_cooldown(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> bo true } +fn with_notification_status( + metadata: &Value, + status: &str, + reason: Option<&str>, + provider: &str, +) -> Value { + let mut updated = match metadata { + Value::Object(map) => Value::Object(map.clone()), + other => json!({"data": other}), + }; + let mut notification = serde_json::Map::new(); + notification.insert("status".into(), Value::String(status.into())); + if let Some(reason) = reason { + notification.insert("reason".into(), Value::String(reason.into())); + } + if !provider.trim().is_empty() { + notification.insert("provider".into(), Value::String(provider.into())); + } + if let Some(object) = updated.as_object_mut() { + object.insert("notification".into(), Value::Object(notification)); + } + updated +} + +fn persist_notification_status( + state: &AppState, + event_id: Option, + metadata: &Value, + status: &str, + reason: Option<&str>, + provider: &str, +) { + let Some(event_id) = event_id else { + return; + }; + let updated = with_notification_status(metadata, status, reason, provider); + match state.db.update_event_metadata(event_id, &updated) { + Ok(true) => state.broadcast( + "log.updated", + json!({"id": event_id, "metadata": updated}), + ), + Ok(false) => tracing::warn!(event_id, "cannot update missing event log metadata"), + Err(err) => tracing::warn!(event_id, error=?err, "cannot update event log metadata"), + } +} + pub async fn dispatch( state: AppState, + event_id: Option, level: String, kind: String, message: String, metadata: Value, ) { let cfg = state.settings.read().await.notifications.clone(); - if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) { + match delivery_decision(&cfg, &level, &kind) { + DeliveryDecision::NotApplicable => return, + DeliveryDecision::Silent(reason) => { + persist_notification_status( + &state, + event_id, + &metadata, + "silent", + Some(reason), + &cfg.provider, + ); + return; + } + DeliveryDecision::Send => {} + } + if !take_cooldown(&cfg, &kind, &metadata) { + persist_notification_status( + &state, + event_id, + &metadata, + "silent", + Some("cooldown"), + &cfg.provider, + ); return; } let title = format!( @@ -139,8 +223,26 @@ pub async fn dispatch( } _ => Err("unsupported notification provider".into()), }; - if let Err(err) = result { - tracing::warn!(provider=%cfg.provider, error=%err, "notification delivery failed"); + match result { + Ok(()) => persist_notification_status( + &state, + event_id, + &metadata, + "sent", + None, + &cfg.provider, + ), + Err(err) => { + persist_notification_status( + &state, + event_id, + &metadata, + "failed", + Some("delivery_failed"), + &cfg.provider, + ); + tracing::warn!(provider=%cfg.provider, error=%err, "notification delivery failed"); + } } } @@ -161,6 +263,28 @@ mod tests { assert!(alert_type_enabled(&cfg, "zone.sensor_discrepancy")); assert!(!alert_type_enabled(&cfg, "zone.action_error")); } + + #[test] + fn disabled_alert_type_is_recorded_as_silent() { + let mut cfg = NotificationSettings::default(); + cfg.enabled = true; + cfg.mode = "important".into(); + cfg.alert_types.sensor_discrepancy = false; + + assert_eq!( + delivery_decision(&cfg, "warn", "zone.sensor_discrepancy"), + DeliveryDecision::Silent("alert_type_disabled") + ); + } + + #[test] + fn ordinary_info_event_has_no_notification_status() { + let cfg = NotificationSettings::default(); + assert_eq!( + delivery_decision(&cfg, "info", "zone.quick_control"), + DeliveryDecision::NotApplicable + ); + } } async fn send_pushover( diff --git a/src/queries/maintenance.rs b/src/queries/maintenance.rs index d616608..3c33f04 100644 --- a/src/queries/maintenance.rs +++ b/src/queries/maintenance.rs @@ -51,6 +51,7 @@ SELECT pub const INSERT_EVENT: &str = "INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)"; +pub const UPDATE_EVENT_METADATA: &str = "UPDATE event_log SET metadata=?1 WHERE id=?2"; pub const LIST_EVENTS: &str = "SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1"; pub const PRUNE_EVENTS: &str = "DELETE FROM event_log WHERE timestamp < ?1"; diff --git a/src/state.rs b/src/state.rs index eda7aa1..02e81db 100644 --- a/src/state.rs +++ b/src/state.rs @@ -165,12 +165,17 @@ impl AppState { } pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) { - if let Err(err) = self.db.log_event(level, kind, message, &metadata) { - tracing::warn!(error=?err, "cannot persist event log"); - } + let event_id = match self.db.log_event(level, kind, message, &metadata) { + Ok(id) => Some(id), + Err(err) => { + tracing::warn!(error=?err, "cannot persist event log"); + None + } + }; self.broadcast( "log.created", serde_json::json!({ + "id": event_id, "level": level, "kind": kind, "message": message, @@ -183,7 +188,10 @@ impl AppState { let kind = kind.to_string(); let message = message.to_string(); handle.spawn(async move { - crate::notifications::dispatch(state, level, kind, message, metadata).await; + crate::notifications::dispatch( + state, event_id, level, kind, message, metadata, + ) + .await; }); } } diff --git a/web/index.html b/web/index.html index 5174da1..7762466 100644 --- a/web/index.html +++ b/web/index.html @@ -891,8 +891,8 @@
+ aria-expanded="false">Interpretation and Flow cycle
Interpretation
diff --git a/web/js/realtime.js b/web/js/realtime.js index 9b4bb27..712ae92 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -1,3 +1,15 @@ +function logNotificationBadge(item) { + const notification = item?.metadata?.notification; + if (notification?.status !== 'silent') return ''; + const reasonKey = { + alert_type_disabled: 'logs.silentAlertTypeDisabled', + notifications_disabled: 'logs.silentNotificationsDisabled', + mode_filtered: 'logs.silentModeFiltered', + cooldown: 'logs.silentCooldown', + }[notification.reason] || 'logs.silentHint'; + return `${esc(tr('logs.silent'))}`; +} + async function loadLogs() { renderLogRetention(); try { @@ -5,7 +17,7 @@ async function loadLogs() { const level = $('#logLevelFilter')?.value || 'all', category = $('#logCategoryFilter')?.value || 'all'; const logs = (data.events || []).filter(item => (level === 'all' || item.level === level) && (category === 'all' || logCategory(item.kind) === category)); $('#logList').innerHTML = logs.length - ? logs.map(item => `
${esc(logCategory(item.kind))}${esc(item.kind)}${esc(item.message)}
`).join('') + ? logs.map(item => `
${esc(logCategory(item.kind))}${esc(item.kind)}${logNotificationBadge(item)}${esc(item.message)}
`).join('') : `
${esc(tr('logs.emptyTitle'))}${esc(tr('logs.emptyText'))}
`; } catch (error) { toast(error.message, true); } } @@ -70,6 +82,7 @@ async function handleWebSocketMessage(event) { else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); } else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); } + else if (message.event === 'log.updated') { if (app.currentView === 'logs') loadLogs(); } } catch (_) { } } diff --git a/web/styles.css b/web/styles.css index 11dffc1..64607e8 100644 --- a/web/styles.css +++ b/web/styles.css @@ -9255,4 +9255,86 @@ body.flow-editor-open { display: none; content: none; } -} \ No newline at end of file +} +/* Notification delivery audit in Events. */ +.log-row .kind .log-notification-badge { + margin-left: .45rem; + padding: 3px 7px; + border: 1px solid var(--line-strong); + background: transparent; + color: var(--muted); + font-size: .64rem; + font-weight: 700; + letter-spacing: .06em; + vertical-align: middle; +} + +/* Keep the Flow footer compact before the full mobile layout kicks in. */ +@media (min-width:761px) and (max-width:1200px) { + .flow-natural-preview { + display: block; + min-height: 0; + padding: 0; + overflow: hidden; + } + + .flow-preview-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 40px; + padding: 8px 14px; + border: 0; + border-radius: 0; + background: var(--surface); + color: var(--text); + text-align: left; + } + + .flow-preview-toggle strong { + font-size: .8rem; + } + + .flow-preview-toggle-icon { + color: var(--muted); + font-size: 1rem; + transition: transform .15s ease; + } + + .flow-preview-details { + display: none; + flex-direction: column; + align-items: stretch; + gap: 8px; + max-height: 32dvh; + padding: 9px 14px 11px; + overflow: auto; + } + + .flow-natural-preview.is-expanded .flow-preview-details { + display: flex; + } + + .flow-natural-preview.is-expanded .flow-preview-toggle { + border-bottom: 1px solid var(--line); + } + + .flow-natural-preview.is-expanded .flow-preview-toggle-icon { + transform: rotate(180deg); + } + + .flow-preview-item { + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + } + + .flow-preview-runtime { + justify-content: start; + text-align: left; + } + + .flow-preview-runtime span { + white-space: normal; + } +}