Location Settings (#302)

* Delete locations

+ Show online status

* style tweaks

* tweaks

* location rescan button

* fix location delete db locking bug

* opting to remove self referencial relation on file_path

* correct query

* consolodate migration

* consolodate migrations
This commit is contained in:
Jamie Pine 2022-06-24 06:26:45 -07:00 committed by GitHub
parent 7cb6e4f574
commit 2cc3f3d95d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 416 additions and 167 deletions

View file

@ -17,6 +17,7 @@
"proptype", "proptype",
"quicktime", "quicktime",
"repr", "repr",
"rescan",
"Roadmap", "Roadmap",
"subpackage", "subpackage",
"svgr", "svgr",

View file

@ -2,5 +2,8 @@
"name": "@sd/server", "name": "@sd/server",
"version": "0.0.0", "version": "0.0.0",
"main": "index.js", "main": "index.js",
"license": "GPL-3.0-only" "license": "GPL-3.0-only",
"scripts": {
"dev": "cargo watch -x 'run -p server'"
}
} }

View file

@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ClientCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileDelete", params: { id: number, } } | { key: "LibDelete", params: { id: number, } } | { key: "TagCreate", params: { name: string, color: string, } } | { key: "TagUpdate", params: { name: string, color: string, } } | { key: "TagAssign", params: { file_id: number, tag_id: number, } } | { key: "TagDelete", params: { id: number, } } | { key: "LocCreate", params: { path: string, } } | { key: "LocUpdate", params: { id: number, name: string | null, } } | { key: "LocDelete", params: { id: number, } } | { key: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } }; export type ClientCommand = { key: "FileReadMetaData", params: { id: number, } } | { key: "FileSetNote", params: { id: number, note: string | null, } } | { key: "FileDelete", params: { id: number, } } | { key: "LibDelete", params: { id: number, } } | { key: "TagCreate", params: { name: string, color: string, } } | { key: "TagUpdate", params: { name: string, color: string, } } | { key: "TagAssign", params: { file_id: number, tag_id: number, } } | { key: "TagDelete", params: { id: number, } } | { key: "LocCreate", params: { path: string, } } | { key: "LocUpdate", params: { id: number, name: string | null, } } | { key: "LocDelete", params: { id: number, } } | { key: "LocRescan", params: { id: number, } } | { key: "SysVolumeUnmount", params: { id: number, } } | { key: "GenerateThumbsForLocation", params: { id: number, path: string, } } | { key: "IdentifyUniqueFiles", params: { id: number, path: string, } };

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JobStatus } from "./JobStatus"; import type { JobStatus } from "./JobStatus";
export interface JobReport { id: string, name: string, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: string, } export interface JobReport { id: string, name: string, data: string | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: string, }

View file

@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Platform } from "./Platform"; import type { Platform } from "./Platform";
export interface LibraryNode { uuid: string, name: string, platform: Platform, tcp_address: string, last_seen: string, last_synchronized: string, } export interface LibraryNode { uuid: string, name: string, platform: Platform, last_seen: string, }

View file

@ -1,3 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LibraryNode } from "./LibraryNode";
export interface LocationResource { id: number, name: string | null, path: string | null, total_capacity: number | null, available_capacity: number | null, is_removable: boolean | null, is_online: boolean, date_created: string, } export interface LocationResource { id: number, name: string | null, path: string | null, total_capacity: number | null, available_capacity: number | null, is_removable: boolean | null, node: LibraryNode | null, is_online: boolean, date_created: string, }

View file

@ -0,0 +1,48 @@
-- AlterTable
ALTER TABLE "jobs" ADD COLUMN "data" TEXT;
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_locations" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pub_id" TEXT NOT NULL,
"node_id" INTEGER,
"name" TEXT,
"local_path" TEXT,
"total_capacity" INTEGER,
"available_capacity" INTEGER,
"filesystem" TEXT,
"disk_type" INTEGER,
"is_removable" BOOLEAN,
"is_online" BOOLEAN NOT NULL DEFAULT true,
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "locations_node_id_fkey" FOREIGN KEY ("node_id") REFERENCES "nodes" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_locations" ("available_capacity", "date_created", "disk_type", "filesystem", "id", "is_online", "is_removable", "local_path", "name", "node_id", "pub_id", "total_capacity") SELECT "available_capacity", "date_created", "disk_type", "filesystem", "id", "is_online", "is_removable", "local_path", "name", "node_id", "pub_id", "total_capacity" FROM "locations";
DROP TABLE "locations";
ALTER TABLE "new_locations" RENAME TO "locations";
CREATE UNIQUE INDEX "locations_pub_id_key" ON "locations"("pub_id");
CREATE TABLE "new_file_paths" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"is_dir" BOOLEAN NOT NULL DEFAULT false,
"location_id" INTEGER,
"materialized_path" TEXT NOT NULL,
"name" TEXT NOT NULL,
"extension" TEXT,
"file_id" INTEGER,
"parent_id" INTEGER,
"key_id" INTEGER,
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date_modified" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date_indexed" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "file_paths_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "locations" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "file_paths_file_id_fkey" FOREIGN KEY ("file_id") REFERENCES "files" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "file_paths_key_id_fkey" FOREIGN KEY ("key_id") REFERENCES "keys" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_file_paths" ("date_created", "date_indexed", "date_modified", "extension", "file_id", "id", "is_dir", "key_id", "location_id", "materialized_path", "name", "parent_id") SELECT "date_created", "date_indexed", "date_modified", "extension", "file_id", "id", "is_dir", "key_id", "location_id", "materialized_path", "name", "parent_id" FROM "file_paths";
DROP TABLE "file_paths";
ALTER TABLE "new_file_paths" RENAME TO "file_paths";
CREATE INDEX "file_paths_location_id_idx" ON "file_paths"("location_id");
CREATE UNIQUE INDEX "file_paths_location_id_materialized_path_name_extension_key" ON "file_paths"("location_id", "materialized_path", "name", "extension");
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -74,6 +74,7 @@ model Node {
sync_events SyncEvent[] sync_events SyncEvent[]
jobs Job[] jobs Job[]
Location Location[]
@@map("nodes") @@map("nodes")
} }
@ -107,14 +108,14 @@ model Location {
is_online Boolean @default(true) is_online Boolean @default(true)
date_created DateTime @default(now()) date_created DateTime @default(now())
node Node? @relation(fields: [node_id], references: [id])
file_paths FilePath[] file_paths FilePath[]
@@map("locations") @@map("locations")
} }
model File { model File {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
// content addressable storage id - sha256 // content addressable storage id - sha256 sampled checksum
// this does not need to be unique, as incoming replicas will always ignore if at least one exists
cas_id String @unique cas_id String @unique
// full byte contents digested into sha256 checksum // full byte contents digested into sha256 checksum
integrity_checksum String? @unique integrity_checksum String? @unique
@ -157,7 +158,7 @@ model FilePath {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
is_dir Boolean @default(false) is_dir Boolean @default(false)
// location that owns this path // location that owns this path
location_id Int location_id Int?
// a path generated from local file_path ids eg: "34/45/67/890" // a path generated from local file_path ids eg: "34/45/67/890"
materialized_path String materialized_path String
// the name and extension // the name and extension
@ -175,14 +176,17 @@ model FilePath {
date_modified DateTime @default(now()) date_modified DateTime @default(now())
date_indexed DateTime @default(now()) date_indexed DateTime @default(now())
file File? @relation(fields: [file_id], references: [id], onDelete: Cascade, onUpdate: Cascade) file File? @relation(fields: [file_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
location Location? @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: Cascade) location Location? @relation(fields: [location_id], references: [id], onDelete: Cascade, onUpdate: Cascade)
parent FilePath? @relation("directory_file_paths", fields: [parent_id], references: [id])
children FilePath[] @relation("directory_file_paths") // NOTE: this self relation for the file tree was causing SQLite to go to forever bed, disabling until workaround
// parent FilePath? @relation("directory_file_paths", fields: [parent_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
// children FilePath[] @relation("directory_file_paths")
key Key? @relation(fields: [key_id], references: [id]) key Key? @relation(fields: [key_id], references: [id])
@@unique([location_id, materialized_path, name, extension]) @@unique([location_id, materialized_path, name, extension])
@@index([location_id])
@@map("file_paths") @@map("file_paths")
} }
@ -282,11 +286,12 @@ model LabelOnFile {
} }
model Job { model Job {
id String @id id String @id
name String name String
node_id Int node_id Int
action Int action Int
status Int @default(0) status Int @default(0)
data String?
task_count Int @default(1) task_count Int @default(1)
completed_task_count Int @default(0) completed_task_count Int @default(0)

View file

@ -151,7 +151,7 @@ pub async fn get_images(
path: &str, path: &str,
) -> Result<Vec<file_path::Data>, std::io::Error> { ) -> Result<Vec<file_path::Data>, std::io::Error> {
let mut params = vec![ let mut params = vec![
file_path::location_id::equals(location_id), file_path::location_id::equals(Some(location_id)),
file_path::extension::in_vec(vec![ file_path::extension::in_vec(vec![
"png".to_string(), "png".to_string(),
"jpeg".to_string(), "jpeg".to_string(),

View file

@ -159,7 +159,12 @@ impl Job for FileIdentifierJob {
} }
// handle loop end // handle loop end
let last_row = file_paths.last().unwrap(); let last_row = match file_paths.last() {
Some(l) => l,
None => {
break;
}
};
cursor = last_row.id; cursor = last_row.id;
completed += 1; completed += 1;

View file

@ -22,7 +22,7 @@ pub async fn open_dir(
let directory = db let directory = db
.file_path() .file_path()
.find_first(vec![ .find_first(vec![
file_path::location_id::equals(location.id), file_path::location_id::equals(Some(location.id)),
file_path::materialized_path::equals(path.into()), file_path::materialized_path::equals(path.into()),
file_path::is_dir::equals(true), file_path::is_dir::equals(true),
]) ])
@ -35,7 +35,7 @@ pub async fn open_dir(
let mut file_paths: Vec<FilePath> = db let mut file_paths: Vec<FilePath> = db
.file_path() .file_path()
.find_many(vec![ .find_many(vec![
file_path::location_id::equals(location.id), file_path::location_id::equals(Some(location.id)),
file_path::parent_id::equals(Some(directory.id)), file_path::parent_id::equals(Some(directory.id)),
]) ])
.with(file_path::file::fetch()) .with(file_path::file::fetch())

View file

@ -110,7 +110,7 @@ impl Into<FilePath> for file_path::Data {
materialized_path: self.materialized_path, materialized_path: self.materialized_path,
file_id: self.file_id, file_id: self.file_id,
parent_id: self.parent_id, parent_id: self.parent_id,
location_id: self.location_id, location_id: self.location_id.unwrap_or(0),
date_indexed: self.date_indexed.into(), date_indexed: self.date_indexed.into(),
name: self.name, name: self.name,
extension: self.extension, extension: self.extension,

View file

@ -18,7 +18,8 @@ use std::{
use tokio::sync::Mutex; use tokio::sync::Mutex;
use ts_rs::TS; use ts_rs::TS;
const MAX_WORKERS: usize = 4; // db is single threaded, nerd
const MAX_WORKERS: usize = 1;
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Job: Send + Sync + Debug { pub trait Job: Send + Sync + Debug {
@ -40,6 +41,7 @@ impl Jobs {
running_workers: HashMap::new(), running_workers: HashMap::new(),
} }
} }
pub async fn ingest(&mut self, ctx: &CoreContext, job: Box<dyn Job>) { pub async fn ingest(&mut self, ctx: &CoreContext, job: Box<dyn Job>) {
// create worker to process job // create worker to process job
if self.running_workers.len() < MAX_WORKERS { if self.running_workers.len() < MAX_WORKERS {
@ -57,6 +59,7 @@ impl Jobs {
self.job_queue.push_back(job); self.job_queue.push_back(job);
} }
} }
pub fn ingest_queue(&mut self, _ctx: &CoreContext, job: Box<dyn Job>) { pub fn ingest_queue(&mut self, _ctx: &CoreContext, job: Box<dyn Job>) {
self.job_queue.push_back(job); self.job_queue.push_back(job);
} }
@ -69,6 +72,7 @@ impl Jobs {
self.ingest(ctx, job).await; self.ingest(ctx, job).await;
} }
} }
pub async fn get_running(&self) -> Vec<JobReport> { pub async fn get_running(&self) -> Vec<JobReport> {
let mut ret = vec![]; let mut ret = vec![];
@ -78,6 +82,19 @@ impl Jobs {
} }
ret ret
} }
pub async fn queue_pending_job(ctx: &CoreContext) -> Result<(), JobError> {
let db = &ctx.database;
let next_job = db
.job()
.find_first(vec![job::status::equals(JobStatus::Queued.int_value())])
.exec()
.await?;
Ok(())
}
pub async fn get_history(ctx: &CoreContext) -> Result<Vec<JobReport>, JobError> { pub async fn get_history(ctx: &CoreContext) -> Result<Vec<JobReport>, JobError> {
let db = &ctx.database; let db = &ctx.database;
let jobs = db let jobs = db
@ -103,6 +120,7 @@ pub enum JobReportUpdate {
pub struct JobReport { pub struct JobReport {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub data: Option<String>,
// client_id: i32, // client_id: i32,
#[ts(type = "string")] #[ts(type = "string")]
pub date_created: chrono::DateTime<chrono::Utc>, pub date_created: chrono::DateTime<chrono::Utc>,
@ -131,6 +149,7 @@ impl Into<JobReport> for job::Data {
completed_task_count: self.completed_task_count, completed_task_count: self.completed_task_count,
date_created: self.date_created.into(), date_created: self.date_created.into(),
date_modified: self.date_modified.into(), date_modified: self.date_modified.into(),
data: self.data,
message: String::new(), message: String::new(),
seconds_elapsed: self.seconds_elapsed, seconds_elapsed: self.seconds_elapsed,
} }
@ -147,6 +166,7 @@ impl JobReport {
date_modified: chrono::Utc::now(), date_modified: chrono::Utc::now(),
status: JobStatus::Queued, status: JobStatus::Queued,
task_count: 0, task_count: 0,
data: None,
completed_task_count: 0, completed_task_count: 0,
message: String::new(), message: String::new(),
seconds_elapsed: 0, seconds_elapsed: 0,
@ -154,6 +174,13 @@ impl JobReport {
} }
pub async fn create(&self, ctx: &CoreContext) -> Result<(), JobError> { pub async fn create(&self, ctx: &CoreContext) -> Result<(), JobError> {
let config = get_nodestate(); let config = get_nodestate();
let mut params = Vec::new();
if let Some(_) = &self.data {
params.push(job::data::set(self.data.clone()))
}
ctx.database ctx.database
.job() .job()
.create( .create(
@ -161,7 +188,7 @@ impl JobReport {
job::name::set(self.name.clone()), job::name::set(self.name.clone()),
job::action::set(1), job::action::set(1),
job::nodes::link(node::id::equals(config.node_id)), job::nodes::link(node::id::equals(config.node_id)),
vec![], params,
) )
.exec() .exec()
.await?; .await?;

View file

@ -36,6 +36,10 @@ impl WorkerContext {
.send(WorkerEvent::Progressed(updates)) .send(WorkerEvent::Progressed(updates))
.unwrap_or(()); .unwrap_or(());
} }
// save the job data to
// pub fn save_data () {
// }
} }
// a worker is a dedicated thread that runs a single job // a worker is a dedicated thread that runs a single job

View file

@ -258,13 +258,11 @@ impl Node {
CoreResponse::Success(()) CoreResponse::Success(())
} }
ClientCommand::LocDelete { id } => { ClientCommand::LocDelete { id } => {
ctx.database sys::delete_location(&ctx, id).await?;
.location() CoreResponse::Success(())
.find_unique(location::id::equals(id)) }
.delete() ClientCommand::LocRescan { id } => {
.exec() sys::scan_location(&ctx, id, String::new());
.await?;
CoreResponse::Success(()) CoreResponse::Success(())
} }
// CRUD for files // CRUD for files
@ -374,6 +372,7 @@ pub enum ClientCommand {
LocCreate { path: String }, LocCreate { path: String },
LocUpdate { id: i32, name: Option<String> }, LocUpdate { id: i32, name: Option<String> },
LocDelete { id: i32 }, LocDelete { id: i32 },
LocRescan { id: i32 },
// System // System
SysVolumeUnmount { id: i32 }, SysVolumeUnmount { id: i32 },
GenerateThumbsForLocation { id: i32, path: String }, GenerateThumbsForLocation { id: i32, path: String },

View file

@ -19,11 +19,18 @@ pub struct LibraryNode {
pub uuid: String, pub uuid: String,
pub name: String, pub name: String,
pub platform: Platform, pub platform: Platform,
pub tcp_address: String,
#[ts(type = "string")]
pub last_seen: DateTime<Utc>, pub last_seen: DateTime<Utc>,
#[ts(type = "string")] }
pub last_synchronized: DateTime<Utc>,
impl Into<LibraryNode> for node::Data {
fn into(self) -> LibraryNode {
LibraryNode {
uuid: self.pub_id,
name: self.name,
platform: IntEnum::from_int(self.platform).unwrap(),
last_seen: self.last_seen.into(),
}
}
} }
#[repr(i32)] #[repr(i32)]

View file

@ -1,10 +1,11 @@
use crate::{ use crate::{
encode::ThumbnailJob, encode::ThumbnailJob,
file::{cas::FileIdentifierJob, indexer::IndexerJob}, file::{cas::FileIdentifierJob, indexer::IndexerJob},
node::get_nodestate, node::{get_nodestate, LibraryNode},
prisma::location, prisma::{file_path, location},
ClientQuery, CoreContext, CoreEvent, ClientQuery, CoreContext, CoreEvent,
}; };
use prisma_client_rust::{raw, PrismaValue};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{fs, io, io::Write, path::Path}; use std::{fs, io, io::Write, path::Path};
use thiserror::Error; use thiserror::Error;
@ -21,13 +22,14 @@ pub struct LocationResource {
pub total_capacity: Option<i32>, pub total_capacity: Option<i32>,
pub available_capacity: Option<i32>, pub available_capacity: Option<i32>,
pub is_removable: Option<bool>, pub is_removable: Option<bool>,
pub node: Option<LibraryNode>,
pub is_online: bool, pub is_online: bool,
#[ts(type = "string")] #[ts(type = "string")]
pub date_created: chrono::DateTime<chrono::Utc>, pub date_created: chrono::DateTime<chrono::Utc>,
} }
impl Into<LocationResource> for location::Data { impl Into<LocationResource> for location::Data {
fn into(self) -> LocationResource { fn into(mut self) -> LocationResource {
LocationResource { LocationResource {
id: self.id, id: self.id,
name: self.name, name: self.name,
@ -35,6 +37,7 @@ impl Into<LocationResource> for location::Data {
total_capacity: self.total_capacity, total_capacity: self.total_capacity,
available_capacity: self.available_capacity, available_capacity: self.available_capacity,
is_removable: self.is_removable, is_removable: self.is_removable,
node: self.node.take().unwrap_or(None).map(|node| (*node).into()),
is_online: self.is_online, is_online: self.is_online,
date_created: self.date_created.into(), date_created: self.date_created.into(),
} }
@ -81,26 +84,24 @@ pub async fn get_location(
Ok(location.into()) Ok(location.into())
} }
pub fn scan_location(ctx: &CoreContext, location_id: i32, path: String) {
ctx.spawn_job(Box::new(IndexerJob { path: path.clone() }));
ctx.queue_job(Box::new(FileIdentifierJob { location_id, path }));
// TODO: make a way to stop jobs so this can be canceled without rebooting app
// ctx.queue_job(Box::new(ThumbnailJob {
// location_id,
// path: "".to_string(),
// background: false,
// }));
}
pub async fn new_location_and_scan( pub async fn new_location_and_scan(
ctx: &CoreContext, ctx: &CoreContext,
path: &str, path: &str,
) -> Result<LocationResource, SysError> { ) -> Result<LocationResource, SysError> {
let location = create_location(&ctx, path).await?; let location = create_location(&ctx, path).await?;
ctx.spawn_job(Box::new(IndexerJob { scan_location(&ctx, location.id, path.to_string());
path: path.to_string(),
}));
ctx.queue_job(Box::new(FileIdentifierJob {
location_id: location.id,
path: path.to_string(),
}));
ctx.queue_job(Box::new(ThumbnailJob {
location_id: location.id,
path: "".to_string(),
background: false,
}));
Ok(location) Ok(location)
} }
@ -108,7 +109,12 @@ pub async fn new_location_and_scan(
pub async fn get_locations(ctx: &CoreContext) -> Result<Vec<LocationResource>, SysError> { pub async fn get_locations(ctx: &CoreContext) -> Result<Vec<LocationResource>, SysError> {
let db = &ctx.database; let db = &ctx.database;
let locations = db.location().find_many(vec![]).exec().await?; let locations = db
.location()
.find_many(vec![])
.with(location::node::fetch())
.exec()
.await?;
// turn locations into LocationResource // turn locations into LocationResource
let locations: Vec<LocationResource> = locations let locations: Vec<LocationResource> = locations
@ -175,6 +181,7 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
)), )),
location::is_online::set(true), location::is_online::set(true),
location::local_path::set(Some(path.to_string())), location::local_path::set(Some(path.to_string())),
location::node_id::set(Some(config.node_id)),
], ],
) )
.exec() .exec()
@ -213,6 +220,29 @@ pub async fn create_location(ctx: &CoreContext, path: &str) -> Result<LocationRe
Ok(location.into()) Ok(location.into())
} }
pub async fn delete_location(ctx: &CoreContext, location_id: i32) -> Result<(), SysError> {
let db = &ctx.database;
db.file_path()
.find_many(vec![file_path::location_id::equals(Some(location_id))])
.delete()
.exec()
.await?;
db.location()
.find_unique(location::id::equals(location_id))
.delete()
.exec()
.await?;
ctx.emit(CoreEvent::InvalidateQuery(ClientQuery::SysGetLocations))
.await;
println!("Location {} deleted", location_id);
Ok(())
}
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum LocationError { pub enum LocationError {
#[error("Failed to create location (uuid {uuid:?})")] #[error("Failed to create location (uuid {uuid:?})")]

View file

@ -19,7 +19,7 @@ export function AppLayout() {
return false; return false;
}} }}
className={clsx( className={clsx(
'flex flex-row h-screen overflow-hidden text-gray-900 select-none dark:text-white', 'flex flex-row h-screen overflow-hidden text-gray-900 select-none dark:text-white cursor-default',
isWindowRounded && 'rounded-xl', isWindowRounded && 'rounded-xl',
hasWindowBorder && 'border border-gray-200 dark:border-gray-500' hasWindowBorder && 'border border-gray-200 dark:border-gray-500'
)} )}

View file

@ -6,6 +6,7 @@ import React, { useState } from 'react';
import { Rings } from 'react-loading-icons'; import { Rings } from 'react-loading-icons';
import FileItem from '../file/FileItem'; import FileItem from '../file/FileItem';
import Loader from '../primitive/Loader';
import ProgressBar from '../primitive/ProgressBar'; import ProgressBar from '../primitive/ProgressBar';
export interface DeviceProps { export interface DeviceProps {
@ -45,13 +46,7 @@ export function Device(props: DeviceProps) {
<div className="flex flex-grow" /> <div className="flex flex-grow" />
{props.runningJob && ( {props.runningJob && (
<div className="flex flex-row ml-5 bg-gray-300 bg-opacity-50 rounded-md dark:bg-gray-550"> <div className="flex flex-row ml-5 bg-gray-300 bg-opacity-50 rounded-md dark:bg-gray-550">
<Rings <Loader />
stroke="#2599FF"
strokeOpacity={4}
strokeWidth={10}
speed={0.5}
className="ml-0.5 mt-[2px] -mr-1 w-7 h-7"
/>
<div className="flex flex-col p-2"> <div className="flex flex-col p-2">
<span className="mb-[2px] -mt-1 truncate text-gray-450 text-tiny"> <span className="mb-[2px] -mt-1 truncate text-gray-450 text-tiny">
{props.runningJob.task}... {props.runningJob.task}...

View file

@ -35,20 +35,20 @@ export const Inspector = (props: {
location?: LocationResource; location?: LocationResource;
selectedFile?: FilePath; selectedFile?: FilePath;
}) => { }) => {
const file_path = props.selectedFile; const file_path = props.selectedFile,
let full_path = `${props.location?.path}/${file_path?.materialized_path}`; full_path = `${props.location?.path}/${file_path?.materialized_path}`,
file_id = props.selectedFile?.file?.id || -1;
// notes are stored in global state by their file id // notes are cached in a store by their file id
// this is so we can ensure every note has been sent to Rust even // this is so we can ensure every note has been sent to Rust even
// when quickly navigating files, which cancels update function // when quickly navigating files, which cancels update function
const { notes, setNote, unCacheNote } = useInspectorState(); const { notes, setNote, unCacheNote } = useInspectorState();
const file_id = props.selectedFile?.file?.id || -1; // show cached note over server note, important to check for undefined not falsey
// show cached note over server note
const note = const note =
notes[file_id] === undefined ? props.selectedFile?.file?.note || null : notes[file_id]; notes[file_id] === undefined ? props.selectedFile?.file?.note || null : notes[file_id];
// when input is updated // when input is updated, cache note
function handleNoteUpdate(e: React.ChangeEvent<HTMLTextAreaElement>) { function handleNoteUpdate(e: React.ChangeEvent<HTMLTextAreaElement>) {
if (e.target.value !== note) { if (e.target.value !== note) {
setNote(file_id, e.target.value); setNote(file_id, e.target.value);
@ -63,95 +63,95 @@ export const Inspector = (props: {
}, [note]); }, [note]);
return ( return (
<Transition // <Transition
as={React.Fragment} // as={React.Fragment}
show={true} // show={true}
enter="transition-translate ease-in-out duration-200" // enter="transition-translate ease-in-out duration-200"
enterFrom="translate-x-64" // enterFrom="translate-x-64"
enterTo="translate-x-0" // enterTo="translate-x-0"
leave="transition-translate ease-in-out duration-200" // leave="transition-translate ease-in-out duration-200"
leaveFrom="translate-x-0" // leaveFrom="translate-x-0"
leaveTo="translate-x-64" // leaveTo="translate-x-64"
> // >
<div className="flex p-2 pr-1 mr-1 pb-[51px] w-72 flex-wrap overflow-x-hidden custom-scroll inspector-scroll"> <div className="flex p-2 pr-1 mr-1 pb-[51px] w-[330px] flex-wrap overflow-x-hidden custom-scroll inspector-scroll">
{!!file_path && ( {!!file_path && (
<div className="flex flex-col pb-2 overflow-hidden bg-white rounded-lg select-text dark:bg-gray-600 bg-opacity-70"> <div className="flex flex-col w-full pb-2 overflow-hidden bg-white rounded-lg select-text dark:bg-gray-600 bg-opacity-70">
<div className="flex items-center justify-center w-full h-64 overflow-hidden rounded-t-lg bg-gray-50 dark:bg-gray-900"> <div className="flex items-center justify-center w-full h-64 overflow-hidden rounded-t-lg bg-gray-50 dark:bg-gray-900">
<FileThumb <FileThumb
hasThumbnailOverride={false} hasThumbnailOverride={false}
className="!m-0 flex flex-shrink flex-grow-0" className="!m-0 flex flex-shrink flex-grow-0"
file={file_path} file={file_path}
locationId={props.locationId} locationId={props.locationId}
/>
</div>
<h3 className="pt-3 pl-3 text-base font-bold">{file_path?.name}</h3>
<div className="flex flex-row m-3 space-x-2">
<Button size="sm" noPadding>
<Heart className="w-[18px] h-[18px]" />
</Button>
<Button size="sm" noPadding>
<ShareIcon className="w-[18px] h-[18px]" />
</Button>
<Button size="sm" noPadding>
<Link className="w-[18px] h-[18px]" />
</Button>
</div>
{file_path?.file?.cas_id && (
<MetaItem title="Unique Content ID" value={file_path.file.cas_id as string} />
)}
<Divider />
<MetaItem title="URI" value={full_path} />
<Divider />
<MetaItem
title="Date Created"
value={moment(file_path?.date_created).format('MMMM Do YYYY, h:mm:ss a')}
/> />
<Divider /> </div>
<MetaItem <h3 className="pt-3 pl-3 text-base font-bold">{file_path?.name}</h3>
title="Date Indexed" <div className="flex flex-row m-3 space-x-2">
value={moment(file_path?.date_indexed).format('MMMM Do YYYY, h:mm:ss a')} <Button size="sm" noPadding>
/> <Heart className="w-[18px] h-[18px]" />
{!file_path?.is_dir && ( </Button>
<> <Button size="sm" noPadding>
<Divider /> <ShareIcon className="w-[18px] h-[18px]" />
<div className="flex flex-row items-center px-3 py-2 meta-item"> </Button>
{file_path?.extension && ( <Button size="sm" noPadding>
<span className="inline px-1 mr-1 text-xs font-bold uppercase bg-gray-500 rounded-md text-gray-150"> <Link className="w-[18px] h-[18px]" />
{file_path?.extension} </Button>
</span> </div>
)} {file_path?.file?.cas_id && (
<p className="text-xs text-gray-600 break-all truncate dark:text-gray-300"> <MetaItem title="Unique Content ID" value={file_path.file.cas_id as string} />
{file_path?.extension )}
? //@ts-ignore <Divider />
types[file_path.extension.toUpperCase()]?.descriptions.join(' / ') <MetaItem title="URI" value={full_path} />
: 'Unknown'} <Divider />
</p> <MetaItem
</div> title="Date Created"
{file_path.file && ( value={moment(file_path?.date_created).format('MMMM Do YYYY, h:mm:ss a')}
<> />
<Divider /> <Divider />
<MetaItem <MetaItem
title="Note" title="Date Indexed"
value={ value={moment(file_path?.date_indexed).format('MMMM Do YYYY, h:mm:ss a')}
<TextArea />
className="mt-2 text-xs leading-snug !py-2" {!file_path?.is_dir && (
value={note || ''} <>
onChange={handleNoteUpdate} <Divider />
/> <div className="flex flex-row items-center px-3 py-2 meta-item">
} {file_path?.extension && (
/> <span className="inline px-1 mr-1 text-xs font-bold uppercase bg-gray-500 rounded-md text-gray-150">
</> {file_path?.extension}
</span>
)} )}
</> <p className="text-xs text-gray-600 break-all truncate dark:text-gray-300">
)} {file_path?.extension
{/* <div className="flex flex-row m-3"> ? //@ts-ignore
types[file_path.extension.toUpperCase()]?.descriptions.join(' / ')
: 'Unknown'}
</p>
</div>
{file_path.file && (
<>
<Divider />
<MetaItem
title="Note"
value={
<TextArea
className="mt-2 text-xs leading-snug !py-2"
value={note || ''}
onChange={handleNoteUpdate}
/>
}
/>
</>
)}
</>
)}
{/* <div className="flex flex-row m-3">
<Button size="sm">Mint</Button> <Button size="sm">Mint</Button>
</div> */} </div> */}
{/* <MetaItem title="Date Last Modified" value={file?.date_modified} /> {/* <MetaItem title="Date Last Modified" value={file?.date_modified} />
<MetaItem title="Date Indexed" value={file?.date_indexed} /> */} <MetaItem title="Date Indexed" value={file?.date_indexed} /> */}
</div> </div>
)} )}
</div> </div>
</Transition> // </Transition>
); );
}; };

View file

@ -93,7 +93,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
return ( return (
<div <div
className={clsx( className={clsx(
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-600', 'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-750',
{ {
'dark:!bg-opacity-40': appProps?.platform === 'macOS' 'dark:!bg-opacity-40': appProps?.platform === 'macOS'
} }

View file

@ -1,14 +1,19 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Button } from '@sd/ui'; import { Button } from '@sd/ui';
import clsx from 'clsx';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import Loader from '../primitive/Loader';
export interface DialogProps { export interface DialogProps {
trigger: ReactNode; trigger: ReactNode;
ctaLabel?: string; ctaLabel?: string;
ctaDanger?: boolean;
ctaAction?: () => void; ctaAction?: () => void;
title?: string; title?: string;
description?: string; description?: string;
children: ReactNode; children?: ReactNode;
loading?: boolean;
} }
export default function Dialog(props: DialogProps) { export default function Dialog(props: DialogProps) {
@ -26,12 +31,21 @@ export default function Dialog(props: DialogProps) {
{props.children} {props.children}
</div> </div>
<div className="flex flex-row justify-end px-3 py-3 space-x-2 bg-gray-600 border-t border-gray-550"> <div className="flex flex-row justify-end px-3 py-3 space-x-2 bg-gray-600 border-t border-gray-550">
{props.loading && <Loader />}
<div className="flex-grow" />
<DialogPrimitive.Close asChild> <DialogPrimitive.Close asChild>
<Button size="sm" variant="gray"> <Button loading={props.loading} disabled={props.loading} size="sm" variant="gray">
Close Close
</Button> </Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
<Button onClick={props.ctaAction} size="sm" variant="primary"> <Button
onClick={props.ctaAction}
size="sm"
loading={props.loading}
disabled={props.loading}
variant={props.ctaDanger ? 'colored' : 'primary'}
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
>
{props.ctaLabel} {props.ctaLabel}
</Button> </Button>
</div> </div>

View file

@ -104,7 +104,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
<TopBarButton <TopBarButton
icon={ArrowsClockwise} icon={ArrowsClockwise}
onClick={() => { onClick={() => {
generateThumbsForLocation({ id: locationId, path: '' }); // generateThumbsForLocation({ id: locationId, path: '' });
}} }}
/> />
</div> </div>

View file

@ -0,0 +1,86 @@
import { DotsVerticalIcon, RefreshIcon } from '@heroicons/react/outline';
import { CogIcon, TrashIcon } from '@heroicons/react/solid';
import { command, useBridgeCommand } from '@sd/client';
import { LocationResource } from '@sd/core';
import { Button } from '@sd/ui';
import clsx from 'clsx';
import React, { useState } from 'react';
import { Folder } from '../icons/Folder';
import Dialog from '../layout/Dialog';
interface LocationListItemProps {
location: LocationResource;
}
export default function LocationListItem({ location }: LocationListItemProps) {
const [hide, setHide] = useState(false);
const { mutate: locRescan } = useBridgeCommand('LocRescan');
const { mutate: deleteLoc, isLoading: locDeletePending } = useBridgeCommand('LocDelete', {
onSuccess: () => {
setHide(true);
}
});
if (hide) return <></>;
return (
<div className="flex w-full px-4 py-2 border border-gray-500 rounded-lg bg-gray-550">
<DotsVerticalIcon className="w-5 h-5 mt-3 mr-1 -ml-3 cursor-move drag-handle opacity-10" />
<Folder size={30} className="mr-3" />
<div className="flex flex-col">
<h1 className="pt-0.5 text-sm font-semibold">{location.name}</h1>
<p className="mt-0.5 text-sm select-text text-gray-250">
<span className="py-[1px] px-1 bg-gray-500 rounded mr-1">{location.node?.name}</span>
{location.path}
</p>
</div>
<div className="flex flex-grow" />
<div className="flex h-[45px] p-2 space-x-2">
<Button disabled variant="gray" className="!py-1.5 !px-2 pointer-events-none flex">
<>
<div
className={clsx(
'w-2 h-2 rounded-full',
location.is_online ? 'bg-green-500' : 'bg-red-500'
)}
/>
<span className="ml-1.5 text-xs text-gray-350">
{location.is_online ? 'Online' : 'Offline'}
</span>
</>
</Button>
<Dialog
title="Delete Location"
description="Deleting a location will also remove all files associated with it from the Spacedrive database, the files themselves will not be deleted."
ctaAction={() => {
deleteLoc({ id: location.id });
}}
loading={locDeletePending}
ctaDanger
ctaLabel="Delete"
trigger={
<Button variant="gray" className="!p-1.5">
<TrashIcon className="w-4 h-4" />
</Button>
}
/>
<Button
variant="gray"
className="!p-1.5"
onClick={() => {
// this should cause a lite directory rescan, but this will do for now, so the button does something useful
locRescan({ id: location.id });
}}
>
<RefreshIcon className="w-4 h-4" />
</Button>
{/* <Button variant="gray" className="!p-1.5">
<CogIcon className="w-4 h-4" />
</Button> */}
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import clsx from 'clsx';
import React from 'react';
import { Puff } from 'react-loading-icons';
export default function Loader(props: { className?: string }) {
return (
<Puff
stroke="#2599FF"
strokeOpacity={4}
strokeWidth={5}
speed={1}
className={clsx('ml-0.5 mt-[2px] -mr-1 w-7 h-7', props.className)}
/>
);
}

View file

@ -17,8 +17,9 @@ export const DebugScreen: React.FC<{}> = (props) => {
// }); // });
const { mutate: identifyFiles } = useBridgeCommand('IdentifyUniqueFiles'); const { mutate: identifyFiles } = useBridgeCommand('IdentifyUniqueFiles');
return ( return (
<div className="flex flex-col w-full h-screen p-5 custom-scroll page-scroll"> <div className="flex flex-col w-full h-screen custom-scroll page-scroll">
<div className="flex flex-col space-y-5 pb-7"> <div data-tauri-drag-region className="flex flex-shrink-0 w-full h-5" />
<div className="flex flex-col p-5 pt-2 space-y-5 pb-7">
<h1 className="text-lg font-bold ">Developer Debugger</h1> <h1 className="text-lg font-bold ">Developer Debugger</h1>
<div className="flex flex-row pb-4 space-x-2"> <div className="flex flex-row pb-4 space-x-2">
<Button <Button

View file

@ -1,5 +1,7 @@
import { useBridgeQuery } from '@sd/client';
import React from 'react'; import React from 'react';
import LocationListItem from '../../components/location/LocationListItem';
import { InputContainer } from '../../components/primitive/InputContainer'; import { InputContainer } from '../../components/primitive/InputContainer';
import { SettingsContainer } from '../../components/settings/SettingsContainer'; import { SettingsContainer } from '../../components/settings/SettingsContainer';
import { SettingsHeader } from '../../components/settings/SettingsHeader'; import { SettingsHeader } from '../../components/settings/SettingsHeader';
@ -11,18 +13,20 @@ import { SettingsHeader } from '../../components/settings/SettingsHeader';
// ]; // ];
export default function LocationSettings() { export default function LocationSettings() {
// const locations = useBridgeQuery("SysGetLocation") const { data: locations } = useBridgeQuery('SysGetLocations');
console.log({ locations });
return ( return (
<SettingsContainer> <SettingsContainer>
{/*<Button size="sm">Add Location</Button>*/} {/*<Button size="sm">Add Location</Button>*/}
<SettingsHeader title="Locations" description="Manage your settings related to locations." /> <SettingsHeader title="Locations" description="Manage your storage locations." />
<InputContainer
title="Something about a vault" <div className="grid space-y-2">
description="Local cache storage for media previews and thumbnails." {locations?.map((location) => (
> <LocationListItem key={location.id} location={location} />
<div className="flex flex-row space-x-2"></div> ))}
</InputContainer> </div>
</SettingsContainer> </SettingsContainer>
); );
} }

View file

@ -40,14 +40,12 @@ const variants = {
dark:hover:bg-gray-500 dark:hover:bg-gray-500
dark:bg-opacity-80 dark:bg-opacity-80
dark:hover:bg-opacity-100 dark:hover:bg-opacity-100
dark:active:bg-gray-550
dark:active:opacity-80 dark:active:opacity-80
border-gray-200 border-gray-200
hover:border-gray-300 hover:border-gray-300
active:border-gray-200 active:border-gray-200
dark:border-gray-500 dark:border-gray-500
dark:active:border-gray-600
dark:hover:border-gray-500 dark:hover:border-gray-500
text-gray-700 text-gray-700
@ -68,6 +66,12 @@ const variants = {
hover:border-primary-500 hover:border-primary-500
active:border-primary-700 active:border-primary-700
`, `,
colored: `
text-white
shadow-sm
hover:bg-opacity-90
active:bg-opacity-100
`,
selected: `bg-gray-100 dark:bg-gray-500 selected: `bg-gray-100 dark:bg-gray-500
text-black hover:text-black active:text-black dark:hover:text-white dark:text-white text-black hover:text-black active:text-black dark:hover:text-white dark:text-white
` `
@ -114,7 +118,7 @@ export const Button = forwardRef<
) => { ) => {
className = clsx( className = clsx(
'border rounded-md items-center transition-colors duration-100 cursor-default', 'border rounded-md items-center transition-colors duration-100 cursor-default',
{ 'opacity-5': loading, '!p-1': noPadding }, { 'opacity-70': loading, '!p-1': noPadding },
{ 'justify-center': !justifyLeft }, { 'justify-center': !justifyLeft },
sizes[size || 'default'], sizes[size || 'default'],
variants[variant || 'default'], variants[variant || 'default'],