Locked volumes L5: volume state in the app, CLI and peer volume facts (#3141)

* Locked volumes L5: volume state in the app, CLI and peer volume facts

RemoteVolumeInfo carries VolumeState and a device publishes its tracked
unmounted and locked volumes, so a peer lists the owner's dataset as
locked instead of a replica that stopped updating. RemoteSourceInfo
carries each source's volume_state and sources.get reads the live index.

VolumeMountChanged carries the state and fires on any state change,
including a key unloaded from an already unmounted dataset. The CLI
event printer names a lock; sd sources list has a State column and
sd volumes list a State line.

The sidebar, Storage cards, source detail and Overview show Locked,
Unmounted or Offline and say the index stays browsable. The Add to
Library modal refuses a locked or unmounted drive with the reason.

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Refuse an add only for this device's away drives; printer falls back to is_mounted without a state

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Say why RemoteVolumeInfo.state stays last

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

* Hold the Add to Library refusal until the device list resolves

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
This commit is contained in:
Jamie Pineandcapy-ai-staging[bot] authored and GitHub committed 2026-10-08 19:03:02 +00:00
1 parent 306f97fda5
commit a19553f6f1
26 files changed
+870 -80

No files matched your search

+1 -1
View File
@@ -196,7 +196,7 @@ Recent landed.
| File operations on preflight | F1 through F7 landed 2026-09-23; CI acceptance landed, Linux trash restore failing | unowned | Plan: `docs/plans/2026-09-23-file-operations.md`. Delete and duplicate in the explorer on preflight; rename and batch rename on preflight with filesystem name rules; the operation journal every mutating job writes, the trash with recorded locations and a Spacedrive trash directory where a volume has none, stashing on replace, and `files.undo` on preflight reached from the job list, ⌘Z and the trash view; mirror as `remove_extras` on merge; organize and flatten; zip and tar.zst archive and extract; attributes and links. CLI commands and dialogs for each, 30 tests over the fixture. The plan's acceptance case is now CI tests in `core/src/ops/files/acceptance.rs` (matrix: `docs/core/acceptance/entries-drop-and-file-operations.md`): a dated batch rename runs as previewed and undoes, a mirror's deletes equal the reversed path comparison, every file a job touched is in its journal, a 1,000-file batch rename previews under the ceiling. Failing on Linux and Windows, ignored: trash restore and mirror undo, because `trash_os` records the `.trashinfo` path as the item's location and `restore` renames it over the original (matrix F-a; half a day). Still live only: trash on a network mount, the preview-time measurement on the largest folder. |
| Action validation, preview, and folder merge | V1 through V9 and dedupe landed; CI acceptance landed, NAS run still open | Opus | Plan: `docs/plans/2026-09-22-action-previews.md`. Preflight (`validate:` and `preview:`), `FsPlan`, copy, move, merge, and delete on both methods, the merge job, dedupe as a delete target (first copy in walk order or chosen copies, read in full before removal) with `sd file dedupe` and a Duplicates screen, `sd file copy`, `merge`, and `delete` rendering preflight, the merge dialog, the modifier drop, the preflight-driven operation modal, plan handles, and overlay listings with the explorer's preview mode; the old validation hook is gone. Remote execution rides the existing `--device` forwarding. Two opt-in preflight methods on actions, both taking the action's exact input: `validate:` answers whether and how an operation runs (findings with stable codes plus execution facts, re-run server-side at dispatch, errors refuse) and `preview:` answers what will exist afterward (`FsPlan` from the index, advisory, browsable through plan handles as an overlay on `files.directory_listing`). The plan catalogs both methods across copy, move, merge, delete, batch rename, and dedupe. Folder merge is the first action on them: recursive merge with integrity-confirmed duplicate skipping, per-leaf conflict policy, optional source consumption, remote execution via `--device`. Also fixes copy's silent blind overwrite inside directory collisions, deletes the CLI's local conflict logic per the operations-are-the-unit principle, and removes the dead `ValidationResult`/`ConfirmationRequest` hook. Six decisions are James's. Landed ahead of preflight: `files.delete` takes a comparison as its targets (`sd file delete A --against B --show both`); the job derives the set from the index as it runs, reads both copies in full before removing one, checkpoints the compare cursor, and reports skips. The plan's acceptance clauses that a fixture can prove are CI tests in `core/src/ops/files/acceptance.rs` (matrix: `docs/core/acceptance/entries-drop-and-file-operations.md`): preflight makes no filesystem write and moves no store revision across six operations, copy/move/merge/delete previews project exactly the files their jobs leave, no destination file changes without a `Replace` row, and dedupe keeps a copy whose bytes differ from its keeper (with SPAC-19's `dedupe_own_hash_test` for the store side). Still live only: the preview-time measurement on the largest source and the consuming merge of the Expansion drive into `jamie-nas`. |
| Addressing | P0 and P1a landed 2026-10-06; P1b, P1c and D1 to D6 open | unowned | Plan: `docs/plans/2026-09-25-addressing.md`. P0 rewrote `docs/core/addressing.mdx` to match the code. P1a landed: source stores carry a schema version in `PRAGMA user_version`, migration 1 rebuilt `content` so a row's integrity hash belongs to every record on it (candidate rows by sampled hash, confirmed rows by integrity hash, `candidate_uuid` beside `uuid`), and compare-delete and dedupe now read a hash a file's own bytes produced. Upgrade note: an existing store migrates to `user_version` 1 on its first writer open (daemon start or source attach); the rebuild is one transaction and measured 11 s for 1M content rows and 32 s for 3M in release on a cloud machine, so a 3M-record MacBook pauses that source for well under a minute once. Every row that held both hashes loses its integrity hash, so dedupe and compare-delete re-read each shared file once afterwards. Replicas keep version 0 until their owner upgrades and are read in their own shape. Next: P1b, network admission for fully paired devices only; P1c, refusals for addresses destructive operations can't honor (cross-device moves wait on D6); then P2 to P6 behind D1 to D6. |
| Locked and unmounted volumes | L1 and L2 landed 2026-10-07 (SPAC-25); L3 and L4 built 2026-10-08 (SPAC-40), PR open | Fable | Plan: `docs/plans/2026-09-28-locked-volumes.md`; acceptance: `docs/core/acceptance/volumes.md`, run on a loop-mounted image and a file-backed ZFS pool in the acceptance CI job. L1: the monitor marks a tracked volume offline when detection stops returning it, `attach_library` resolves against live detection and corrects a stale row, the volume index carries each mapped drive's mounted state, and a source whose volume is away comes up detached with its map restored read-only and no watch armed, reattaching when the volume returns. L2: attached means mounted; a mount point check (device differs from the parent's) runs before any walk, hash or thumbnail job and inside the jobs themselves, so the directory an unmounted drive leaves behind is never walked, a forced heal dispatches nothing, and `sources.track` on it refuses. L3: Linux detection reads `mounted`, `encryption` and `keystatus` from the one `zfs list` per refresh and lists a dataset that is unmounted or whose key is not loaded as a volume with `locked` set; `VolumeState::{Mounted, Unmounted, Locked}` reaches `sources.list` as `volume_state` and `volumes.list` through the volume's flags; a locked dataset matches its tracked row by mount point, since nothing inside it can be read. L4: the volume index follows the volume manager's events (`follow_volume_events`) instead of the monitor calling it; an unmount or lock drops the watches the OS dropped, a mount or key load resolves the roots, restores the map if needed, re-arms the watches on the mounted filesystem, puts identifications that failed while the volume was away back in the pending set and dispatches a background identity pass. The ZFS suite (`zfs_locked_volumes_acceptance_test`) builds a file-backed pool with an encrypted dataset and runs only on the CI runner, since the cloud build machines have no ZFS kernel module. Next: L5 shows the state in the app and CLI and in the volume facts a device publishes to its peers (`RemoteVolumeInfo` still carries only `is_mounted`). One decision is James's: whether a locked source's index stays browsable; the default built keeps it browsable, labeled detached. Follow-up landed: the volume index keeps one source registry per open library (keyed by library id) while drive detection stays shared, so a daemon with two libraries lists every library's sources whatever order `read_dir` loads them in; closing a library takes only its registrations away. `core/tests/multi_library_acceptance_test.rs` creates two libraries, restarts the core and lists both (the SPAC-25 flake). That test then flaked on Blacksmith with one library reopening (SPAC-34, 2026-10-07): the statistics task a finished job spawns and the shutdown save both truncated `library.json` in place, and an overlap left the short document with the long one's tail, which the reopen could not parse. `LibraryConfig::save` now writes beside the file and renames over it, and every writer goes through it. |
| Locked and unmounted volumes | L1 and L2 landed 2026-10-07 (SPAC-25); L3 and L4 landed 2026-10-08 (SPAC-40, `306f97f`); L5 built 2026-10-08 (SPAC-43), PR open | Fable | Plan: `docs/plans/2026-09-28-locked-volumes.md`; acceptance: `docs/core/acceptance/volumes.md`, run on a loop-mounted image and a file-backed ZFS pool in the acceptance CI job. L1: the monitor marks a tracked volume offline when detection stops returning it, `attach_library` resolves against live detection and corrects a stale row, the volume index carries each mapped drive's mounted state, and a source whose volume is away comes up detached with its map restored read-only and no watch armed, reattaching when the volume returns. L2: attached means mounted; a mount point check (device differs from the parent's) runs before any walk, hash or thumbnail job and inside the jobs themselves, so the directory an unmounted drive leaves behind is never walked, a forced heal dispatches nothing, and `sources.track` on it refuses. L3: Linux detection reads `mounted`, `encryption` and `keystatus` from the one `zfs list` per refresh and lists a dataset that is unmounted or whose key is not loaded as a volume with `locked` set; `VolumeState::{Mounted, Unmounted, Locked}` reaches `sources.list` as `volume_state` and `volumes.list` through the volume's flags; a locked dataset matches its tracked row by mount point, since nothing inside it can be read. L4: the volume index follows the volume manager's events (`follow_volume_events`) instead of the monitor calling it; an unmount or lock drops the watches the OS dropped, a mount or key load resolves the roots, restores the map if needed, re-arms the watches on the mounted filesystem, puts identifications that failed while the volume was away back in the pending set and dispatches a background identity pass. The ZFS suite (`zfs_locked_volumes_acceptance_test`) builds a file-backed pool with an encrypted dataset and runs only on the CI runner, since the cloud build machines have no ZFS kernel module. L5: `RemoteVolumeInfo` carries `VolumeState` and a device publishes its tracked unmounted and locked volumes too, so a peer lists the owner's dataset as locked instead of a replica that stopped updating; `RemoteSourceInfo` carries the source's `volume_state` and `sources.get` reads the live index like `sources.list`; `sd sources list` has a State column and `sd volumes list` a State line (mounted, unmounted, locked, offline for a paired device nothing reaches); the event printer names a lock (`VolumeMountChanged` now carries the state and fires on a lock of an already unmounted dataset); the sidebar, Storage cards, source detail and the Overview volume row show Locked (with a lock glyph), Unmounted or Offline and say the index stays browsable; the Add to Library modal refuses a locked or unmounted drive, or a path under one, with the reason. One decision is James's: whether a locked source's index stays browsable; the default built keeps it browsable, labeled detached. Follow-up landed: the volume index keeps one source registry per open library (keyed by library id) while drive detection stays shared, so a daemon with two libraries lists every library's sources whatever order `read_dir` loads them in; closing a library takes only its registrations away. `core/tests/multi_library_acceptance_test.rs` creates two libraries, restarts the core and lists both (the SPAC-25 flake). That test then flaked on Blacksmith with one library reopening (SPAC-34, 2026-10-07): the statistics task a finished job spawns and the shutdown save both truncated `library.json` in place, and an overlap left the short document with the long one's tail, which the reopen could not parse. `LibraryConfig::save` now writes beside the file and renames over it, and every writer goes through it. |
| Add to Library, core half | core landed 2026-10-07 (Fable, SPAC-27); modal in review (Fable, SPAC-32) | Fable | Plan: `docs/plans/2026-09-16-add-to-library.md`; acceptance: `docs/core/acceptance/add-to-library.md`. Library Settings > Adding content holds the defaults (`LibrarySettings.adding`: store in library, offline copy kept, filtered capture, content identified) and `sources.track` and `volumes.track` take the same `overrides` on top; `config.library.update` changes the defaults for later adds only. Store placement is in each source's config and resolved per library by `VolumeIndex::store_dir`: in library under `<data>/sources/<id>`, on source under `<root>/.spacedrive/sources/<id>`; every open, read, freeze and listing goes through it, and on source is refused for network and cloud volumes. Adding any path registers its volume row (synced, event emitted) and maps it in the index, so a source always has its volume. Every store carries `source.json`; re-adding a scope the same library wrote adopts that identity and reopens the catalog. `crate::config::is_managed` keeps the data directory, every `.spacedrive` directory and the volume marker out of walks, watcher events and the writer, whatever the rules say. `sources.delete` removes the registration, retires the handles and keeps the catalog and the volume row unless `delete_catalog` is set; `sd source untrack` wraps it. The modal (`packages/interface/.../AddToLibraryModal.tsx`, replacing `AddStorageModal`) is the one entry for folders and whole drives from the Explorer, path bar, Home and the volume menu: it shows the library defaults resolved for the scope, takes per-add overrides, explains an unavailable placement, offers Use these settings as defaults, says how the path is already included (exact, enclosing, nested) and reports the saved `TrackSourceOutput` with a jobs link; `volumes.track` now returns its source result and `sources.list` carries each source's saved settings. Library Settings has the Adding content section. Next: offline copies, remount of on-source stores, relocation and consolidation, in the plan's order. |
| Retire Spacebot, Console, and storefront surfaces | done (2026-09-25) | Opus | The Spacebot UI, voice overlay, `spacebot` app config, window, build aliases, generated types, and design docs are removed; the Alt+Space global shortcut and its plugin went with the voice overlay. Console and Pod design docs are removed. Private copies are in `company/archive/spacedrive-sources-public-cleanup/`. No storefront code remained. |
| CI green on `sources` | landed 2026-10-04 (Fable, SPAC-4); Rust Formatting, Clippy and TypeScript green on #3102 | Fable | The three `ci.yml` jobs were red on `sources` before any PR: the self-hosted runner's preinstalled toolchain had no rustfmt or clippy (`setup-rust` now installs both every run, and `rust-toolchain.toml` names them), `maximize-build-space` left the Blacksmith runner's root disk with 100 MB so no toolchain could install (step removed), the clippy job ran on GitHub's ubuntu-22.04 whose FFmpeg 4.4 cannot build `sd-ffmpeg` (now `blacksmith-4vcpu-ubuntu-2404`), and the server and desktop crates failed to compile without a built web UI (debug builds now get an empty dist with a warning; release builds still refuse), `cargo clippy --workspace -D warnings` failed in seven crates plus the Tauri sidecar check and the two platform-only file-opening crates, and `bun run typecheck` in `apps/tauri` had 30 errors in `packages/interface`. All fixed with no product change: sqlx row aliases in `sd-store`, exact casts and raw FFI borrows in `sd-ffmpeg`, `DaemonRequest.device` in `sd-mobile-core`, target-gated `file-opening-macos`/`-windows`, a Tauri build script that drops `externalBin` when no daemon sidecar exists, unused imports and `SourceInfo.adapter_id` nullability in the interface, the `/redundancy/duplicates` route the dashboard already linked. `core/tests/delete_strategy_test.rs` compiles again (calls `ops::files::trash::trash` directly), the 13 stale doctests compile, `scripts/setup.sh` installs the libav dev packages, bun is pinned to 1.3.4 with `--frozen-lockfile`, workspace `rust-version` is 1.95 (gpui's floor). Still red and out of this scope: 20 integration-test failures on a bare Linux VM (see SPAC-2); the three unit-test bugs have their own row below. Next: confirm the three jobs green on the PR, then decide whether `core_tests.yml` should run on pull requests. |
+84 -9
View File
@@ -9,6 +9,7 @@ use anyhow::Result;
use chrono::{DateTime, Utc};
use sd_core::infra::daemon::types::EventFilter;
use sd_core::infra::event::Event;
use sd_core::volume::VolumeState;
use std::collections::HashSet;
/// Run events command
@@ -175,8 +176,23 @@ fn summarize_event(event: &Event) -> String {
Event::VolumeRemoved { fingerprint } => {
format!("Volume removed: {}", fingerprint.0)
}
Event::VolumeUpdated { fingerprint, .. } => {
format!("Volume updated: {}", fingerprint.0)
Event::VolumeUpdated {
fingerprint,
old_info,
new_info,
} => {
let before = VolumeState::from_flags(old_info.is_mounted, old_info.locked);
let after = VolumeState::from_flags(new_info.is_mounted, new_info.locked);
if before == after {
format!("Volume updated: {}", fingerprint.0)
} else {
format!(
"Volume updated: {} ({} -> {})",
fingerprint.0,
before.as_str(),
after.as_str()
)
}
}
Event::VolumeSpeedTested {
fingerprint,
@@ -191,13 +207,12 @@ fn summarize_event(event: &Event) -> String {
Event::VolumeMountChanged {
fingerprint,
is_mounted,
} => {
format!(
"Volume {} mount changed: {}",
fingerprint.0,
if *is_mounted { "mounted" } else { "unmounted" }
)
}
state,
} => match state.unwrap_or(VolumeState::from_flags(*is_mounted, false)) {
VolumeState::Locked => format!("Volume {} locked: key not loaded", fingerprint.0),
VolumeState::Unmounted => format!("Volume {} unmounted", fingerprint.0),
VolumeState::Mounted => format!("Volume {} mounted", fingerprint.0),
},
Event::VolumeError { fingerprint, error } => {
format!("Volume {} error: {}", fingerprint.0, error)
}
@@ -397,3 +412,63 @@ fn summarize_event(event: &Event) -> String {
}
}
}
#[cfg(test)]
mod tests {
use super::summarize_event;
use sd_core::infra::event::Event;
use sd_core::volume::{VolumeFingerprint, VolumeInfo, VolumeState};
fn info(is_mounted: bool, locked: bool) -> VolumeInfo {
VolumeInfo {
is_mounted,
locked,
total_bytes_available: 0,
read_speed_mbps: None,
write_speed_mbps: None,
error_status: None,
}
}
#[test]
fn a_lock_and_an_unlock_are_named_as_such() {
let fingerprint = VolumeFingerprint("vault".to_string());
let locked = Event::VolumeMountChanged {
fingerprint: fingerprint.clone(),
is_mounted: false,
state: Some(VolumeState::Locked),
};
assert_eq!(
summarize_event(&locked),
"Volume vault locked: key not loaded"
);
let mounted = Event::VolumeMountChanged {
fingerprint: fingerprint.clone(),
is_mounted: true,
state: Some(VolumeState::Mounted),
};
assert_eq!(summarize_event(&mounted), "Volume vault mounted");
let from_an_older_daemon = Event::VolumeMountChanged {
fingerprint: fingerprint.clone(),
is_mounted: true,
state: None,
};
assert_eq!(
summarize_event(&from_an_older_daemon),
"Volume vault mounted",
"without a state the flag decides"
);
let updated = Event::VolumeUpdated {
fingerprint,
old_info: info(false, false),
new_info: info(false, true),
};
assert_eq!(
summarize_event(&updated),
"Volume updated: vault (unmounted -> locked)"
);
}
}
+106 -24
View File
@@ -161,31 +161,11 @@ pub async fn run(ctx: &Context, cmd: SourceCmd) -> Result<()> {
}
let mut table = Table::new();
table.load_preset(UTF8_BORDERS_ONLY);
table.set_header(vec!["ID", "Name", "Type", "Records", "Status", "Root"]);
table.set_header(vec![
"ID", "Name", "Type", "Records", "Status", "State", "Root",
]);
for source in sources {
let mut status = if source.attached {
source.status.clone()
} else {
format!("{} (detached)", source.status)
};
if let Some(transfer) = &source.transfer {
status = format!(
"{status}: fetching {}",
crate::util::output::format_transfer(
transfer.bytes,
transfer.total,
transfer.bytes_per_sec
)
);
}
table.add_row(vec![
source.id.to_string(),
source.name.clone(),
source.data_type.clone(),
source.item_count.to_string(),
status,
source.root.clone().unwrap_or_default(),
]);
table.add_row(source_row(source));
}
println!("{table}");
});
@@ -269,3 +249,105 @@ pub async fn run(ctx: &Context, cmd: SourceCmd) -> Result<()> {
Ok(())
}
/// One `sd sources list` row.
///
/// The state column names what stands between the library and the
/// source's files: `mounted`, `unmounted` or `locked` for a drive the
/// volume index knows, `offline` for a root or a replica nothing answers
/// for, and `online` for an adapter, whose origin is a service.
fn source_row(source: &SourceInfo) -> Vec<String> {
let mut status = if source.attached {
source.status.clone()
} else {
format!("{} (detached)", source.status)
};
if let Some(transfer) = &source.transfer {
status = format!(
"{status}: fetching {}",
crate::util::output::format_transfer(
transfer.bytes,
transfer.total,
transfer.bytes_per_sec
)
);
}
vec![
source.id.to_string(),
source.name.clone(),
source.data_type.clone(),
source.item_count.to_string(),
status,
source_state_label(source).to_string(),
source.root.clone().unwrap_or_default(),
]
}
fn source_state_label(source: &SourceInfo) -> &'static str {
match source.volume_state {
Some(state) => state.as_str(),
None if source.data_type != "filesystem" => "online",
None if source.attached => "mounted",
None => "offline",
}
}
#[cfg(test)]
mod tests {
use super::*;
use sd_core::volume::VolumeState;
fn source(attached: bool, volume_state: Option<VolumeState>) -> SourceInfo {
SourceInfo {
id: uuid::Uuid::nil(),
name: "vault".to_string(),
data_type: "filesystem".to_string(),
adapter_id: None,
item_count: 5,
last_synced: None,
status: "idle".to_string(),
root: Some("/mnt/vault".to_string()),
volume_uuid: None,
attached,
volume_state,
total_bytes: None,
last_seen_at: None,
device_id: None,
device_label: None,
transfer: None,
placement: None,
store_path: None,
settings: None,
}
}
#[test]
fn the_state_column_says_what_stands_between_the_library_and_the_files() {
assert_eq!(
source_state_label(&source(true, Some(VolumeState::Mounted))),
"mounted"
);
assert_eq!(
source_state_label(&source(false, Some(VolumeState::Locked))),
"locked"
);
assert_eq!(
source_state_label(&source(false, Some(VolumeState::Unmounted))),
"unmounted"
);
assert_eq!(source_state_label(&source(false, None)), "offline");
assert_eq!(source_state_label(&source(true, None)), "mounted");
let mut adapter = source(true, None);
adapter.data_type = "github".to_string();
assert_eq!(source_state_label(&adapter), "online");
}
#[test]
fn a_locked_source_lists_detached_with_its_state() {
let row = source_row(&source(false, Some(VolumeState::Locked)));
assert_eq!(row[4], "idle (detached)");
assert_eq!(row[5], "locked");
assert_eq!(row[6], "/mnt/vault");
}
}
+57 -2
View File
@@ -79,6 +79,23 @@ pub async fn run(ctx: &Context, cmd: VolumeCmd) -> Result<()> {
return Ok(());
}
// A paired device's volume stands as its owner last reported it;
// whether that report is current depends on the owner being
// reachable, which only the device list says.
let devices: Vec<sd_core::domain::Device> = execute_query!(
ctx,
sd_core::ops::devices::list::query::ListLibraryDevicesInput {
include_offline: true,
include_details: false,
show_paired: true,
}
);
let reachable: std::collections::HashSet<uuid::Uuid> = devices
.iter()
.filter(|device| device.is_current || device.is_online)
.map(|device| device.id)
.collect();
println!("Tracked {} volume(s):\n", output.volumes.len());
for volume in output.volumes {
@@ -93,8 +110,10 @@ pub async fn run(ctx: &Context, cmd: VolumeCmd) -> Result<()> {
format_bytes(volume.available_space),
);
println!(
" Visible: {}, Tracked: {}, Mounted: {}",
volume.is_user_visible, volume.is_tracked, volume.is_mounted,
" Visible: {}, Tracked: {}, State: {}",
volume.is_user_visible,
volume.is_tracked,
volume_state_label(&volume, reachable.contains(&volume.device_id)),
);
println!();
}
@@ -107,6 +126,16 @@ pub async fn run(ctx: &Context, cmd: VolumeCmd) -> Result<()> {
Ok(())
}
/// `mounted`, `unmounted` or `locked` as the volume's owner has it, or
/// `offline` when the owner is a paired device nothing can reach, in which
/// case its last report says nothing about the drive now.
fn volume_state_label(volume: &sd_core::volume::Volume, owner_reachable: bool) -> &'static str {
if !owner_reachable {
return "offline";
}
volume.state().as_str()
}
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
if bytes == 0 {
@@ -124,3 +153,29 @@ fn format_bytes(bytes: u64) -> String {
format!("{:.2} {}", value, UNITS[unit])
}
}
#[cfg(test)]
mod tests {
use super::volume_state_label;
use sd_core::volume::{Volume, VolumeFingerprint};
fn volume(is_mounted: bool, locked: bool) -> Volume {
let mut volume = Volume::new(
uuid::Uuid::nil(),
VolumeFingerprint("fp".to_string()),
"vault".to_string(),
std::path::PathBuf::from("/mnt/vault"),
);
volume.is_mounted = is_mounted;
volume.locked = locked;
volume
}
#[test]
fn the_state_line_names_the_owners_state_or_the_owner_being_away() {
assert_eq!(volume_state_label(&volume(true, false), true), "mounted");
assert_eq!(volume_state_label(&volume(false, false), true), "unmounted");
assert_eq!(volume_state_label(&volume(false, true), true), "locked");
assert_eq!(volume_state_label(&volume(false, true), false), "offline");
}
}
+6
View File
@@ -164,6 +164,12 @@ pub enum Event {
VolumeMountChanged {
fingerprint: crate::volume::VolumeFingerprint,
is_mounted: bool,
/// Mounted, unmounted, or locked: what the volume became. A dataset
/// whose key is unloaded after it was already unmounted changes
/// state without changing `is_mounted`. Absent from a daemon built
/// before the field existed; readers fall back to `is_mounted`.
#[serde(default)]
state: Option<crate::volume::VolumeState>,
},
VolumeError {
fingerprint: crate::volume::VolumeFingerprint,
+5
View File
@@ -13,6 +13,11 @@ pub struct IndexSourceInfo {
pub volume_uuid: Option<uuid::Uuid>,
/// The root exists on disk right now
pub attached: bool,
/// How the drive under the source stands: mounted, unmounted, or locked
/// because its key is not loaded. Absent on media Spacedrive does not
/// track.
#[serde(default)]
pub volume_state: Option<crate::volume::VolumeState>,
/// A snapshot restore has populated this source's index this session
pub restored: bool,
pub last_seen_secs: u64,
+1
View File
@@ -171,6 +171,7 @@ impl CoreQuery for IndexStatusQuery {
root: s.root,
volume_uuid: s.volume_uuid,
attached: s.attached,
volume_state: s.volume_state,
restored: s.restored,
last_seen_secs: s.last_seen_secs,
entry_count: s.entry_count,
+28 -1
View File
@@ -6,6 +6,7 @@ use crate::{
infra::query::{LibraryQuery, QueryError, QueryResult},
ops::sources::registry,
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
@@ -57,7 +58,33 @@ impl LibraryQuery for GetSourceQuery {
.await
.map_err(|e| QueryError::Internal(format!("{e}")))?;
Ok(SourceInfo::from_row(row, None))
let mount = match row.volume_uuid {
Some(uuid) => crate::infra::db::entities::volume::Entity::find()
.filter(crate::infra::db::entities::volume::Column::Uuid.eq(uuid))
.one(library.db().conn())
.await
.map_err(|e| QueryError::Internal(format!("Failed to read volume: {e}")))?
.filter(|volume| volume.is_online)
.and_then(|volume| volume.mount_point.map(std::path::PathBuf::from)),
None => None,
};
let mut info = SourceInfo::from_row(row, mount.as_deref());
// The volume index knows whether the drive is mounted or locked, which
// the row cannot tell from a mount point left behind as a directory.
if let Some(live) = context
.volume_index()
.sources_of(library.id())
.into_iter()
.find(|source| source.id == source_id)
{
info.attached = live.attached;
info.volume_state = live.volume_state;
info.store_path = live
.directory
.as_ref()
.map(|dir| dir.to_string_lossy().into_owned());
}
Ok(info)
}
}
+2 -2
View File
@@ -147,7 +147,7 @@ impl SourceInfo {
root: Some(root.to_string_lossy().into_owned()),
volume_uuid: entry.info.volume_uuid,
attached: false,
volume_state: None,
volume_state: entry.info.volume_state,
total_bytes: entry.info.total_bytes.map(|bytes| bytes as i64),
last_seen_at: synced_at,
device_id: Some(device_id),
@@ -217,7 +217,7 @@ impl SourceInfo {
root: Some(root.to_string_lossy().into_owned()),
volume_uuid: share.info.volume_uuid,
attached: share.info.attached,
volume_state: None,
volume_state: share.info.volume_state,
total_bytes: share.info.total_bytes.map(|bytes| bytes as i64),
last_seen_at: synced_at,
device_id: Some(share.device_id),
+2
View File
@@ -1579,6 +1579,7 @@ mod tests {
root: PathBuf::from(root),
volume_uuid: None,
attached: true,
volume_state: None,
entry_count: Some(count),
total_bytes: Some(count * 10),
generation: 7,
@@ -2078,6 +2079,7 @@ mod tests {
is_tracked: true,
read_speed_mbps: None,
write_speed_mbps: None,
state: crate::volume::VolumeState::Mounted,
}],
},
observed_at_secs: 1_789_700_000,
+1
View File
@@ -131,6 +131,7 @@ mod tests {
root: std::path::PathBuf::from("/mnt/pool/kept"),
volume_uuid: None,
attached: true,
volume_state: None,
entry_count: None,
total_bytes: None,
generation: 1,
+106 -9
View File
@@ -17,7 +17,9 @@
use crate::context::CoreContext;
use crate::device::DeviceConfig;
use crate::domain::device::{parse_device_form_factor_from_string, Device};
use crate::domain::volume::{DiskType, FileSystem, Volume, VolumeFingerprint, VolumeType};
use crate::domain::volume::{
DiskType, FileSystem, Volume, VolumeFingerprint, VolumeState, VolumeType,
};
use crate::service::network::device::registry::DeviceRegistry;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
@@ -151,14 +153,18 @@ pub struct RemoteDeviceSummary {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RemoteDeviceFacts {
pub hardware: RemoteHardware,
/// Volumes the device has mounted and shows a person.
/// Volumes the device shows a person: the ones it has mounted, and the
/// tracked ones it knows but cannot read right now, with their state.
pub volumes: Vec<RemoteVolumeInfo>,
}
impl RemoteDeviceFacts {
/// Describe this device from its configuration and its volume manager's
/// live volumes. Hidden and unmounted volumes stay out, as they do from
/// the device's own volume list.
/// live volumes. Hidden volumes stay out, as they do from the device's
/// own volume list. An unmounted or locked volume travels only when it
/// is tracked: a peer holding its replica needs to know the drive is
/// away rather than that it stopped updating, while an untracked
/// dataset nobody added is noise.
pub fn describe(config: &DeviceConfig, volumes: &[Volume]) -> Self {
Self {
hardware: RemoteHardware {
@@ -172,7 +178,7 @@ impl RemoteDeviceFacts {
},
volumes: volumes
.iter()
.filter(|volume| volume.is_user_visible && volume.is_mounted)
.filter(|volume| volume.is_user_visible && (volume.is_mounted || volume.is_tracked))
.map(RemoteVolumeInfo::from)
.collect(),
}
@@ -231,6 +237,16 @@ pub struct RemoteVolumeInfo {
pub is_tracked: bool,
pub read_speed_mbps: Option<u64>,
pub write_speed_mbps: Option<u64>,
/// How the owner has the volume right now. A build that predates the
/// field published only mounted volumes, so its facts read as mounted.
/// Frames are positional, so this stays the last field: anything added
/// after it needs its own default, and nothing may go before it.
#[serde(default = "mounted")]
pub state: VolumeState,
}
fn mounted() -> VolumeState {
VolumeState::Mounted
}
impl From<&Volume> for RemoteVolumeInfo {
@@ -250,13 +266,15 @@ impl From<&Volume> for RemoteVolumeInfo {
is_tracked: volume.is_tracked,
read_speed_mbps: volume.read_speed_mbps,
write_speed_mbps: volume.write_speed_mbps,
state: volume.state(),
}
}
}
impl RemoteVolumeInfo {
/// The volume as this device lists it: owned by `owner`, mounted only
/// while the owner is reachable, and last seen when its facts arrived.
/// while the owner is reachable and has it mounted, locked when the
/// owner last said so, and last seen when its facts arrived.
pub fn to_volume(&self, owner: Uuid, reachable: bool, observed_at: DateTime<Utc>) -> Volume {
let mut volume = Volume::new(
owner,
@@ -273,7 +291,8 @@ impl RemoteVolumeInfo {
volume.available_space = self.available_space;
volume.is_read_only = self.is_read_only;
volume.is_tracked = self.is_tracked;
volume.is_mounted = reachable;
volume.is_mounted = reachable && self.state.is_mounted();
volume.locked = self.state.is_locked();
volume.auto_track_eligible = false;
volume.read_speed_mbps = self.read_speed_mbps;
volume.write_speed_mbps = self.write_speed_mbps;
@@ -290,6 +309,11 @@ pub struct RemoteSourceInfo {
pub root: PathBuf,
pub volume_uuid: Option<uuid::Uuid>,
pub attached: bool,
/// How the drive under the source stands on its owner, so a peer can
/// say locked rather than guess from `attached`. Absent from an older
/// build's answer and for a source on media the owner does not track.
#[serde(default)]
pub volume_state: Option<VolumeState>,
pub entry_count: Option<u64>,
pub total_bytes: Option<u64>,
/// Version of the artifact a peer would receive: the snapshot's size and
@@ -761,6 +785,7 @@ impl ByteRangeProtocolHandler {
root: s.root,
volume_uuid: s.volume_uuid,
attached: s.attached,
volume_state: s.volume_state,
entry_count: s.entry_count,
total_bytes: s.total_bytes,
generation,
@@ -1133,10 +1158,14 @@ mod tests {
config.memory_total_bytes = Some(128 << 30);
let shown = volume("pool", true, true);
let mut vault = volume("vault", true, false);
vault.is_tracked = true;
vault.locked = true;
let facts = RemoteDeviceFacts::describe(
&config,
&[
shown.clone(),
vault.clone(),
volume("boot", false, true),
volume("usb", true, false),
],
@@ -1144,9 +1173,11 @@ mod tests {
assert_eq!(
facts.volumes.iter().map(|v| v.id).collect::<Vec<_>>(),
vec![shown.id],
"hidden and unmounted volumes stay out"
vec![shown.id, vault.id],
"hidden and untracked unmounted volumes stay out; a tracked locked one travels"
);
assert_eq!(facts.volumes[0].state, VolumeState::Mounted);
assert_eq!(facts.volumes[1].state, VolumeState::Locked);
assert_eq!(facts.hardware.cpu_model.as_deref(), Some("AMD EPYC 4464P"));
assert_eq!(facts.hardware.memory_total_bytes, Some(128 << 30));
}
@@ -1184,6 +1215,72 @@ mod tests {
assert_eq!(listed.last_seen_at, observed_at);
}
/// A locked volume reads as locked on its peer while the owner is
/// reachable, as it does on the owner, so a replica over it says the key
/// is not loaded instead of looking like a drive that stopped updating.
/// Facts from a build that published only mounted volumes, and so carry
/// no state, still read as mounted.
#[test]
fn a_volumes_state_reaches_its_peer() {
let mut original = volume("vault", true, false);
original.is_tracked = true;
original.locked = true;
let info = RemoteVolumeInfo::from(&original);
assert_eq!(info.state, VolumeState::Locked);
let encoded = rmp_serde::to_vec(&info).expect("encode");
let decoded: RemoteVolumeInfo = rmp_serde::from_slice(&encoded).expect("decode");
assert_eq!(decoded, info);
let owner = Uuid::now_v7();
let reachable = decoded.to_volume(owner, true, Utc::now());
assert_eq!(reachable.state(), VolumeState::Locked);
assert!(reachable.is_tracked);
let away = decoded.to_volume(owner, false, Utc::now());
assert!(
!away.is_mounted && away.locked,
"an unreachable owner's locked volume keeps the state it last reported"
);
#[derive(Serialize)]
struct LegacyVolumeInfo {
id: Uuid,
fingerprint: String,
name: String,
display_name: Option<String>,
mount_point: PathBuf,
volume_type: String,
disk_type: String,
file_system: String,
total_capacity: u64,
available_space: u64,
is_read_only: bool,
is_tracked: bool,
read_speed_mbps: Option<u64>,
write_speed_mbps: Option<u64>,
}
let bytes = rmp_serde::to_vec(&LegacyVolumeInfo {
id: Uuid::now_v7(),
fingerprint: "fingerprint-pool".to_string(),
name: "pool".to_string(),
display_name: None,
mount_point: PathBuf::from("/mnt/pool"),
volume_type: "External".to_string(),
disk_type: "HDD".to_string(),
file_system: "ZFS".to_string(),
total_capacity: 1,
available_space: 1,
is_read_only: false,
is_tracked: true,
read_speed_mbps: None,
write_speed_mbps: None,
})
.expect("encode");
let old_build: RemoteVolumeInfo = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(old_build.state, VolumeState::Mounted);
assert!(old_build.to_volume(owner, true, Utc::now()).is_mounted);
}
/// A resumed artifact request answers with the whole file's identity and
/// only the bytes from the offset, so a receiver appending to a partial
/// copy validates against the same header a fresh fetch would. An offset
+10 -3
View File
@@ -9,7 +9,7 @@ use crate::volume::{
error::{VolumeError, VolumeResult},
types::{
SpacedriveVolumeId, TrackedVolume, Volume, VolumeDetectionConfig, VolumeFingerprint,
VolumeInfo,
VolumeInfo, VolumeState,
},
VolumeExt,
};
@@ -850,11 +850,18 @@ impl VolumeManager {
new_info: new_info.clone(),
});
// Emit mount status change if applicable
if old_info.is_mounted != new_info.is_mounted {
// A lock or unlock is a state change even when the
// mount flag holds, as when a key is unloaded from an
// already unmounted dataset.
let old_state =
VolumeState::from_flags(old_info.is_mounted, old_info.locked);
let new_state =
VolumeState::from_flags(new_info.is_mounted, new_info.locked);
if old_state != new_state {
events.emit(Event::VolumeMountChanged {
fingerprint: fingerprint.clone(),
is_mounted: new_info.is_mounted,
state: Some(new_state),
});
// Auto-run speed test when volume is mounted
+30 -2
View File
@@ -1,7 +1,7 @@
# Locked and Unmounted Volumes
> Status: L1 and L2 landed 2026-10-07 (#3119), L3 and L4 built 2026-10-08
> (SPAC-40); L5 open
> Status: L1 and L2 landed 2026-10-07 (#3119), L3 and L4 landed 2026-10-08
> (#3140); L5 built 2026-10-08 (SPAC-43)
> Captured: 2026-09-28
> Owns: how a source behaves while its volume is known to the machine but not
> mounted, including an encrypted ZFS dataset whose key is not loaded
@@ -226,6 +226,34 @@ L3 and L4 as built, where the code differs from the design above in detail:
refuses a busy dataset, and forcing it removes files from under the daemon.
A watch alone does not make a dataset busy.
L5 as built:
- A device publishes a tracked volume to its peers whatever its state, with
`RemoteVolumeInfo.state` saying mounted, unmounted or locked; an untracked
volume still travels only while mounted. A peer lists the volume as its
owner last reported it, mounted only while the owner is reachable. The
owner's source listing (`RemoteSourceInfo`) carries each source's
`volume_state`, so a replica reads as locked rather than stale.
`sources.get` reads the live volume index the way `sources.list` does.
- `VolumeMountChanged` carries the new `VolumeState` and fires on any state
change, including a key unloaded from a dataset that was already
unmounted. The CLI event printer says `locked: key not loaded`,
`unmounted` or `mounted`, and `VolumeUpdated` names the transition.
- `sd sources list` has a State column: the volume's state when the index
knows the drive, `offline` for a detached root or replica it does not,
`online` for an adapter. `sd volumes list` prints a State line, `offline`
when the volume's owner is a paired device nothing can reach.
- The sidebar's Places rows, the Storage page cards and a source's detail
banner show Locked (with a lock glyph), Unmounted or Offline, dimmed, and
say the files stay listed from the last index and cannot be opened until
the key loads or the drive mounts, naming the volume where the view knows
it. The Overview's volume row carries the same badge. The Add to Library
modal refuses a whole drive that is unmounted or locked, and a path at or
under its mount point, with the reason and the add button disabled, since
the core would refuse and the directory left at the mount point would
otherwise be added as an empty folder of the parent drive.
- Nothing offers to load a key: ZFS needs root for that.
## Decisions for James
1. What a locked source shows. Proposed: its names, folders and thumbnails
@@ -1,13 +1,18 @@
import { HardDrive } from "@phosphor-icons/react";
import { HardDrive, Lock } from "@phosphor-icons/react";
import { useNavigate } from "react-router-dom";
import type { IndexSourceInfo } from "@sd/ts-client";
import { formatBytes } from "../../routes/explorer/utils";
import {
availabilityBadge,
availabilityDescription,
sourceAvailability,
} from "./sourceAvailability";
/**
* A filesystem source on the Sources page — same card language as adapter
* sources, because a drive is a source whose adapter is the indexer. Opens
* the explorer at the source root; detached drives browse read-only from
* their snapshots.
* the explorer at the source root; detached, unmounted and locked drives
* browse read-only from their snapshots and say so on the card.
*/
function lastSeenLabel(lastSeenSecs: number): string {
@@ -30,6 +35,8 @@ export function DriveSourceCard({
const navigate = useNavigate();
const name =
source.root.split(/[/\\]/).filter(Boolean).pop() ?? source.root;
const availability = sourceAvailability(source);
const badge = availabilityBadge(availability);
const open = () => {
if (!deviceSlug) return;
@@ -42,7 +49,7 @@ export function DriveSourceCard({
return (
<button
onClick={open}
title={source.root}
title={availabilityDescription(availability, undefined) ?? source.root}
className="border-app-line bg-app-box hover:border-app-line/80 hover:bg-app-hover group relative rounded-lg border p-4 text-left transition-all"
>
<div className="mb-3 flex items-center gap-3">
@@ -51,7 +58,9 @@ export function DriveSourceCard({
size={20}
weight="bold"
className={
source.attached ? "text-ink-dull" : "text-ink-faint"
availability === "available"
? "text-ink-dull"
: "text-ink-faint"
}
/>
</div>
@@ -61,9 +70,12 @@ export function DriveSourceCard({
</h3>
<p className="text-ink-faint text-xs">filesystem</p>
</div>
{!source.attached && (
<span className="bg-app-selected/40 text-ink-faint rounded-full px-2 py-0.5 text-[10px] font-medium">
offline
{badge && (
<span className="bg-app-selected/40 text-ink-faint flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium">
{availability === "locked" && (
<Lock size={10} weight="bold" />
)}
{badge.toLowerCase()}
</span>
)}
</div>
@@ -1,13 +1,20 @@
import {HardDrive} from '@phosphor-icons/react';
import {HardDrive, Lock} from '@phosphor-icons/react';
import {useNavigate} from 'react-router-dom';
import type {SourceInfo} from '@sd/ts-client';
import {formatBytes} from '../../routes/explorer/utils';
import {
availabilityBadge,
availabilityDescription,
sourceAvailability
} from './sourceAvailability';
/**
* A paired device's source, replicated through the peer-mount plane — same
* card language as local drives, labelled with the owning device. Opens the
* explorer routed through that device's slug, which is what serves the
* listing from the replicated index.
* listing from the replicated index. The owner's volume state rides along
* in the replica, so a locked dataset on the owner reads as locked here
* rather than as a replica that stopped updating.
*/
function syncedLabel(iso: string): string {
@@ -28,6 +35,8 @@ export function ReplicaSourceCard({
deviceSlug: string | undefined;
}) {
const navigate = useNavigate();
const availability = sourceAvailability(source);
const badge = availabilityBadge(availability);
const open = () => {
if (!deviceSlug || !source.root) return;
@@ -40,12 +49,24 @@ export function ReplicaSourceCard({
return (
<button
onClick={open}
title={source.root ?? source.name}
title={
availabilityDescription(availability, undefined) ??
source.root ??
source.name
}
className="border-app-line bg-app-box hover:border-app-line/80 hover:bg-app-hover group relative rounded-lg border p-4 text-left transition-all"
>
<div className="mb-3 flex items-center gap-3">
<div className="bg-app-selected/40 flex size-9 shrink-0 items-center justify-center rounded-lg">
<HardDrive size={20} weight="bold" className="text-ink-dull" />
<HardDrive
size={20}
weight="bold"
className={
availability === 'available'
? 'text-ink-dull'
: 'text-ink-faint'
}
/>
</div>
<div className="min-w-0 flex-1">
<h3 className="text-ink truncate text-sm font-medium">
@@ -55,6 +76,14 @@ export function ReplicaSourceCard({
{source.device_label ?? 'paired device'}
</p>
</div>
{badge && (
<span className="bg-app-selected/40 text-ink-faint flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium">
{availability === 'locked' && (
<Lock size={10} weight="bold" />
)}
{badge.toLowerCase()}
</span>
)}
<span className="bg-app-selected/40 text-ink-faint rounded-full px-2 py-0.5 text-[10px] font-medium">
replica
</span>
@@ -0,0 +1,50 @@
import {describe, expect, test} from 'bun:test';
import {
availabilityBadge,
availabilityDescription,
sourceAvailability
} from './sourceAvailability';
describe('sourceAvailability', () => {
test('the volume state outranks the attached flag', () => {
expect(
sourceAvailability({attached: false, volume_state: 'Locked'})
).toBe('locked');
expect(
sourceAvailability({attached: false, volume_state: 'Unmounted'})
).toBe('unmounted');
expect(
sourceAvailability({attached: true, volume_state: 'Mounted'})
).toBe('available');
});
test('without a volume state, detached means offline', () => {
expect(sourceAvailability({attached: false, volume_state: null})).toBe(
'offline'
);
expect(sourceAvailability({attached: false})).toBe('offline');
expect(sourceAvailability({attached: true, volume_state: null})).toBe(
'available'
);
});
test('a badge names every state but available', () => {
expect(availabilityBadge('locked')).toBe('Locked');
expect(availabilityBadge('unmounted')).toBe('Unmounted');
expect(availabilityBadge('offline')).toBe('Offline');
expect(availabilityBadge('available')).toBeUndefined();
});
test('the description names the volume and says the index stays', () => {
expect(availabilityDescription('locked', 'vault')).toBe(
'vault is locked: its encryption key is not loaded. Files stay listed from the last index and cannot be opened until the key is loaded.'
);
expect(availabilityDescription('unmounted', undefined)).toBe(
'Its volume is not mounted. Files stay listed from the last index and cannot be opened until it mounts.'
);
expect(availabilityDescription('offline', 'vault')).toContain(
'not connected'
);
expect(availabilityDescription('available', 'vault')).toBeUndefined();
});
});
@@ -0,0 +1,66 @@
import type {VolumeState} from '@sd/ts-client';
/**
* What stands between the library and a source's files right now, as the
* sidebar, the Storage page and a source's detail all say it.
*
* `locked` and `unmounted` come from the volume index's state of the drive
* under the source; `offline` is a detached source the index has no state
* for (a drive that vanished, a replica whose owner is away). A mounted
* drive and an adapter, whose origin is a service, are `available`.
*/
export type SourceAvailability = 'available' | 'unmounted' | 'locked' | 'offline';
export function sourceAvailability(source: {
attached: boolean;
volume_state?: VolumeState | null;
}): SourceAvailability {
switch (source.volume_state) {
case 'Locked':
return 'locked';
case 'Unmounted':
return 'unmounted';
case 'Mounted':
return 'available';
default:
return source.attached ? 'available' : 'offline';
}
}
/** The badge a row or card wears, or nothing while the source is available. */
export function availabilityBadge(
availability: SourceAvailability
): string | undefined {
switch (availability) {
case 'locked':
return 'Locked';
case 'unmounted':
return 'Unmounted';
case 'offline':
return 'Offline';
default:
return undefined;
}
}
/**
* One sentence saying what the state means for the files, naming the
* volume when the caller knows it. The index stays browsable in every
* case: a locked or unmounted source still lists what it indexed.
*/
export function availabilityDescription(
availability: SourceAvailability,
volumeName: string | undefined
): string | undefined {
const volume = volumeName ?? 'Its volume';
switch (availability) {
case 'locked':
return `${volume} is locked: its encryption key is not loaded. Files stay listed from the last index and cannot be opened until the key is loaded.`;
case 'unmounted':
return `${volume} is not mounted. Files stay listed from the last index and cannot be opened until it mounts.`;
case 'offline':
return 'The drive is not connected. Files stay listed from the last index.';
default:
return undefined;
}
}
@@ -17,6 +17,8 @@ export interface SidebarItemProps {
onContextMenu?: (event: React.MouseEvent<HTMLButtonElement>) => void;
badge?: string | number;
badgeLabel?: string;
/** Drawn before the badge text, for a state a glyph says faster than a word. */
badgeIcon?: Icon;
disabled?: boolean;
tone?: SidebarItemTone;
title?: string;
@@ -49,6 +51,7 @@ export function SidebarItem({
onContextMenu,
badge,
badgeLabel,
badgeIcon,
disabled = false,
tone = 'default',
title
@@ -66,6 +69,7 @@ export function SidebarItem({
location.pathname.startsWith(`${prefix}/`)
);
const IconComponent = icon;
const BadgeIcon = badgeIcon;
return (
<button
@@ -108,12 +112,13 @@ export function SidebarItem({
<span
aria-label={badgeLabel}
className={clsx(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none',
'flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none',
tone === 'warning'
? 'bg-status-warning/15 text-status-warning'
: 'bg-sidebar-selected text-sidebar-ink-dull'
)}
>
{BadgeIcon && <BadgeIcon size={10} weight="bold" />}
{badge}
</span>
)}
@@ -9,6 +9,7 @@ import {
House,
Images,
ListBullets,
Lock,
ShieldCheck,
Trash
} from '@phosphor-icons/react';
@@ -44,6 +45,11 @@ import {
import {Thumb} from '../../routes/explorer/File/Thumb';
import {useJobsContext} from '../JobManager/hooks/JobsContext';
import {PairingModal} from '../modals/PairingModal';
import {
availabilityBadge,
availabilityDescription,
sourceAvailability
} from '../Sources/sourceAvailability';
import {useSyncCount} from '../SyncMonitor/hooks/useSyncCount';
import {TagDot, tagColor} from '../Tags';
import {resolveItemMetadata} from './hooks/spaceItemUtils';
@@ -443,6 +449,10 @@ export function SpacesSidebar({
deviceSlug,
deviceSlugById
);
const availability =
sourceAvailability(source);
const badge =
availabilityBadge(availability);
return (
<SidebarItem
key={source.id}
@@ -456,21 +466,28 @@ export function SpacesSidebar({
}
href={href}
tone={
source.attached
availability === 'available'
? 'default'
: 'muted'
}
badge={
source.attached
? undefined
: 'Offline'
badge={badge}
badgeIcon={
availability === 'locked'
? Lock
: undefined
}
badgeLabel={
source.attached
? undefined
: `${source.name} is offline`
badge &&
`${source.name} is ${badge.toLowerCase()}`
}
title={
availabilityDescription(
availability,
undefined
) ??
source.root ??
source.name
}
title={source.root ?? source.name}
onSelect={() => {
const [pathname, query] =
href.split('?');
@@ -32,6 +32,7 @@ import {
} from "../../../contexts/SpacedriveContext";
import { describeError, Failure } from "../../../components/modals/RearrangeModal";
import {
addRefusal,
buildPayload,
changedKeys,
containingVolume,
@@ -368,6 +369,12 @@ function SetupDialog({
},
{ enabled: target.kind === "path" },
);
// Which of the listed volumes are this device's, so a drive another
// device tracked at the same mount point cannot refuse an add here.
const { data: devices } = useLibraryQuery({
type: "devices.list",
input: { include_offline: true, include_details: false, show_paired: false },
});
const trackSource = useLibraryMutation("sources.track");
const trackVolume = useLibraryMutation("volumes.track");
const updateConfig = useLibraryMutation("config.library.update");
@@ -379,7 +386,18 @@ function SetupDialog({
const library = libraryDefaults(config?.adding);
const defaults = effectiveDefaults(library, target, volume, inclusion.exact);
const choices = placementChoices(volume, inclusion.exact);
const loaded = Boolean(config && sources && volumeList);
const loaded = Boolean(config && sources && volumeList && devices);
// A drive that is unmounted or locked cannot be added until it is back;
// the core would refuse, and the modal says why before the attempt. The
// decision waits for the device list, since without it every device's
// rows would be weighed.
const refusal = devices
? addRefusal(
target,
volumes,
devices.find((device) => device.is_current)?.id,
)
: undefined;
const [edits, setEdits] = useState<Partial<AddSettings>>({});
const [typedName, setTypedName] = useState<string | null>(null);
@@ -454,12 +472,19 @@ function SetupDialog({
onSubmit={() => void submit()}
ctaLabel={risk ? "Add anyway" : inclusion.exact ? "Re-add to Library" : "Add to Library"}
ctaDanger={Boolean(risk)}
submitDisabled={!loaded || pending}
submitDisabled={!loaded || pending || Boolean(refusal)}
loading={pending}
>
<div className="space-y-4 py-1">
<IncludeCard target={target} volume={volume} />
{refusal && (
<Notice tone="warning">
<span className="font-medium text-ink">Cannot add this now.</span>{" "}
{refusal}
</Notice>
)}
{inclusion.exact && (
<Notice tone="info">
<span className="font-medium text-ink">
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import type { SourceInfo, Volume } from "@sd/ts-client";
import {
addRefusal,
buildPayload,
containingVolume,
effectiveDefaults,
@@ -246,3 +247,86 @@ describe("containing volume", () => {
expect(containingVolume("/mnt/data/file", volumes)?.id).toBe("data");
});
});
describe("addRefusal", () => {
const mounted = volume({ id: "pool", name: "pool", mount_point: "/mnt/pool" });
const locked = volume({
id: "vault",
name: "vault",
mount_point: "/mnt/pool/vault",
is_mounted: false,
locked: true,
});
const unmounted = volume({
id: "archive",
name: "archive",
display_name: "Archive",
mount_point: "/mnt/archive",
is_mounted: false,
});
const volumes = [mounted, locked, unmounted];
test("a mounted drive or a folder on one adds", () => {
expect(addRefusal({ kind: "volume", volume: mounted }, volumes, "dev")).toBeUndefined();
expect(
addRefusal({ kind: "path", path: "/mnt/pool/photos" }, volumes, "dev"),
).toBeUndefined();
});
test("a locked drive is refused with its key as the reason", () => {
expect(addRefusal({ kind: "volume", volume: locked }, volumes, "dev")).toBe(
"vault is locked: its encryption key is not loaded. Load the key and mount it, then add it.",
);
});
test("an unmounted drive is refused under its display name", () => {
expect(addRefusal({ kind: "volume", volume: unmounted }, volumes, "dev")).toBe(
"Archive is not mounted. Mount it, then add it.",
);
});
test("a path at or under an away drive's mount point is refused like the drive", () => {
expect(addRefusal({ kind: "path", path: "/mnt/pool/vault" }, volumes, "dev")).toContain(
"vault is locked",
);
expect(
addRefusal({ kind: "path", path: "/mnt/pool/vault/photos" }, volumes, "dev"),
).toContain("vault is locked");
expect(addRefusal({ kind: "path", path: "/mnt/archive" }, volumes, "dev")).toContain(
"Archive is not mounted",
);
expect(addRefusal({ kind: "path", path: "/mnt/archives" }, volumes, "dev")).toBeUndefined();
});
test("another device's away drive never refuses a path here", () => {
const laptops = volume({
id: "t7-laptop",
name: "T7",
device_id: "laptop",
mount_point: "/Volumes/T7",
is_mounted: false,
});
expect(
addRefusal({ kind: "path", path: "/Volumes/T7/photos" }, [laptops], "dev"),
).toBeUndefined();
const here = volume({ id: "t7", name: "T7", mount_point: "/Volumes/T7" });
expect(
addRefusal({ kind: "path", path: "/Volumes/T7/photos" }, [laptops, here], "dev"),
).toBeUndefined();
});
test("without a known device id, a mounted drive at the same mount point wins", () => {
const stale = volume({
id: "t7-old",
name: "T7",
device_id: "laptop",
mount_point: "/Volumes/T7",
is_mounted: false,
});
const here = volume({ id: "t7", name: "T7", mount_point: "/Volumes/T7" });
expect(
addRefusal({ kind: "path", path: "/Volumes/T7/photos" }, [stale, here], undefined),
).toBeUndefined();
});
});
@@ -91,6 +91,51 @@ export function containingVolume(
return best?.volume;
}
/**
* Why the add cannot start at all, when the target is a drive this device
* knows but cannot read: a whole drive that is unmounted or locked, or a
* path at or under such a drive's mount point. The core refuses these
* (`sources.track` names the volume's state), and the directory left at
* the mount point would otherwise be added as an empty folder of the
* parent drive.
*
* Only this device's volumes count, since `volumes.list` also carries
* other devices' tracked rows and peers' published volumes under their
* own mount points: a drive the laptop tracked at `/Volumes/T7` must not
* refuse the same path here. Among this device's volumes the deepest
* mount point wins, a mounted one first at equal depth, so a mounted
* drive covering the path is never outranked by an away row beside it.
*/
export function addRefusal(
target: AddTarget,
volumes: readonly Volume[],
deviceId: string | undefined,
): string | undefined {
let away: Volume | undefined;
if (target.kind === "volume") {
away = target.volume;
} else {
let best: { volume: Volume; depth: number } | undefined;
for (const volume of volumes) {
if (deviceId !== undefined && volume.device_id !== deviceId) continue;
for (const mount of [volume.mount_point, ...volume.mount_points]) {
if (!mount || !isUnder(target.path, mount)) continue;
const better =
!best ||
mount.length > best.depth ||
(mount.length === best.depth && volume.is_mounted && !best.volume.is_mounted);
if (better) best = { volume, depth: mount.length };
}
}
away = best?.volume;
}
if (!away || away.is_mounted) return undefined;
const name = away.display_name || away.name;
return away.locked
? `${name} is locked: its encryption key is not loaded. Load the key and mount it, then add it.`
: `${name} is not mounted. Mount it, then add it.`;
}
/** How the library already covers the path, before anything is added. */
export interface Inclusion {
/** A source whose root is exactly this path. */
@@ -1,4 +1,10 @@
import {ArrowDown, ArrowUp, DotsThree, EyeSlash} from '@phosphor-icons/react';
import {
ArrowDown,
ArrowUp,
DotsThree,
EyeSlash,
Lock
} from '@phosphor-icons/react';
import type {SourceInfo, Volume} from '@sd/ts-client';
import {CircleButton} from '@spacedrive/primitives';
import {motion} from 'framer-motion';
@@ -240,6 +246,21 @@ export function VolumeBar({volume, sources, isLocal, index}: VolumeBarProps) {
{volumeTypeStr}
</span>
)}
{volume.locked ? (
<span
title="The encryption key is not loaded"
className="bg-app-box border-app-line text-ink-dull flex items-center gap-1 rounded border px-1.5 py-0.5"
>
<Lock size={10} weight="bold" />
locked
</span>
) : (
!volume.is_mounted && (
<span className="bg-app-box border-app-line text-ink-faint rounded border px-1.5 py-0.5">
unmounted
</span>
)
)}
{indexingProgress ? (
<span className="bg-accent/20 border-accent/30 text-accent rounded border px-1.5 py-0.5 font-medium">
{indexingProgress.filesIndexed.toLocaleString()}{' '}
@@ -6,6 +6,7 @@ import {
ArrowLeft,
ArrowsClockwise,
DotsThree,
Lock,
Trash,
ArrowSquareUp,
} from "@phosphor-icons/react";
@@ -19,6 +20,11 @@ import { CircleButton, Popover, usePopover } from "@spacedrive/primitives";
import { ExpandableSearchButton } from "../explorer/components/ExpandableSearchButton";
import { SourcePathBar } from "../../components/Sources/SourcePathBar";
import { SourceDataRow } from "../../components/Sources/SourceDataRow";
import {
availabilityBadge,
availabilityDescription,
sourceAvailability,
} from "../../components/Sources/sourceAvailability";
const PAGE_SIZE = 100;
@@ -79,6 +85,20 @@ export function SourceDetail() {
input: {},
});
// The drive under a filesystem source, for its name in the state banner.
const { data: volumeList } = useLibraryQuery(
{ type: "volumes.list", input: { filter: "All" } },
{ enabled: source?.data_type === "filesystem" },
);
const availability = source ? sourceAvailability(source) : "available";
const volumeName = volumeList?.volumes.find(
(volume) => volume.id === source?.volume_uuid,
)?.name;
const availabilityNote =
source?.data_type === "filesystem"
? availabilityDescription(availability, volumeName)
: undefined;
const syncMutation = useLibraryMutation("sources.sync");
const deleteMutation = useLibraryMutation("sources.delete");
const updateMutation = useLibraryMutation("adapters.update");
@@ -274,10 +294,27 @@ export function SourceDetail() {
/>
{/* Banners */}
{(updateMutation.data ||
{(availabilityNote ||
updateMutation.data ||
updateMutation.error ||
syncMutation.error) && (
<div className="border-app-line/30 space-y-2 border-b px-6 py-3">
{availabilityNote && (
<div
data-testid="source-availability"
className="border-app-line bg-app-box flex items-center gap-2 rounded-lg border p-3"
>
{availability === "locked" && (
<Lock size={14} weight="bold" className="text-ink-dull" />
)}
<p className="text-ink-dull text-xs">
<span className="text-ink font-medium">
{availabilityBadge(availability)}.
</span>{" "}
{availabilityNote}
</p>
</div>
)}
{updateMutation.data && (
<div className="border-accent/20 rounded-lg border p-3">
<p className="text-accent text-xs">
+14 -1
View File
@@ -1146,7 +1146,14 @@ error_type: string } } | { LibraryStatisticsUpdated: { library_id: string; stati
* Refresh event - signals that all frontend caches should be invalidated
* Emitted after major data recalculations (e.g., volume unique_bytes refresh)
*/
"Refresh" | { ProxyPairingConfirmationRequired: { session_id: string; vouchee_device_name: string; vouchee_device_os: string; voucher_device_name: string; voucher_device_id: string; expires_at: string } } | { ProxyPairingVouchingReady: { session_id: string; vouchee_device_id: string } } | { VolumeAdded: Volume } | { VolumeRemoved: { fingerprint: VolumeFingerprint } } | { VolumeUpdated: { fingerprint: VolumeFingerprint; old_info: VolumeInfo; new_info: VolumeInfo } } | { VolumeSpeedTested: { fingerprint: VolumeFingerprint; read_speed_mbps: number; write_speed_mbps: number } } | { VolumeMountChanged: { fingerprint: VolumeFingerprint; is_mounted: boolean } } | { VolumeError: { fingerprint: VolumeFingerprint; error: string } } | { JobQueued: { job_id: string; job_type: string; device_id: string } } | { JobStarted: { job_id: string; job_type: string; device_id: string } } | { JobProgress: { job_id: string; job_type: string; device_id: string; progress: number; message: string | null; generic_progress: GenericProgress | null } } | { JobCompleted: { job_id: string; job_type: string; device_id: string; output: JobOutput } } | { JobFailed: { job_id: string; job_type: string; device_id: string; error: string } } | { JobCancelled: { job_id: string; job_type: string; device_id: string } } | { JobPaused: { job_id: string; device_id: string } } | { JobResumed: { job_id: string; device_id: string } } | { DeviceConnected: { device_id: string; device_name: string } } | { DeviceDisconnected: { device_id: string } } | { SyncStateChanged: { library_id: string; previous_state: string; new_state: string; timestamp: string } } | { SyncActivity: { library_id: string; peer_device_id: string; activity_type: SyncActivityType; model_type: string | null; count: number; timestamp: string } } | { SyncConnectionChanged: { library_id: string; peer_device_id: string; peer_name: string; connected: boolean; timestamp: string } } | { SyncError: { library_id: string; peer_device_id: string | null; error_type: string; message: string; timestamp: string } } | { ResourceChanged: {
"Refresh" | { ProxyPairingConfirmationRequired: { session_id: string; vouchee_device_name: string; vouchee_device_os: string; voucher_device_name: string; voucher_device_id: string; expires_at: string } } | { ProxyPairingVouchingReady: { session_id: string; vouchee_device_id: string } } | { VolumeAdded: Volume } | { VolumeRemoved: { fingerprint: VolumeFingerprint } } | { VolumeUpdated: { fingerprint: VolumeFingerprint; old_info: VolumeInfo; new_info: VolumeInfo } } | { VolumeSpeedTested: { fingerprint: VolumeFingerprint; read_speed_mbps: number; write_speed_mbps: number } } | { VolumeMountChanged: { fingerprint: VolumeFingerprint; is_mounted: boolean;
/**
* Mounted, unmounted, or locked: what the volume became. A dataset
* whose key is unloaded after it was already unmounted changes
* state without changing `is_mounted`. Absent from a daemon built
* before the field existed; readers fall back to `is_mounted`.
*/
state?: VolumeState | null } } | { VolumeError: { fingerprint: VolumeFingerprint; error: string } } | { JobQueued: { job_id: string; job_type: string; device_id: string } } | { JobStarted: { job_id: string; job_type: string; device_id: string } } | { JobProgress: { job_id: string; job_type: string; device_id: string; progress: number; message: string | null; generic_progress: GenericProgress | null } } | { JobCompleted: { job_id: string; job_type: string; device_id: string; output: JobOutput } } | { JobFailed: { job_id: string; job_type: string; device_id: string; error: string } } | { JobCancelled: { job_id: string; job_type: string; device_id: string } } | { JobPaused: { job_id: string; device_id: string } } | { JobResumed: { job_id: string; device_id: string } } | { DeviceConnected: { device_id: string; device_name: string } } | { DeviceDisconnected: { device_id: string } } | { SyncStateChanged: { library_id: string; previous_state: string; new_state: string; timestamp: string } } | { SyncActivity: { library_id: string; peer_device_id: string; activity_type: SyncActivityType; model_type: string | null; count: number; timestamp: string } } | { SyncConnectionChanged: { library_id: string; peer_device_id: string; peer_name: string; connected: boolean; timestamp: string } } | { SyncError: { library_id: string; peer_device_id: string | null; error_type: string; message: string; timestamp: string } } | { ResourceChanged: {
/**
* Resource type identifier (e.g., "file", "space", "device")
*/
@@ -2042,6 +2049,12 @@ volume_uuid: string | null;
* The root exists on disk right now
*/
attached: boolean;
/**
* How the drive under the source stands: mounted, unmounted, or locked
* because its key is not loaded. Absent on media Spacedrive does not
* track.
*/
volume_state?: VolumeState | null;
/**
* A snapshot restore has populated this source's index this session
*/