From 00ef254356636a6369d24e5aae17292f16a55124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:23:25 +0800 Subject: [PATCH 001/109] test: virt monitor e2e on sbm_virt's contracts --- test/e2e/virt_monitor_test.dart | 94 +++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 34 deletions(-) diff --git a/test/e2e/virt_monitor_test.dart b/test/e2e/virt_monitor_test.dart index 29f0f9d42f..ed47abf4b8 100644 --- a/test/e2e/virt_monitor_test.dart +++ b/test/e2e/virt_monitor_test.dart @@ -73,9 +73,9 @@ /// `_GW` (as in `virt_real_test.dart`) it boots VMs `sbxe2e-l-*` (VMIDs /// 970-979) from that image — one on SATA with Secure Boot, one from a /// `vmdk` made of it next to it in the `import` directory — and logs in to -/// them through the agent's relay; with `SBM_E2E_PVE_USB` it adds that -/// device to a **stopped** VM by vendor/product and by address and takes it -/// off again, never starting it. A backup job `sbxe2e-l-*` is made, run and +/// them through the agent's relay; with `SBM_E2E_PVE_USB` as well it adds +/// that device to a **stopped** VM (the IDE one, made from the image) by +/// vendor/product and by address and takes it off again, never starting it. A backup job `sbxe2e-l-*` is made, run and /// deleted with its backup. Everything it makes is removed. /// /// Every agent is expected to serve TLS with a certificate this device does @@ -675,9 +675,14 @@ void _libvirtVncPassword(_Agent agent) { target.tunnel.address, target.tunnel.port, ); + // Dropped with bytes it never read, the tunnel's end resets: the + // write's error lands on `done`, after the read has ended. + unawaited(stranger.done.catchError((_) {})); stranger.add(List.filled(32, 0x41)); - final leaked = await stranger - .fold(0, (n, b) => n + b.length) + var leaked = 0; + await stranger + .forEach((b) => leaked += b.length) + .catchError((_) {}, test: (e) => e is SocketException) .timeout(const Duration(seconds: 10)); expect(leaked, 0); @@ -1744,7 +1749,8 @@ void _libvirtHardware(_Agent agent) { expect(h.pending, isEmpty); expect(h.limits.hostCpus, greaterThan(0)); expect(h.limits.hostMemoryBytes, greaterThan(0)); - expect(h.revision, startsWith(' g.id == linkedTargetId, - '$name-linked-target', - (g) => g.name == '$name-linked-target', + expect(linkedTarget.type, VirtErrType.unsupported); + expect( + w.state.data!.guests.where((g) => g.name == '$name-linked-target'), + isEmpty, ); - // It shares the template's disk: the storage named was not used. - expect(linkedTarget.template, isFalse); // A full clone of the template, onto the storage it is already on. final fullId = await w.host.clone( @@ -3119,7 +3123,7 @@ void _pveCloneBackup(_Agent agent) { (g) => g.state == VirtGuestState.running, ); - for (final id in [linkedTarget.id, linked.id, full.id, tpl.id]) { + for (final id in [linked.id, full.id, tpl.id]) { await w.host.refresh(); final fresh = w.state.guest(id); if (fresh == null) continue; @@ -3601,15 +3605,17 @@ void _pveTestVm(_Agent agent) { }); test('reboot: running again with the uptime reset', () async { - final before = (await settle( - (g) => (g.uptime?.inSeconds ?? 0) > 5, - )).uptime!; + await settle((g) => (g.uptime?.inSeconds ?? 0) > 5); + // Up for less than the time since the request: started again. Against + // the uptime before, a fresh boot outgrows it while the listing still + // lags by pvestatd's cycle. + final since = Stopwatch()..start(); await w.host.power(vm().id, VirtPowerAction.reboot); final after = await settle( (g) => g.state == VirtGuestState.running && g.uptime != null && - g.uptime! < before, + g.uptime! < since.elapsed, ); expect(w.state.actionsOf(after), contains(VirtPowerAction.reboot)); }); @@ -4036,10 +4042,11 @@ void _p8Pve(_Agent agent) { tearDownAll(() async { await w.host.refresh(); for (final id in made) { - // The listing names a guest a moment after its task: asked again - // before it is given up on, or its disk outlives the storage. + // The listing names a guest a moment after its task, and its state + // (so its actions) a moment after that: asked again before it is + // given up on, or its disk outlives the storage. var g = w.state.guest(id); - for (var i = 0; g == null && i < 10; i++) { + for (var i = 0; (g == null || g.actions.isEmpty) && i < 10; i++) { await Future.delayed(const Duration(seconds: 1)); await w.host.refresh(); g = w.state.guest(id); @@ -4430,7 +4437,9 @@ void _pveUnverified(_Agent agent) { Future remove(int vmid) async { var g = await guestOf(vmid, (g) => g.actions.isNotEmpty || g.template); if (g.state != VirtGuestState.stopped) { - await w.host.power(g.id, VirtPowerAction.forceStop); + // A start right after a stop: qmeventd's cleanup of the old process + // holds the lock until it sees the new one. + await _pveWhileLocked(() => w.host.power(g.id, VirtPowerAction.forceStop)); g = await guestOf(vmid, (g) => g.state == VirtGuestState.stopped); } await _pveWhileLocked(() => w.host.delete(g.id)); @@ -4715,10 +4724,16 @@ void _pveUnverified(_Agent agent) { }, timeout: const Timeout(Duration(minutes: 15))); test('IDE: the disk and the cloud-init drive on it, and the cloud-init edit', () async { + // A cloud-init drive comes only with a cloud image. + if (imageId == null) { + markTestSkipped('SBM_E2E_PVE_CLOUD_IMAGE unset'); + return; + } final vmid = await freeVmid(); final name = 'sbxe2e-l-ide-$run'; made.add((vmid, name)); final login = await _ciLogin(); + final image = await imageVolume(imageId); await w.host.create( VirtCreateSpec( kind: VirtGuestKind.qemu, @@ -4728,8 +4743,9 @@ void _pveUnverified(_Agent agent) { cores: 1, memoryMiB: 256, storage: storage, - diskGiB: 1, - image: _inPool(imageId == null ? null : await imageVolume(imageId), await w.host.storagePools(), node: node), + // The copy is grown, never cut: at least the image's own size. + diskGiB: ((image.capacity ?? 0) / (1 << 30)).ceil().clamp(1, 1 << 20), + image: _inPool(image, await w.host.storagePools(), node: node), network: bridge, bus: 'ide', cloudInit: VirtCloudInit(user: 'sbxe', sshKeys: login.publicKey), @@ -4761,7 +4777,12 @@ void _pveUnverified(_Agent agent) { markTestSkipped('SBM_E2E_PVE_USB unset'); return; } - final vmid = made.lastWhere((m) => m.$2.startsWith('sbxe2e-l-ide-'), orElse: () => fail('no IDE VM')).$1; + final ide = made.where((m) => m.$2.startsWith('sbxe2e-l-ide-')).lastOrNull; + if (ide == null) { + markTestSkipped('no IDE VM: SBM_E2E_PVE_CLOUD_IMAGE unset'); + return; + } + final vmid = ide.$1; // PVE lets only root@pam logged in with a password give a guest a raw // USB device. A ticket the node itself issues for root@pam is what // stands in for the password: PVE takes a valid ticket as the @@ -4818,7 +4839,12 @@ void _pveUnverified(_Agent agent) { }); test('a VM with a snapshot made a template: PVE\'s own refusal, in its words', () async { - final vmid = made.lastWhere((m) => m.$2.startsWith('sbxe2e-l-ide-'), orElse: () => fail('no IDE VM')).$1; + final ide = made.where((m) => m.$2.startsWith('sbxe2e-l-ide-')).lastOrNull; + if (ide == null) { + markTestSkipped('no IDE VM: SBM_E2E_PVE_CLOUD_IMAGE unset'); + return; + } + final vmid = ide.$1; var g = await guestOf(vmid, (g) => g.state == VirtGuestState.stopped); await w.host.createSnapshot(g.id, name: 'sbxe2e_t'); // A second app instance, which has never read the snapshot listing: From a0657ea21a5adc0006e45474f669fbd0a44e4e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:56:12 +0800 Subject: [PATCH 002/109] feat(monitor): desk preferences, window session and notifications --- monitor/CLAUDE.md | 8 + monitor/migrations/018_desk.sql | 111 +++ monitor/src/api/desk.rs | 1167 ++++++++++++++++++++++++++ monitor/src/api/machine.rs | 3 + monitor/src/api/mod.rs | 1 + monitor/src/api/server.rs | 26 + monitor/src/monitoring/monitoring.rs | 5 +- monitor/src/monitoring/rules.rs | 26 +- monitor/tests/desk.rs | 439 ++++++++++ monitor/tests/migration_upgrade.rs | 1 + 10 files changed, 1775 insertions(+), 12 deletions(-) create mode 100644 monitor/migrations/018_desk.sql create mode 100644 monitor/src/api/desk.rs create mode 100644 monitor/tests/desk.rs diff --git a/monitor/CLAUDE.md b/monitor/CLAUDE.md index f3fe3b917d..247d4fc464 100644 --- a/monitor/CLAUDE.md +++ b/monitor/CLAUDE.md @@ -96,6 +96,14 @@ Files in `~/.config/server_box/custom_cmds` (`sbm_parser::script`), the same set - Writes stream to `.sbm-part--` (hidden from listings) and rename, keeping the target's mode; a mode that cannot be carried over fails the write, and the staged copy is removed on every other way out, a dropped request included (`Staged`). `PUT /fs/write?if_version=` (the editor) answers `409 {"error":"modified"}` when the file's `version` (signed nanosecond mtime + size, from `EntryView.version`; a listed link has none, since a write compares the file it resolves to, which `/fs/stat` describes) differs or the file is gone; uploads send none. The check runs after the upload under one lock (`REPLACE`) held through the rename and by `remove`/`rename`, so the agent's own writers cannot interleave; another process can still write in that window (POSIX has no rename-if-unchanged). `modified` stays seconds for the app. - Known limitation: resolve-then-use is two steps (a symlink swapped between them is followed); closing it needs per-component `openat`+`O_NOFOLLOW`, not portable. `tests/fs_roots.rs`, `tests/fs_write.rs`. +### `/api/v1/desk*` (`api/desk.rs`, any signed-in account) + +- The panel's desk: preferences (accent, wallpaper `preset:`|`custom`, fit, dock, icons as child tables), one custom wallpaper (PNG/JPEG/WebP sniffed from the bytes, ≤8 MiB, ETag = SHA-256), the window session per account + device, notifications. All keyed by `users.id`, `ON DELETE CASCADE` (migration 018). +- **Session writes are compare-and-swap on `revision`**: the transaction's first statement is the `UPDATE … WHERE revision = expected` (or `INSERT OR IGNORE` from 0), so SQLite's write lock is taken there; a loser gets 409 `{error: conflict, current}`. Never read-then-write. `app_state` is opaque JSON ≤16 KiB. +- `/desk/events`: `text/event-stream` over `fetch` + bearer (not `EventSource`), `data:` JSON lines, `: ping` every 25 s, `{type: resync}` on lag. A hint to refetch, never the only copy. Session/preferences events go only to their own account; it ends when the account's password changes or it is deleted. +- Notifications: `DeskHub::rules_checked` gets the firing rules each cycle and stores one per rule that *starts* firing; the newest 500 are kept; read state per account. +- Feature flag `desk` in `machine::FEATURES`; a panel without it keeps the desk in the browser. + ### `/api/v1/terminal/ws` - The agent is an SSH **client** to the local sshd: a session has the SSH account's privileges; the panel password grants none. Binary frames are PTY bytes, text frames control JSON (`api/ws/terminal.rs`). diff --git a/monitor/migrations/018_desk.sql b/monitor/migrations/018_desk.sql new file mode 100644 index 0000000000..41b9c51ab0 --- /dev/null +++ b/monitor/migrations/018_desk.sql @@ -0,0 +1,111 @@ +-- The panel's desk (its web desktop): what each account arranged there, and +-- what the agent has to tell it. +-- +-- Everything an account owns is keyed by `users.id` and goes with it +-- (`ON DELETE CASCADE`): a new account of the same name starts from nothing. +-- Text times are RFC 3339, bound by the writer (see migration 012). + +-- One row per account; absent is the defaults. +CREATE TABLE desk_preferences ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + -- `#rrggbb`, or NULL for the panel's own. + accent TEXT, + -- `preset:` (a wallpaper the panel ships) or `custom` (desk_wallpaper). + wallpaper TEXT NOT NULL, + -- `cover` | `contain` | `fill`. + wallpaper_fit TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- The dock's apps, in order. An app id is the panel's (`desk/apps`). +CREATE TABLE desk_dock ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + app_id TEXT NOT NULL, + PRIMARY KEY (user_id, position), + UNIQUE (user_id, app_id) +); + +-- The desk's icons: an app, or a path on one server opened with an app. +CREATE TABLE desk_icon ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Minted by the panel. + id TEXT NOT NULL, + -- `app` | `path`. + kind TEXT NOT NULL, + app_id TEXT NOT NULL, + -- The panel's id of the server a `path` is on; NULL for an `app`. + server_id TEXT, + path TEXT, + label TEXT NOT NULL, + -- The grid cell; NULL lets the panel place it. + col INTEGER, + row INTEGER, + position INTEGER NOT NULL, + PRIMARY KEY (user_id, id) +); + +-- A custom wallpaper, at most one per account. +CREATE TABLE desk_wallpaper ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + mime TEXT NOT NULL, + bytes BLOB NOT NULL, + -- Lowercase hex SHA-256 of `bytes`: the ETag, and what tells a panel its + -- copy is current. + sha256 TEXT NOT NULL +); + +-- The windows one browser had open, so a reload (or another tab) finds them +-- again. Per device: a phone and a desktop arrange the same account apart. +-- `revision` moves on every write; a write made from an older one is refused. +CREATE TABLE desk_session ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + revision INTEGER NOT NULL, + active_window_id TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, device_id) +); + +CREATE TABLE desk_window ( + user_id INTEGER NOT NULL, + device_id TEXT NOT NULL, + window_id TEXT NOT NULL, + app_id TEXT NOT NULL, + -- The panel's id of the server the window works on; NULL for an app that + -- works on none (Settings). + server_id TEXT, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + z INTEGER NOT NULL, + minimized INTEGER NOT NULL, + maximized INTEGER NOT NULL, + -- What the app keeps for itself (a path, a tab): JSON the agent only + -- bounds, never reads. + app_state TEXT, + PRIMARY KEY (user_id, device_id, window_id), + FOREIGN KEY (user_id, device_id) REFERENCES desk_session(user_id, device_id) ON DELETE CASCADE +); + +-- What the machine has to say to whoever is looking: a rule that started +-- firing, for now. Shared by every account; read state is per account. +CREATE TABLE desk_notification ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + -- `info` | `warning` | `critical`. + level TEXT NOT NULL, + -- `alert`: a monitoring rule; `subject` is its name. + source TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL +); + +CREATE INDEX idx_desk_notification_created ON desk_notification(created_at); + +CREATE TABLE desk_notification_read ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notification_id INTEGER NOT NULL REFERENCES desk_notification(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, notification_id) +); diff --git a/monitor/src/api/desk.rs b/monitor/src/api/desk.rs new file mode 100644 index 0000000000..552db2a9ca --- /dev/null +++ b/monitor/src/api/desk.rs @@ -0,0 +1,1167 @@ +//! `/api/v1/desk*` — what the panel's desk (its web desktop) keeps on the +//! agent, and what the agent has to tell it. +//! +//! - `GET /desk`, `PUT /desk/preferences`: accent, wallpaper, the dock and +//! the desk's icons, per account. +//! - `GET/PUT/DELETE /desk/wallpaper`: one custom image per account. +//! - `GET/PUT /desk/session?device=`: the windows one browser had open, per +//! account and device. A write names the revision it was made from and is +//! refused (409, with what is current) when another tab moved it since. +//! - `GET /desk/notifications`, `POST /desk/notifications/read`: what the +//! machine said (a monitoring rule that started firing), read per account. +//! - `GET /desk/events`: a `text/event-stream` of the above as they happen, +//! read with `fetch` and the bearer header (an `EventSource` cannot send +//! one). A hint to refetch, never the only copy. +//! +//! # Privilege +//! +//! Any signed-in account: a desk is its own, and what a notification says is +//! what `/status` already shows to every role. Nothing here reaches the +//! machine. Every check of shape is here, so a stored row is one the panel +//! can draw; the panel's own checks are for its forms only. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use ntex::util::Bytes; +use ntex::web::{self, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::SqlitePool; +use tokio::sync::broadcast; + +use super::authz::{self, Caller}; +use super::server::AppState; + +/// The wallpapers the panel ships, by id (`desk/wallpapers`). +pub const WALLPAPER_PRESETS: &[&str] = &["bloom", "dusk", "nightfall", "graphite"]; +pub const DEFAULT_WALLPAPER: &str = "preset:bloom"; +const FITS: &[&str] = &["cover", "contain", "fill"]; + +/// A JSON body here: the session at its bounds is about 1.1 MiB. +pub const MAX_REQUEST: usize = 2 << 20; +pub const MAX_WALLPAPER_BYTES: usize = 8 << 20; +pub const MAX_WINDOWS: usize = 64; +pub const MAX_APP_STATE: usize = 16 << 10; +pub const MAX_DOCK: usize = 64; +pub const MAX_ICONS: usize = 256; +const MAX_LABEL_CHARS: usize = 64; +const MAX_PATH_BYTES: usize = 4096; +/// Window geometry, in CSS pixels; generous, and only so a stored number +/// stays a number the panel can draw. +const COORD: i64 = 100_000; +const MAX_Z: i64 = 1_000_000; +/// Kept notifications; older ones go when a new one comes. +pub const KEEP_NOTIFICATIONS: i64 = 500; +const HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(25); + +// ----------------------------------------------------------------------------- +// The hub: events, and which rules are firing +// ----------------------------------------------------------------------------- + +/// What `/desk/events` sends. `user` scopes an event to one account; a +/// notification is everybody's. +#[derive(Debug, Clone)] +pub enum DeskEvent { + Notification(Notification), + /// Another tab of `user` wrote its session (or preferences). + Session { user: i64, device: String, revision: i64 }, + Preferences { user: i64 }, +} + +pub struct DeskHub { + events: broadcast::Sender, + /// The monitoring rules firing at the last check: a notification is for a + /// rule that starts, not for every cycle it stays over. + firing: Mutex>, +} + +impl Default for DeskHub { + fn default() -> Self { + Self { + events: broadcast::channel(64).0, + firing: Mutex::new(HashSet::new()), + } + } +} + +impl DeskHub { + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + fn send(&self, event: DeskEvent) { + // No receiver is no panel open: nothing to tell. + let _ = self.events.send(event); + } + + /// The rules firing now, by name, with what each says: a notification for + /// each that was not firing at the last check. + pub async fn rules_checked(&self, db: &SqlitePool, firing: Vec<(String, String)>) { + let started: Vec<(String, String)> = { + let mut was = self.firing.lock().unwrap_or_else(|e| e.into_inner()); + let now: HashSet = firing.iter().map(|(name, _)| name.clone()).collect(); + let started = firing + .into_iter() + .filter(|(name, _)| !was.contains(name)) + .collect(); + *was = now; + started + }; + for (rule, body) in started { + if let Err(e) = self.notify(db, Level::Warning, Source::Alert, &rule, &body).await { + tracing::warn!("desk notification for rule '{rule}' not stored: {e}"); + } + } + } + + pub async fn notify( + &self, + db: &SqlitePool, + level: Level, + source: Source, + subject: &str, + body: &str, + ) -> Result<(), sqlx::Error> { + let created_at = chrono::Utc::now().to_rfc3339(); + let id = sqlx::query( + "INSERT INTO desk_notification (created_at, level, source, subject, body) VALUES (?, ?, ?, ?, ?)", + ) + .bind(&created_at) + .bind(level.as_str()) + .bind(source.as_str()) + .bind(subject) + .bind(body) + .execute(db) + .await? + .last_insert_rowid(); + sqlx::query("DELETE FROM desk_notification WHERE id <= ?") + .bind(id - KEEP_NOTIFICATIONS) + .execute(db) + .await?; + self.send(DeskEvent::Notification(Notification { + id, + created_at, + level: level.as_str().to_string(), + source: source.as_str().to_string(), + subject: subject.to_string(), + body: body.to_string(), + read: false, + })); + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + Info, + Warning, + Critical, +} + +impl Level { + fn as_str(self) -> &'static str { + match self { + Level::Info => "info", + Level::Warning => "warning", + Level::Critical => "critical", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + Alert, +} + +impl Source { + fn as_str(self) -> &'static str { + match self { + Source::Alert => "alert", + } + } +} + +// ----------------------------------------------------------------------------- +// Shapes +// ----------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Preferences { + #[serde(default)] + pub accent: Option, + pub wallpaper: String, + pub wallpaper_fit: String, + pub dock: Vec, + pub icons: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Icon { + pub id: String, + /// `app` | `path`. + pub kind: String, + pub app_id: String, + #[serde(default)] + pub server_id: Option, + #[serde(default)] + pub path: Option, + pub label: String, + #[serde(default)] + pub col: Option, + #[serde(default)] + pub row: Option, +} + +#[derive(Serialize)] +struct DeskView { + /// `None` until the account saved any: the panel's defaults. + preferences: Option, + /// The custom wallpaper's SHA-256, when there is one. + wallpaper_sha256: Option, + presets: &'static [&'static str], +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Window { + pub window_id: String, + pub app_id: String, + #[serde(default)] + pub server_id: Option, + pub x: i64, + pub y: i64, + pub width: i64, + pub height: i64, + pub z: i64, + pub minimized: bool, + pub maximized: bool, + /// The app's own, opaque here; bounded in size. + #[serde(default)] + pub app_state: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Session { + /// 0: nothing stored for this device yet. + pub revision: i64, + pub active_window_id: Option, + pub windows: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionWrite { + device: String, + /// The revision this was made from; 0 for none. + expected_revision: i64, + #[serde(default)] + active_window_id: Option, + windows: Vec, +} + +#[derive(Deserialize)] +pub struct DeviceQuery { + device: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Notification { + pub id: i64, + pub created_at: String, + pub level: String, + pub source: String, + pub subject: String, + pub body: String, + pub read: bool, +} + +#[derive(Deserialize)] +pub struct NotificationQuery { + #[serde(default)] + limit: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReadRequest { + #[serde(default)] + ids: Vec, + #[serde(default)] + all: bool, +} + +// ----------------------------------------------------------------------------- +// Checks +// ----------------------------------------------------------------------------- + +/// Why a write was refused: a stable code for the panel, and where. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Refusal { + pub error: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option, +} + +fn refused(error: &'static str, index: Option) -> Refusal { + Refusal { error, index } +} + +/// A panel-minted id: what `crypto.randomUUID` and short names look like. +pub fn valid_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// An app id (`desk/apps`): lowercase, digits and `_`, a letter first. +pub fn valid_app_id(id: &str) -> bool { + (1..=32).contains(&id.len()) + && id.as_bytes()[0].is_ascii_lowercase() + && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} + +fn valid_label(label: &str) -> bool { + let chars = label.chars().count(); + (1..=MAX_LABEL_CHARS).contains(&chars) && !label.chars().any(char::is_control) +} + +fn valid_server_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && !id.chars().any(char::is_control) +} + +fn valid_accent(accent: &str) -> bool { + accent.len() == 7 + && accent.starts_with('#') + && accent[1..].bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +pub fn valid_wallpaper(wallpaper: &str) -> bool { + wallpaper == "custom" + || wallpaper + .strip_prefix("preset:") + .is_some_and(|id| WALLPAPER_PRESETS.contains(&id)) +} + +/// What is stored is what was checked: the accent lowercased, labels trimmed. +pub fn check_preferences(mut p: Preferences) -> Result { + if let Some(accent) = &p.accent { + let accent = accent.to_ascii_lowercase(); + if !valid_accent(&accent) { + return Err(refused("invalidAccent", None)); + } + p.accent = Some(accent); + } + if !valid_wallpaper(&p.wallpaper) { + return Err(refused("invalidWallpaper", None)); + } + if !FITS.contains(&p.wallpaper_fit.as_str()) { + return Err(refused("invalidWallpaperFit", None)); + } + if p.dock.len() > MAX_DOCK { + return Err(refused("tooMany", None)); + } + let mut seen = HashSet::new(); + for (index, app) in p.dock.iter().enumerate() { + if !valid_app_id(app) { + return Err(refused("invalidAppId", Some(index))); + } + if !seen.insert(app.as_str()) { + return Err(refused("duplicateApp", Some(index))); + } + } + if p.icons.len() > MAX_ICONS { + return Err(refused("tooMany", None)); + } + let mut ids = HashSet::new(); + for (index, icon) in p.icons.iter_mut().enumerate() { + icon.label = icon.label.trim().to_string(); + let at = Some(index); + if !valid_id(&icon.id) { + return Err(refused("invalidId", at)); + } + if !ids.insert(icon.id.clone()) { + return Err(refused("duplicateId", at)); + } + if !valid_app_id(&icon.app_id) { + return Err(refused("invalidAppId", at)); + } + if !valid_label(&icon.label) { + return Err(refused("invalidLabel", at)); + } + match icon.kind.as_str() { + "app" => { + if icon.path.is_some() || icon.server_id.is_some() { + return Err(refused("invalidIcon", at)); + } + } + "path" => { + let path_ok = icon + .path + .as_deref() + .is_some_and(|p| (1..=MAX_PATH_BYTES).contains(&p.len()) && !p.contains('\0')); + let server_ok = icon.server_id.as_deref().is_some_and(valid_server_id); + if !path_ok || !server_ok { + return Err(refused("invalidIcon", at)); + } + } + _ => return Err(refused("invalidIcon", at)), + } + let cell_ok = |v: Option| v.is_none_or(|v| (0..=1000).contains(&v)); + if !cell_ok(icon.col) || !cell_ok(icon.row) { + return Err(refused("invalidIcon", at)); + } + } + Ok(p) +} + +pub fn check_session(write: &SessionWrite) -> Result<(), Refusal> { + if !valid_id(&write.device) { + return Err(refused("invalidDevice", None)); + } + if write.expected_revision < 0 { + return Err(refused("invalidRevision", None)); + } + if write.windows.len() > MAX_WINDOWS { + return Err(refused("tooMany", None)); + } + let mut ids = HashSet::new(); + for (index, w) in write.windows.iter().enumerate() { + let at = Some(index); + if !valid_id(&w.window_id) { + return Err(refused("invalidId", at)); + } + if !ids.insert(w.window_id.as_str()) { + return Err(refused("duplicateId", at)); + } + if !valid_app_id(&w.app_id) { + return Err(refused("invalidAppId", at)); + } + if w.server_id.as_deref().is_some_and(|s| !valid_server_id(s)) { + return Err(refused("invalidServerId", at)); + } + let coord = |v: i64| (-COORD..=COORD).contains(&v); + let size = |v: i64| (1..=COORD).contains(&v); + if !coord(w.x) || !coord(w.y) || !size(w.width) || !size(w.height) || !(0..=MAX_Z).contains(&w.z) { + return Err(refused("invalidGeometry", at)); + } + if let Some(state) = &w.app_state + && state.to_string().len() > MAX_APP_STATE + { + return Err(refused("appStateTooLarge", at)); + } + } + if let Some(active) = &write.active_window_id + && !ids.contains(active.as_str()) + { + return Err(refused("invalidActiveWindow", None)); + } + Ok(()) +} + +/// The image's type, read from its first bytes rather than taken from a +/// header the client chose: PNG, JPEG or WebP, nothing else an `` would +/// be asked to decode. +pub fn sniff_image(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some("image/webp") + } else { + None + } +} + +// ----------------------------------------------------------------------------- +// Handlers +// ----------------------------------------------------------------------------- + +macro_rules! require { + ($result:expr) => {{ + match $result { + Ok(value) => value, + Err(response) => return Ok(response), + } + }}; +} + +/// The caller and its account id. A token for an account deleted since is +/// already 401 in `jwt_caller`; the id lookup failing after it is the same. +async fn account(req: &HttpRequest, state: &AppState) -> Result<(Caller, i64), HttpResponse> { + let caller = authz::jwt_caller(req, state).await?; + match user_id(&state.db, &caller.username).await { + Ok(Some(id)) => Ok((caller, id)), + Ok(None) => Err(HttpResponse::Unauthorized().finish()), + Err(e) => Err(internal_error(&e)), + } +} + +async fn user_id(db: &SqlitePool, username: &str) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT id FROM users WHERE username = ?") + .bind(username) + .fetch_optional(db) + .await +} + +fn internal_error(e: &sqlx::Error) -> HttpResponse { + tracing::error!("desk: {e}"); + HttpResponse::InternalServerError().json(&serde_json::json!({ "error": "internal" })) +} + +pub async fn get(req: HttpRequest, state: web::types::State>) -> Result { + let (_, user) = require!(account(&req, &state).await); + let preferences = match load_preferences(&state.db, user).await { + Ok(p) => p, + Err(e) => return Ok(internal_error(&e)), + }; + let wallpaper_sha256 = match sqlx::query_scalar::<_, String>("SELECT sha256 FROM desk_wallpaper WHERE user_id = ?") + .bind(user) + .fetch_optional(&state.db) + .await + { + Ok(sha) => sha, + Err(e) => return Ok(internal_error(&e)), + }; + Ok(HttpResponse::Ok().json(&DeskView { + preferences, + wallpaper_sha256, + presets: WALLPAPER_PRESETS, + })) +} + +pub async fn put_preferences( + req: HttpRequest, + body: web::types::Json, + state: web::types::State>, +) -> Result { + let (_, user) = require!(account(&req, &state).await); + let preferences = match check_preferences(body.into_inner()) { + Ok(p) => p, + Err(refusal) => return Ok(HttpResponse::BadRequest().json(&refusal)), + }; + if let Err(e) = store_preferences(&state.db, user, &preferences).await { + return Ok(internal_error(&e)); + } + state.desk.send(DeskEvent::Preferences { user }); + Ok(HttpResponse::Ok().json(&preferences)) +} + +pub async fn get_wallpaper(req: HttpRequest, state: web::types::State>) -> Result { + let (_, user) = require!(account(&req, &state).await); + let row = sqlx::query_as::<_, (String, Vec, String)>( + "SELECT mime, bytes, sha256 FROM desk_wallpaper WHERE user_id = ?", + ) + .bind(user) + .fetch_optional(&state.db) + .await; + match row { + Ok(Some((mime, bytes, sha))) => { + let etag = format!("\"{sha}\""); + let matches = req + .headers() + .get("if-none-match") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == etag); + let mut res = if matches { HttpResponse::NotModified() } else { HttpResponse::Ok() }; + res.header("etag", etag) + .header("cache-control", "private, no-cache") + .header("x-content-type-options", "nosniff"); + Ok(if matches { res.finish() } else { res.content_type(mime).body(bytes) }) + } + Ok(None) => Ok(HttpResponse::NotFound().json(&refused("notFound", None))), + Err(e) => Ok(internal_error(&e)), + } +} + +/// The image is the whole body; read in full before anything is written. +pub async fn put_wallpaper( + req: HttpRequest, + mut body: web::types::Payload, + state: web::types::State>, +) -> Result { + let (_, user) = require!(account(&req, &state).await); + let mut data = Vec::new(); + while let Some(chunk) = body.next().await { + let Ok(chunk) = chunk else { + return Ok(HttpResponse::BadRequest().finish()); + }; + if data.len() + chunk.len() > MAX_WALLPAPER_BYTES { + return Ok(HttpResponse::PayloadTooLarge().json(&refused("tooLarge", None))); + } + data.extend_from_slice(&chunk); + } + let Some(mime) = sniff_image(&data) else { + return Ok(HttpResponse::UnsupportedMediaType().json(&refused("notAnImage", None))); + }; + let sha = hex(&Sha256::digest(&data)); + let stored = sqlx::query( + "INSERT INTO desk_wallpaper (user_id, mime, bytes, sha256) VALUES (?, ?, ?, ?) \ + ON CONFLICT(user_id) DO UPDATE SET mime = excluded.mime, bytes = excluded.bytes, sha256 = excluded.sha256", + ) + .bind(user) + .bind(mime) + .bind(&data) + .bind(&sha) + .execute(&state.db) + .await; + match stored { + Ok(_) => { + state.desk.send(DeskEvent::Preferences { user }); + Ok(HttpResponse::Ok().json(&serde_json::json!({ "sha256": sha }))) + } + Err(e) => Ok(internal_error(&e)), + } +} + +/// Removes the custom image; a desk that showed it falls back to the default. +pub async fn delete_wallpaper(req: HttpRequest, state: web::types::State>) -> Result { + let (_, user) = require!(account(&req, &state).await); + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => return Ok(internal_error(&e)), + }; + let done = async { + sqlx::query("DELETE FROM desk_wallpaper WHERE user_id = ?").bind(user).execute(&mut *tx).await?; + sqlx::query("UPDATE desk_preferences SET wallpaper = ? WHERE user_id = ? AND wallpaper = 'custom'") + .bind(DEFAULT_WALLPAPER) + .bind(user) + .execute(&mut *tx) + .await?; + tx.commit().await + } + .await; + match done { + Ok(()) => { + state.desk.send(DeskEvent::Preferences { user }); + Ok(HttpResponse::NoContent().finish()) + } + Err(e) => Ok(internal_error(&e)), + } +} + +pub async fn get_session( + req: HttpRequest, + query: web::types::Query, + state: web::types::State>, +) -> Result { + let (_, user) = require!(account(&req, &state).await); + if !valid_id(&query.device) { + return Ok(HttpResponse::BadRequest().json(&refused("invalidDevice", None))); + } + let loaded = async { + let mut conn = state.db.acquire().await?; + load_session(&mut conn, user, &query.device).await + } + .await; + match loaded { + Ok(session) => Ok(HttpResponse::Ok().json(&session)), + Err(e) => Ok(internal_error(&e)), + } +} + +pub async fn put_session( + req: HttpRequest, + body: web::types::Json, + state: web::types::State>, +) -> Result { + let (_, user) = require!(account(&req, &state).await); + let write = body.into_inner(); + if let Err(refusal) = check_session(&write) { + return Ok(HttpResponse::BadRequest().json(&refusal)); + } + match store_session(&state.db, user, &write).await { + Ok(Stored::Written(revision)) => { + state.desk.send(DeskEvent::Session { + user, + device: write.device.clone(), + revision, + }); + Ok(HttpResponse::Ok().json(&serde_json::json!({ "revision": revision }))) + } + Ok(Stored::Conflict(current)) => Ok(HttpResponse::Conflict().json(&serde_json::json!({ + "error": "conflict", + "current": current, + }))), + Err(e) => Ok(internal_error(&e)), + } +} + +pub async fn notifications( + req: HttpRequest, + query: web::types::Query, + state: web::types::State>, +) -> Result { + let (_, user) = require!(account(&req, &state).await); + let limit = query.limit.unwrap_or(50).clamp(1, 200); + let rows = sqlx::query_as::<_, (i64, String, String, String, String, String, bool)>( + "SELECT n.id, n.created_at, n.level, n.source, n.subject, n.body, r.user_id IS NOT NULL \ + FROM desk_notification n \ + LEFT JOIN desk_notification_read r ON r.notification_id = n.id AND r.user_id = ? \ + ORDER BY n.id DESC LIMIT ?", + ) + .bind(user) + .bind(limit) + .fetch_all(&state.db) + .await; + let unread = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM desk_notification n WHERE NOT EXISTS \ + (SELECT 1 FROM desk_notification_read r WHERE r.notification_id = n.id AND r.user_id = ?)", + ) + .bind(user) + .fetch_one(&state.db) + .await; + match (rows, unread) { + (Ok(rows), Ok(unread)) => { + let notifications: Vec = rows + .into_iter() + .map(|(id, created_at, level, source, subject, body, read)| Notification { + id, + created_at, + level, + source, + subject, + body, + read, + }) + .collect(); + Ok(HttpResponse::Ok().json(&serde_json::json!({ + "notifications": notifications, + "unread": unread, + }))) + } + (Err(e), _) | (_, Err(e)) => Ok(internal_error(&e)), + } +} + +pub async fn mark_read( + req: HttpRequest, + body: web::types::Json, + state: web::types::State>, +) -> Result { + let (_, user) = require!(account(&req, &state).await); + let body = body.into_inner(); + if body.ids.len() > 200 { + return Ok(HttpResponse::BadRequest().json(&refused("tooMany", None))); + } + let done = if body.all { + sqlx::query( + "INSERT OR IGNORE INTO desk_notification_read (user_id, notification_id) \ + SELECT ?, id FROM desk_notification", + ) + .bind(user) + .execute(&state.db) + .await + .map(|_| ()) + } else { + mark_ids(&state.db, user, &body.ids).await + }; + match done { + Ok(()) => Ok(HttpResponse::NoContent().finish()), + Err(e) => Ok(internal_error(&e)), + } +} + +async fn mark_ids(db: &SqlitePool, user: i64, ids: &[i64]) -> Result<(), sqlx::Error> { + let mut tx = db.begin().await?; + for id in ids { + // An id that is gone (or never was) marks nothing. + sqlx::query( + "INSERT OR IGNORE INTO desk_notification_read (user_id, notification_id) \ + SELECT ?, id FROM desk_notification WHERE id = ?", + ) + .bind(user) + .bind(id) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +/// `text/event-stream`, one `data:` line of JSON per event: +/// `{"type":"notification","notification":{…}}`, +/// `{"type":"session","device":…,"revision":…}`, `{"type":"preferences"}`, +/// and `{"type":"resync"}` when events were dropped (refetch everything). +/// Ends when the account's password changes or the account goes. +pub async fn events(req: HttpRequest, state: web::types::State>) -> Result { + let (caller, user) = require!(account(&req, &state).await); + let mut rx = state.desk.subscribe(); + let mut changes = state.grants_changed.subscribe(); + let state = state.get_ref().clone(); + let stream = async_stream::stream! { + yield Ok::<_, std::io::Error>(Bytes::from_static(b": ok\n\n")); + let mut beat = tokio::time::interval(HEARTBEAT); + beat.tick().await; + loop { + tokio::select! { + event = rx.recv() => { + let json = match event { + Ok(DeskEvent::Notification(n)) => serde_json::json!({ "type": "notification", "notification": n }), + Ok(DeskEvent::Session { user: u, device, revision }) if u == user => { + serde_json::json!({ "type": "session", "device": device, "revision": revision }) + } + Ok(DeskEvent::Preferences { user: u }) if u == user => serde_json::json!({ "type": "preferences" }), + Ok(_) => continue, + Err(broadcast::error::RecvError::Lagged(_)) => serde_json::json!({ "type": "resync" }), + Err(broadcast::error::RecvError::Closed) => break, + }; + yield Ok(Bytes::from(format!("data: {json}\n\n"))); + } + changed = changes.recv() => { + if matches!(changed, Err(broadcast::error::RecvError::Closed)) { + break; + } + if !still_signed_in(&state, &caller).await { + break; + } + } + _ = beat.tick() => { + yield Ok(Bytes::from_static(b": ping\n\n")); + } + } + } + }; + Ok(HttpResponse::Ok() + .content_type("text/event-stream") + .header("cache-control", "no-cache") + .header("x-accel-buffering", "no") + .streaming(Box::pin(stream))) +} + +/// Whether the account behind [caller] is the one that opened the stream: +/// still there, and its password not changed since. +async fn still_signed_in(state: &AppState, caller: &Caller) -> bool { + authz::caller_named(state, &caller.username) + .await + .is_some_and(|now| now.since == caller.since) +} + +// ----------------------------------------------------------------------------- +// Storage +// ----------------------------------------------------------------------------- + +async fn load_preferences(db: &SqlitePool, user: i64) -> Result, sqlx::Error> { + let Some((accent, wallpaper, wallpaper_fit)) = sqlx::query_as::<_, (Option, String, String)>( + "SELECT accent, wallpaper, wallpaper_fit FROM desk_preferences WHERE user_id = ?", + ) + .bind(user) + .fetch_optional(db) + .await? + else { + return Ok(None); + }; + let dock = sqlx::query_scalar::<_, String>("SELECT app_id FROM desk_dock WHERE user_id = ? ORDER BY position") + .bind(user) + .fetch_all(db) + .await?; + let icons = sqlx::query_as::<_, (String, String, String, Option, Option, String, Option, Option)>( + "SELECT id, kind, app_id, server_id, path, label, col, row FROM desk_icon WHERE user_id = ? ORDER BY position", + ) + .bind(user) + .fetch_all(db) + .await? + .into_iter() + .map(|(id, kind, app_id, server_id, path, label, col, row)| Icon { + id, + kind, + app_id, + server_id, + path, + label, + col, + row, + }) + .collect(); + Ok(Some(Preferences { + accent, + wallpaper, + wallpaper_fit, + dock, + icons, + })) +} + +async fn store_preferences(db: &SqlitePool, user: i64, p: &Preferences) -> Result<(), sqlx::Error> { + let mut tx = db.begin().await?; + sqlx::query( + "INSERT INTO desk_preferences (user_id, accent, wallpaper, wallpaper_fit, updated_at) VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT(user_id) DO UPDATE SET accent = excluded.accent, wallpaper = excluded.wallpaper, \ + wallpaper_fit = excluded.wallpaper_fit, updated_at = excluded.updated_at", + ) + .bind(user) + .bind(&p.accent) + .bind(&p.wallpaper) + .bind(&p.wallpaper_fit) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM desk_dock WHERE user_id = ?").bind(user).execute(&mut *tx).await?; + for (position, app) in p.dock.iter().enumerate() { + sqlx::query("INSERT INTO desk_dock (user_id, position, app_id) VALUES (?, ?, ?)") + .bind(user) + .bind(position as i64) + .bind(app) + .execute(&mut *tx) + .await?; + } + sqlx::query("DELETE FROM desk_icon WHERE user_id = ?").bind(user).execute(&mut *tx).await?; + for (position, icon) in p.icons.iter().enumerate() { + sqlx::query( + "INSERT INTO desk_icon (user_id, id, kind, app_id, server_id, path, label, col, row, position) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(user) + .bind(&icon.id) + .bind(&icon.kind) + .bind(&icon.app_id) + .bind(&icon.server_id) + .bind(&icon.path) + .bind(&icon.label) + .bind(icon.col) + .bind(icon.row) + .bind(position as i64) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +type WindowRow = (String, String, Option, i64, i64, i64, i64, i64, bool, bool, Option); + +async fn load_session(conn: &mut sqlx::SqliteConnection, user: i64, device: &str) -> Result { + let Some((revision, active_window_id)) = sqlx::query_as::<_, (i64, Option)>( + "SELECT revision, active_window_id FROM desk_session WHERE user_id = ? AND device_id = ?", + ) + .bind(user) + .bind(device) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(Session { + revision: 0, + active_window_id: None, + windows: Vec::new(), + }); + }; + let windows = sqlx::query_as::<_, WindowRow>( + "SELECT window_id, app_id, server_id, x, y, width, height, z, minimized, maximized, app_state \ + FROM desk_window WHERE user_id = ? AND device_id = ? ORDER BY z", + ) + .bind(user) + .bind(device) + .fetch_all(&mut *conn) + .await? + .into_iter() + .map(|(window_id, app_id, server_id, x, y, width, height, z, minimized, maximized, app_state)| Window { + window_id, + app_id, + server_id, + x, + y, + width, + height, + z, + minimized, + maximized, + // Written from a `Value`, so it parses; a row that does not is one + // this agent did not write, and the window opens without it. + app_state: app_state.and_then(|s| serde_json::from_str(&s).ok()), + }) + .collect(); + Ok(Session { + revision, + active_window_id, + windows, + }) +} + +enum Stored { + Written(i64), + Conflict(Session), +} + +/// Compare-and-swap on the revision. The transaction's first statement is +/// the write that checks it, so SQLite's write lock is taken there: a second +/// writer from the same revision waits, then matches nothing and is told +/// what is current. +async fn store_session(db: &SqlitePool, user: i64, write: &SessionWrite) -> Result { + let mut tx = db.begin().await?; + let revision = write.expected_revision + 1; + let now = chrono::Utc::now().to_rfc3339(); + let swapped = if write.expected_revision == 0 { + sqlx::query( + "INSERT OR IGNORE INTO desk_session (user_id, device_id, revision, active_window_id, updated_at) \ + VALUES (?, ?, ?, ?, ?)", + ) + .bind(user) + .bind(&write.device) + .bind(revision) + .bind(&write.active_window_id) + .bind(&now) + .execute(&mut *tx) + .await? + } else { + sqlx::query( + "UPDATE desk_session SET revision = ?, active_window_id = ?, updated_at = ? \ + WHERE user_id = ? AND device_id = ? AND revision = ?", + ) + .bind(revision) + .bind(&write.active_window_id) + .bind(&now) + .bind(user) + .bind(&write.device) + .bind(write.expected_revision) + .execute(&mut *tx) + .await? + } + .rows_affected() + == 1; + if !swapped { + let current = load_session(&mut tx, user, &write.device).await?; + tx.rollback().await?; + return Ok(Stored::Conflict(current)); + } + sqlx::query("DELETE FROM desk_window WHERE user_id = ? AND device_id = ?") + .bind(user) + .bind(&write.device) + .execute(&mut *tx) + .await?; + for w in &write.windows { + sqlx::query( + "INSERT INTO desk_window (user_id, device_id, window_id, app_id, server_id, x, y, width, height, z, \ + minimized, maximized, app_state) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(user) + .bind(&write.device) + .bind(&w.window_id) + .bind(&w.app_id) + .bind(&w.server_id) + .bind(w.x) + .bind(w.y) + .bind(w.width) + .bind(w.height) + .bind(w.z) + .bind(w.minimized) + .bind(w.maximized) + .bind(w.app_state.as_ref().map(|v| v.to_string())) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(Stored::Written(revision)) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn prefs() -> Preferences { + Preferences { + accent: Some("#8B2252".into()), + wallpaper: "preset:dusk".into(), + wallpaper_fit: "cover".into(), + dock: vec!["files".into(), "terminal".into()], + icons: vec![Icon { + id: "i1".into(), + kind: "path".into(), + app_id: "files".into(), + server_id: Some("local".into()), + path: Some("/etc".into()), + label: " etc ".into(), + col: Some(0), + row: None, + }], + } + } + + #[test] + fn preferences_are_normalised() { + let p = check_preferences(prefs()).unwrap(); + assert_eq!(p.accent.as_deref(), Some("#8b2252")); + assert_eq!(p.icons[0].label, "etc"); + } + + #[test] + fn preferences_refusals() { + type Edit = fn(&mut Preferences); + let cases: Vec<(Edit, &str)> = vec![ + (|p| p.accent = Some("red".into()), "invalidAccent"), + (|p| p.wallpaper = "preset:nope".into(), "invalidWallpaper"), + (|p| p.wallpaper = "https://x/y.png".into(), "invalidWallpaper"), + (|p| p.wallpaper_fit = "tile".into(), "invalidWallpaperFit"), + (|p| p.dock.push("files".into()), "duplicateApp"), + (|p| p.dock.push("Files".into()), "invalidAppId"), + (|p| p.icons[0].path = None, "invalidIcon"), + (|p| p.icons[0].server_id = None, "invalidIcon"), + (|p| p.icons[0].kind = "app".into(), "invalidIcon"), + (|p| p.icons[0].label = "\u{7}".into(), "invalidLabel"), + (|p| p.icons[0].col = Some(-1), "invalidIcon"), + (|p| p.icons.push(p.icons[0].clone()), "duplicateId"), + ]; + for (edit, code) in cases { + let mut p = prefs(); + edit(&mut p); + assert_eq!(check_preferences(p).unwrap_err().error, code); + } + } + + fn window(id: &str) -> Window { + Window { + window_id: id.into(), + app_id: "files".into(), + server_id: None, + x: 10, + y: 10, + width: 800, + height: 600, + z: 1, + minimized: false, + maximized: false, + app_state: Some(serde_json::json!({ "path": "/" })), + } + } + + fn write(windows: Vec) -> SessionWrite { + SessionWrite { + device: "dev-1".into(), + expected_revision: 0, + active_window_id: windows.first().map(|w| w.window_id.clone()), + windows, + } + } + + #[test] + fn session_refusals() { + assert!(check_session(&write(vec![window("a"), window("b")])).is_ok()); + assert_eq!(check_session(&write(vec![window("a"), window("a")])).unwrap_err().error, "duplicateId"); + let mut w = window("a"); + w.width = 0; + assert_eq!(check_session(&write(vec![w])).unwrap_err().error, "invalidGeometry"); + let mut w = window("a"); + w.app_state = Some(serde_json::json!("x".repeat(MAX_APP_STATE))); + assert_eq!(check_session(&write(vec![w])).unwrap_err().error, "appStateTooLarge"); + let mut s = write(vec![window("a")]); + s.active_window_id = Some("b".into()); + assert_eq!(check_session(&s).unwrap_err().error, "invalidActiveWindow"); + let mut s = write(vec![]); + s.device = "a b".into(); + assert_eq!(check_session(&s).unwrap_err().error, "invalidDevice"); + let too_many: Vec = (0..=MAX_WINDOWS).map(|i| window(&format!("w{i}"))).collect(); + assert_eq!(check_session(&write(too_many)).unwrap_err().error, "tooMany"); + } + + #[test] + fn images_are_sniffed() { + assert_eq!(sniff_image(b"\x89PNG\r\n\x1a\nrest"), Some("image/png")); + assert_eq!(sniff_image(&[0xff, 0xd8, 0xff, 0xe0]), Some("image/jpeg")); + assert_eq!(sniff_image(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp")); + assert_eq!(sniff_image(b""), None); + assert_eq!(sniff_image(b"GIF89a"), None); + } +} diff --git a/monitor/src/api/machine.rs b/monitor/src/api/machine.rs index c0ab374435..056502eb40 100644 --- a/monitor/src/api/machine.rs +++ b/monitor/src/api/machine.rs @@ -52,6 +52,9 @@ pub const FEATURES: &[&str] = &[ "bmc", "virt", "firewall", + // The panel's desk keeps its arrangement here (`api::desk`); without it + // the panel keeps it in the browser. + "desk", ]; /// Who is asking, and from where, once [`gate`] let them through. diff --git a/monitor/src/api/mod.rs b/monitor/src/api/mod.rs index 152be504be..d1bf06fb1b 100644 --- a/monitor/src/api/mod.rs +++ b/monitor/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod virt_backups; pub mod virt_guests; pub mod virt_hardware; pub mod virt_resources; +pub mod desk; pub mod desktops; pub mod custom_cmds; pub mod exec; diff --git a/monitor/src/api/server.rs b/monitor/src/api/server.rs index 9b852c9f37..55c3c055d2 100644 --- a/monitor/src/api/server.rs +++ b/monitor/src/api/server.rs @@ -119,6 +119,9 @@ pub struct AppState { /// and re-checks its own account when it fires; terminal sessions, which /// outlive their sockets, are swept by `authz::revoke_lost` instead. pub grants_changed: broadcast::Sender<&'static str>, + /// The desk's events and which monitoring rules are firing — see + /// `api::desk`. + pub desk: Arc, /// The last process table read, which the next one's read and write /// speeds are differenced against — see `api::process`. pub process_sample: Arc>>, @@ -208,6 +211,7 @@ impl AppState { pending: Arc::new(PendingStore::default()), login_throttle: Arc::new(LoginThrottle::new()), grants_changed: broadcast::channel(16).0, + desk: Arc::new(crate::api::desk::DeskHub::default()), process_sample: Arc::new(tokio::sync::Mutex::new(None)), virt: Arc::new(crate::api::virt::VirtState::default()), config, @@ -453,6 +457,28 @@ fn configure_api_inner(cfg: &mut web::ServiceConfig, exec_max_request: usize) { .route(web::get().to(crate::api::desktops::list)) .route(web::put().to(crate::api::desktops::replace)), ) + .service(web::resource("/desk").route(web::get().to(crate::api::desk::get))) + .service( + web::resource("/desk/preferences") + .state(web::types::JsonConfig::default().limit(crate::api::desk::MAX_REQUEST)) + .route(web::put().to(crate::api::desk::put_preferences)), + ) + .service( + // A streamed body, read whole and bounded in the handler. + web::resource("/desk/wallpaper") + .route(web::get().to(crate::api::desk::get_wallpaper)) + .route(web::put().to(crate::api::desk::put_wallpaper)) + .route(web::delete().to(crate::api::desk::delete_wallpaper)), + ) + .service( + web::resource("/desk/session") + .state(web::types::JsonConfig::default().limit(crate::api::desk::MAX_REQUEST)) + .route(web::get().to(crate::api::desk::get_session)) + .route(web::put().to(crate::api::desk::put_session)), + ) + .service(web::resource("/desk/notifications").route(web::get().to(crate::api::desk::notifications))) + .service(web::resource("/desk/notifications/read").route(web::post().to(crate::api::desk::mark_read))) + .service(web::resource("/desk/events").route(web::get().to(crate::api::desk::events))) .service( web::resource("/snippets/plan") .state( diff --git a/monitor/src/monitoring/monitoring.rs b/monitor/src/monitoring/monitoring.rs index 3c614bf9f2..e22ea8ba44 100644 --- a/monitor/src/monitoring/monitoring.rs +++ b/monitor/src/monitoring/monitoring.rs @@ -363,14 +363,15 @@ pub async fn run_monitoring_loop(app_state: Arc) -> Result<()> { .await; // Check rules and send alerts with velocity data - if let Err(e) = crate::monitoring::rules::check_rules_with_velocity( + match crate::monitoring::rules::check_rules_with_velocity( &metrics, &app_state.config, &*app_state.velocity_manager.read().await, ) .await { - error!("Failed to check enhanced rules: {}", e); + Ok(firing) => app_state.desk.rules_checked(&app_state.db, firing).await, + Err(e) => error!("Failed to check enhanced rules: {}", e), } // Update current metrics in app state diff --git a/monitor/src/monitoring/rules.rs b/monitor/src/monitoring/rules.rs index 0ccea9e2f0..7475195f37 100644 --- a/monitor/src/monitoring/rules.rs +++ b/monitor/src/monitoring/rules.rs @@ -2,17 +2,22 @@ use crate::{core::config::{Config, MonitoringRule}, monitoring::SystemMetrics, u use crate::monitoring::threshold::{Threshold, ThresholdType}; use tracing::{info, warn}; +/// Checks every rule, pushes what fires, and answers the rules firing now +/// with what each said — for the desk's notifications (`api::desk`). pub async fn check_rules_with_velocity( - metrics: &SystemMetrics, - config: &Config, + metrics: &SystemMetrics, + config: &Config, velocity_manager: &VelocityManager -) -> Result<()> { +) -> Result> { + let mut firing = Vec::new(); for rule in &config.get_monitoring().rules { - if let Err(e) = check_enhanced_rule(rule, metrics, config, velocity_manager).await { - warn!("Failed to check enhanced rule '{}': {}", rule.name, e); + match check_enhanced_rule(rule, metrics, config, velocity_manager).await { + Ok(Some(message)) => firing.push((rule.name.clone(), message)), + Ok(None) => {} + Err(e) => warn!("Failed to check enhanced rule '{}': {}", rule.name, e), } } - Ok(()) + Ok(firing) } /// What a rule's `monitor_type` names. @@ -60,10 +65,10 @@ async fn check_enhanced_rule( metrics: &SystemMetrics, config: &Config, velocity_manager: &VelocityManager -) -> Result<()> { +) -> Result> { let Some(kind) = RuleKind::parse(&rule.monitor_type) else { warn!("Unknown monitor type: {}", rule.monitor_type); - return Ok(()); + return Ok(None); }; let (should_alert, _current_value, formatted_value) = match kind { @@ -98,9 +103,10 @@ async fn check_enhanced_rule( Err(e) => warn!("Failed to send push notification via '{}': {}", push_config.name, e), } } + return Ok(Some(message)); } - - Ok(()) + + Ok(None) } diff --git a/monitor/tests/desk.rs b/monitor/tests/desk.rs new file mode 100644 index 0000000000..fd4ec6782d --- /dev/null +++ b/monitor/tests/desk.rs @@ -0,0 +1,439 @@ +//! `/api/v1/desk*`: what the panel's desk keeps on the agent, whose it is, +//! and the revision that keeps two tabs from overwriting each other. + +mod common; + +use std::sync::Arc; + +use ntex::http::Method; +use ntex::web::test::{self as web_test, TestServer}; +use ntex::web::{self, App}; +use server_box_monitor::api::auth::generate_token; +use server_box_monitor::api::desk::{self, Level, Source}; +use server_box_monitor::api::server::AppState; +use server_box_monitor::core::config::Config; + +const SECRET: &str = "test-secret-that-is-long-enough-32ch"; + +async fn state() -> Arc { + state_over(common::database().await).await +} + +async fn state_over(db: sqlx::SqlitePool) -> Arc { + let _ = rustls::crypto::ring::default_provider().install_default(); + let config = Config { + jwt_secret: Some(SECRET.to_string()), + ..Default::default() + }; + common::seed_as_upgrade(&db, &config).await; + AppState::new(Arc::new(config), db) +} + +async fn server(state: Arc) -> TestServer { + web_test::server(move || { + let state = state.clone(); + async move { + App::new().state(state).service( + web::scope("/api/v1") + .route("/desk", web::get().to(desk::get)) + .route("/desk/preferences", web::put().to(desk::put_preferences)) + .service( + web::resource("/desk/wallpaper") + .route(web::get().to(desk::get_wallpaper)) + .route(web::put().to(desk::put_wallpaper)) + .route(web::delete().to(desk::delete_wallpaper)), + ) + .service( + web::resource("/desk/session") + .route(web::get().to(desk::get_session)) + .route(web::put().to(desk::put_session)), + ) + .route("/desk/notifications", web::get().to(desk::notifications)) + .route("/desk/notifications/read", web::post().to(desk::mark_read)) + .route("/desk/events", web::get().to(desk::events)), + ) + } + }) + .await +} + +/// One request as [user], and its status and body (JSON, or the raw bytes as +/// a JSON string of their length when they are not JSON). +async fn call( + srv: &TestServer, + user: Option<&str>, + method: Method, + path: &str, + body: Option>, +) -> (u16, serde_json::Value, Vec) { + let mut req = srv + .request(method, srv.url(path)) + .timeout(std::time::Duration::from_secs(30)); + if let Some(user) = user { + req = req.header( + "Authorization", + format!("Bearer {}", generate_token(user, SECRET).unwrap()), + ); + } + let resp = match body { + Some(body) => req + .header("content-type", "application/json") + .send_body(body) + .await + .unwrap(), + None => req.send().await.unwrap(), + }; + let status = resp.status().as_u16(); + let bytes = resp.body().limit(16 << 20).await.unwrap_or_default().to_vec(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json, bytes) +} + +fn json(value: serde_json::Value) -> Option> { + Some(value.to_string().into_bytes()) +} + +fn prefs() -> serde_json::Value { + serde_json::json!({ + "accent": "#2563EB", + "wallpaper": "preset:dusk", + "wallpaper_fit": "cover", + "dock": ["status", "files", "terminal"], + "icons": [ + { "id": "a", "kind": "app", "app_id": "settings", "label": "Settings" }, + { "id": "b", "kind": "path", "app_id": "files", "server_id": "local", "path": "/etc", "label": "etc", "col": 0, "row": 1 } + ] + }) +} + +fn session(expected: i64, windows: usize) -> serde_json::Value { + let windows: Vec = (0..windows) + .map(|i| { + serde_json::json!({ + "window_id": format!("w{i}"), + "app_id": "files", + "x": 10 * i, "y": 20, "width": 800, "height": 600, "z": i, + "minimized": false, "maximized": i == 0, + "app_state": { "path": "/root" } + }) + }) + .collect(); + serde_json::json!({ + "device": "dev-1", + "expected_revision": expected, + "active_window_id": if windows.is_empty() { None } else { Some("w0") }, + "windows": windows, + }) +} + +#[ntex::test] +async fn nothing_without_a_token() { + let srv = server(state().await).await; + for path in ["/api/v1/desk", "/api/v1/desk/session?device=d", "/api/v1/desk/notifications", "/api/v1/desk/events"] { + let (status, _, _) = call(&srv, None, Method::GET, path, None).await; + assert_eq!(status, 401, "{path}"); + } +} + +#[ntex::test] +async fn preferences_round_trip_per_account() { + let srv = server(state().await).await; + let (status, body, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk", None).await; + assert_eq!(status, 200); + assert!(body["preferences"].is_null()); + assert!(body["presets"].as_array().unwrap().iter().any(|p| p == "bloom")); + + let (status, saved, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/preferences", json(prefs())).await; + assert_eq!(status, 200, "{saved}"); + assert_eq!(saved["accent"], "#2563eb"); + + let (_, body, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk", None).await; + assert_eq!(body["preferences"], saved); + assert_eq!(body["preferences"]["dock"], serde_json::json!(["status", "files", "terminal"])); + assert_eq!(body["preferences"]["icons"][1]["path"], "/etc"); + + // Another account's desk is its own. + let (_, other, _) = call(&srv, Some("intruder"), Method::GET, "/api/v1/desk", None).await; + assert!(other["preferences"].is_null()); +} + +#[ntex::test] +async fn a_refused_preference_names_why_and_stores_nothing() { + let srv = server(state().await).await; + let mut bad = prefs(); + bad["icons"][1]["path"] = serde_json::Value::Null; + let (status, body, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/preferences", json(bad)).await; + assert_eq!(status, 400); + assert_eq!(body, serde_json::json!({ "error": "invalidIcon", "index": 1 })); + let mut unknown = prefs(); + unknown["extra"] = serde_json::json!(1); + let (status, _, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/preferences", json(unknown)).await; + assert_eq!(status, 400); + let (_, body, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk", None).await; + assert!(body["preferences"].is_null()); +} + +#[ntex::test] +async fn wallpaper_is_an_image_and_cached_by_its_hash() { + let srv = server(state().await).await; + let svg = b"".to_vec(); + let (status, body, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/wallpaper", Some(svg)).await; + assert_eq!(status, 415); + assert_eq!(body["error"], "notAnImage"); + + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend_from_slice(&[7u8; 64]); + let (status, body, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/wallpaper", Some(png.clone())).await; + assert_eq!(status, 200); + let sha = body["sha256"].as_str().unwrap().to_owned(); + assert_eq!(sha.len(), 64); + + let resp = srv + .get("/api/v1/desk/wallpaper") + .header("Authorization", format!("Bearer {}", generate_token("admin", SECRET).unwrap())) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 200); + assert_eq!(resp.headers().get("content-type").unwrap(), "image/png"); + assert_eq!(resp.headers().get("etag").unwrap().to_str().unwrap(), format!("\"{sha}\"")); + let resp = srv + .get("/api/v1/desk/wallpaper") + .header("Authorization", format!("Bearer {}", generate_token("admin", SECRET).unwrap())) + .header("If-None-Match", format!("\"{sha}\"")) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 304); + + // Not the other account's to read. + let (status, _, _) = call(&srv, Some("intruder"), Method::GET, "/api/v1/desk/wallpaper", None).await; + assert_eq!(status, 404); + + // Too large is refused before it is stored. + let mut big = b"\x89PNG\r\n\x1a\n".to_vec(); + big.resize(desk::MAX_WALLPAPER_BYTES + 1, 0); + let (status, _, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/wallpaper", Some(big)).await; + assert_eq!(status, 413); + + // Removing it takes a desk that showed it back to the default. + let mut custom = prefs(); + custom["wallpaper"] = serde_json::json!("custom"); + call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/preferences", json(custom)).await; + let (status, _, _) = call(&srv, Some("admin"), Method::DELETE, "/api/v1/desk/wallpaper", None).await; + assert_eq!(status, 204); + let (_, body, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk", None).await; + assert_eq!(body["preferences"]["wallpaper"], desk::DEFAULT_WALLPAPER); + assert!(body["wallpaper_sha256"].is_null()); +} + +#[ntex::test] +async fn session_moves_by_revision() { + let srv = server(state().await).await; + let (_, empty, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk/session?device=dev-1", None).await; + assert_eq!(empty, serde_json::json!({ "revision": 0, "active_window_id": null, "windows": [] })); + + let (status, body, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/session", json(session(0, 2))).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["revision"], 1); + + // A tab still at 0 is told what is current rather than overwriting it. + let (status, body, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/session", json(session(0, 1))).await; + assert_eq!(status, 409); + assert_eq!(body["error"], "conflict"); + assert_eq!(body["current"]["revision"], 1); + assert_eq!(body["current"]["windows"].as_array().unwrap().len(), 2); + assert_eq!(body["current"]["windows"][0]["app_state"]["path"], "/root"); + + let (status, body, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/session", json(session(1, 1))).await; + assert_eq!(status, 200); + assert_eq!(body["revision"], 2); + let (_, now, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk/session?device=dev-1", None).await; + assert_eq!(now["windows"].as_array().unwrap().len(), 1); + assert_eq!(now["active_window_id"], "w0"); + + // Per account and per device. + let (_, other, _) = call(&srv, Some("intruder"), Method::GET, "/api/v1/desk/session?device=dev-1", None).await; + assert_eq!(other["revision"], 0); + let (_, phone, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk/session?device=dev-2", None).await; + assert_eq!(phone["revision"], 0); +} + +/// Two tabs writing from the same revision at once: exactly one lands. On a +/// file database, where SQLite's locking is what it is in the agent. +#[ntex::test] +async fn two_writes_from_one_revision_land_once() { + let dir = tempfile::tempdir().unwrap(); + let url = format!("sqlite://{}?mode=rwc", dir.path().join("desk.db").display()); + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(4) + .connect_with( + url.parse::() + .unwrap() + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(std::time::Duration::from_secs(5)), + ) + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&db).await.unwrap(); + let srv = server(state_over(db).await).await; + call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/session", json(session(0, 1))).await; + + for round in 1..=10 { + let (a, b) = futures::join!( + call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/session", json(session(round, 2))), + call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/session", json(session(round, 3))), + ); + let mut statuses = [a.0, b.0]; + statuses.sort_unstable(); + assert_eq!(statuses, [200, 409], "round {round}: {} {}", a.1, b.1); + } +} + +#[ntex::test] +async fn a_refused_session_says_why() { + let srv = server(state().await).await; + let mut bad = session(0, 1); + bad["windows"][0]["app_id"] = serde_json::json!("Files!"); + let (status, body, _) = call(&srv, Some("admin"), Method::PUT, "/api/v1/desk/session", json(bad)).await; + assert_eq!(status, 400); + assert_eq!(body, serde_json::json!({ "error": "invalidAppId", "index": 0 })); + let (status, _, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk/session?device=../x", None).await; + assert_eq!(status, 400); +} + +#[ntex::test] +async fn a_deleted_account_takes_its_desk_with_it() { + let state = state().await; + let srv = server(state.clone()).await; + call(&srv, Some("intruder"), Method::PUT, "/api/v1/desk/preferences", json(prefs())).await; + call(&srv, Some("intruder"), Method::PUT, "/api/v1/desk/session", json(session(0, 2))).await; + sqlx::query("DELETE FROM users WHERE username = 'intruder'") + .execute(&state.db) + .await + .unwrap(); + for table in ["desk_preferences", "desk_dock", "desk_icon", "desk_session", "desk_window"] { + let left: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!("SELECT COUNT(*) FROM {table}"))) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(left, 0, "{table}"); + } +} + +#[ntex::test] +async fn notifications_are_read_per_account() { + let state = state().await; + let srv = server(state.clone()).await; + for i in 0..3 { + state + .desk + .notify(&state.db, Level::Warning, Source::Alert, "High CPU", &format!("cpu {i}")) + .await + .unwrap(); + } + let (_, body, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk/notifications", None).await; + assert_eq!(body["unread"], 3); + let list = body["notifications"].as_array().unwrap(); + assert_eq!(list[0]["body"], "cpu 2", "newest first"); + let newest = list[0]["id"].as_i64().unwrap(); + + let (status, _, _) = call( + &srv, + Some("admin"), + Method::POST, + "/api/v1/desk/notifications/read", + json(serde_json::json!({ "ids": [newest, 999_999] })), + ) + .await; + assert_eq!(status, 204); + let (_, body, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk/notifications", None).await; + assert_eq!(body["unread"], 2); + assert_eq!(body["notifications"][0]["read"], true); + let (_, theirs, _) = call(&srv, Some("intruder"), Method::GET, "/api/v1/desk/notifications", None).await; + assert_eq!(theirs["unread"], 3); + + call(&srv, Some("admin"), Method::POST, "/api/v1/desk/notifications/read", json(serde_json::json!({ "all": true }))).await; + let (_, body, _) = call(&srv, Some("admin"), Method::GET, "/api/v1/desk/notifications", None).await; + assert_eq!(body["unread"], 0); +} + +#[ntex::test] +async fn a_rule_notifies_when_it_starts_firing_not_every_cycle() { + let state = state().await; + let firing = || vec![("High CPU".to_string(), "Alert: cpu 99%".to_string())]; + state.desk.rules_checked(&state.db, firing()).await; + state.desk.rules_checked(&state.db, firing()).await; + state.desk.rules_checked(&state.db, vec![]).await; + state.desk.rules_checked(&state.db, firing()).await; + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM desk_notification") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(count, 2); +} + +#[ntex::test] +async fn old_notifications_make_room() { + let state = state().await; + for i in 0..(desk::KEEP_NOTIFICATIONS + 5) { + state + .desk + .notify(&state.db, Level::Info, Source::Alert, "r", &i.to_string()) + .await + .unwrap(); + } + let (count, oldest): (i64, String) = sqlx::query_as( + "SELECT COUNT(*), (SELECT body FROM desk_notification ORDER BY id LIMIT 1) FROM desk_notification", + ) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(count, desk::KEEP_NOTIFICATIONS); + assert_eq!(oldest, "5"); +} + +/// The event stream carries a notification to whoever is reading, and a +/// session write only to its own account. +#[ntex::test] +async fn events_reach_the_right_readers() { + use futures::StreamExt; + + let state = state().await; + let srv = server(state.clone()).await; + let open = |user: &str| { + srv.get("/api/v1/desk/events") + .header("Authorization", format!("Bearer {}", generate_token(user, SECRET).unwrap())) + .send() + }; + let mut admin = open("admin").await.unwrap(); + let mut other = open("intruder").await.unwrap(); + assert_eq!(admin.headers().get("content-type").unwrap(), "text/event-stream"); + + let srv2 = &srv; + call(srv2, Some("admin"), Method::PUT, "/api/v1/desk/session", json(session(0, 1))).await; + state + .desk + .notify(&state.db, Level::Warning, Source::Alert, "High CPU", "cpu 99%") + .await + .unwrap(); + + async fn read_until(resp: &mut ntex::client::ClientResponse, needle: &str) -> String { + let mut seen = String::new(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while !seen.contains(needle) { + let chunk = tokio::time::timeout_at(deadline, resp.next()) + .await + .expect("event in time") + .expect("stream open") + .unwrap(); + seen.push_str(&String::from_utf8_lossy(&chunk)); + } + seen + } + + let seen = read_until(&mut admin, "\"notification\"").await; + assert!(seen.contains("\"type\":\"session\""), "{seen}"); + let seen = read_until(&mut other, "\"notification\"").await; + assert!(!seen.contains("\"type\":\"session\""), "another account's session leaked: {seen}"); +} diff --git a/monitor/tests/migration_upgrade.rs b/monitor/tests/migration_upgrade.rs index 5ea1e3eb27..333873284e 100644 --- a/monitor/tests/migration_upgrade.rs +++ b/monitor/tests/migration_upgrade.rs @@ -693,6 +693,7 @@ fn shipped_migrations_keep_their_checksums() { (15, "3f6636970de6cca7356e0e1107330778898416f76f71bf5aae1bd3baa78e011ca6551f5e2c27f03166c9cdb7ae9d5618"), (16, "7196b34f2a606e66eb4c66659542d023b3d9e2dcb10f859efc0dae770102088963dda18b5cdc93a4c00fbbf54d16f2c1"), (17, "ba885b434ae998eb589c4ea50f2d7a68790e5a44007ddfe50ebed79f767d8a81a04744bf10774e94056e58d51c3920ac"), + (18, "d01153e6b7558e25e007cce38b62ce6d64117f07ba932c1726317b48625c9fdd438ea3b58bc86e0d6b90d421c8dcc77d"), ]; let migrator = sqlx::migrate!("./migrations"); let mut seen = std::collections::BTreeMap::new(); From 4e293c979449159a1a2741f05ce09e21125404ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Wed, 7 Oct 2026 06:00:10 +0800 Subject: [PATCH 003/109] =?UTF-8?q?feat(monitor):=20the=20panel=20as=20a?= =?UTF-8?q?=20desk=20=E2=80=94=20macOS-style=20desktop,=20every=20feature?= =?UTF-8?q?=20an=20app=20in=20a=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- monitor/CLAUDE.md | 1 + monitor/frontend/src/App.svelte | 78 +- .../src/components/FeatureTabs.svelte | 56 -- .../frontend/src/components/LoginForm.svelte | 71 -- .../frontend/src/components/PageHeader.svelte | 63 -- .../src/components/ServerFormModal.svelte | 154 ---- .../frontend/src/components/Sidebar.svelte | 253 ------ .../frontend/src/components/StatCard.svelte | 44 - monitor/frontend/src/desk/Desk.svelte | 169 ++++ monitor/frontend/src/desk/DeskRoot.svelte | 61 ++ monitor/frontend/src/desk/apps.ts | 268 ++++++ .../src/desk/apps/backup/BackupApp.svelte | 219 +++++ .../apps/benchmark/BenchmarkApp.svelte} | 73 +- .../apps/bmc/BmcApp.svelte} | 75 +- .../apps/bmc}/BmcForm.svelte | 12 +- .../apps/containers/ContainersApp.svelte} | 462 +++++----- .../apps/cron/CronApp.svelte} | 142 ++-- .../apps/files}/FileEditor.svelte | 8 +- .../src/desk/apps/files/FilesApp.svelte | 796 ++++++++++++++++++ .../apps/firewall/FirewallApp.svelte} | 105 ++- .../apps/process/ProcessApp.svelte} | 255 +++--- .../apps/remote_desktop}/DesktopForm.svelte | 12 +- .../apps/remote_desktop}/RdpViewer.svelte | 10 +- .../remote_desktop/RemoteDesktopApp.svelte} | 90 +- .../apps/services}/ServiceDetail.svelte | 6 +- .../apps/services/ServicesApp.svelte} | 266 +++--- .../apps/settings}/AccessAdmin.svelte | 10 +- .../desk/apps/settings/AccessSection.svelte | 31 + .../desk/apps/settings/AccountSection.svelte | 25 + .../apps/settings/AppearanceSection.svelte | 213 +++++ .../desk/apps/settings/GeneralSection.svelte | 26 + .../apps/settings}/LocaleToggle.svelte | 6 +- .../apps/settings}/MyAccount.svelte | 10 +- .../apps/settings}/PushChannels.svelte | 16 +- .../apps/settings}/ReauthDialog.svelte | 4 +- .../apps/settings}/RoleEditor.svelte | 6 +- .../apps/settings/ServerSection.svelte} | 75 +- .../src/desk/apps/settings/SettingsApp.svelte | 97 +++ .../apps/settings}/ThemeToggle.svelte | 4 +- .../src/desk/apps/settings/sections.ts | 20 + .../apps/snippets}/SnippetForm.svelte | 6 +- .../apps/snippets/SnippetsApp.svelte} | 95 +-- .../apps/status}/DetailPanel.svelte | 36 +- .../apps/status}/IperfModal.svelte | 8 +- .../apps/status}/PowerModal.svelte | 6 +- .../src/desk/apps/status/StatCard.svelte | 48 ++ .../apps/status/StatusApp.svelte} | 219 ++--- .../apps/system_users/SystemUsersApp.svelte} | 165 ++-- .../apps/system_users}/UserForm.svelte | 12 +- .../apps/system_users}/UserSecurity.svelte | 8 +- .../src/desk/apps/terminal/TerminalApp.svelte | 724 ++++++++++++++++ .../apps/virt}/PveForm.svelte | 12 +- .../apps/virt/VirtApp.svelte} | 164 ++-- .../apps/virt}/VirtBackupJobForm.svelte | 10 +- .../apps/virt}/VirtBackupJobs.svelte | 10 +- .../apps/virt}/VirtBackups.svelte | 12 +- .../apps/virt}/VirtConsole.svelte | 16 +- .../apps/virt}/VirtCreate.svelte | 10 +- .../apps/virt}/VirtHardware.svelte | 10 +- .../apps/virt}/VirtManage.svelte | 8 +- .../apps/virt}/VirtNetworks.svelte | 8 +- .../apps/virt}/VirtSnapshots.svelte | 8 +- .../apps/virt}/VirtStorage.svelte | 12 +- monitor/frontend/src/desk/calendar.ts | 22 + monitor/frontend/src/desk/desk.css | 246 ++++++ monitor/frontend/src/desk/deskApi.ts | 189 +++++ monitor/frontend/src/desk/deskState.svelte.ts | 241 ++++++ monitor/frontend/src/desk/geometry.ts | 119 +++ monitor/frontend/src/desk/iconGrid.ts | 46 + .../frontend/src/desk/lock/LockScreen.svelte | 236 ++++++ .../frontend/src/desk/notifications.svelte.ts | 93 ++ monitor/frontend/src/desk/prefs.svelte.ts | 139 +++ monitor/frontend/src/desk/session.svelte.ts | 121 +++ .../frontend/src/desk/shell/AppIcon.svelte | 22 + monitor/frontend/src/desk/shell/Banner.svelte | 27 + .../src/desk/shell/CalendarPanel.svelte | 52 ++ .../src/desk/shell/ContextMenu.svelte | 82 ++ .../src/desk/shell/ControlCenter.svelte | 109 +++ .../frontend/src/desk/shell/DeskIcons.svelte | 158 ++++ monitor/frontend/src/desk/shell/Dock.svelte | 143 ++++ .../frontend/src/desk/shell/Launchpad.svelte | 73 ++ .../frontend/src/desk/shell/Menubar.svelte | 163 ++++ .../src/desk/shell/NotificationCenter.svelte | 58 ++ .../frontend/src/desk/shell/Spotlight.svelte | 165 ++++ .../frontend/src/desk/shell/Wallpaper.svelte | 59 ++ monitor/frontend/src/desk/storage.ts | 138 +++ .../frontend/src/desk/ui/AppToolbar.svelte | 50 ++ .../frontend/src/desk/ui/SourceGroup.svelte | 19 + .../frontend/src/desk/ui/SourceItem.svelte | 28 + monitor/frontend/src/desk/ui/SplitView.svelte | 44 + .../frontend/src/desk/window/Window.svelte | 238 ++++++ .../src/desk/window/WindowLayer.svelte | 33 + monitor/frontend/src/desk/windows.svelte.ts | 298 +++++++ monitor/frontend/src/i18n/de/index.ts | 84 ++ monitor/frontend/src/i18n/en/index.ts | 84 ++ monitor/frontend/src/i18n/es/index.ts | 84 ++ monitor/frontend/src/i18n/fr/index.ts | 84 ++ monitor/frontend/src/i18n/i18n-types.ts | 673 +++++++++++++++ monitor/frontend/src/i18n/id/index.ts | 84 ++ monitor/frontend/src/i18n/it/index.ts | 84 ++ monitor/frontend/src/i18n/ja/index.ts | 84 ++ monitor/frontend/src/i18n/ko/index.ts | 84 ++ monitor/frontend/src/i18n/nl/index.ts | 84 ++ monitor/frontend/src/i18n/pt/index.ts | 84 ++ monitor/frontend/src/i18n/ru/index.ts | 84 ++ monitor/frontend/src/i18n/tr/index.ts | 84 ++ monitor/frontend/src/i18n/uk/index.ts | 84 ++ monitor/frontend/src/i18n/zh-CN/index.ts | 84 ++ monitor/frontend/src/i18n/zh-TW/index.ts | 84 ++ monitor/frontend/src/index.css | 10 +- monitor/frontend/src/lib/access.ts | 2 +- monitor/frontend/src/lib/api.ts | 20 +- .../frontend/src/lib/capabilities.svelte.ts | 4 +- monitor/frontend/src/lib/features.ts | 11 +- monitor/frontend/src/lib/health.svelte.ts | 2 +- monitor/frontend/src/lib/layout.svelte.ts | 40 - monitor/frontend/src/lib/serverSearch.ts | 4 +- monitor/frontend/src/lib/terminal.svelte.ts | 25 +- monitor/frontend/src/pages/Backup.svelte | 196 ----- monitor/frontend/src/pages/Files.svelte | 442 ---------- .../frontend/src/pages/PanelSettings.svelte | 25 - monitor/frontend/src/pages/Terminal.svelte | 606 ------------- monitor/frontend/src/tests/benchmark.test.ts | 6 +- monitor/frontend/src/tests/bmc.test.ts | 8 +- monitor/frontend/src/tests/containers.test.ts | 22 +- monitor/frontend/src/tests/cron.test.ts | 6 +- monitor/frontend/src/tests/dashboard.test.ts | 2 +- .../frontend/src/tests/deskSession.test.ts | 194 +++++ .../frontend/src/tests/deskWindows.test.ts | 156 ++++ monitor/frontend/src/tests/desktop.test.ts | 2 +- .../frontend/src/tests/desktopPage.test.ts | 8 +- monitor/frontend/src/tests/fileEditor.test.ts | 118 ++- monitor/frontend/src/tests/firewall.test.ts | 16 +- .../fixtures/SettingsWindowHarness.svelte | 22 + .../src/tests/helpers/fakeServers.svelte.ts | 31 - monitor/frontend/src/tests/iperf.test.ts | 2 +- monitor/frontend/src/tests/lockScreen.test.ts | 98 +++ monitor/frontend/src/tests/login.test.ts | 59 -- monitor/frontend/src/tests/power.test.ts | 2 +- monitor/frontend/src/tests/process.test.ts | 4 +- .../frontend/src/tests/serverSearch.test.ts | 139 +-- monitor/frontend/src/tests/services.test.ts | 20 +- .../frontend/src/tests/settingsApp.test.ts | 150 ++++ monitor/frontend/src/tests/snippets.test.ts | 24 +- .../frontend/src/tests/systemUsers.test.ts | 8 +- .../frontend/src/tests/terminalTmux.test.ts | 5 +- monitor/frontend/src/tests/virt.test.ts | 40 +- .../frontend/src/tests/virtBackups.test.ts | 12 +- .../frontend/src/tests/virtHardware.test.ts | 4 +- monitor/frontend/src/types/index.ts | 2 + monitor/frontend/src/types/ironrdp.d.ts | 2 +- monitor/src/api/desk.rs | 5 +- monitor/tests/desk.rs | 2 +- packages/webui/src/theme.css | 4 + 155 files changed, 10234 insertions(+), 3571 deletions(-) delete mode 100644 monitor/frontend/src/components/FeatureTabs.svelte delete mode 100644 monitor/frontend/src/components/LoginForm.svelte delete mode 100644 monitor/frontend/src/components/PageHeader.svelte delete mode 100644 monitor/frontend/src/components/ServerFormModal.svelte delete mode 100644 monitor/frontend/src/components/Sidebar.svelte delete mode 100644 monitor/frontend/src/components/StatCard.svelte create mode 100644 monitor/frontend/src/desk/Desk.svelte create mode 100644 monitor/frontend/src/desk/DeskRoot.svelte create mode 100644 monitor/frontend/src/desk/apps.ts create mode 100644 monitor/frontend/src/desk/apps/backup/BackupApp.svelte rename monitor/frontend/src/{pages/Benchmark.svelte => desk/apps/benchmark/BenchmarkApp.svelte} (92%) rename monitor/frontend/src/{pages/Bmc.svelte => desk/apps/bmc/BmcApp.svelte} (90%) rename monitor/frontend/src/{components => desk/apps/bmc}/BmcForm.svelte (94%) rename monitor/frontend/src/{pages/Containers.svelte => desk/apps/containers/ContainersApp.svelte} (66%) rename monitor/frontend/src/{pages/Cron.svelte => desk/apps/cron/CronApp.svelte} (69%) rename monitor/frontend/src/{components => desk/apps/files}/FileEditor.svelte (97%) create mode 100644 monitor/frontend/src/desk/apps/files/FilesApp.svelte rename monitor/frontend/src/{pages/Firewall.svelte => desk/apps/firewall/FirewallApp.svelte} (91%) rename monitor/frontend/src/{pages/Process.svelte => desk/apps/process/ProcessApp.svelte} (63%) rename monitor/frontend/src/{components => desk/apps/remote_desktop}/DesktopForm.svelte (95%) rename monitor/frontend/src/{components => desk/apps/remote_desktop}/RdpViewer.svelte (96%) rename monitor/frontend/src/{pages/Desktop.svelte => desk/apps/remote_desktop/RemoteDesktopApp.svelte} (84%) rename monitor/frontend/src/{components => desk/apps/services}/ServiceDetail.svelte (96%) rename monitor/frontend/src/{pages/Services.svelte => desk/apps/services/ServicesApp.svelte} (64%) rename monitor/frontend/src/{components => desk/apps/settings}/AccessAdmin.svelte (96%) create mode 100644 monitor/frontend/src/desk/apps/settings/AccessSection.svelte create mode 100644 monitor/frontend/src/desk/apps/settings/AccountSection.svelte create mode 100644 monitor/frontend/src/desk/apps/settings/AppearanceSection.svelte create mode 100644 monitor/frontend/src/desk/apps/settings/GeneralSection.svelte rename monitor/frontend/src/{components => desk/apps/settings}/LocaleToggle.svelte (84%) rename monitor/frontend/src/{components => desk/apps/settings}/MyAccount.svelte (90%) rename monitor/frontend/src/{components => desk/apps/settings}/PushChannels.svelte (97%) rename monitor/frontend/src/{components => desk/apps/settings}/ReauthDialog.svelte (95%) rename monitor/frontend/src/{components => desk/apps/settings}/RoleEditor.svelte (97%) rename monitor/frontend/src/{pages/ServerSettings.svelte => desk/apps/settings/ServerSection.svelte} (90%) create mode 100644 monitor/frontend/src/desk/apps/settings/SettingsApp.svelte rename monitor/frontend/src/{components => desk/apps/settings}/ThemeToggle.svelte (91%) create mode 100644 monitor/frontend/src/desk/apps/settings/sections.ts rename monitor/frontend/src/{components => desk/apps/snippets}/SnippetForm.svelte (97%) rename monitor/frontend/src/{pages/Snippets.svelte => desk/apps/snippets/SnippetsApp.svelte} (81%) rename monitor/frontend/src/{components => desk/apps/status}/DetailPanel.svelte (92%) rename monitor/frontend/src/{components => desk/apps/status}/IperfModal.svelte (90%) rename monitor/frontend/src/{components => desk/apps/status}/PowerModal.svelte (96%) create mode 100644 monitor/frontend/src/desk/apps/status/StatCard.svelte rename monitor/frontend/src/{pages/Dashboard.svelte => desk/apps/status/StatusApp.svelte} (77%) rename monitor/frontend/src/{pages/SystemUsers.svelte => desk/apps/system_users/SystemUsersApp.svelte} (72%) rename monitor/frontend/src/{components => desk/apps/system_users}/UserForm.svelte (96%) rename monitor/frontend/src/{components => desk/apps/system_users}/UserSecurity.svelte (96%) create mode 100644 monitor/frontend/src/desk/apps/terminal/TerminalApp.svelte rename monitor/frontend/src/{components => desk/apps/virt}/PveForm.svelte (94%) rename monitor/frontend/src/{pages/Virt.svelte => desk/apps/virt/VirtApp.svelte} (87%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtBackupJobForm.svelte (97%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtBackupJobs.svelte (97%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtBackups.svelte (98%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtConsole.svelte (92%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtCreate.svelte (98%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtHardware.svelte (99%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtManage.svelte (98%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtNetworks.svelte (98%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtSnapshots.svelte (98%) rename monitor/frontend/src/{components => desk/apps/virt}/VirtStorage.svelte (98%) create mode 100644 monitor/frontend/src/desk/calendar.ts create mode 100644 monitor/frontend/src/desk/desk.css create mode 100644 monitor/frontend/src/desk/deskApi.ts create mode 100644 monitor/frontend/src/desk/deskState.svelte.ts create mode 100644 monitor/frontend/src/desk/geometry.ts create mode 100644 monitor/frontend/src/desk/iconGrid.ts create mode 100644 monitor/frontend/src/desk/lock/LockScreen.svelte create mode 100644 monitor/frontend/src/desk/notifications.svelte.ts create mode 100644 monitor/frontend/src/desk/prefs.svelte.ts create mode 100644 monitor/frontend/src/desk/session.svelte.ts create mode 100644 monitor/frontend/src/desk/shell/AppIcon.svelte create mode 100644 monitor/frontend/src/desk/shell/Banner.svelte create mode 100644 monitor/frontend/src/desk/shell/CalendarPanel.svelte create mode 100644 monitor/frontend/src/desk/shell/ContextMenu.svelte create mode 100644 monitor/frontend/src/desk/shell/ControlCenter.svelte create mode 100644 monitor/frontend/src/desk/shell/DeskIcons.svelte create mode 100644 monitor/frontend/src/desk/shell/Dock.svelte create mode 100644 monitor/frontend/src/desk/shell/Launchpad.svelte create mode 100644 monitor/frontend/src/desk/shell/Menubar.svelte create mode 100644 monitor/frontend/src/desk/shell/NotificationCenter.svelte create mode 100644 monitor/frontend/src/desk/shell/Spotlight.svelte create mode 100644 monitor/frontend/src/desk/shell/Wallpaper.svelte create mode 100644 monitor/frontend/src/desk/storage.ts create mode 100644 monitor/frontend/src/desk/ui/AppToolbar.svelte create mode 100644 monitor/frontend/src/desk/ui/SourceGroup.svelte create mode 100644 monitor/frontend/src/desk/ui/SourceItem.svelte create mode 100644 monitor/frontend/src/desk/ui/SplitView.svelte create mode 100644 monitor/frontend/src/desk/window/Window.svelte create mode 100644 monitor/frontend/src/desk/window/WindowLayer.svelte create mode 100644 monitor/frontend/src/desk/windows.svelte.ts delete mode 100644 monitor/frontend/src/lib/layout.svelte.ts delete mode 100644 monitor/frontend/src/pages/Backup.svelte delete mode 100644 monitor/frontend/src/pages/Files.svelte delete mode 100644 monitor/frontend/src/pages/PanelSettings.svelte delete mode 100644 monitor/frontend/src/pages/Terminal.svelte create mode 100644 monitor/frontend/src/tests/deskSession.test.ts create mode 100644 monitor/frontend/src/tests/deskWindows.test.ts create mode 100644 monitor/frontend/src/tests/fixtures/SettingsWindowHarness.svelte delete mode 100644 monitor/frontend/src/tests/helpers/fakeServers.svelte.ts create mode 100644 monitor/frontend/src/tests/lockScreen.test.ts delete mode 100644 monitor/frontend/src/tests/login.test.ts create mode 100644 monitor/frontend/src/tests/settingsApp.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 325abfab88..2ac2a5b217 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,4 +89,4 @@ A server is reached over SSH, a monitor agent, both, or is this device (`Spi.loc ## Feature notes -`crates/sbm_ffi/CLAUDE.md` (SSH crypto) · `ios/CLAUDE.md` · `macos/CLAUDE.md` · `android/CLAUDE.md` · `lib/data/store/CLAUDE.md` · `lib/data/model/file/CLAUDE.md` (SFTP/SCP) · `lib/data/model/server/benchmark/CLAUDE.md` · `lib/core/service/CLAUDE.md` (watch, home widgets) · `lib/view/page/server/monitor_settings/CLAUDE.md` · `docs/dev/virt.md` · `monitor/CLAUDE.md`. +`crates/sbm_ffi/CLAUDE.md` (SSH crypto) · `ios/CLAUDE.md` · `macos/CLAUDE.md` · `android/CLAUDE.md` · `lib/data/store/CLAUDE.md` · `lib/data/model/file/CLAUDE.md` (SFTP/SCP) · `lib/data/model/server/benchmark/CLAUDE.md` · `lib/core/service/CLAUDE.md` (watch, home widgets) · `lib/view/page/server/monitor_settings/CLAUDE.md` · `docs/dev/virt.md` · `monitor/CLAUDE.md` · `monitor/frontend/src/desk/CLAUDE.md` (the panel's desk). diff --git a/monitor/CLAUDE.md b/monitor/CLAUDE.md index 247d4fc464..5139deb70d 100644 --- a/monitor/CLAUDE.md +++ b/monitor/CLAUDE.md @@ -29,6 +29,7 @@ cargo sqlx migrate run / cargo sqlx prepare # migrations; offline query cache in ## Panel +- **The panel is a desk** (a macOS-style web desktop, every feature an app in a window): `frontend/src/desk/CLAUDE.md`. - **State and UI only.** The panel never composes a shell command or parses output: it calls an agent endpoint, which builds and parses through `sbm_parser` (the app reaches the same functions over FFI). Sending text to `/exec` and parsing it in TypeScript is ruled out. - The terminal store (`lib/terminal.svelte.ts`) owns protocol and reconnect and knows nothing about xterm.js; xterm is a dynamic import (`lib/xterm.ts`). Dialog terminals (`TargetTerminal`) use `persist: false` and `close()` on destroy, never the page's stored handle. An explicit open forgets the stored handle; only Resume rejoins it. - An answer that arrives after the sidebar switched servers is dropped (`stale(serverId)` pattern); anything that writes binds to the server it was opened on. diff --git a/monitor/frontend/src/App.svelte b/monitor/frontend/src/App.svelte index 8743e98cdf..bd936129f5 100644 --- a/monitor/frontend/src/App.svelte +++ b/monitor/frontend/src/App.svelte @@ -1,79 +1,5 @@ -
-
- -
- {#key pageKey} - {@const back = layout.navDirection === 'back'} -
- {#if layout.view === 'panel'} - - {:else if layout.view === 'server-settings'} - layout.back('dashboard')} /> - {:else if layout.view === 'terminal'} - - {:else if layout.view === 'files'} - - {:else if layout.view === 'process'} - layout.back('dashboard')} /> - {:else if layout.view === 'services'} - layout.back('dashboard')} /> - {:else if layout.view === 'cron'} - layout.back('dashboard')} /> - {:else if layout.view === 'containers'} - layout.back('dashboard')} /> - {:else if layout.view === 'benchmark'} - layout.back('dashboard')} /> - {:else if layout.view === 'system_users'} - layout.back('dashboard')} /> - {:else if layout.view === 'firewall'} - layout.back('dashboard')} /> - {:else if layout.view === 'snippets'} - layout.back('dashboard')} /> - {:else if layout.view === 'desktop'} - layout.back('dashboard')} /> - {:else if layout.view === 'virt'} - layout.back('dashboard')} /> - {:else if layout.view === 'bmc'} - layout.back('dashboard')} /> - {:else if layout.view === 'backup'} - layout.back('dashboard')} /> - {:else} - - {/if} -
- {/key} -
-
-
+ diff --git a/monitor/frontend/src/components/FeatureTabs.svelte b/monitor/frontend/src/components/FeatureTabs.svelte deleted file mode 100644 index 4fff2c286d..0000000000 --- a/monitor/frontend/src/components/FeatureTabs.svelte +++ /dev/null @@ -1,56 +0,0 @@ - - - -{#if served.length > 0} - -{/if} diff --git a/monitor/frontend/src/components/LoginForm.svelte b/monitor/frontend/src/components/LoginForm.svelte deleted file mode 100644 index fa7fe0ef8b..0000000000 --- a/monitor/frontend/src/components/LoginForm.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -
- {#if error} -
-
- -

{error}

-
-
- {/if} - -
- - -
-
- - -
- - -
diff --git a/monitor/frontend/src/components/PageHeader.svelte b/monitor/frontend/src/components/PageHeader.svelte deleted file mode 100644 index faf8cd8635..0000000000 --- a/monitor/frontend/src/components/PageHeader.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - - -
-
-
- {#if onback} - - - - {:else} - (layout.mobileOpen = true)}> - - - {/if} -
- {#if titleIcon}{@render titleIcon()}{/if} -
-

{title}

- {#if subtitle} -

{subtitle}

- {/if} -
-
- - {#if actions}{@render actions()}{/if} -
- {#if tabs}{@render tabs()}{/if} -
-
diff --git a/monitor/frontend/src/components/ServerFormModal.svelte b/monitor/frontend/src/components/ServerFormModal.svelte deleted file mode 100644 index 790479f43e..0000000000 --- a/monitor/frontend/src/components/ServerFormModal.svelte +++ /dev/null @@ -1,154 +0,0 @@ - - - -
-
- - (testState = 'idle')} - /> -
- -
- - {#if testState === 'ok'} - {$LL.connected()} - {:else if testState === 'fail'} - {$LL.disconnected()} - {/if} -
- -
- - -
-
- - -
- {#if loginError} -

{loginError}

- {/if} - -
- {#if entry && entry.id !== 'local'} - - - - {:else} - - {/if} -
- - -
-
-
-
diff --git a/monitor/frontend/src/components/Sidebar.svelte b/monitor/frontend/src/components/Sidebar.svelte deleted file mode 100644 index 97c667d629..0000000000 --- a/monitor/frontend/src/components/Sidebar.svelte +++ /dev/null @@ -1,253 +0,0 @@ - - - - -
(layout.mobileOpen = false)} ->
- - - - (layout.addServerOpen = false)} -/> diff --git a/monitor/frontend/src/components/StatCard.svelte b/monitor/frontend/src/components/StatCard.svelte deleted file mode 100644 index 45b23db74d..0000000000 --- a/monitor/frontend/src/components/StatCard.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - -
-
- -

{label}

-
- {#if onclick} - - {/if} -
-

{value}

-

{detail || ' '}

-
diff --git a/monitor/frontend/src/desk/Desk.svelte b/monitor/frontend/src/desk/Desk.svelte new file mode 100644 index 0000000000..5189aeec2d --- /dev/null +++ b/monitor/frontend/src/desk/Desk.svelte @@ -0,0 +1,169 @@ + + + + +
{ + // Only the bare desk: a window, an icon or a bar has its own menu or none. + if ((e.target as HTMLElement).closest('[data-desk-background]')) deskMenu(e) + }} +> +
+ +
+ {#if desk.ready && !desk.windows.compact} + + {/if} + {#if desk.ready} + + {:else} +
+ {/if} + + + + + {#if desk.panel === 'launchpad'} + + {:else if desk.panel} + + {/if} + + {#if desk.spotlight} + + {/if} + + +
diff --git a/monitor/frontend/src/desk/DeskRoot.svelte b/monitor/frontend/src/desk/DeskRoot.svelte new file mode 100644 index 0000000000..0187358619 --- /dev/null +++ b/monitor/frontend/src/desk/DeskRoot.svelte @@ -0,0 +1,61 @@ + + +{#if servers.current?.token} + {#key `${servers.current.id}:${servers.current.token}`} + (locked = true)} onswitch={switchTo} /> + {/key} +{/if} +{#if locked || !servers.current?.token} + +{/if} diff --git a/monitor/frontend/src/desk/apps.ts b/monitor/frontend/src/desk/apps.ts new file mode 100644 index 0000000000..b813420826 --- /dev/null +++ b/monitor/frontend/src/desk/apps.ts @@ -0,0 +1,268 @@ +/// The desk's apps: one entry each, the only list. The dock, the launchpad, +/// Spotlight, the window manager and the session all read it; an app is added +/// here and nowhere else (see `desk/CLAUDE.md`). + +import type { Component } from 'svelte' +import { + Activity, + ArchiveRestore, + Boxes, + BrickWall, + CalendarClock, + Container, + Cpu, + FolderOpen, + Gauge, + type LucideIcon, + MonitorPlay, + ScrollText, + ServerCog, + Settings, + SquareTerminal, + Users, + ChartNoAxesCombined, +} from '@lucide/svelte' +import type { TranslationFunctions } from '../i18n/i18n-types' +import { dashboardAccess } from '../lib/access' +import { enabledFeatures, type FeatureId } from '../lib/features' +import type { Capabilities } from '../types' +import type { WindowPolicy } from './windows.svelte' + +export type AppId = + | 'status' + | 'files' + | 'terminal' + | 'containers' + | 'process' + | 'services' + | 'cron' + | 'system_users' + | 'firewall' + | 'snippets' + | 'remote_desktop' + | 'benchmark' + | 'virt' + | 'bmc' + | 'backup' + | 'settings' + +export interface AppSpec extends WindowPolicy { + id: AppId + title: (ll: TranslationFunctions) => string + /// Words Spotlight also finds it by, besides its title. + keywords?: (ll: TranslationFunctions) => string[] + icon: LucideIcon + /// The icon tile's two gradient stops, top to bottom. + tint: [string, string] + /// Whether this server and this account can use it. `undefined` + /// capabilities (not fetched yet) answer false for anything gated. + available: (caps: Capabilities | undefined) => boolean + /// The window's content. Lazy, so an app nobody opens is never loaded. + load: () => Promise<{ default: Component }> +} + +const always = () => true + +function feature(id: FeatureId) { + return (caps: Capabilities | undefined) => enabledFeatures(caps).some((f) => f.id === id) +} + +const SIZE = { width: 1040, height: 680 } +const MIN = { width: 420, height: 300 } + +export const APPS: AppSpec[] = [ + { + id: 'status', + title: (ll) => ll.deskAppStatus(), + keywords: (ll) => [ll.cpuUsage(), ll.memory(), ll.diskUsage(), ll.network()], + icon: ChartNoAxesCombined, + tint: ['#34d399', '#059669'], + available: always, + instances: 1, + size: { width: 1100, height: 720 }, + minSize: MIN, + load: () => import('./apps/status/StatusApp.svelte'), + }, + { + id: 'files', + title: (ll) => ll.files(), + icon: FolderOpen, + tint: ['#60a5fa', '#2563eb'], + available: (caps) => dashboardAccess(caps).files, + instances: 4, + size: SIZE, + minSize: MIN, + load: () => import('./apps/files/FilesApp.svelte'), + }, + { + id: 'terminal', + title: (ll) => ll.terminal(), + icon: SquareTerminal, + tint: ['#525252', '#171717'], + available: (caps) => dashboardAccess(caps).terminal, + instances: 6, + size: { width: 860, height: 540 }, + minSize: { width: 360, height: 220 }, + load: () => import('./apps/terminal/TerminalApp.svelte'), + }, + { + id: 'containers', + title: (ll) => ll.containers(), + icon: Container, + tint: ['#38bdf8', '#0369a1'], + available: feature('containers'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/containers/ContainersApp.svelte'), + }, + { + id: 'process', + title: (ll) => ll.processes(), + icon: Activity, + tint: ['#fb923c', '#c2410c'], + available: feature('process'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/process/ProcessApp.svelte'), + }, + { + id: 'services', + title: (ll) => ll.services(), + icon: ServerCog, + tint: ['#a78bfa', '#6d28d9'], + available: feature('services'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/services/ServicesApp.svelte'), + }, + { + id: 'cron', + title: (ll) => ll.cron(), + icon: CalendarClock, + tint: ['#f472b6', '#be185d'], + available: feature('cron'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/cron/CronApp.svelte'), + }, + { + id: 'system_users', + title: (ll) => ll.systemUsers(), + icon: Users, + tint: ['#fbbf24', '#b45309'], + available: feature('system_users'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/system_users/SystemUsersApp.svelte'), + }, + { + id: 'firewall', + title: (ll) => ll.fwTitle(), + icon: BrickWall, + tint: ['#f87171', '#b91c1c'], + available: feature('firewall'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/firewall/FirewallApp.svelte'), + }, + { + id: 'snippets', + title: (ll) => ll.snippets(), + icon: ScrollText, + tint: ['#2dd4bf', '#0f766e'], + available: feature('snippets'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/snippets/SnippetsApp.svelte'), + }, + { + id: 'remote_desktop', + title: (ll) => ll.desktop(), + keywords: () => ['VNC', 'RDP'], + icon: MonitorPlay, + tint: ['#818cf8', '#4338ca'], + available: feature('desktop'), + instances: 4, + size: { width: 1180, height: 760 }, + minSize: MIN, + load: () => import('./apps/remote_desktop/RemoteDesktopApp.svelte'), + }, + { + id: 'benchmark', + title: (ll) => ll.benchmark(), + icon: Gauge, + tint: ['#facc15', '#a16207'], + available: feature('benchmark'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/benchmark/BenchmarkApp.svelte'), + }, + { + id: 'virt', + title: (ll) => ll.virt(), + keywords: () => ['KVM', 'libvirt', 'Proxmox', 'PVE'], + icon: Boxes, + tint: ['#c084fc', '#7e22ce'], + available: feature('virt'), + instances: 1, + size: { width: 1180, height: 760 }, + minSize: MIN, + load: () => import('./apps/virt/VirtApp.svelte'), + }, + { + id: 'bmc', + title: (ll) => ll.bmc(), + keywords: () => ['IPMI', 'Redfish'], + icon: Cpu, + tint: ['#94a3b8', '#334155'], + available: feature('bmc'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/bmc/BmcApp.svelte'), + }, + { + id: 'backup', + title: (ll) => ll.backup(), + icon: ArchiveRestore, + tint: ['#4ade80', '#15803d'], + available: feature('backup'), + instances: 1, + size: SIZE, + minSize: MIN, + load: () => import('./apps/backup/BackupApp.svelte'), + }, + { + id: 'settings', + title: (ll) => ll.deskAppSettings(), + icon: Settings, + tint: ['#a3a3a3', '#525252'], + available: always, + instances: 1, + size: { width: 900, height: 640 }, + minSize: MIN, + load: () => import('./apps/settings/SettingsApp.svelte'), + }, +] + +const BY_ID = new Map(APPS.map((a) => [a.id, a])) + +export function app(id: string): AppSpec | undefined { + return BY_ID.get(id) +} + +/// The apps this server and account can use, in the registry's order. +export function availableApps(caps: Capabilities | undefined): AppSpec[] { + return APPS.filter((a) => a.available(caps)) +} + +/// What a fresh desk's dock holds, before anyone arranged it. +export const DEFAULT_DOCK: AppId[] = ['status', 'files', 'terminal', 'containers', 'virt', 'settings'] diff --git a/monitor/frontend/src/desk/apps/backup/BackupApp.svelte b/monitor/frontend/src/desk/apps/backup/BackupApp.svelte new file mode 100644 index 0000000000..0bd5d41793 --- /dev/null +++ b/monitor/frontend/src/desk/apps/backup/BackupApp.svelte @@ -0,0 +1,219 @@ + + +
+ + {#snippet actions()} + uploadInput?.click()}> + + + void load()}> + + + {/snippet} + + + + +
+
+ {#if error} + +

{error}

+
+ {/if} + {#if notice} + +

{notice}

+
+ {/if} + + +
+ +

{$LL.backupHowItWorks()}

+
+

{$LL.backupWhatItIs()}

+
+ + {#if loading && !view} + + {:else if view} +
+
+ +

{$LL.backupStoredFiles()}

+

{blobs.length}

+
+ +

{$LL.backupTotalSize()}

+

{fmtBytes(storedBytes)}

+
+ +

{$LL.backupPerFileLimit()}

+

{fmtBytes(view.max_bytes)}

+
+
+
+

{$LL.backupStoredFiles()}

+ {blobs.length} +
+ {#if blobs.length === 0} + +

{$LL.backupEmpty()}

+
+ {:else} +
+ {#each blobs as entry (entry.name)} +
+ + + +
+

+ {entry.name} +

+

+ {fmtBytes(entry.size)} · {fmtTime(entry.updated_at, { withDate: true })} +

+
+ {#if entry.name === APP_BACKUP_NAME} + {$LL.backupAppFile()} + {/if} + {#if busy === entry.name} + + {/if} +
+ download(entry)}> + + + (removing = entry)}> + + +
+
+ {/each} +
+ {/if} +
+ {/if} +
+
+
+ +{#if removing} + {@const target = removing} + (removing = null)}> +
+

{$LL.backupConfirmRemove({ name: target.name })}

+
+ + +
+
+
+{/if} diff --git a/monitor/frontend/src/pages/Benchmark.svelte b/monitor/frontend/src/desk/apps/benchmark/BenchmarkApp.svelte similarity index 92% rename from monitor/frontend/src/pages/Benchmark.svelte rename to monitor/frontend/src/desk/apps/benchmark/BenchmarkApp.svelte index 9a2b598ac9..5aa9b5b318 100644 --- a/monitor/frontend/src/pages/Benchmark.svelte +++ b/monitor/frontend/src/desk/apps/benchmark/BenchmarkApp.svelte @@ -1,21 +1,14 @@ - - {#snippet tabs()} - - {/snippet} - + {#snippet actions()} void refresh()} disabled={loading}> {/snippet} - + -
+
{#if error}

{error}

@@ -353,6 +337,19 @@
{/if} +
+ {#each [ + { label: $LL.benchmarkStatusRunning(), value: running ? 1 : 0 }, + { label: $LL.benchmarkStatusCompleted(), value: finished.filter((run) => run.status === 'completed').length }, + { label: $LL.benchmarkStatusFailed(), value: finished.filter((run) => run.status === 'failed').length }, + ] as stat (stat.label)} + +

{stat.label}

+

{stat.value}

+
+ {/each} +
+ {#if running && live} - {#if actionOutput} - -
{actionOutput}
-
- {/if} - {:else} - - - {#if actionExit === undefined} -

{$LL.containerActionUnfinished()}

- {:else} -

{$LL.containerActionFailed({ code: actionExit ?? '?' })}

- {/if} - {#if actionOutput} -
{actionOutput}
- {/if} -
- {/if} - - {#if loading && !view} - - {:else if view && !view.available} - -

{reasonText(view)}

- {#if view.reason} -
{view.reason}
+ + + +
+ + {#snippet sidebar()} + + (tab = 'containers')} + /> + (tab = 'images')} + /> + + {/snippet} + +
+ {#if error} + +

{error}

+
{/if} - - {:else if view} - {#if usage?.usage} - -

- {$LL.containerUsage({ - images: usage.usage.image_count ?? 0, - reclaimable: fmtBytes(usage.usage.reclaimable_bytes ?? 0), - })} -

-
- {/if} -
- {#each TABS as name (name)} - - {/each} -
+ {#if actionExit === 0} + + {#if actionOutput} + +
{actionOutput}
+
+ {/if} + {:else} + + + {#if actionExit === undefined} +

{$LL.containerActionUnfinished()}

+ {:else} +

{$LL.containerActionFailed({ code: actionExit ?? '?' })}

+ {/if} + {#if actionOutput} +
{actionOutput}
+ {/if} +
+ {/if} - {#if tab === 'containers'} - {#if rows.length === 0} - -

{$LL.containerEmpty()}

+ {#if loading && !view} + + {:else if view && !view.available} + +

{reasonText(view)}

+ {#if view.reason} +
{view.reason}
+ {/if}
- {:else} - - {#each rows as row (row.id ?? row.name)} -
-
- - {row.name ?? row.id ?? $LL.containerUnknown()} - - {statusText(row)} - {#if row.project} - {row.project} - {/if} - - {#if !busy} -
- {#each lifecycle(row) as kind (kind)} - {@const button = BUTTONS[kind]} - {#if button} - {@const ActionIcon = button.icon} - click(kind, row)}> - - - {/if} - {/each} + {:else if view} + {#if usage?.usage} + + +

+ {$LL.containerUsage({ + images: usage.usage.image_count ?? 0, + reclaimable: fmtBytes(usage.usage.reclaimable_bytes ?? 0), + })} +

+
+ {/if} + + {#if tab === 'containers'} + {#if rows.length === 0} + +

{$LL.containerEmpty()}

+
+ {:else} + +
    + {#each rows as row (row.id ?? row.name)} +
  • +
    + + + {row.name ?? row.id ?? $LL.containerUnknown()} + + {statusText(row)} + {#if row.project} + {row.project} + {/if} + {#if !busy} +
    + {#each lifecycle(row) as kind (kind)} + {@const button = BUTTONS[kind]} + {#if button} + {@const ActionIcon = button.icon} + click(kind, row)}> + + + {/if} + {/each} +
    + {/if}
    - {/if} -
-

- {row.image ?? $LL.containerUnknown()} - {#if row.ports} - · {row.ports} - {/if} - {#if row.raw_status} - · {row.raw_status} - {/if} -

- {#if row.stats} -

- {$LL.containerCpu()}: {row.stats.cpu ?? '—'} - {#if row.stats.cpu_avg} - / {row.stats.cpu_avg} +

+ {row.image ?? $LL.containerUnknown()} + {#if row.ports} + · {row.ports} + {/if} + {#if row.raw_status} + · {row.raw_status} + {/if} +

+ {#if row.stats} +

+ {$LL.containerCpu()}: {row.stats.cpu ?? '—'} + {#if row.stats.cpu_avg} + / {row.stats.cpu_avg} + {/if} + · {$LL.containerMemory()}: {row.stats.mem ?? '—'} + · {$LL.containerNetwork()}: ↓ {row.stats.net_down ?? '—'} ↑ {row.stats.net_up ?? '—'} + · {$LL.containerDisk()}: R {row.stats.disk_read ?? '—'} W {row.stats.disk_write ?? '—'} +

{/if} - · {$LL.containerMemory()}: {row.stats.mem ?? '—'} - · {$LL.containerNetwork()}: ↓ {row.stats.net_down ?? '—'} ↑ {row.stats.net_up ?? '—'} - · {$LL.containerDisk()}: R {row.stats.disk_read ?? '—'} W {row.stats.disk_write ?? '—'} -

- {/if} - {#if row.actions.includes('logs') && row.id} - - {/if} -
- {/each} - - {/if} - {:else if images.length === 0} - -

{$LL.containerNoImages()}

-
- {:else} - - {#each images as image (image.id ?? image.repository)} -
-
-

- {image.repository}{#if image.tag}:{image.tag}{/if} -

-

- {#if image.id}{image.id.slice(0, 12)} · {/if} - {image.size ?? $LL.containerUnknown()} - · - {#if image.containers === null} - - {$LL.containerUsageUnknown()} - {:else if image.containers === 0} - {$LL.containerUnused()} - {:else} - {$LL.containerInUse({ count: image.containers })} + {#if row.actions.includes('logs') && row.id} + + {/if} + + {/each} + + {/if} + {:else if images.length === 0} + +

{$LL.containerNoImages()}

+ + {:else} +
    + {#each images as image (image.id ?? image.repository)} +
  • + +
    +

    + {image.repository}{#if image.tag}:{image.tag}{/if} +

    +

    + {#if image.id}{image.id.slice(0, 12)} · {/if} + {image.size ?? $LL.containerUnknown()} + · + {#if image.containers === null} + + {$LL.containerUsageUnknown()} + {:else if image.containers === 0} + {$LL.containerUnused()} + {:else} + {$LL.containerInUse({ count: image.containers })} + {/if} + {#if image.created_at}· {image.created_at}{/if} +

    +
    + {#if !busy} +
    + + openPull(image)} + > + + + (imageConfirm = image)}> + + +
    {/if} - {#if image.created_at}· {image.created_at}{/if} -

    -
- {#if !busy} -
- - openPull(image)} - > - - - (imageConfirm = image)}> - - -
- {/if} + + {/each} + + {#if view.unused_tagged !== null && view.unused_tagged > 0} +

+ {$LL.containerUnusedImages({ count: view.unused_tagged })} +

+ {/if} + {/if} + + {#if busy} +
+
- {/each} - - {#if view.unused_tagged !== null && view.unused_tagged > 0} -

- {$LL.containerUnusedImages({ count: view.unused_tagged })} -

+ {/if} {/if} - {/if} - - {#if busy} -
- -
- {/if} - {/if} -
+ + + +
    {#each jobs as job (job.line_index)} -
    - - {job.schedule} - -
    +
  • + +
    +
    + + {job.schedule} + + {#if !job.enabled} + {$LL.cronDisabled()} + {/if} +

    {job.command}

    -

    - {describe(job)} - {#if job.enabled && job.next_run} - {describe(job) ? '·' : ''} - {nextRun(job)} - {/if} -

    + {#if metaText(job)} +

    {metaText(job)}

    + {/if}
    - {#if !job.enabled} - {$LL.cronDisabled()} - {/if} -
    +
    void apply({ op: 'set_enabled', line_index: job.line_index, enabled: !job.enabled })} > - + openEdit(job)}> - + void apply({ op: 'remove', line_index: job.line_index })} > - +
    -
    +
  • {/each} - +
{/if} {#if view.preserved.length > 0} - -

{$LL.cronPreserved()}

-

{$LL.cronPreservedHint()}

-
{view.preserved.join('\n')}
-
+
+

+ {$LL.cronPreserved()} +

+ +

{$LL.cronPreservedHint()}

+
{view.preserved.join('\n')}
+
+
{/if} {#if busy} -
+
{/if} @@ -304,26 +306,26 @@

{editError}

{/if}
- - -

{$LL.cronScheduleHint()}

+ + +

{$LL.cronScheduleHint()}

- - -

{$LL.cronCommandHint()}

+ + +

{$LL.cronCommandHint()}

-