[ENG-765] Reported Total capacity and Free space are wrong (#1066)

* Attempt at fixing stats

* Fix macOS disk stats retrieve logic
 - Ignore mounted dmgs when calculation disk total/free size
 - Only take into account disk mounted by macOS

* macos only import

* Fix Linux

* Replace byte-size with a custom implementation that supports BigInt

* Fix NaN in Statistics

* clippy

* fmt

* Move linux get_volumes to a specilized function
 - Fix ZFS handling
 - Improve handling of disk symlinks and multiple mounts

* Fix macOS
This commit is contained in:
Vítor Vasconcellos
2023-07-05 15:22:56 -03:00
committed by GitHub
parent 7067dcb35c
commit dac54e44d1
25 changed files with 442 additions and 155 deletions

View File

@@ -2,7 +2,7 @@
This container based on alpine 3.17, with the most common build decencies installed, and a built version of [`osxcross`](https://github.com/tpoechtrager/osxcross) plus the macOS SDK 12.3 (Monterey) targeting a minimum compatibility of macOS 10.15 (Catalina) for x86_64 and macOS 11.0 (BigSur) for arm64.
__Image Tag__: macOS SDK version + osxcross commit hash + revision
**Image Tag**: macOS SDK version + osxcross commit hash + revision
This container is currently available at:
https://hub.docker.com/r/vvasconcellos/osxcross.

BIN
Cargo.lock generated
View File

Binary file not shown.

View File

@@ -30,7 +30,6 @@
"@sd/client": "workspace:*",
"@shopify/flash-list": "1.4.2",
"@tanstack/react-query": "^4.29.1",
"byte-size": "^8.1.0",
"class-variance-authority": "^0.5.3",
"dayjs": "^1.11.8",
"expo": "~48.0.19",

View File

@@ -1,4 +1,3 @@
import byteSize from 'byte-size';
import dayjs from 'dayjs';
import {
Copy,
@@ -13,7 +12,7 @@ import {
} from 'phosphor-react-native';
import { PropsWithChildren, useRef } from 'react';
import { Pressable, Text, View, ViewStyle } from 'react-native';
import { bytesToNumber, getItemFilePath, getItemObject } from '@sd/client';
import { byteSize, getItemFilePath, getItemObject } from '@sd/client';
import FileThumb from '~/components/explorer/FileThumb';
import FavoriteButton from '~/components/explorer/sections/FavoriteButton';
import InfoTagPills from '~/components/explorer/sections/InfoTagPills';
@@ -85,12 +84,7 @@ export const ActionsModal = () => {
</Text>
<View style={tw`flex flex-row`}>
<Text style={tw`text-xs text-ink-faint`}>
{filePath?.size_in_bytes_bytes
? byteSize(
bytesToNumber(filePath.size_in_bytes_bytes)
).toString()
: 0}
,
{`${byteSize(filePath?.size_in_bytes_bytes)}`},
</Text>
<Text style={tw`text-xs text-ink-faint`}>
{' '}

View File

@@ -1,4 +1,3 @@
import byteSize from 'byte-size';
import dayjs from 'dayjs';
import {
Barcode,
@@ -13,7 +12,7 @@ import { forwardRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import {
ExplorerItem,
bytesToNumber,
byteSize,
getItemFilePath,
getItemObject,
useLibraryQuery
@@ -97,13 +96,7 @@ const FileInfoModal = forwardRef<ModalRef, FileInfoModalProps>((props, ref) => {
<MetaItem
title="Size"
icon={Cube}
value={
filePathData?.size_in_bytes_bytes
? byteSize(
bytesToNumber(filePathData.size_in_bytes_bytes)
).toString()
: 0
}
value={`${byteSize(filePathData?.size_in_bytes_bytes)}`}
/>
{/* Duration */}
{fullObjectData.data?.media_data?.duration_seconds && (

View File

@@ -1,8 +1,7 @@
import byteSize from 'byte-size';
import { FC, useEffect, useState } from 'react';
import { ScrollView, Text, View } from 'react-native';
import RNFS from 'react-native-fs';
import { Statistics, useLibraryQuery } from '@sd/client';
import { Statistics, byteSize, useLibraryQuery } from '@sd/client';
import useCounter from '~/hooks/useCounter';
import { tw, twStyle } from '~/lib/tailwind';
@@ -28,9 +27,9 @@ const EMPTY_STATISTICS = {
};
const StatItem: FC<{ title: string; bytes: bigint }> = ({ title, bytes }) => {
const { value, unit } = byteSize(Number(bytes)); // TODO: This BigInt to Number conversion will truncate the number if the number is too large. `byteSize` doesn't support BigInt so we are gonna need to come up with a longer term solution at some point.
const { value, unit } = byteSize(bytes);
const count = useCounter({ name: title, end: Number(value) });
const count = useCounter({ name: title, end: value });
return (
<View style={tw`flex flex-col p-4`}>

View File

@@ -10,8 +10,10 @@ edition = { workspace = true }
[features]
default = []
mobile = [] # This feature allows features to be disabled when the Core is running on mobile.
ffmpeg = ["dep:sd-ffmpeg"] # This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg.
# This feature allows features to be disabled when the Core is running on mobile.
mobile = []
# This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg.
ffmpeg = ["dep:sd-ffmpeg"]
location-watcher = ["dep:notify"]
sync-messages = []
heif = ["dep:sd-heif"]
@@ -46,6 +48,7 @@ tokio = { workspace = true, features = [
"io-util",
"macros",
"time",
"process",
] }
base64 = "0.21.2"
@@ -93,6 +96,9 @@ hex = "0.4.3"
int-enum = "0.5.0"
tokio-stream = "0.1.14"
[target.'cfg(target_os = "macos")'.dependencies]
plist = "1"
[target.'cfg(windows)'.dependencies.winapi-util]
version = "0.1.5"

View File

@@ -2,7 +2,7 @@ use crate::{
library::{LibraryConfig, LibraryName},
prisma::statistics,
util::MaybeUndefined,
volume::{get_volumes, save_volume},
volume::get_volumes,
};
use chrono::Utc;
@@ -26,25 +26,22 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
})
.procedure("statistics", {
R.with2(library()).query(|(_, library), _: ()| async move {
let _statistics = library
.db
.statistics()
.find_unique(statistics::id::equals(library.node_local_id))
.exec()
.await?;
// TODO: get from database if library is offline
// let _statistics = library
// .db
// .statistics()
// .find_unique(statistics::id::equals(library.node_local_id))
// .exec()
// .await?;
// TODO: get from database, not sys
let volumes = get_volumes();
save_volume(&library).await?;
let volumes = get_volumes().await;
// save_volume(&library).await?;
let mut available_capacity: u64 = 0;
let mut total_capacity: u64 = 0;
if let Ok(volumes) = volumes {
for volume in volumes {
total_capacity += volume.total_capacity;
available_capacity += volume.available_capacity;
}
let mut available_capacity: u64 = 0;
for volume in volumes {
total_capacity += volume.total_capacity;
available_capacity += volume.available_capacity;
}
let library_db_size = get_size(

View File

@@ -6,7 +6,7 @@ use crate::{
library::{Category, Library},
location::{
file_path_helper::{check_file_path_exists, IsolatedFilePathData},
find_location, LocationError,
LocationError,
},
object::preview::get_thumb_key,
prisma::{self, file_path, location, object, tag, tag_on_object, PrismaClient},

View File

@@ -6,6 +6,6 @@ use super::{Ctx, R};
pub(crate) fn mount() -> AlphaRouter<Ctx> {
R.router().procedure("list", {
R.query(|_, _: ()| async move { Ok(get_volumes()?) })
R.query(|_, _: ()| async move { Ok(get_volumes().await) })
})
}

View File

@@ -1,14 +1,18 @@
use crate::{
library::Library,
prisma::volume::{self, *},
};
// Adapted from: https://github.com/kimlimjustin/xplorer/blob/f4f3590d06783d64949766cc2975205a3b689a56/src-tauri/src/drives.rs
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
use specta::Type;
use std::{fmt::Display, process::Command};
use std::{ffi::OsString, fmt::Display, path::PathBuf, sync::OnceLock};
use sysinfo::{DiskExt, System, SystemExt};
use thiserror::Error;
use tokio::sync::Mutex;
use tracing::error;
fn sys_guard() -> &'static Mutex<System> {
static SYS: OnceLock<Mutex<System>> = OnceLock::new();
SYS.get_or_init(|| Mutex::new(System::new_all()))
}
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
#[allow(clippy::upper_case_acronyms)]
@@ -31,16 +35,15 @@ impl Display for DiskType {
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
pub struct Volume {
pub name: String,
pub mount_point: String,
pub name: OsString,
pub mount_points: Vec<PathBuf>,
#[specta(type = String)]
#[serde_as(as = "DisplayFromStr")]
pub total_capacity: u64,
#[specta(type = String)]
#[serde_as(as = "DisplayFromStr")]
pub available_capacity: u64,
pub is_removable: bool,
pub disk_type: Option<DiskType>,
pub disk_type: DiskType,
pub file_system: Option<String>,
pub is_root_filesystem: bool,
}
@@ -59,102 +62,307 @@ impl From<VolumeError> for rspc::Error {
}
}
pub async fn save_volume(library: &Library) -> Result<(), VolumeError> {
let volumes = get_volumes()?;
#[cfg(target_os = "linux")]
pub async fn get_volumes() -> Vec<Volume> {
use std::{collections::HashMap, path::Path};
// enter all volumes associate with this client add to db
for volume in volumes {
let params = vec![
disk_type::set(volume.disk_type.map(|t| t.to_string())),
filesystem::set(volume.file_system.clone()),
total_bytes_capacity::set(volume.total_capacity.to_string()),
total_bytes_available::set(volume.available_capacity.to_string()),
];
let mut sys = sys_guard().lock().await;
sys.refresh_disks_list();
library
.db
.volume()
.upsert(
node_id_mount_point_name(
library.node_local_id,
volume.mount_point.to_string(),
volume.name.to_string(),
),
volume::create(
library.node_local_id,
volume.name,
volume.mount_point,
params.clone(),
),
params,
)
.exec()
.await?;
}
// cleanup: remove all unmodified volumes associate with this client
let mut volumes: Vec<Volume> = Vec::new();
let mut path_to_volume_index = HashMap::new();
for disk in sys.disks() {
let disk_name = disk.name();
let mount_point = disk.mount_point().to_path_buf();
let file_system = String::from_utf8(disk.file_system().to_vec())
.map(|s| s.to_uppercase())
.ok();
let total_capacity = disk.total_space();
let available_capacity = disk.available_space();
let is_root_filesystem = mount_point.is_absolute() && mount_point.parent().is_none();
Ok(())
}
let mut disk_path: PathBuf = PathBuf::from(disk_name);
if file_system.as_ref().map(|fs| fs == "ZFS").unwrap_or(false) {
// Use a custom path for ZFS disks to avoid conflicts with normal disks paths
disk_path = Path::new("zfs://").join(disk_path);
} else {
// Ignore non-devices disks (overlay, fuse, tmpfs, etc.)
if !disk_path.starts_with("/dev") {
continue;
}
// TODO: Error handling in this function
pub fn get_volumes() -> Result<Vec<Volume>, VolumeError> {
System::new_all()
.disks()
.iter()
.filter_map(|disk| {
let mut total_capacity = disk.total_space();
let mount_point = disk.mount_point().to_str().unwrap_or("/").to_string();
let available_capacity = disk.available_space();
let name = disk.name().to_str().unwrap_or("Volume").to_string();
let is_removable = disk.is_removable();
let file_system = String::from_utf8(disk.file_system().to_vec())
.unwrap_or_else(|_| "Err".to_string());
let disk_type = match disk.type_() {
sysinfo::DiskType::SSD => DiskType::SSD,
sysinfo::DiskType::HDD => DiskType::HDD,
_ => DiskType::Removable,
// Ensure disk has a valid device path
let real_path = match tokio::fs::canonicalize(disk_name).await {
Err(real_path) => {
error!(
"Failed to canonicalize disk path {}: {:#?}",
disk_name.to_string_lossy(),
real_path
);
continue;
}
Ok(real_path) => real_path,
};
if total_capacity < available_capacity && cfg!(target_os = "windows") {
let mut caption = mount_point.clone();
// Check if disk is a symlink to another disk
if real_path != disk_path {
// Disk is a symlink to another disk, assign it to the same volume
path_to_volume_index.insert(
real_path.into_os_string(),
path_to_volume_index
.get(disk_name)
.cloned()
.unwrap_or(path_to_volume_index.len()),
);
}
}
if let Some(volume_index) = path_to_volume_index.get(disk_name) {
// Disk already has a volume assigned, update it
let volume: &mut Volume = volumes
.get_mut(*volume_index)
.expect("Volume index is present so the Volume must be present too");
// Update mount point if not already present
if volume.mount_points.iter().all(|p| p != &mount_point) {
volume.mount_points.push(mount_point);
if !volume.is_root_filesystem {
volume.is_root_filesystem = is_root_filesystem;
}
}
// Update mount capacity, it can change between mounts due to quotas (ZFS, BTRFS?)
if volume.total_capacity < total_capacity {
volume.total_capacity = total_capacity;
}
// This shouldn't change between mounts, but just in case
if volume.available_capacity > available_capacity {
volume.available_capacity = available_capacity;
}
continue;
}
// Assign volume to disk path
path_to_volume_index.insert(disk_path.into_os_string(), volumes.len());
volumes.push(Volume {
name: disk_name.to_os_string(),
disk_type: if disk.is_removable() {
DiskType::Removable
} else {
match disk.type_() {
sysinfo::DiskType::SSD => DiskType::SSD,
sysinfo::DiskType::HDD => DiskType::HDD,
_ => DiskType::Removable,
}
},
file_system,
mount_points: vec![mount_point],
total_capacity,
available_capacity,
is_root_filesystem,
});
}
volumes
}
#[cfg(target_os = "macos")]
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct ImageSystemEntity {
mount_point: Option<String>,
}
#[cfg(target_os = "macos")]
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct ImageInfo {
system_entities: Vec<ImageSystemEntity>,
}
#[cfg(target_os = "macos")]
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct HDIUtilInfo {
images: Vec<ImageInfo>,
}
#[cfg(not(target_os = "linux"))]
pub async fn get_volumes() -> Vec<Volume> {
use futures::future;
use tokio::process::Command;
let mut sys = sys_guard().lock().await;
sys.refresh_disks_list();
// Ignore mounted DMGs
#[cfg(target_os = "macos")]
let dmgs = &Command::new("hdiutil")
.args(["info", "-plist"])
.output()
.await
.map_err(|err| error!("Failed to execute hdiutil: {err:#?}"))
.ok()
.and_then(|wmic_process| {
use std::str::FromStr;
if wmic_process.status.success() {
let info: Result<HDIUtilInfo, _> = plist::from_bytes(&wmic_process.stdout);
match info {
Err(err) => {
error!("Failed to parse hdiutil output: {err:#?}");
None
}
Ok(info) => Some(
info.images
.into_iter()
.flat_map(|image| image.system_entities)
.flat_map(|entity: ImageSystemEntity| entity.mount_point)
.flat_map(|mount_point| PathBuf::from_str(mount_point.as_str()))
.collect::<std::collections::HashSet<_>>(),
),
}
} else {
error!("Command hdiutil return error");
None
}
});
future::join_all(sys.disks().iter().map(|disk| async {
let disk_name = disk.name();
let mount_point = disk.mount_point().to_path_buf();
#[cfg(target_os = "macos")]
{
// Ignore mounted DMGs
if dmgs
.as_ref()
.map(|dmgs| dmgs.contains(&mount_point))
.unwrap_or(false)
{
return None;
}
if !(mount_point.starts_with("/Volumes") || mount_point.starts_with("/System/Volumes"))
{
return None;
}
}
#[allow(unused_mut)] // mut is used in windows
let mut total_capacity = disk.total_space();
let available_capacity = disk.available_space();
let is_root_filesystem = mount_point.is_absolute() && mount_point.parent().is_none();
// Fix broken google drive partition size in Windows
#[cfg(windows)]
if total_capacity < available_capacity && is_root_filesystem {
// Use available capacity as total capacity in the case we can't get the correct value
total_capacity = available_capacity;
let caption = mount_point.to_str();
if let Some(caption) = caption {
let mut caption = caption.to_string();
// Remove path separator from Disk letter
caption.pop();
let wmic_process = Command::new("cmd")
let wmic_output = Command::new("cmd")
.args([
"/C",
&format!("wmic logical disk where Caption='{caption}' get Size"),
])
.output()
.expect("failed to execute process");
let wmic_process_output = String::from_utf8(wmic_process.stdout).ok()?;
let parsed_size =
wmic_process_output.split("\r\r\n").collect::<Vec<&str>>()[1].to_string();
.await
.map_err(|err| error!("Failed to execute hdiutil: {err:#?}"))
.ok()
.and_then(|wmic_process| {
if wmic_process.status.success() {
String::from_utf8(wmic_process.stdout).ok()
} else {
error!("Command wmic return error");
None
}
});
if let Ok(n) = parsed_size.trim().parse::<u64>() {
total_capacity = n;
if let Some(wmic_output) = wmic_output {
match wmic_output.split("\r\r\n").collect::<Vec<&str>>()[1]
.to_string()
.trim()
.parse::<u64>()
{
Err(err) => error!("Failed to parse wmic output: {err:#?}"),
Ok(n) => total_capacity = n,
}
}
}
}
(!mount_point.starts_with("/System")).then_some(Ok(Volume {
name,
is_root_filesystem: mount_point == "/",
mount_point,
total_capacity,
available_capacity,
is_removable,
disk_type: Some(disk_type),
file_system: Some(file_system),
}))
Some(Volume {
name: disk_name.to_os_string(),
disk_type: if disk.is_removable() {
DiskType::Removable
} else {
match disk.type_() {
sysinfo::DiskType::SSD => DiskType::SSD,
sysinfo::DiskType::HDD => DiskType::HDD,
_ => DiskType::Removable,
}
},
mount_points: vec![mount_point],
file_system: String::from_utf8(disk.file_system().to_vec()).ok(),
total_capacity,
available_capacity,
is_root_filesystem,
})
.collect::<Result<Vec<_>, _>>()
}))
.await
.into_iter()
.flatten()
.collect::<Vec<Volume>>()
}
// pub async fn save_volume(library: &Library) -> Result<(), VolumeError> {
// // enter all volumes associate with this client add to db
// for volume in get_volumes() {
// let params = vec![
// disk_type::set(volume.disk_type.map(|t| t.to_string())),
// filesystem::set(volume.file_system.clone()),
// total_bytes_capacity::set(volume.total_capacity.to_string()),
// total_bytes_available::set(volume.available_capacity.to_string()),
// ];
// library
// .db
// .volume()
// .upsert(
// node_id_mount_point_name(
// library.node_local_id,
// volume.mount_point,
// volume.name,
// ),
// volume::create(
// library.node_local_id,
// volume.name,
// volume.mount_point,
// params.clone(),
// ),
// params,
// )
// .exec()
// .await?;
// }
// // cleanup: remove all unmodified volumes associate with this client
// Ok(())
// }
// #[test]
// fn test_get_volumes() {
// let volumes = get_volumes()?;
// dbg!(&volumes);
// assert!(volumes.len() > 0);
// }
// Adapted from: https://github.com/kimlimjustin/xplorer/blob/f4f3590d06783d64949766cc2975205a3b689a56/src-tauri/src/drives.rs

View File

@@ -1,6 +1,5 @@
// import types from '../../constants/file-types.json';
import { Image, Image_Light } from '@sd/assets/icons';
import byteSize from 'byte-size';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { Barcode, CircleWavyCheck, Clock, Cube, Hash, Link, Lock, Snowflake } from 'phosphor-react';
@@ -10,7 +9,7 @@ import {
Location,
ObjectKind,
Tag,
bytesToNumber,
byteSize,
getItemFilePath,
getItemObject,
isPath,
@@ -157,9 +156,7 @@ export const Inspector = ({ data, context, showThumbnail = true, ...props }: Pro
<InspectorIcon component={Cube} />
<span className="mr-1.5">Size</span>
<MetaValue>
{byteSize(
bytesToNumber(filePathData.size_in_bytes_bytes)
).toString()}
{`${byteSize(filePathData.size_in_bytes_bytes)}`}
</MetaValue>
</MetaTextLine>
)}

View File

@@ -1,8 +1,7 @@
import byteSize from 'byte-size';
import clsx from 'clsx';
import { memo } from 'react';
import { ExplorerItem, bytesToNumber, getItemFilePath, getItemLocation } from '@sd/client';
import GridList from '~/components/GridList';
import { ExplorerItem, byteSize, getItemFilePath, getItemLocation } from '@sd/client';
import { GridList } from '~/components';
import { ViewItem } from '.';
import FileThumb from '../FilePath/Thumb';
import { useExplorerViewContext } from '../ViewContext';
@@ -50,7 +49,7 @@ const GridViewItem = memo(({ data, selected, index, cut, ...props }: GridViewIte
'cursor-default truncate rounded-md px-1.5 py-[1px] text-center text-tiny text-ink-dull '
)}
>
{byteSize(bytesToNumber(filePathData.size_in_bytes_bytes)).toString()}
{`${byteSize(filePathData.size_in_bytes_bytes)}`}
</span>
)}
</div>

View File

@@ -7,7 +7,6 @@ import {
useReactTable
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import byteSize from 'byte-size';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { CaretDown, CaretUp } from 'phosphor-react';
@@ -19,7 +18,7 @@ import {
ExplorerItem,
FilePath,
ObjectKind,
bytesToNumber,
byteSize,
getExplorerItemData,
getItemFilePath,
getItemLocation,
@@ -210,7 +209,7 @@ export default () => {
const file_path = getItemFilePath(file);
if (!file_path || !file_path.size_in_bytes_bytes) return;
return byteSize(bytesToNumber(file_path.size_in_bytes_bytes));
return byteSize(file_path.size_in_bytes_bytes);
}
},
{

View File

@@ -3,7 +3,7 @@ import { ArrowsOutSimple } from 'phosphor-react';
import { memo } from 'react';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import GridList from '~/components/GridList';
import { GridList } from '~/components';
import { ViewItem } from '.';
import FileThumb from '../FilePath/Thumb';
import { useExplorerViewContext } from '../ViewContext';

View File

@@ -1,9 +1,8 @@
import byteSize from 'byte-size';
import clsx from 'clsx';
import { Info } from 'phosphor-react';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
import { Statistics, useLibraryContext, useLibraryQuery } from '@sd/client';
import { Statistics, byteSize, useLibraryContext, useLibraryQuery } from '@sd/client';
import { Tooltip } from '@sd/ui';
import { useCounter } from '~/hooks';
import { usePlatform } from '~/util/Platform';
@@ -46,12 +45,12 @@ const displayableStatItems = Object.keys(StatItemNames) as unknown as keyof type
let mounted = false;
const StatItem = (props: StatItemProps) => {
const { title, bytes = BigInt('0'), isLoading } = props;
const { title, bytes, isLoading } = props;
const size = byteSize(Number(bytes)); // TODO: This BigInt to Number conversion will truncate the number if the number is too large. `byteSize` doesn't support BigInt so we are gonna need to come up with a longer term solution at some point.
const size = byteSize(bytes);
const count = useCounter({
name: title,
end: +size.value,
end: size.value,
duration: mounted ? 0 : 1,
saveState: false
});

View File

@@ -47,7 +47,10 @@ interface ResizeProps<T extends GridListSelection> extends GridListDefaults<T> {
type GridListProps<T extends GridListSelection> = WrapProps<T> | ResizeProps<T>;
export default <T extends GridListSelection>({ selectable = true, ...props }: GridListProps<T>) => {
export const GridList = <T extends GridListSelection>({
selectable = true,
...props
}: GridListProps<T>) => {
const scrollBarWidth = 6;
const multiSelect = Array.isArray(props.selected);

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useCountUp } from 'use-count-up';
import { proxy, useSnapshot } from 'valtio';
@@ -31,13 +31,24 @@ type UseCounterProps = {
* default: `true`
*/
saveState?: boolean;
/**
* Number of decimal places. Defaults to `1`.
*/
precision?: number;
/**
* The locale to use for number formatting (e.g. `'de-DE'`).
* Defaults to your system locale. Passed directed into [Intl.NumberFormat()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat).
*/
locales?: string | string[];
};
export const useCounter = ({
name,
start = 0,
end,
locales,
duration = 2,
precision = 1,
saveState = true
}: UseCounterProps) => {
const { lastValue, setLastValue } = useCounterState(name);
@@ -46,12 +57,23 @@ export const useCounter = ({
start = lastValue;
}
const formatter = useMemo(
() =>
new Intl.NumberFormat(locales, {
style: 'decimal',
minimumFractionDigits: precision,
maximumFractionDigits: precision
}),
[locales, precision]
);
const { value } = useCountUp({
isCounting: !(start === end),
start,
end,
duration,
easing: 'easeOutCubic'
easing: 'easeOutCubic',
formatter: (value) => formatter.format(value)
});
useEffect(() => {

View File

@@ -39,7 +39,6 @@
"@types/react-scroll-sync": "^0.8.4",
"@vitejs/plugin-react": "^2.1.0",
"autoprefixer": "^10.4.12",
"byte-size": "^8.1.0",
"class-variance-authority": "^0.5.3",
"clsx": "^1.2.1",
"crypto-random-string": "^5.0.0",
@@ -72,7 +71,6 @@
"devDependencies": {
"@sd/config": "workspace:*",
"@types/babel-core": "^6.25.7",
"@types/byte-size": "^8.1.0",
"@types/loadable__component": "^5.13.4",
"@types/node": "^18.11.9",
"@types/react": "^18.0.21",

View File

@@ -264,4 +264,4 @@ export type TagCreateArgs = { name: string; color: string }
export type TagUpdateArgs = { id: number; name: string | null; color: string | null }
export type Volume = { name: string; mount_point: string; total_capacity: string; available_capacity: string; is_removable: boolean; disk_type: DiskType | null; file_system: string | null; is_root_filesystem: boolean }
export type Volume = { name: string; mount_points: string[]; total_capacity: string; available_capacity: string; disk_type: DiskType; file_system: string | null; is_root_filesystem: boolean }

View File

@@ -0,0 +1,77 @@
// Inspired by: https://github.com/75lb/byte-size
const DECIMAL_UNITS = [
{ short: 'B', long: 'bytes', from: 0n },
{ short: 'kB', long: 'kilobytes', from: 1000n },
{ short: 'MB', long: 'megabytes', from: 1000n ** 2n },
{ short: 'GB', long: 'gigabytes', from: 1000n ** 3n },
{ short: 'TB', long: 'terabytes', from: 1000n ** 4n },
{ short: 'PB', long: 'petabytes', from: 1000n ** 5n },
{ short: 'EB', long: 'exabytes', from: 1000n ** 6n },
{ short: 'ZB', long: 'zettabytes', from: 1000n ** 7n },
{ short: 'YB', long: 'yottabytes', from: 1000n ** 8n },
{ short: 'RB', long: 'ronnabyte', from: 1000n ** 9n },
{ short: 'QB', long: 'quettabyte', from: 1000n ** 10n }
];
const getDecimalUnit = (n: bigint) => {
const s = n.toString(10);
const log10 = s.length + Math.log10(Number('0.' + s.substring(0, 15)));
const index = (log10 / 3) | 0;
return (
DECIMAL_UNITS[index] ??
(DECIMAL_UNITS[DECIMAL_UNITS.length - 1] as Exclude<
(typeof DECIMAL_UNITS)[number],
undefined
>)
);
};
function bytesToNumber(bytes: string[] | number[] | bigint[]) {
return bytes
.map((b) => (typeof b === 'bigint' ? b : BigInt(b)))
.reduce((acc, curr, i) => acc + curr * 256n ** BigInt(bytes.length - i - 1));
}
export interface ByteSizeOpts {
locales?: string | string[];
precision: number;
}
/**
* Returns an object with the spec `{ value: string, unit: string, long: string }`. The returned object defines a `toString` method meaning it can be used in any string context.
*
* @param value - The bytes value to convert.
* @param options - Optional config.
* @param options.locales - The locale to use for number formatting (e.g. `'de-DE'`). Defaults to your system locale. Passed directed into [Intl.NumberFormat()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat).
* @param options.precision - Number of decimal places. Defaults to `1`.
*/
export const byteSize = (
value: null | string | number | bigint | string[] | number[] | bigint[] | undefined,
{ precision, locales }: ByteSizeOpts = { precision: 1 }
) => {
if (value == null) value = 0n;
if (Array.isArray(value)) value = bytesToNumber(value);
else if (typeof value !== 'bigint') value = BigInt(value);
const [isNegative, bytes] = value < 0n ? [true, -value] : [false, value];
const unit = getDecimalUnit(bytes);
const defaultFormat = new Intl.NumberFormat(locales, {
style: 'decimal',
minimumFractionDigits: precision,
maximumFractionDigits: precision
});
const precisionFactor = 10 ** precision;
return {
unit: unit.short,
long: unit.long,
value:
(isNegative ? -1 : 1) *
(unit.from === 0n
? Number(bytes)
: Number((bytes * BigInt(precisionFactor)) / unit.from) / precisionFactor),
toString() {
return `${defaultFormat.format(this.value)} ${this.unit}`;
}
};
};

View File

@@ -1,2 +1,3 @@
export * from './byte-size';
export * from './passwordStrength';
export * from './valito';

View File

@@ -1,3 +0,0 @@
export function bytesToNumber(bytes: number[]) {
return bytes.reduce((acc, curr, i) => acc + curr * Math.pow(256, bytes.length - i - 1), 0);
}

View File

@@ -1,7 +1,6 @@
import { ExplorerItem } from '../core';
export * from './objectKind';
export * from './formatBytes';
export * from './explorerItem';
// export * from './keys';

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.