├── .npmrc ├── src-tauri ├── rustfmt.toml ├── src │ ├── _tests │ │ ├── models │ │ │ └── mod.rs │ │ ├── services │ │ │ └── mod.rs │ │ ├── infrastructure │ │ │ ├── mod.rs │ │ │ └── providers │ │ │ │ ├── mod.rs │ │ │ │ └── linux │ │ │ │ └── mod.rs │ │ ├── enums │ │ │ └── mod.rs │ │ ├── commands │ │ │ ├── mod.rs │ │ │ ├── background_image_test.rs │ │ │ └── hardware_test.rs │ │ ├── mod.rs │ │ └── utils │ │ │ ├── mod.rs │ │ │ ├── color_test.rs │ │ │ ├── rounding_test.rs │ │ │ └── ip_test.rs │ ├── infrastructure │ │ ├── mod.rs │ │ ├── providers │ │ │ ├── windows │ │ │ │ └── mod.rs │ │ │ ├── linux │ │ │ │ ├── mod.rs │ │ │ │ ├── kernel.rs │ │ │ │ ├── lspci.rs │ │ │ │ └── procfs.rs │ │ │ ├── mod.rs │ │ │ └── sysinfo_provider.rs │ │ └── database │ │ │ ├── mod.rs │ │ │ ├── db.rs │ │ │ ├── process_stats.rs │ │ │ ├── hardware_archive.rs │ │ │ ├── gpu_archive.rs │ │ │ └── migration.rs │ ├── enums │ │ ├── mod.rs │ │ ├── error.rs │ │ └── hardware.rs │ ├── models │ │ ├── mod.rs │ │ ├── background_image.rs │ │ └── hardware_archive.rs │ ├── commands │ │ ├── mod.rs │ │ ├── system.rs │ │ ├── ui.rs │ │ └── background_image.rs │ ├── main.rs │ ├── utils │ │ ├── rounding.rs │ │ ├── mod.rs │ │ ├── ip.rs │ │ ├── color.rs │ │ ├── tauri.rs │ │ └── file.rs │ ├── platform │ │ ├── mod.rs │ │ ├── linux │ │ │ ├── network.rs │ │ │ ├── cache.rs │ │ │ ├── memory.rs │ │ │ └── mod.rs │ │ ├── windows │ │ │ ├── network.rs │ │ │ ├── memory.rs │ │ │ └── mod.rs │ │ ├── factory.rs │ │ └── traits.rs │ ├── services │ │ ├── mod.rs │ │ ├── network_service.rs │ │ ├── system_service.rs │ │ ├── language_service.rs │ │ ├── cpu_service.rs │ │ ├── memory_service.rs │ │ ├── gpu_service.rs │ │ ├── hardware_service.rs │ │ └── ui_service.rs │ ├── workers │ │ ├── updater.rs │ │ └── mod.rs │ └── constants.rs ├── build.rs ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── tauri.microsoftstore.conf.json ├── capabilities │ ├── desktop.json │ ├── default.json │ └── migrated.json └── assets │ └── deb │ └── com.HardwareVisualizer.app.policy ├── src ├── features │ ├── hardware │ │ ├── insights │ │ │ ├── snapshot │ │ │ │ ├── components │ │ │ │ │ ├── HardwareInfo.tsx │ │ │ │ │ └── ProcessInfo.tsx │ │ │ │ └── types │ │ │ │ │ └── snapshotType.ts │ │ │ ├── types │ │ │ │ ├── insight.ts │ │ │ │ └── processStats.ts │ │ │ ├── process │ │ │ │ ├── hooks │ │ │ │ │ ├── useProcessStatsAtom.ts │ │ │ │ │ └── useProcessStats.ts │ │ │ │ └── funcs │ │ │ │ │ └── getProcessStatsRecord.ts │ │ │ └── components │ │ │ │ └── SelectPeriod.tsx │ │ ├── dashboard │ │ │ ├── types │ │ │ │ └── dashboardItem.ts │ │ │ ├── components │ │ │ │ ├── ExportHardwareInfo.tsx │ │ │ │ └── SortableItem.tsx │ │ │ └── hooks │ │ │ │ ├── useSortableDashboard.ts │ │ │ │ └── useDashboardSelector.ts │ │ ├── store │ │ │ └── chart.ts │ │ ├── hooks │ │ │ ├── useGpuNames.ts │ │ │ ├── useProcessInfo.ts │ │ │ └── useHardwareInfoAtom.ts │ │ ├── types │ │ │ ├── hardwareDataType.ts │ │ │ └── chart.ts │ │ └── consts │ │ │ └── chart.ts │ ├── settings │ │ ├── components │ │ │ ├── insights │ │ │ │ ├── InsightsSettings.tsx │ │ │ │ ├── InsightsTitle.tsx │ │ │ │ └── InsightsToggle.tsx │ │ │ ├── graph │ │ │ │ ├── GraphStyleSettings.tsx │ │ │ │ ├── GraphColorSettings.tsx │ │ │ │ ├── GraphColorReset.tsx │ │ │ │ ├── BackgroundOpacitySlider.tsx │ │ │ │ ├── GraphColorPicker.tsx │ │ │ │ ├── GraphStyleToggle.tsx │ │ │ │ ├── GraphSizeSlider.tsx │ │ │ │ ├── GraphTypeSelector.tsx │ │ │ │ └── LineChartTypeSelector.tsx │ │ │ ├── general │ │ │ │ ├── GeneralSettings.tsx │ │ │ │ ├── BurnInShiftIdleCheckbox.tsx │ │ │ │ ├── BurnInShiftOverrideCheckbox.tsx │ │ │ │ ├── LanguageSelect.tsx │ │ │ │ ├── AutoStartToggle.tsx │ │ │ │ ├── TemperatureUnitSelect.tsx │ │ │ │ └── BurnInShiftModeRadio.tsx │ │ │ ├── about │ │ │ │ └── AboutSection.tsx │ │ │ └── Preview.tsx │ │ ├── types │ │ │ └── settingsType.ts │ │ └── Settings.tsx │ └── menu │ │ └── hooks │ │ └── useMenu.ts ├── vite-env.d.ts ├── lib │ ├── math.ts │ ├── utils.ts │ ├── array.ts │ ├── file.ts │ ├── tauriStore.ts │ ├── color.ts │ ├── openUrl.ts │ ├── i18n.ts │ ├── sqlite.ts │ └── formatter.ts ├── test │ ├── setup.ts │ └── unit │ │ ├── lib │ │ ├── file.test.ts │ │ ├── color.test.ts │ │ ├── array.test.ts │ │ ├── utils.test.ts │ │ ├── formatter.test.ts │ │ ├── openUrl.test.ts │ │ ├── i18n.test.ts │ │ ├── math.test.ts │ │ └── tauriStore.test.ts │ │ ├── hooks │ │ ├── useFullScreenMode.test.ts │ │ ├── useBurnInShift.test.ts │ │ └── useStickyObserver.test.ts │ │ ├── store │ │ └── ui.test.ts │ │ ├── components │ │ └── ErrorFallback.test.tsx │ │ └── features │ │ └── hardware │ │ └── hooks │ │ └── useExportToClipboard.test.ts ├── consts │ └── style.ts ├── store │ └── ui.ts ├── main.tsx ├── types │ ├── ui.ts │ ├── i18next.d.ts │ └── result.ts ├── components │ ├── ErrorFallback.tsx │ ├── ui │ │ ├── skeleton.tsx │ │ ├── typography.tsx │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ ├── switch.tsx │ │ ├── radio-group.tsx │ │ ├── FullScreenExit.tsx │ │ ├── scroll-area.tsx │ │ ├── tooltip.tsx │ │ ├── tabs.tsx │ │ └── accordion.tsx │ ├── charts │ │ └── CustomLegend.tsx │ ├── shared │ │ ├── ScreenTemplate.tsx │ │ ├── BurnInShift.tsx │ │ ├── InfoTable.tsx │ │ └── System.tsx │ └── icons │ │ └── LineChartIcon.tsx ├── hooks │ ├── useFullScreenMode.ts │ ├── useStickyObserver.ts │ ├── useTauriEventListener.ts │ ├── useTitleIconVisualSelector.ts │ ├── useInputListener.ts │ ├── useTauriStore.ts │ ├── useWindowSize.ts │ ├── useColorTheme.ts │ └── useTauriDialog.ts └── lazyScreens.tsx ├── tmp └── THIRD_PARTY_NOTICES.md ├── app-icon.png ├── rust-toolchain.toml ├── .vscode ├── extensions.json ├── tasks.json ├── launch.json └── settings.json ├── tsconfig.node.json ├── index.html ├── .gitignore ├── components.json ├── .github ├── scripts │ ├── updateTauriConfig.cjs │ ├── extract-apache-notices.ts │ └── check-licenses.ts ├── actions │ ├── setup-linux-deps │ │ └── action.yml │ ├── setup-rust │ │ └── action.yml │ └── setup-node │ │ └── action.yml ├── workflows │ ├── check-version.yml │ ├── dependabot-auto-merge.yml │ └── claude.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── copilot-instructions.md └── dependabot.yml ├── tsconfig.json ├── vitest.config.ts ├── LICENSE ├── biome.jsonc └── CONTRIBUTING.md /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /src-tauri/rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | max_width = 90 3 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod settings_test; 2 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src/features/hardware/insights/snapshot/components/HardwareInfo.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/hardware/insights/snapshot/components/ProcessInfo.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/THIRD_PARTY_NOTICES.md: -------------------------------------------------------------------------------- 1 | # THIRD_PARTY_NOTICES 2 | 3 | dummy 4 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/services/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod memory_service_test; 2 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/infrastructure/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod providers; 3 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod database; 2 | pub mod providers; 3 | -------------------------------------------------------------------------------- /app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/app-icon.png -------------------------------------------------------------------------------- /src-tauri/src/enums/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | pub mod hardware; 3 | pub mod settings; 4 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/src/_tests/enums/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod error_test; 2 | pub mod hardware_test; 3 | pub mod settings_test; 4 | -------------------------------------------------------------------------------- /src/features/hardware/insights/types/insight.ts: -------------------------------------------------------------------------------- 1 | export type InsightType = "main" | "gpu" | "process" | "snapshot"; 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | /gen/ 6 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/src/_tests/infrastructure/providers/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "linux")] 2 | #[cfg(test)] 3 | pub mod linux; 4 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/providers/windows/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod directx; 2 | pub mod nvapi_provider; 3 | pub mod wmi_provider; 4 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod background_image; 2 | pub mod hardware; 3 | pub mod hardware_archive; 4 | pub mod settings; 5 | -------------------------------------------------------------------------------- /src/lib/math.ts: -------------------------------------------------------------------------------- 1 | export const randInt = (min: number, max: number) => 2 | Math.floor(min + Math.random() * (max - min + 1)); 3 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.92.0" 3 | components = ["rustfmt", "clippy"] 4 | targets = [] 5 | profile = "minimal" 6 | -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shm11C3/HardwareVisualizer/HEAD/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod background_image; 2 | pub mod hardware; 3 | pub mod settings; 4 | pub mod system; 5 | pub mod ui; 6 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | 3 | fn main() { 4 | hardware_monitor_lib::run(); 5 | } 6 | -------------------------------------------------------------------------------- /src-tauri/src/utils/rounding.rs: -------------------------------------------------------------------------------- 1 | /// 2 | /// Round to first decimal place 3 | /// 4 | pub fn round1(v: f32) -> f32 { 5 | (v * 10.0).round() / 10.0 6 | } 7 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import * as matchers from "@testing-library/jest-dom/matchers"; 2 | import { expect } from "vitest"; 3 | 4 | expect.extend(matchers); 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "biomejs.biome", 4 | "rust-lang.rust-analyzer", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/database/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod db; 2 | pub mod gpu_archive; 3 | pub mod hardware_archive; 4 | pub mod migration; 5 | pub mod process_stats; 6 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/providers/linux/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod dmidecode; 2 | pub mod drm_sys; 3 | pub mod kernel; 4 | pub mod lspci; 5 | pub mod net_sys; 6 | pub mod procfs; 7 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/commands/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod background_image_test; 3 | #[cfg(test)] 4 | pub mod settings_test; 5 | //#[cfg(test)] 6 | //pub mod hardware_test; 7 | -------------------------------------------------------------------------------- /src/consts/style.ts: -------------------------------------------------------------------------------- 1 | import type { Theme } from "@/rspc/bindings"; 2 | 3 | export const minOpacity = 0.5; 4 | 5 | export const darkClasses: Theme[] = ["dark", "darkPlus", "nebula", "espresso"]; 6 | -------------------------------------------------------------------------------- /src-tauri/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod color; 2 | pub mod file; 3 | pub mod formatter; 4 | pub mod logger; 5 | pub mod rounding; 6 | pub mod tauri; 7 | 8 | #[cfg(target_os = "windows")] 9 | pub mod ip; 10 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/array.ts: -------------------------------------------------------------------------------- 1 | export const transpose = (matrix: number[][]): number[][] => { 2 | if (matrix.length === 0) return []; 3 | 4 | return matrix[0].map((_, colIndex) => matrix.map((row) => row[colIndex])); 5 | }; 6 | -------------------------------------------------------------------------------- /src/store/ui.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export const modalAtoms = { 4 | showSettingsModal: atom(false), 5 | }; 6 | 7 | export const settingAtoms = { 8 | isRequiredRestart: atom(false), 9 | }; 10 | -------------------------------------------------------------------------------- /src-tauri/src/commands/system.rs: -------------------------------------------------------------------------------- 1 | /// Restart the application 2 | #[tauri::command] 3 | #[specta::specta] 4 | pub async fn restart_app(app_handle: tauri::AppHandle) { 5 | crate::services::system_service::restart_app(&app_handle).await; 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/tauri.microsoftstore.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "HardwareVisualizer", 3 | "bundle": { 4 | "windows": { 5 | "webviewInstallMode": { 6 | "type": "offlineInstaller" 7 | } 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod commands; 3 | 4 | #[cfg(test)] 5 | pub mod enums; 6 | 7 | #[cfg(test)] 8 | pub mod infrastructure; 9 | 10 | #[cfg(test)] 11 | pub mod models; 12 | 13 | #[cfg(test)] 14 | pub mod utils; 15 | -------------------------------------------------------------------------------- /src-tauri/src/platform/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod factory; 2 | pub mod traits; 3 | 4 | #[cfg(target_os = "linux")] 5 | pub mod linux; 6 | 7 | #[cfg(target_os = "windows")] 8 | pub mod windows; 9 | 10 | #[cfg(target_os = "macos")] 11 | pub mod macos; 12 | -------------------------------------------------------------------------------- /src/features/hardware/insights/snapshot/types/snapshotType.ts: -------------------------------------------------------------------------------- 1 | export type SnapshotPeriod = { 2 | start: string; 3 | end: string; 4 | }; 5 | 6 | export type UsageRange = { 7 | type: "cpu" | "memory"; 8 | value: [number, number]; 9 | }; 10 | -------------------------------------------------------------------------------- /src/features/hardware/insights/types/processStats.ts: -------------------------------------------------------------------------------- 1 | export type ProcessStat = { 2 | pid: number; 3 | process_name: string; 4 | avg_cpu_usage: number; 5 | avg_memory_usage: number; 6 | total_execution_sec: number; 7 | latest_timestamp: string; 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/utils/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod color_test; 3 | #[cfg(test)] 4 | pub mod file_test; 5 | #[cfg(test)] 6 | pub mod formatter_test; 7 | #[cfg(test)] 8 | #[cfg(target_os = "windows")] 9 | pub mod ip_test; 10 | #[cfg(test)] 11 | pub mod rounding_test; 12 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { App } from "./App"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 6 | 7 | 8 | , 9 | ); 10 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/infrastructure/providers/linux/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "linux")] 2 | #[cfg(test)] 3 | pub mod dmidecode_test; 4 | 5 | #[cfg(target_os = "linux")] 6 | #[cfg(test)] 7 | pub mod kernel_test; 8 | 9 | #[cfg(target_os = "linux")] 10 | #[cfg(test)] 11 | pub mod procfs_test; 12 | -------------------------------------------------------------------------------- /src/types/ui.ts: -------------------------------------------------------------------------------- 1 | export type SelectedDisplayType = 2 | | "dashboard" 3 | | "usage" 4 | | "cpuDetail" 5 | | "insights" 6 | | "settings"; 7 | 8 | export const insightChildMenu = ["main", "gpu"] as const; 9 | 10 | export type InsightChildMenuType = (typeof insightChildMenu)[number]; 11 | -------------------------------------------------------------------------------- /src-tauri/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-capability", 3 | "platforms": [ 4 | "macOS", 5 | "windows", 6 | "linux" 7 | ], 8 | "windows": [ 9 | "main" 10 | ], 11 | "permissions": [ 12 | "autostart:default", 13 | "updater:default" 14 | ] 15 | } -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/providers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod sysinfo_provider; 2 | 3 | #[cfg(target_os = "windows")] 4 | pub mod windows; 5 | 6 | #[cfg(target_os = "windows")] 7 | pub use windows::*; 8 | 9 | #[cfg(target_os = "linux")] 10 | pub mod linux; 11 | 12 | #[cfg(target_os = "linux")] 13 | pub use linux::*; 14 | -------------------------------------------------------------------------------- /src-tauri/src/platform/linux/network.rs: -------------------------------------------------------------------------------- 1 | use crate::{enums::error::BackendError, infrastructure, models::hardware::NetworkInfo}; 2 | 3 | pub fn get_network_info() -> Result, BackendError> { 4 | infrastructure::providers::net_sys::get_network_info() 5 | .map_err(|_| BackendError::UnexpectedError) 6 | } 7 | -------------------------------------------------------------------------------- /src-tauri/src/platform/windows/network.rs: -------------------------------------------------------------------------------- 1 | use crate::{enums::error::BackendError, infrastructure, models::hardware::NetworkInfo}; 2 | 3 | pub fn get_network_info() -> Result, BackendError> { 4 | infrastructure::providers::wmi_provider::query_network_info() 5 | .map_err(|_| BackendError::UnexpectedError) 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/file.ts: -------------------------------------------------------------------------------- 1 | export const convertFileToBase64 = (file: File): Promise => { 2 | return new Promise((resolve, reject) => { 3 | const reader = new FileReader(); 4 | reader.readAsDataURL(file); 5 | reader.onload = () => resolve(reader.result as string); 6 | reader.onerror = (error) => reject(error); 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/ErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | import type { FallbackProps } from "react-error-boundary"; 2 | 3 | function ErrorFallback({ error }: FallbackProps) { 4 | return ( 5 |
6 |

An unexpected error has occurred.

7 |
{error.message}
8 |
9 | ); 10 | } 11 | export default ErrorFallback; 12 | -------------------------------------------------------------------------------- /src/lib/tauriStore.ts: -------------------------------------------------------------------------------- 1 | import { load, type Store } from "@tauri-apps/plugin-store"; 2 | 3 | let storeInstance: Store | null = null; 4 | 5 | export const getStoreInstance = async () => { 6 | if (!storeInstance) { 7 | storeInstance = await load("store.json", { autoSave: true, defaults: {} }); 8 | } 9 | return storeInstance; 10 | }; 11 | -------------------------------------------------------------------------------- /src/features/hardware/dashboard/types/dashboardItem.ts: -------------------------------------------------------------------------------- 1 | export const dashBoardItems = [ 2 | "cpu", 3 | "gpu", 4 | "memory", 5 | "storage", 6 | "process", 7 | "network", 8 | ] as const; 9 | 10 | export type DashboardItemType = (typeof dashBoardItems)[number]; 11 | 12 | export type DashboardSelectItemType = DashboardItemType | "title"; 13 | -------------------------------------------------------------------------------- /src/types/i18next.d.ts: -------------------------------------------------------------------------------- 1 | import "i18next"; 2 | import type en from "@/lang/en.json"; 3 | import type ja from "@/lang/ja.json"; 4 | 5 | type Resources = { 6 | en: typeof en; 7 | ja: typeof ja; 8 | }; 9 | 10 | declare module "i18next" { 11 | interface CustomTypeOptions { 12 | defaultNS: keyof Resources; 13 | resources: Resources; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src-tauri/src/utils/ip.rs: -------------------------------------------------------------------------------- 1 | use std::net::IpAddr; 2 | 3 | /// 4 | /// ## Determine if IP is a unicast link-local address 5 | /// 6 | pub fn is_unicast_link_local(ip: &T) -> bool 7 | where 8 | T: Into + Clone, 9 | { 10 | match ip.clone().into() { 11 | IpAddr::V6(v6) => v6.segments()[0] & 0xffc0 == 0xfe80, 12 | _ => false, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/color.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert xxx,yyy,zzz to HEX 3 | * 4 | * @param rgb 5 | * @returns 6 | * 7 | * @todo Support rgb(xxx,yyy,zzz) and rgba(xxx,yyy,zzz,a.a) formats 8 | */ 9 | export const RGB2HEX = (rgb: string): string => { 10 | return `#${rgb 11 | .split(",") 12 | .map((value) => Number(value).toString(16).padStart(2, "0")) 13 | .join("")}`; 14 | }; 15 | -------------------------------------------------------------------------------- /src-tauri/src/models/background_image.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | 4 | /// 5 | /// - `file_id` : Image file ID 6 | /// - `image_data` : Base64 string of image data 7 | /// 8 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct BackgroundImage { 11 | pub file_id: String, 12 | pub image_data: String, 13 | } 14 | -------------------------------------------------------------------------------- /src-tauri/src/services/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod archive_service; 2 | pub mod background_image_service; 3 | pub mod cpu_service; 4 | pub mod gpu_service; 5 | pub mod hardware_service; 6 | pub mod language_service; 7 | pub mod memory_service; 8 | pub mod monitoring_service; 9 | pub mod network_service; 10 | pub mod process_service; 11 | pub mod settings_service; 12 | pub mod system_service; 13 | pub mod ui_service; 14 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/providers/linux/kernel.rs: -------------------------------------------------------------------------------- 1 | pub fn read_pm_info_sclk(card_id: u8) -> Option { 2 | use regex::Regex; 3 | 4 | let path = format!("/sys/kernel/debug/dri/{card_id}/amdgpu_pm_info"); 5 | let content = std::fs::read_to_string(path).ok()?; 6 | let re = Regex::new(r"SCLK.*?(\d+)\s+MHz").ok()?; 7 | re.captures(&content) 8 | .and_then(|cap| cap[1].parse::().ok()) 9 | } 10 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "main-capability", 4 | "description": "Capability for the main window", 5 | "windows": ["main"], 6 | "permissions": [ 7 | { 8 | "identifier": "opener:allow-open-path", 9 | "allow": [ 10 | { 11 | "path": "$RESOURCE" 12 | } 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/database/db.rs: -------------------------------------------------------------------------------- 1 | use crate::utils; 2 | use sqlx::sqlite::SqlitePool; 3 | 4 | pub async fn get_pool() -> Result { 5 | let dir_path = utils::file::get_app_data_dir("hv-database.db"); 6 | let database_url = format!("sqlite:{dir_path}", dir_path = dir_path.to_str().unwrap()); 7 | 8 | let pool = SqlitePool::connect(&database_url).await?; 9 | 10 | Ok(pool) 11 | } 12 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
15 | ); 16 | } 17 | 18 | export { Skeleton }; 19 | -------------------------------------------------------------------------------- /src/components/ui/typography.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from "react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function TypographyP({ 5 | children, 6 | className, 7 | }: { 8 | children: JSX.Element | string; 9 | className?: string; 10 | }) { 11 | return ( 12 |

13 | {children} 14 |

15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | HardwareVisualizer 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/openUrl.ts: -------------------------------------------------------------------------------- 1 | import { open } from "@tauri-apps/plugin-shell"; 2 | 3 | // TODO Consider implementing a whitelist 4 | export const openURL = async (url: string) => { 5 | if (!isValidURL(url)) { 6 | throw new Error("Invalid URL"); 7 | } 8 | 9 | await open(url); 10 | }; 11 | 12 | const isValidURL = (url: string) => { 13 | try { 14 | new URL(url); 15 | return true; 16 | } catch { 17 | return false; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src-tauri/src/utils/color.rs: -------------------------------------------------------------------------------- 1 | pub fn hex_to_rgb(hex: &str) -> Result<[u8; 3], &str> { 2 | if hex.len() != 7 || !hex.starts_with('#') { 3 | return Err("Invalid hex format"); 4 | } 5 | let r = u8::from_str_radix(&hex[1..3], 16).map_err(|_| "Invalid hex value")?; 6 | let g = u8::from_str_radix(&hex[3..5], 16).map_err(|_| "Invalid hex value")?; 7 | let b = u8::from_str_radix(&hex[5..7], 16).map_err(|_| "Invalid hex value")?; 8 | Ok([r, g, b]) 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | coverage 15 | licenses.json 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | .claude 28 | .mcp.json 29 | 30 | # Rust files 31 | target 32 | -------------------------------------------------------------------------------- /src/components/charts/CustomLegend.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from "react"; 2 | 3 | export type LegendItem = { 4 | label: string; 5 | icon: JSX.Element; 6 | }; 7 | 8 | export const CustomLegend = ({ item }: { item: LegendItem }) => { 9 | return ( 10 |
11 |
12 | {item.icon} 13 | {item.label} 14 |
15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import en from "@/lang/en.json"; 4 | import ja from "@/lang/ja.json"; 5 | 6 | const resources = { 7 | en: { 8 | translation: en, 9 | }, 10 | ja: { 11 | translation: ja, 12 | }, 13 | }; 14 | 15 | i18n.use(initReactI18next).init({ 16 | resources, 17 | lng: "en", 18 | interpolation: { 19 | escapeValue: false, 20 | }, 21 | }); 22 | 23 | export default i18n; 24 | -------------------------------------------------------------------------------- /src-tauri/src/utils/tauri.rs: -------------------------------------------------------------------------------- 1 | use tauri::Config; 2 | 3 | /// 4 | /// Get the Config structure 5 | /// 6 | pub fn get_config() -> Config { 7 | let context: tauri::Context = tauri::generate_context!(); 8 | context.config().clone() 9 | } 10 | 11 | /// 12 | /// Get application version from Config structure 13 | /// 14 | pub fn get_app_version(config: &Config) -> String { 15 | config 16 | .version 17 | .clone() 18 | .unwrap_or_else(|| "unknown".to_string()) 19 | } 20 | -------------------------------------------------------------------------------- /src/test/unit/lib/file.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { convertFileToBase64 } from "@/lib/file"; 3 | 4 | describe("convertFileToBase64", () => { 5 | it("should convert a valid file to a base64 string", async () => { 6 | const file = new File(["test, test, test"], "test.txt", { 7 | type: "text/plain", 8 | }); 9 | const base64Str = await convertFileToBase64(file); 10 | expect(base64Str).toMatch(/^data:text\/plain;base64,/); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/features/hardware/insights/process/hooks/useProcessStatsAtom.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from "jotai"; 2 | import type { ProcessStat } from "../../types/processStats"; 3 | 4 | const processStatsAtom = atom(null); 5 | 6 | export const useProcessStatsAtom = () => { 7 | const [processStats, setProcessStats] = useAtom(processStatsAtom); 8 | 9 | const setProcessStatsAtom = (processes: ProcessStat[]) => { 10 | setProcessStats(processes); 11 | }; 12 | 13 | return { processStats, setProcessStatsAtom }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/test/unit/lib/color.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { RGB2HEX } from "@/lib/color"; 3 | 4 | describe("RGB2HEX", () => { 5 | it.each([ 6 | { input: "0,0,0", expected: "#000000" }, 7 | { input: "255,255,255", expected: "#ffffff" }, 8 | { input: "255,0,0", expected: "#ff0000" }, 9 | { input: "0,255,0", expected: "#00ff00" }, 10 | ])("converts RGB string to HEX string", ({ input, expected }) => { 11 | const result = RGB2HEX(input); 12 | expect(result).toBe(expected); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/hooks/useFullScreenMode.ts: -------------------------------------------------------------------------------- 1 | import { commands } from "@/rspc/bindings"; 2 | import { useTauriStore } from "./useTauriStore"; 3 | 4 | export const useFullScreenMode = () => { 5 | const [isFullScreen, setIsFullScreen] = useTauriStore("isFullScreen", false); 6 | 7 | const toggleFullScreen = async () => { 8 | // If switching to fullscreen, remove window decorations 9 | await commands.setDecoration(Boolean(isFullScreen)); 10 | setIsFullScreen(!isFullScreen); 11 | }; 12 | 13 | return { 14 | isFullScreen, 15 | toggleFullScreen, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src-tauri/capabilities/migrated.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "migrated", 3 | "description": "permissions that were migrated from v1", 4 | "local": true, 5 | "windows": ["main"], 6 | "permissions": [ 7 | "core:default", 8 | "dialog:allow-open", 9 | "dialog:allow-save", 10 | "dialog:allow-message", 11 | "dialog:allow-ask", 12 | "dialog:allow-confirm", 13 | "dialog:default", 14 | "store:default", 15 | "shell:allow-open", 16 | "shell:allow-execute", 17 | "sql:default", 18 | "clipboard-manager:allow-write-text" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/test/unit/lib/array.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { transpose } from "@/lib/array"; 3 | 4 | describe("transpose", () => { 5 | it("should return an empty array when given an empty matrix", () => { 6 | expect(transpose([])).toEqual([]); 7 | }); 8 | 9 | it("should transpose a matrix", () => { 10 | const matrix = [ 11 | [1, 2, 3], 12 | [4, 5, 6], 13 | ]; 14 | const result = transpose(matrix); 15 | expect(result).toEqual([ 16 | [1, 4], 17 | [2, 5], 18 | [3, 6], 19 | ]); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "registries": { 21 | "@acme": "https://acme.com/r/{name}.json" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/sqlite.ts: -------------------------------------------------------------------------------- 1 | import Database from "@tauri-apps/plugin-sql"; 2 | 3 | async function initializeDB() { 4 | const db = await Database.load("sqlite:hv-database.db"); 5 | return { 6 | load: async (sql: string): Promise => { 7 | return db.select(sql).catch((err) => { 8 | console.error(err); 9 | return []; 10 | }); 11 | }, 12 | save: async (sql: string): Promise => { 13 | await db.execute(sql).catch((err) => { 14 | console.error(err); 15 | }); 16 | }, 17 | }; 18 | } 19 | 20 | export const sqlitePromise = initializeDB(); 21 | -------------------------------------------------------------------------------- /src-tauri/src/services/network_service.rs: -------------------------------------------------------------------------------- 1 | use crate::enums; 2 | use crate::models::hardware::NetworkInfo; 3 | use crate::platform::factory::PlatformFactory; 4 | 5 | /// 6 | /// Get network interface information 7 | /// Returns `BackendError::UnexpectedError` if Platform is unsupported / fails 8 | /// 9 | pub fn fetch_network_info() -> Result, enums::error::BackendError> { 10 | let platform = 11 | PlatformFactory::create().map_err(|_| enums::error::BackendError::UnexpectedError)?; 12 | platform 13 | .get_network_info() 14 | .map_err(|_| enums::error::BackendError::UnexpectedError) 15 | } 16 | -------------------------------------------------------------------------------- /src/features/hardware/store/chart.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import type { NameValues } from "@/features/hardware/types/hardwareDataType"; 3 | 4 | export const cpuUsageHistoryAtom = atom([]); 5 | export const processorsUsageHistoryAtom = atom([]); 6 | export const memoryUsageHistoryAtom = atom([]); 7 | export const graphicUsageHistoryAtom = atom([]); 8 | export const cpuTempAtom = atom([]); 9 | export const cpuFanSpeedAtom = atom([]); 10 | export const gpuTempAtom = atom([]); 11 | export const gpuFanSpeedAtom = atom([]); 12 | -------------------------------------------------------------------------------- /src/features/settings/components/insights/InsightsSettings.tsx: -------------------------------------------------------------------------------- 1 | import { DataRetentionSettings } from "./DataRetentionSettings"; 2 | import { InsightsTitle } from "./InsightsTitle"; 3 | import { InsightsToggle } from "./InsightsToggle"; 4 | 5 | export const InsightsSettings = () => { 6 | return ( 7 |
8 |
9 | 10 |
11 | 12 | 13 |
14 |
15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src-tauri/src/commands/ui.rs: -------------------------------------------------------------------------------- 1 | use tauri::App; 2 | 3 | use crate::services::ui_service; 4 | 5 | pub fn init(app: &mut App) { 6 | let app_handle = app.handle(); 7 | let _ = ui_service::apply_saved_window_decoration(app_handle); 8 | } 9 | 10 | /// 11 | /// Set window decoration state 12 | /// 13 | #[tauri::command] 14 | #[specta::specta] 15 | pub fn set_decoration( 16 | window: tauri::WebviewWindow, 17 | is_decorated: bool, 18 | app_handle: tauri::AppHandle, 19 | ) -> Result<(), String> { 20 | ui_service::set_window_decoration(&window, is_decorated)?; 21 | ui_service::persist_window_decoration(&app_handle, is_decorated)?; 22 | 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/providers/linux/lspci.rs: -------------------------------------------------------------------------------- 1 | pub fn get_gpu_name_from_lspci_by_vendor_id(vendor_id: &str) -> Option { 2 | use std::process::Command; 3 | 4 | let output = Command::new("lspci").arg("-nn").output().ok()?; 5 | 6 | if !output.status.success() { 7 | return None; 8 | } 9 | 10 | let stdout = String::from_utf8_lossy(&output.stdout); 11 | 12 | for line in stdout.lines() { 13 | if line.contains("VGA") && line.contains(vendor_id) { 14 | // Example: "03:00.0 VGA compatible controller [0300]: AMD/ATI Renoir [1002:1636]" 15 | return Some(line.trim().to_string()); 16 | } 17 | } 18 | 19 | None 20 | } 21 | -------------------------------------------------------------------------------- /src-tauri/src/services/system_service.rs: -------------------------------------------------------------------------------- 1 | use tauri::Manager; 2 | 3 | pub async fn restart_app(app_handle: &tauri::AppHandle) { 4 | // Get current executable file path 5 | let exe_path = std::env::current_exe().expect("Failed to obtain executable file path"); 6 | let args: Vec = std::env::args().collect(); 7 | 8 | // Spawn new process 9 | #[allow(clippy::zombie_processes)] 10 | std::process::Command::new(exe_path) 11 | .args(args) 12 | .spawn() 13 | .expect("Failed to restart process"); 14 | 15 | let state = app_handle.state::(); 16 | state.terminate_all().await; 17 | 18 | app_handle.exit(0); 19 | } 20 | -------------------------------------------------------------------------------- /src/features/hardware/dashboard/components/ExportHardwareInfo.tsx: -------------------------------------------------------------------------------- 1 | import { ClipboardTextIcon } from "@phosphor-icons/react"; 2 | import { useExportToClipboard } from "../hooks/useExportToClipboard"; 3 | 4 | export const ExportHardwareInfo = () => { 5 | const { exportToClipboard } = useExportToClipboard(); 6 | 7 | return ( 8 |
9 | 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/types/result.ts: -------------------------------------------------------------------------------- 1 | import type { Result } from "@/rspc/bindings"; 2 | 3 | export const isResult = (unknown: unknown): unknown is Result => { 4 | const result = unknown as Result; 5 | 6 | return ( 7 | (result.status === "ok" && result.data !== undefined) || 8 | (result.status === "error" && result.error !== undefined) 9 | ); 10 | }; 11 | 12 | export const isOk = ( 13 | result: Result, 14 | ): result is { status: "ok"; data: T } => { 15 | return result.status === "ok"; 16 | }; 17 | 18 | export const isError = ( 19 | result: Result, 20 | ): result is { status: "error"; error: E } => { 21 | return result.status === "error"; 22 | }; 23 | -------------------------------------------------------------------------------- /.github/scripts/updateTauriConfig.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * リリース用のtauri.conf.jsonに更新する 3 | * 4 | * @param {string[]} args 5 | */ 6 | function updateTauriConfig(args) { 7 | const fs = require("node:fs"); 8 | const configPath = "src-tauri/tauri.conf.json"; 9 | 10 | const signCommand = args[0]; 11 | const pubkey = args[1]; 12 | 13 | const config = JSON.parse(fs.readFileSync(configPath, "utf8")); 14 | 15 | config.bundle.windows.signCommand = signCommand; 16 | config.bundle.createUpdaterArtifacts = true; 17 | 18 | fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); 19 | console.log("tauri.conf.json has been updated"); 20 | } 21 | 22 | updateTauriConfig(process.argv.slice(2)); 23 | -------------------------------------------------------------------------------- /src/features/hardware/hooks/useGpuNames.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { sqlitePromise } from "@/lib/sqlite"; 3 | 4 | export const useGpuNames = () => { 5 | const [gpuNames, setGpuNames] = useState([]); 6 | 7 | useEffect(() => { 8 | const fetchGpuNames = async () => { 9 | const db = await sqlitePromise; 10 | const result = await db.load<{ gpu_name: string }>( 11 | "SELECT DISTINCT gpu_name FROM GPU_DATA_ARCHIVE WHERE gpu_name IS NOT NULL AND gpu_name != 'Unknown'", 12 | ); 13 | setGpuNames(result.map((row) => row.gpu_name)); 14 | }; 15 | 16 | fetchGpuNames(); 17 | }, []); 18 | 19 | return gpuNames; 20 | }; 21 | -------------------------------------------------------------------------------- /.github/actions/setup-linux-deps/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup Linux Dependencies' 2 | description: 'Install Linux build dependencies for Tauri' 3 | inputs: 4 | extra-packages: 5 | description: 'Additional packages to install' 6 | required: false 7 | default: '' 8 | 9 | runs: 10 | using: 'composite' 11 | steps: 12 | - name: Install Linux build dependencies 13 | run: | 14 | sudo apt-get update 15 | sudo apt-get install -y \ 16 | libgtk-3-dev \ 17 | libwebkit2gtk-4.1-dev \ 18 | librsvg2-dev \ 19 | libjavascriptcoregtk-4.1-dev \ 20 | libsoup-3.0-dev \ 21 | ${{ inputs.extra-packages }} 22 | shell: bash 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/check-version.yml: -------------------------------------------------------------------------------- 1 | name: Check Version 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - release/** 7 | - master 8 | 9 | jobs: 10 | check-version: 11 | permissions: 12 | contents: read 13 | pull-requests: read 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v6 19 | 20 | - uses: shm11C3/tauri-check-release-version@v1.0.4 21 | with: 22 | github_token: ${{ secrets.GITHUB_TOKEN }} 23 | owner: shm11C3 24 | repo: HardwareVisualizer 25 | tauri_config_path: ./src-tauri/tauri.conf.json 26 | tag_name_format: v{VERSION} 27 | -------------------------------------------------------------------------------- /src/test/unit/lib/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | describe("cn", () => { 5 | it("should merge class names and remove duplicates", () => { 6 | const result = cn("bg-red-500", "text-white", "bg-red-500"); 7 | const classes = result.split(/\s+/).sort(); 8 | expect(classes).toEqual(["bg-red-500", "text-white"]); 9 | }); 10 | 11 | it("should handle conditional class names", () => { 12 | const result = cn("foo", { bar: true, baz: false }, [ 13 | "qux", 14 | { quux: true }, 15 | ]); 16 | // Order of classes should match clsx/twMerge behavior 17 | expect(result).toBe("foo bar qux quux"); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/providers/linux/procfs.rs: -------------------------------------------------------------------------------- 1 | pub fn get_mem_total_kb() -> std::io::Result { 2 | use std::fs; 3 | use std::io; 4 | 5 | let content = fs::read_to_string("/proc/meminfo")?; 6 | for line in content.lines() { 7 | if let Some(mem_kb_str) = line.strip_prefix("MemTotal:") { 8 | let kb = mem_kb_str 9 | .split_whitespace() 10 | .next() 11 | .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "No value found"))?; 12 | return kb 13 | .parse::() 14 | .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)); 15 | } 16 | } 17 | Err(io::Error::new( 18 | io::ErrorKind::NotFound, 19 | "MemTotal entry not found", 20 | )) 21 | } 22 | -------------------------------------------------------------------------------- /src-tauri/src/platform/windows/memory.rs: -------------------------------------------------------------------------------- 1 | use crate::infrastructure::providers::wmi_provider; 2 | use crate::models::hardware::MemoryInfo; 3 | use std::future::Future; 4 | use std::pin::Pin; 5 | 6 | pub fn get_memory_info() 7 | -> Pin> + Send + 'static>> { 8 | Box::pin(async { 9 | // Use actual WMI implementation 10 | match wmi_provider::query_memory_info().await { 11 | Ok(info) => Ok(info), 12 | Err(e) => Err(e), 13 | } 14 | }) 15 | } 16 | 17 | pub fn get_memory_info_detail() 18 | -> Pin> + Send + 'static>> { 19 | Box::pin(async { Err("Detailed memory info is not implemented yet".to_string()) }) 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/useStickyObserver.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | export const useStickyObserver = () => { 4 | const sentinelRef = useRef(null); 5 | const [isStuck, setIsStuck] = useState(false); 6 | 7 | useEffect(() => { 8 | const observer = new IntersectionObserver( 9 | ([entry]) => { 10 | setIsStuck(!entry.isIntersecting); 11 | }, 12 | { 13 | root: null, 14 | threshold: 0, 15 | }, 16 | ); 17 | 18 | const el = sentinelRef.current; 19 | if (el) observer.observe(el); 20 | 21 | return () => { 22 | if (el) observer.unobserve(el); 23 | }; 24 | }, []); 25 | 26 | return { sentinelRef, isStuck }; 27 | }; 28 | -------------------------------------------------------------------------------- /.github/actions/setup-rust/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Rust Environment" 2 | description: "Setup Rust toolchain with caching and components" 3 | inputs: 4 | components: 5 | description: "Additional components to install" 6 | required: false 7 | default: "rustfmt clippy" 8 | 9 | runs: 10 | using: "composite" 11 | steps: 12 | - uses: dtolnay/rust-toolchain@6d9817901c499d6b02debbb57edb38d33daa680b # stable 13 | - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 14 | with: 15 | workspaces: "./src-tauri -> target" 16 | 17 | - name: Install Rust components 18 | if: inputs.components != '' 19 | run: rustup component add ${{ inputs.components }} 20 | shell: bash 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature request]" 5 | labels: enhancement 6 | assignees: shm11C3 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/GraphStyleSettings.tsx: -------------------------------------------------------------------------------- 1 | import { GraphSizeSlider } from "./GraphSizeSlider"; 2 | import { GraphStyleToggle } from "./GraphStyleToggle"; 3 | import { LineChartTypeSelector } from "./LineChartTypeSelector"; 4 | 5 | export const GraphStyleSettings = () => { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/hooks/useTauriEventListener.ts: -------------------------------------------------------------------------------- 1 | import { listen } from "@tauri-apps/api/event"; 2 | import { message } from "@tauri-apps/plugin-dialog"; 3 | import { useEffect } from "react"; 4 | 5 | /** 6 | * Listen for error events from the backend and display error dialogs 7 | */ 8 | export const useErrorModalListener = () => { 9 | useEffect(() => { 10 | const unListen = listen("error_event", (event) => { 11 | const { title, message: errorMessage } = event.payload as { 12 | title: string; 13 | message: string; 14 | }; 15 | 16 | message(errorMessage, { 17 | title: title, 18 | kind: "error", 19 | }); 20 | }); 21 | 22 | return () => { 23 | unListen.then((off) => off()); 24 | }; 25 | }, []); 26 | }; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2024", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2024", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["src"], 27 | "references": [{ "path": "./tsconfig.node.json" }] 28 | } 29 | -------------------------------------------------------------------------------- /src-tauri/src/workers/updater.rs: -------------------------------------------------------------------------------- 1 | use tauri_plugin_updater::UpdaterExt; 2 | 3 | pub async fn update(app: tauri::AppHandle) -> tauri_plugin_updater::Result<()> { 4 | if let Some(update) = app.updater()?.check().await? { 5 | let mut downloaded = 0; 6 | 7 | // alternatively we could also call update.download() and update.install() separately 8 | update 9 | .download_and_install( 10 | |chunk_length, content_length| { 11 | downloaded += chunk_length; 12 | println!("downloaded {downloaded} from {content_length:?}"); 13 | }, 14 | || { 15 | println!("download finished"); 16 | }, 17 | ) 18 | .await?; 19 | 20 | println!("update installed"); 21 | app.restart(); 22 | } 23 | 24 | Ok(()) 25 | } 26 | -------------------------------------------------------------------------------- /src/features/hardware/types/hardwareDataType.ts: -------------------------------------------------------------------------------- 1 | export const chartHardwareTypes = ["cpu", "memory", "gpu"] as const; 2 | 3 | export type ChartDataType = (typeof chartHardwareTypes)[number]; 4 | 5 | export type ChartDataHardwareType = ChartDataType | "processors"; 6 | 7 | export type HardwareDataType = "temp" | "usage" | "clock" | "memoryUsageValue"; 8 | 9 | export type GpuDataType = "temp" | "usage" | "dedicatedMemory"; 10 | 11 | export type NameValues = Array<{ 12 | name: string; 13 | value: number; 14 | }>; 15 | 16 | export type DataStats = "avg" | "max" | "min"; 17 | 18 | export const isChartDataType = (param: unknown): param is ChartDataType => { 19 | return ( 20 | typeof param === "string" && 21 | ([...chartHardwareTypes] as string[]).includes(param) 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/GraphColorSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { GraphColorPicker } from "./GraphColorPicker"; 3 | import { GraphColorReset } from "./GraphColorReset"; 4 | 5 | export const GraphColorSettings = () => { 6 | const { t } = useTranslation(); 7 | 8 | return ( 9 |
10 |

11 | {t("pages.settings.customTheme.lineColor")} 12 |

13 |
14 | 15 | 16 | 17 |
18 | 19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: "jsdom", 7 | setupFiles: ["src/test/setup.ts"], 8 | include: ["src/test/unit/**/*.test.ts", "src/test/unit/**/*.test.tsx"], 9 | coverage: { 10 | include: ["src/**/*.ts"], 11 | exclude: [ 12 | "src/rspc/**", 13 | "src/test/**", 14 | "src/**/*.d.ts", 15 | "src/**/types/**", 16 | ], 17 | reporter: ["text", "html", "json-summary"], 18 | thresholds: { 19 | statements: 60, 20 | branches: 60, 21 | functions: 60, 22 | lines: 60, 23 | }, 24 | }, 25 | }, 26 | resolve: { 27 | alias: { 28 | "@": path.resolve(__dirname, "src"), 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /src-tauri/src/services/language_service.rs: -------------------------------------------------------------------------------- 1 | use sys_locale; 2 | 3 | // List of languages currently supported by the app 4 | pub const SUPPORTED_LANGUAGES: [&str; 2] = ["en", "ja"]; 5 | 6 | /// 7 | /// Get default language setting 8 | /// 9 | pub fn get_default_language() -> String { 10 | let os_language = get_os_language(); 11 | 12 | // Return the language if it can be obtained and is supported 13 | if let Some(language) = os_language 14 | && SUPPORTED_LANGUAGES.contains(&language.as_str()) 15 | { 16 | return language; 17 | } 18 | 19 | // Return English (default) if no match 20 | "en".to_string() 21 | } 22 | 23 | /// 24 | /// Get system locale (language setting) 25 | /// 26 | fn get_os_language() -> Option { 27 | sys_locale::get_locale() 28 | .map(|locale| locale.split('-').next().unwrap_or(&locale).to_string()) 29 | } 30 | -------------------------------------------------------------------------------- /src/test/unit/hooks/useFullScreenMode.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | import { useFullScreenMode } from "@/hooks/useFullScreenMode"; 4 | 5 | // Mock dependencies 6 | vi.mock("@/rspc/bindings", () => ({ 7 | commands: { 8 | setDecoration: vi.fn().mockResolvedValue(undefined), 9 | }, 10 | })); 11 | 12 | vi.mock("@/hooks/useTauriStore", () => ({ 13 | useTauriStore: vi.fn().mockReturnValue([false, vi.fn()]), 14 | })); 15 | 16 | describe("useFullScreenMode (Simple)", () => { 17 | it("should return fullscreen state and toggle function", () => { 18 | const { result } = renderHook(() => useFullScreenMode()); 19 | 20 | expect(result.current.isFullScreen).toBe(false); 21 | expect(typeof result.current.toggleFullScreen).toBe("function"); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "ui:dev", 8 | "type": "shell", 9 | // `dev` keeps running in the background 10 | // ideally you should also configure a `problemMatcher` 11 | // see https://code.visualstudio.com/docs/editor/tasks#_can-a-background-task-be-used-as-a-prelaunchtask-in-launchjson 12 | "isBackground": true, 13 | // change this to your `beforeDevCommand`: 14 | "command": "npm", 15 | "args": ["run", "dev"] 16 | }, 17 | { 18 | "label": "ui:build", 19 | "type": "shell", 20 | // change this to your `beforeBuildCommand`: 21 | "command": "npm", 22 | "args": ["run", "build"] 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/features/hardware/types/chart.ts: -------------------------------------------------------------------------------- 1 | export type DataArchive = { 2 | id: number; 3 | cpu_avg: number | null; 4 | cpu_max: number | null; 5 | cpu_min: number | null; 6 | ram_avg: number | null; 7 | ram_max: number | null; 8 | ram_min: number | null; 9 | timestamp: number; 10 | }; 11 | 12 | export type GpuDataArchive = { 13 | id: number; 14 | gpu_name: string; 15 | usage_avg: number | null; 16 | usage_max: number | null; 17 | usage_min: number | null; 18 | temperature_avg: number | null; 19 | temperature_max: number | null; 20 | temperature_min: number | null; 21 | dedicated_memory_avg: number | null; 22 | dedicated_memory_max: number | null; 23 | dedicated_memory_min: number | null; 24 | timestamp: number; 25 | }; 26 | 27 | export type SingleDataArchive = { 28 | id: number; 29 | value: number | null; 30 | timestamp: string; 31 | }; 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug, enhancement 6 | assignees: shm11C3 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Windows11] 28 | - Version [e.g. 24H2] 29 | - PC Configuration 30 | - CPU 31 | - GPU 32 | - Mainboard 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/commands/background_image_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use crate::commands::background_image::*; 4 | 5 | #[tokio::test] 6 | async fn test_save_background_image_invalid_data() { 7 | let invalid_base64 = "invalid_base64_data"; 8 | 9 | let result = save_background_image(invalid_base64.to_string()).await; 10 | assert!(result.is_err()); 11 | } 12 | 13 | #[tokio::test] 14 | async fn test_get_background_image_nonexistent_file() { 15 | let file_id = "nonexistent_file_id"; 16 | 17 | let result = get_background_image(file_id.to_string()).await; 18 | assert!(result.is_err()); 19 | } 20 | 21 | #[tokio::test] 22 | async fn test_delete_background_image_nonexistent_file() { 23 | let file_id = "nonexistent_file_id"; 24 | 25 | let result = delete_background_image(file_id.to_string()).await; 26 | assert!(result.is_err()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/utils/color_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use crate::utils::color::hex_to_rgb; 4 | 5 | #[test] 6 | fn test_valid_hex() { 7 | assert_eq!(hex_to_rgb("#FFFFFF").unwrap(), [255, 255, 255]); 8 | assert_eq!(hex_to_rgb("#000000").unwrap(), [0, 0, 0]); 9 | assert_eq!(hex_to_rgb("#123ABC").unwrap(), [18, 58, 188]); 10 | assert_eq!(hex_to_rgb("#abcdef").unwrap(), [171, 205, 239]); 11 | } 12 | 13 | #[test] 14 | fn test_invalid_format() { 15 | assert!(hex_to_rgb("123456").is_err()); 16 | assert!(hex_to_rgb("#12345").is_err()); 17 | assert!(hex_to_rgb("#1234567").is_err()); 18 | assert!(hex_to_rgb("123ABC").is_err()); 19 | } 20 | 21 | #[test] 22 | fn test_invalid_characters() { 23 | assert!(hex_to_rgb("#GGGGGG").is_err()); 24 | assert!(hex_to_rgb("#ZZZZZZ").is_err()); 25 | assert!(hex_to_rgb("#12345G").is_err()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/features/settings/components/general/GeneralSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { AutoStartToggle } from "./AutoStartToggle"; 3 | import { BurnInShiftSettings } from "./BurnInShiftSettings"; 4 | import { LanguageSelect } from "./LanguageSelect"; 5 | import { TemperatureUnitSelect } from "./TemperatureUnitSelect"; 6 | import { ThemeSelect } from "./ThemeSelect"; 7 | 8 | export const GeneralSettings = () => { 9 | const { t } = useTranslation(); 10 | 11 | return ( 12 |
13 |

14 | {t("pages.settings.general.name")} 15 |

16 |
17 | 18 | 19 | 20 | 21 | 22 |
23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/features/hardware/consts/chart.ts: -------------------------------------------------------------------------------- 1 | import type { ChartDataType } from "@/features/hardware/types/hardwareDataType"; 2 | 3 | export const chartConfig = { 4 | /** 5 | * Length of graph history (in seconds) 6 | */ 7 | historyLengthSec: 60, 8 | archiveUpdateIntervalMilSec: 60000, 9 | } as const; 10 | 11 | export const displayHardType: Record = { 12 | cpu: "CPU", 13 | memory: "RAM", 14 | gpu: "GPU", 15 | } as const; 16 | 17 | export const sizeOptions = ["sm", "md", "lg", "xl", "2xl"] as const; 18 | 19 | export const defaultColorRGB: Record = { 20 | cpu: "75, 192, 192", 21 | memory: "255, 99, 132", 22 | gpu: "255, 206, 86", 23 | }; 24 | 25 | /** 26 | * Display period for insight feature 27 | */ 28 | export const archivePeriods = [ 29 | 10, 30, 60, 180, 720, 1440, 10080, 20160, 43200, 30 | ] as const; 31 | 32 | export const bubbleChartColor = "#8884d8"; 33 | -------------------------------------------------------------------------------- /src/components/shared/ScreenTemplate.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from "react"; 2 | import { BurnInShift } from "./BurnInShift"; 3 | 4 | interface ScreenTemplateProps { 5 | title?: string; 6 | icon?: JSX.Element; 7 | enabledBurnInShift?: boolean; 8 | children: React.ReactNode; 9 | } 10 | 11 | export const ScreenTemplate: React.FC = ({ 12 | title, 13 | icon, 14 | enabledBurnInShift = false, 15 | children, 16 | }) => { 17 | return ( 18 | 19 |
20 |
21 | {icon != null && icon} 22 | {title && ( 23 |

24 | {title} 25 |

26 | )} 27 |
28 | {children} 29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src-tauri/src/services/cpu_service.rs: -------------------------------------------------------------------------------- 1 | use crate::models::hardware::HardwareMonitorState; 2 | 3 | /// 4 | /// ## Return overall CPU usage (%) 5 | /// 6 | /// Average the usage of each core from sysinfo's recent sample and round. 7 | /// Returns 0 if CPU count is 0 (error case). 8 | /// 9 | pub fn overall_cpu_usage(state: &HardwareMonitorState) -> i32 { 10 | let system = state.system.lock().unwrap(); 11 | let cpus = system.cpus(); 12 | if cpus.is_empty() { 13 | return 0; 14 | } 15 | let total: f32 = cpus.iter().map(|c| c.cpu_usage()).sum(); 16 | (total / cpus.len() as f32).round() as i32 17 | } 18 | 19 | /// 20 | /// ## Return vector of CPU usage (%) for each processor 21 | /// 22 | /// Return raw f32 values (sysinfo provided values) without rounding. 23 | /// 24 | pub fn per_cpu_usage(state: &HardwareMonitorState) -> Vec { 25 | let system = state.system.lock().unwrap(); 26 | system.cpus().iter().map(|c| c.cpu_usage()).collect() 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'shm11C3/HardwareVisualizer' 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | # Auto-merge only patch version updates 20 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' 21 | run: gh pr merge --auto --merge "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | -------------------------------------------------------------------------------- /src-tauri/assets/deb/com.HardwareVisualizer.app.policy: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | Run dmidecode for hardware visualization 9 | Authentication is required to retrieve detailed memory information 10 | utilities-terminal 11 | 12 | 13 | auth_admin 14 | auth_admin 15 | auth_admin 16 | 17 | 18 | /usr/sbin/dmidecode 19 | true 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/test/unit/lib/formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { formatBytes, formatDuration } from "@/lib/formatter"; 3 | 4 | describe("formatBytes", () => { 5 | it("should return [0,'B'] for invalid numbers", () => { 6 | expect(formatBytes(-1)).toEqual([0, "B"]); 7 | expect(formatBytes(Number.NaN)).toEqual([0, "B"]); 8 | }); 9 | 10 | it("should convert bytes to appropriate units", () => { 11 | expect(formatBytes(1024)).toEqual([1, "KB"]); 12 | expect(formatBytes(1024 ** 2)).toEqual([1, "MB"]); 13 | expect(formatBytes(1024 ** 3)).toEqual([1, "GB"]); 14 | }); 15 | }); 16 | 17 | describe("formatDuration", () => { 18 | it("should format duration in English", () => { 19 | expect(formatDuration(3661, "en-US")).toBe("1hour 1minute 1second"); 20 | }); 21 | 22 | it("should format duration in Japanese", () => { 23 | expect(formatDuration(90061, "ja-JP")).toBe("1日 1時間 1分 1秒"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src-tauri/src/workers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod hardware_archive; 2 | pub mod system_monitor; 3 | pub mod updater; 4 | 5 | use std::sync::{Mutex, atomic::AtomicBool}; 6 | 7 | #[derive(Default)] 8 | pub struct WorkersState { 9 | pub monitor: Mutex>, 10 | pub hw_archive: Mutex>, 11 | pub shutting_down: AtomicBool, 12 | } 13 | 14 | impl WorkersState { 15 | pub async fn terminate_all(&self) { 16 | if self 17 | .shutting_down 18 | .swap(true, std::sync::atomic::Ordering::SeqCst) 19 | { 20 | return; 21 | } 22 | let monitor = self.monitor.lock().unwrap().take(); 23 | let hw_archive = self.hw_archive.lock().unwrap().take(); 24 | 25 | if let Some(monitor) = monitor { 26 | monitor.terminate().await; 27 | } 28 | 29 | if let Some(hw_archive) = hw_archive { 30 | hw_archive.terminate().await; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src-tauri/src/services/memory_service.rs: -------------------------------------------------------------------------------- 1 | use crate::models::hardware::{HardwareMonitorState, MemoryInfo}; 2 | use crate::platform::factory::PlatformFactory; 3 | 4 | /// 5 | /// ## Return memory usage (%). Round used / total * 100 6 | /// 7 | /// Returns 0 if total is 0 8 | /// 9 | pub fn memory_usage_percent(state: &HardwareMonitorState) -> i32 { 10 | let system = state.system.lock().unwrap(); 11 | let used = system.used_memory() as f64; 12 | let total = system.total_memory() as f64; 13 | if total == 0.0 { 14 | 0 15 | } else { 16 | ((used / total) * 100.0).round() as i32 17 | } 18 | } 19 | 20 | /// 21 | /// ## Get detailed memory information via Platform 22 | /// Returns `MemoryInfo` on success, error message on failure 23 | /// 24 | pub async fn fetch_memory_detail() -> Result { 25 | let platform = 26 | PlatformFactory::create().map_err(|e| format!("Failed to create platform: {e}"))?; 27 | platform.get_memory_info_detail().await 28 | } 29 | -------------------------------------------------------------------------------- /src/test/unit/store/ui.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { modalAtoms, settingAtoms } from "@/store/ui"; 3 | 4 | describe("UI Store", () => { 5 | describe("modalAtoms", () => { 6 | it("should have showSettingsModal atom with default value false", () => { 7 | expect(modalAtoms.showSettingsModal).toBeDefined(); 8 | // Atoms don't expose their initial values directly, but we can verify the structure 9 | expect(typeof modalAtoms.showSettingsModal).toBe("object"); 10 | }); 11 | }); 12 | 13 | describe("settingAtoms", () => { 14 | it("should have isRequiredRestart atom with default value false", () => { 15 | expect(settingAtoms.isRequiredRestart).toBeDefined(); 16 | expect(typeof settingAtoms.isRequiredRestart).toBe("object"); 17 | }); 18 | }); 19 | 20 | it("should export both modalAtoms and settingAtoms", () => { 21 | expect(modalAtoms).toBeDefined(); 22 | expect(settingAtoms).toBeDefined(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/features/settings/types/settingsType.ts: -------------------------------------------------------------------------------- 1 | import type { sizeOptions } from "@/features/hardware/consts/chart"; 2 | import type { ChartDataType } from "../../hardware/types/hardwareDataType"; 3 | 4 | export type Settings = { 5 | language: string; 6 | theme: 7 | | "light" 8 | | "dark" 9 | | "sky" 10 | | "grove" 11 | | "sunset" 12 | | "nebula" 13 | | "orbit" 14 | | "cappuccino" 15 | | "espresso"; 16 | displayTargets: Array; 17 | graphSize: (typeof sizeOptions)[number]; 18 | lineGraphBorder: boolean; 19 | lineGraphFill: boolean; 20 | lineGraphColor: { 21 | cpu: string; 22 | memory: string; 23 | gpu: string; 24 | }; 25 | lineGraphMix: boolean; 26 | lineGraphShowLegend: boolean; 27 | lineGraphShowScale: boolean; 28 | backgroundImgOpacity: number; 29 | selectedBackgroundImg: string | null; 30 | temperatureUnit: "C" | "F"; 31 | }; 32 | 33 | export type BackgroundImage = { 34 | fileId: string; 35 | imageData: string; 36 | }; 37 | -------------------------------------------------------------------------------- /src/features/hardware/dashboard/components/SortableItem.tsx: -------------------------------------------------------------------------------- 1 | import { useSortable } from "@dnd-kit/sortable"; 2 | import { CSS } from "@dnd-kit/utilities"; 3 | import { GripVertical } from "lucide-react"; 4 | import type { DashboardItemType } from "../types/dashboardItem"; 5 | 6 | export const SortableItem = ({ 7 | id, 8 | children, 9 | }: { 10 | id: DashboardItemType; 11 | children: React.ReactNode; 12 | }) => { 13 | const { setNodeRef, attributes, listeners, transform, transition } = 14 | useSortable({ id }); 15 | 16 | const style = { 17 | transform: CSS.Transform.toString(transform), 18 | transition, 19 | }; 20 | 21 | return ( 22 |
23 |
28 | 29 |
30 | 31 |
{children}
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/shared/BurnInShift.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode, useRef } from "react"; 2 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 3 | import { useBurnInShift } from "@/hooks/useBurnInShift"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export const BurnInShift = ({ 7 | enabled, 8 | children, 9 | }: { 10 | enabled: boolean; 11 | children: ReactNode; 12 | }) => { 13 | const shiftRef = useRef(null); 14 | const { settings } = useSettingsAtom(); 15 | useBurnInShift(shiftRef, enabled, settings.burnInShiftOptions ?? undefined); 16 | 17 | const isDriftEnabled = 18 | enabled && settings.burnInShift && settings.burnInShiftMode === "drift"; 19 | 20 | return ( 21 |
22 |
26 |
{children}
27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/GraphColorReset.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Button } from "@/components/ui/button"; 3 | import { defaultColorRGB } from "@/features/hardware/consts/chart"; 4 | import { chartHardwareTypes } from "@/features/hardware/types/hardwareDataType"; 5 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 6 | import { RGB2HEX } from "@/lib/color"; 7 | 8 | export const GraphColorReset = () => { 9 | const { updateLineGraphColorAtom } = useSettingsAtom(); 10 | const { t } = useTranslation(); 11 | 12 | const updateGraphColor = async () => { 13 | await Promise.all( 14 | chartHardwareTypes.map((type) => 15 | updateLineGraphColorAtom(type, RGB2HEX(defaultColorRGB[type])), 16 | ), 17 | ); 18 | }; 19 | 20 | return ( 21 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import type * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ); 19 | } 20 | 21 | export { Input }; 22 | -------------------------------------------------------------------------------- /src/features/settings/components/insights/InsightsTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from "lucide-react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/tooltip"; 9 | import { TypographyP } from "@/components/ui/typography"; 10 | 11 | export const InsightsTitle = () => { 12 | const { t } = useTranslation(); 13 | return ( 14 |
15 |

16 | {t("pages.settings.insights.name")} 17 |

18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {t("pages.settings.insights.about.description")} 26 | 27 | 28 | 29 | 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/hooks/useTitleIconVisualSelector.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtomValue, useSetAtom } from "jotai"; 2 | import type { SelectedDisplayType } from "@/types/ui"; 3 | 4 | const showTitleIconAtom = atom([ 5 | "dashboard", 6 | "cpuDetail", 7 | "insights", 8 | "settings", 9 | ]); 10 | 11 | export const useTitleIconVisualSelector = () => { 12 | const setShowTitleIcon = useSetAtom(showTitleIconAtom); 13 | const visibleTypes = useAtomValue(showTitleIconAtom); 14 | 15 | const isTitleIconVisible = (type: SelectedDisplayType): boolean => { 16 | return visibleTypes.includes(type); 17 | }; 18 | 19 | const toggleTitleIconVisibility = ( 20 | type: SelectedDisplayType, 21 | visible: boolean, 22 | ) => { 23 | setShowTitleIcon((prev) => { 24 | if (visible) { 25 | if (!prev.includes(type)) { 26 | return [...prev, type]; 27 | } 28 | return prev; 29 | } 30 | return prev.filter((t) => t !== type); 31 | }); 32 | }; 33 | 34 | return { visibleTypes, isTitleIconVisible, toggleTitleIconVisibility }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/test/unit/lib/openUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { open } from "@tauri-apps/plugin-shell"; 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { openURL } from "@/lib/openUrl"; 4 | 5 | vi.mock("@tauri-apps/plugin-shell", () => ({ 6 | open: vi.fn(), 7 | })); 8 | 9 | describe("openURL", () => { 10 | beforeEach(() => { 11 | vi.clearAllMocks(); 12 | }); 13 | 14 | it("When a valid URL is passed, open is called", async () => { 15 | const validURL = "https://example.com"; 16 | 17 | // Execute openURL 18 | await openURL(validURL); 19 | 20 | // Verify that open was called with validURL 21 | expect(open).toHaveBeenCalledWith(validURL); 22 | }); 23 | 24 | it("When an invalid URL is passed, an error is thrown", async () => { 25 | const invalidURL = "invalid-url"; 26 | 27 | // Since creating new URL(url) throws an error for invalidURL, openURL should throw an error 28 | await expect(openURL(invalidURL)).rejects.toThrow("Invalid URL"); 29 | 30 | // open should not be called 31 | expect(open).not.toHaveBeenCalled(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/features/settings/components/general/BurnInShiftIdleCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Checkbox } from "@/components/ui/checkbox"; 4 | import { Label } from "@/components/ui/label"; 5 | import type { ClientSettings } from "@/rspc/bindings"; 6 | 7 | export const BurnInShiftIdleCheckbox = ({ 8 | settings, 9 | toggleIdleOnly, 10 | }: { 11 | settings: ClientSettings; 12 | toggleIdleOnly: (value: boolean) => Promise; 13 | }) => { 14 | const { t } = useTranslation(); 15 | const burnInShiftIdleOnlyId = useId(); 16 | 17 | return ( 18 |
19 | 24 | 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/hooks/useInputListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { commands } from "@/rspc/bindings"; 3 | import { useTauriDialog } from "./useTauriDialog"; 4 | 5 | export const useKeydown = ({ 6 | isDecorated, 7 | setDecorated, 8 | }: { 9 | isDecorated: boolean; 10 | setDecorated: (newValue: boolean) => Promise; 11 | }) => { 12 | const { error } = useTauriDialog(); 13 | 14 | useEffect(() => { 15 | const handleDecoration = async () => { 16 | try { 17 | await commands.setDecoration(!isDecorated); 18 | await setDecorated(!isDecorated); 19 | } catch (e) { 20 | error(e as string); 21 | console.error("Failed to toggle window decoration:", e); 22 | } 23 | }; 24 | 25 | const handleKeyDown = async (event: KeyboardEvent) => { 26 | if (event.key === "F11") handleDecoration(); 27 | }; 28 | 29 | window.addEventListener("keydown", handleKeyDown, { passive: true }); 30 | return () => { 31 | window.removeEventListener("keydown", handleKeyDown); 32 | }; 33 | }, [isDecorated, setDecorated, error]); 34 | }; 35 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/database/process_stats.rs: -------------------------------------------------------------------------------- 1 | use super::db; 2 | use crate::models; 3 | 4 | pub async fn insert( 5 | processes: Vec, 6 | ) -> Result<(), sqlx::Error> { 7 | let pool = db::get_pool().await?; 8 | 9 | for proc in processes { 10 | sqlx::query( 11 | "INSERT INTO PROCESS_STATS (pid, process_name, cpu_usage, memory_usage, execution_sec, timestamp) 12 | VALUES ($1, $2, $3, $4, $5, $6)" 13 | ) 14 | .bind(proc.pid) 15 | .bind(&proc.process_name) 16 | .bind(proc.cpu_usage) 17 | .bind(proc.memory_usage) 18 | .bind(proc.execution_sec) 19 | .bind(chrono::Utc::now()) 20 | .execute(&pool) 21 | .await?; 22 | } 23 | 24 | Ok(()) 25 | } 26 | 27 | pub async fn delete_old_data(refresh_interval_days: u32) -> Result<(), sqlx::Error> { 28 | let pool = db::get_pool().await?; 29 | 30 | sqlx::query("DELETE FROM PROCESS_STATS WHERE timestamp < $1") 31 | .bind(chrono::Utc::now() - chrono::Duration::days(refresh_interval_days as i64)) 32 | .execute(&pool) 33 | .await?; 34 | 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Tauri Development Debug", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--manifest-path=./src-tauri/Cargo.toml", 15 | "--no-default-features" 16 | ] 17 | }, 18 | // task for the `beforeDevCommand` if used, must be configured in `.vscode/tasks.json` 19 | "preLaunchTask": "ui:dev" 20 | }, 21 | { 22 | "type": "lldb", 23 | "request": "launch", 24 | "name": "Tauri Production Debug", 25 | "cargo": { 26 | "args": ["build", "--release", "--manifest-path=./src-tauri/Cargo.toml"] 27 | }, 28 | // task for the `beforeBuildCommand` if used, must be configured in `.vscode/tasks.json` 29 | "preLaunchTask": "ui:build" 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/BackgroundOpacitySlider.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Label } from "@/components/ui/label"; 3 | import { Slider } from "@/components/ui/slider"; 4 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 5 | 6 | export const BackgroundOpacitySlider = () => { 7 | const { settings, updateSettingAtom } = useSettingsAtom(); 8 | const { t } = useTranslation(); 9 | 10 | const changeBackGroundOpacity = async (value: number[]) => { 11 | updateSettingAtom("backgroundImgOpacity", value[0]); 12 | }; 13 | 14 | return ( 15 | settings.selectedBackgroundImg && ( 16 |
17 | 20 | 28 |
29 | ) 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 shm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/features/hardware/hooks/useProcessInfo.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom, useSetAtom } from "jotai"; 2 | import { useEffect } from "react"; 3 | import { useTauriDialog } from "@/hooks/useTauriDialog"; 4 | import { commands, type ProcessInfo } from "@/rspc/bindings"; 5 | 6 | const processesAtom = atom([]); 7 | 8 | export const useProcessInfo = () => { 9 | const { error } = useTauriDialog(); 10 | const [processes] = useAtom(processesAtom); 11 | const setAtom = useSetAtom(processesAtom); 12 | 13 | // biome-ignore lint/correctness/useExhaustiveDependencies: This effect runs only once to fetch processes 14 | useEffect(() => { 15 | const fetchProcesses = async () => { 16 | try { 17 | const processesData = await commands.getProcessList(); 18 | setAtom(processesData); 19 | } catch (err) { 20 | error(err as string); 21 | console.error("Failed to fetch processes:", err); 22 | } 23 | }; 24 | 25 | fetchProcesses(); 26 | 27 | const interval = setInterval(fetchProcesses, 3000); 28 | 29 | return () => clearInterval(interval); 30 | }, []); 31 | 32 | return processes; 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/shared/InfoTable.tsx: -------------------------------------------------------------------------------- 1 | import { minOpacity } from "@/consts/style"; 2 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | export const InfoTable = ({ 6 | data, 7 | className, 8 | }: { 9 | data: { [key: string]: string | number }; 10 | className?: string; 11 | }) => { 12 | const { settings } = useSettingsAtom(); 13 | 14 | return ( 15 |
30 | {Object.keys(data).map((key) => ( 31 |
32 |

33 | {key} 34 |

35 |

{data[key]}

36 |
37 | ))} 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 4 | "linter": { 5 | "enabled": true, 6 | "rules": { 7 | "recommended": true, 8 | "nursery": { 9 | "useSortedClasses": { 10 | "level": "error", 11 | "fix": "safe", 12 | "options": {} 13 | } 14 | }, 15 | "style": { 16 | "noParameterAssign": "error", 17 | "useAsConstAssertion": "error", 18 | "useDefaultParameterLast": "error", 19 | "useEnumInitializers": "error", 20 | "useSelfClosingElements": "error", 21 | "useSingleVarDeclarator": "error", 22 | "noUnusedTemplateLiteral": "error", 23 | "useNumberNamespace": "error", 24 | "noInferrableTypes": "error", 25 | "noUselessElse": "error" 26 | } 27 | } 28 | }, 29 | "formatter": { 30 | "indentStyle": "space", 31 | "indentWidth": 2 32 | }, 33 | "files": { 34 | "includes": ["**", "!**/src/rspc"] 35 | }, 36 | "css": { 37 | "parser": { 38 | "tailwindDirectives": true 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/scripts/extract-apache-notices.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | const licensesFile = process.argv[2]; 5 | const outputDir = process.argv[3]; 6 | 7 | if (!licensesFile || !outputDir) { 8 | console.error( 9 | "Usage: node extract-apache-notices ", 10 | ); 11 | process.exit(1); 12 | } 13 | 14 | const licenses: Record< 15 | string, 16 | { licenses: string; path: string; licenseFile: string } 17 | > = JSON.parse(fs.readFileSync(licensesFile, "utf-8")); 18 | 19 | if (!fs.existsSync(outputDir)) { 20 | fs.mkdirSync(outputDir, { recursive: true }); 21 | } 22 | 23 | for (const [pkgName, info] of Object.entries(licenses)) { 24 | if (info.licenses === "Apache-2.0") { 25 | const noticePath = path.join(info.path, "NOTICE"); 26 | if (fs.existsSync(noticePath)) { 27 | const content = fs.readFileSync(noticePath, "utf-8"); 28 | const sanitized = pkgName.replace(/[\\/]/g, "_"); 29 | fs.writeFileSync( 30 | path.join(outputDir, `${sanitized}_NOTICE.txt`), 31 | content, 32 | ); 33 | } else { 34 | console.warn(`⚠️ NOTICE not found for: ${pkgName}`); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/test/unit/lib/i18n.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, it } from "vitest"; 2 | import i18n from "@/lib/i18n"; 3 | 4 | describe("i18n configuration", () => { 5 | beforeAll(async () => { 6 | await i18n.init(); 7 | }); 8 | 9 | it("should initialize with English as default language", () => { 10 | expect(i18n.language).toBe("en"); 11 | }); 12 | 13 | it("should have English translation resources", () => { 14 | expect(i18n.hasResourceBundle("en", "translation")).toBe(true); 15 | }); 16 | 17 | it("should have Japanese translation resources", () => { 18 | expect(i18n.hasResourceBundle("ja", "translation")).toBe(true); 19 | }); 20 | 21 | it("should be able to change language to Japanese", async () => { 22 | await i18n.changeLanguage("ja"); 23 | expect(i18n.language).toBe("ja"); 24 | 25 | // Reset to English for other tests 26 | await i18n.changeLanguage("en"); 27 | }); 28 | 29 | it("should have escapeValue disabled for interpolation", () => { 30 | expect(i18n.options.interpolation?.escapeValue).toBe(false); 31 | }); 32 | 33 | it("should use react-i18next plugin", () => { 34 | expect(i18n.isInitialized).toBe(true); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/features/hardware/insights/process/funcs/getProcessStatsRecord.ts: -------------------------------------------------------------------------------- 1 | import { chartConfig } from "@/features/hardware/consts/chart"; 2 | import { sqlitePromise } from "@/lib/sqlite"; 3 | import type { ProcessStat } from "../../types/processStats"; 4 | 5 | /** 6 | * 7 | * @param period 8 | * @param endAt 9 | * @returns 10 | * @todo Also do sorting in SQL 11 | */ 12 | export const getProcessStats = async ( 13 | period: number, 14 | endAt: Date, 15 | ): Promise => { 16 | const adjustedEndAt = new Date( 17 | endAt.getTime() - chartConfig.archiveUpdateIntervalMilSec, 18 | ); 19 | const startTime = new Date(adjustedEndAt.getTime() - period * 60 * 1000); 20 | const db = await sqlitePromise; 21 | 22 | const sql = ` 23 | SELECT 24 | pid, 25 | process_name, 26 | AVG(cpu_usage) AS avg_cpu_usage, 27 | AVG(memory_usage) AS avg_memory_usage, 28 | MAX(execution_sec) AS total_execution_sec, 29 | MAX(timestamp) AS latest_timestamp 30 | FROM process_stats 31 | WHERE timestamp BETWEEN '${startTime.toISOString()}' 32 | AND '${adjustedEndAt.toISOString()}' 33 | GROUP BY pid, process_name 34 | `; 35 | 36 | return db.load(sql); 37 | }; 38 | -------------------------------------------------------------------------------- /src/features/settings/Settings.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { AboutSection } from "@/features/settings/components/about/AboutSection"; 4 | import { GeneralSettings } from "@/features/settings/components/general/GeneralSettings"; 5 | import { GraphSettings } from "@/features/settings/components/graph/GraphSettings"; 6 | import { InsightsSettings } from "@/features/settings/components/insights/InsightsSettings"; 7 | import { LicensePage } from "@/features/settings/components/LicensePage"; 8 | 9 | export const Settings = () => { 10 | const { t } = useTranslation(); 11 | const [showLicensePage, setShowLicensePage] = useState(false); 12 | 13 | if (showLicensePage) { 14 | return setShowLicensePage(false)} />; 15 | } 16 | 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 |
24 |

25 | {t("pages.settings.about.name")} 26 |

27 | setShowLicensePage(true)} /> 28 |
29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/features/settings/components/general/BurnInShiftOverrideCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Checkbox } from "@/components/ui/checkbox"; 4 | import { Label } from "@/components/ui/label"; 5 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 6 | import type { BurnInShiftOptions } from "@/rspc/bindings"; 7 | 8 | export const BurnInShiftOverrideCheckbox = () => { 9 | const { t } = useTranslation(); 10 | const { settings, updateSettingAtom } = useSettingsAtom(); 11 | const id = useId(); 12 | 13 | const handleOverrideChange = (checked: boolean) => { 14 | updateSettingAtom( 15 | "burnInShiftOptions", 16 | checked ? ({} as BurnInShiftOptions) : null, 17 | ); 18 | }; 19 | 20 | return ( 21 |
22 | 27 | 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/GraphColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "@/components/ui/label"; 2 | import type { ChartDataType } from "@/features/hardware/types/hardwareDataType"; 3 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 4 | import { RGB2HEX } from "@/lib/color"; 5 | 6 | export const GraphColorPicker = ({ 7 | label, 8 | hardwareType, 9 | }: { 10 | label: string; 11 | hardwareType: ChartDataType; 12 | }) => { 13 | const { settings, updateLineGraphColorAtom } = useSettingsAtom(); 14 | 15 | const updateGraphColor = async (value: string) => { 16 | await updateLineGraphColorAtom(hardwareType, value); 17 | }; 18 | 19 | // Convert comma-separated RGB values to hexadecimal 20 | const hexValue = RGB2HEX(settings.lineGraphColor[hardwareType]); 21 | 22 | return ( 23 |
24 | 27 | 28 | updateGraphColor(e.target.value)} 33 | /> 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/lib/formatter.ts: -------------------------------------------------------------------------------- 1 | import type { SizeUnit } from "@/rspc/bindings"; 2 | 3 | export const formatBytes = ( 4 | bytes: number, 5 | decimals = 2, 6 | ): [number, SizeUnit] => { 7 | if (!Number.isFinite(bytes) || bytes <= 0) return [0, "B"]; 8 | 9 | const k = 1024; 10 | const dm = decimals < 0 ? 0 : decimals; 11 | const sizes: SizeUnit[] = ["B", "KB", "MB", "GB"]; 12 | 13 | const i = Math.min( 14 | Math.floor(Math.log(bytes) / Math.log(k)), 15 | sizes.length - 1, 16 | ); 17 | 18 | return [Number.parseFloat((bytes / k ** i).toFixed(dm)), sizes[i]]; 19 | }; 20 | 21 | export const formatDuration = (seconds: number, locale: "ja-JP" | "en-US") => { 22 | const d = Math.floor(seconds / 86400); 23 | const h = Math.floor((seconds % 86400) / 3600); 24 | const m = Math.floor((seconds % 3600) / 60); 25 | const s = seconds % 60; 26 | 27 | const translations = { 28 | "ja-JP": { day: "日", hour: "時間", minute: "分", second: "秒" }, 29 | "en-US": { day: "day", hour: "hour", minute: "minute", second: "second" }, 30 | }; 31 | 32 | const t = translations[locale]; 33 | 34 | return `${d > 0 ? `${d}${t.day} ` : ""}${h > 0 ? `${h}${t.hour} ` : ""}${m > 0 ? `${m}${t.minute} ` : ""}${s}${t.second}`.trim(); 35 | }; 36 | -------------------------------------------------------------------------------- /.github/actions/setup-node/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Node.js Environment" 2 | description: "Setup Node.js with caching and dependency installation" 3 | inputs: 4 | node-version: 5 | description: "Node.js version to install" 6 | required: false 7 | default: "24" 8 | install-deps: 9 | description: "Whether to install dependencies" 10 | required: false 11 | default: "true" 12 | 13 | runs: 14 | using: "composite" 15 | steps: 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v6 18 | with: 19 | node-version: ${{ inputs.node-version }} 20 | cache: "npm" 21 | 22 | - name: Setup safe-chain (Windows) 23 | if: runner.os == 'Windows' 24 | shell: pwsh 25 | run: | 26 | iex (iwr "https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.ps1" -UseBasicParsing) 27 | 28 | - name: Setup safe-chain (Unix) 29 | if: runner.os != 'Windows' 30 | shell: bash 31 | run: | 32 | curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci 33 | 34 | - name: Install dependencies 35 | if: inputs.install-deps == 'true' 36 | run: npm ci 37 | shell: bash 38 | -------------------------------------------------------------------------------- /src/test/unit/hooks/useBurnInShift.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import type { RefObject } from "react"; 3 | import { describe, expect, it, vi } from "vitest"; 4 | import { useBurnInShift } from "@/hooks/useBurnInShift"; 5 | 6 | // Simple mock setup 7 | vi.mock("@/features/settings/hooks/useSettingsAtom", () => ({ 8 | useSettingsAtom: () => ({ 9 | settings: { 10 | burnInShift: true, 11 | burnInShiftMode: "jump", 12 | burnInShiftPreset: "balanced", 13 | burnInShiftIdleOnly: false, 14 | }, 15 | }), 16 | })); 17 | 18 | vi.mock("@/lib/math", () => ({ 19 | randInt: vi.fn().mockReturnValue(5), 20 | })); 21 | 22 | describe("useBurnInShift (Simple)", () => { 23 | it("should not throw when called with null ref", () => { 24 | const nullRef: RefObject = { current: null }; 25 | 26 | expect(() => { 27 | renderHook(() => useBurnInShift(nullRef, true)); 28 | }).not.toThrow(); 29 | }); 30 | 31 | it("should not throw when called with disabled state", () => { 32 | const nullRef: RefObject = { current: null }; 33 | 34 | expect(() => { 35 | renderHook(() => useBurnInShift(nullRef, false)); 36 | }).not.toThrow(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/features/menu/hooks/useMenu.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from "jotai"; 2 | import { useCallback, useEffect } from "react"; 3 | import { useTauriStore } from "@/hooks/useTauriStore"; 4 | import type { SelectedDisplayType } from "@/types/ui"; 5 | 6 | export const displayTargetAtom = atom(null); 7 | 8 | export const useMenu = () => { 9 | const [, setDisplayTargetAtom] = useAtom(displayTargetAtom); 10 | const [isOpen, setMenuOpen] = useTauriStore("sideMenuOpen", false); 11 | const [displayTarget, setDisplayTarget] = useTauriStore( 12 | "display", 13 | "dashboard", 14 | ); 15 | 16 | useEffect(() => { 17 | if (displayTarget) { 18 | setDisplayTargetAtom(displayTarget); 19 | } 20 | }, [displayTarget, setDisplayTargetAtom]); 21 | 22 | const toggleMenu = useCallback(() => { 23 | setMenuOpen(!isOpen); 24 | }, [isOpen, setMenuOpen]); 25 | 26 | const handleMenuClick = useCallback( 27 | (type: SelectedDisplayType) => { 28 | setDisplayTarget(type); 29 | setDisplayTargetAtom(type); 30 | }, 31 | [setDisplayTarget, setDisplayTargetAtom], 32 | ); 33 | 34 | return { 35 | isOpen, 36 | toggleMenu, 37 | handleMenuClick, 38 | displayTarget, 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/features/hardware/dashboard/hooks/useSortableDashboard.ts: -------------------------------------------------------------------------------- 1 | import type { DragEndEvent } from "@dnd-kit/core"; 2 | import { arraySwap } from "@dnd-kit/sortable"; 3 | import { useEffect } from "react"; 4 | import { useTauriStore } from "@/hooks/useTauriStore"; 5 | import { useHardwareInfoAtom } from "../../hooks/useHardwareInfoAtom"; 6 | import type { DashboardItemType } from "../types/dashboardItem"; 7 | 8 | export const useSortableDashboard = () => { 9 | const { init } = useHardwareInfoAtom(); 10 | const [dashboardItemMap, setDashboardItemMap] = useTauriStore< 11 | DashboardItemType[] 12 | >("dashboardItem", ["cpu", "gpu", "memory", "storage", "network", "process"]); 13 | 14 | const handleDragOver = (event: DragEndEvent) => { 15 | const { active, over } = event; 16 | if (!over || active.id === over.id) return; 17 | if (!dashboardItemMap) return; 18 | 19 | const oldIndex = dashboardItemMap.indexOf(active.id as DashboardItemType); 20 | const newIndex = dashboardItemMap.indexOf(over.id as DashboardItemType); 21 | 22 | setDashboardItemMap(arraySwap(dashboardItemMap, oldIndex, newIndex)); 23 | }; 24 | 25 | useEffect(() => { 26 | init(); 27 | }, [init]); 28 | 29 | return { 30 | dashboardItemMap, 31 | handleDragOver, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src-tauri/src/enums/error.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Serializer}; 2 | use specta::Type; 3 | 4 | #[allow(dead_code)] 5 | #[derive(Debug, PartialEq, Eq, Clone, Type)] 6 | #[serde(rename_all = "camelCase")] 7 | pub enum BackendError { 8 | CpuInfoNotAvailable, 9 | StorageInfoNotAvailable, 10 | MemoryInfoNotAvailable, 11 | GraphicInfoNotAvailable, 12 | NetworkInfoNotAvailable, 13 | NetworkUsageNotAvailable, 14 | UnexpectedError, 15 | // SystemError(String), 16 | } 17 | 18 | impl Serialize for BackendError { 19 | fn serialize(&self, serializer: S) -> Result 20 | where 21 | S: Serializer, 22 | { 23 | let s = match *self { 24 | BackendError::CpuInfoNotAvailable => "cpuInfoNotAvailable", 25 | BackendError::StorageInfoNotAvailable => "storageInfoNotAvailable", 26 | BackendError::MemoryInfoNotAvailable => "memoryInfoNotAvailable", 27 | BackendError::GraphicInfoNotAvailable => "graphicInfoNotAvailable", 28 | BackendError::NetworkInfoNotAvailable => "networkInfoNotAvailable", 29 | BackendError::NetworkUsageNotAvailable => "networkUsageNotAvailable", 30 | BackendError::UnexpectedError => "unexpectedError", 31 | // BackendError::SystemError(ref e) => e, 32 | }; 33 | serializer.serialize_str(s) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "[javascript]": { 5 | "editor.defaultFormatter": "biomejs.biome" 6 | }, 7 | "[javascriptreact]": { 8 | "editor.defaultFormatter": "biomejs.biome" 9 | }, 10 | "[typescript]": { 11 | "editor.defaultFormatter": "biomejs.biome" 12 | }, 13 | "[typescriptreact]": { 14 | "editor.defaultFormatter": "biomejs.biome" 15 | }, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.biome": "explicit" 18 | }, 19 | "[rust]": { 20 | "editor.defaultFormatter": "rust-lang.rust-analyzer", 21 | "editor.formatOnSave": true, 22 | "editor.inlayHints.enabled": "off" 23 | }, 24 | "rust-analyzer.check.command": "clippy", 25 | "cSpell.words": [ 26 | "burnin", 27 | "consts", 28 | "cpus", 29 | "directx", 30 | "fullscreen", 31 | "microsoftstore", 32 | "nvapi", 33 | "oklab", 34 | "rspc", 35 | "shadcn", 36 | "specta", 37 | "sysinfo", 38 | "tauri", 39 | "unlisten" 40 | ], 41 | "tailwindCSS.experimental.classRegex": [ 42 | "tv\\(([^)(]*(?:\\([^)(]*(?:\\([^)(]*(?:\\([^)(]*\\)[^)(]*)*\\)[^)(]*)*\\)[^)(]*)*)\\)" 43 | ], 44 | "terminal.integrated.env.linux": { 45 | "GTK_PATH": "", 46 | "LD_LIBRARY_PATH": "" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/database/hardware_archive.rs: -------------------------------------------------------------------------------- 1 | use crate::models; 2 | use crate::utils; 3 | use sqlx::sqlite::SqlitePool; 4 | 5 | pub async fn get_pool() -> Result { 6 | let dir_path = utils::file::get_app_data_dir("hv-database.db"); 7 | let database_url = format!("sqlite:{dir_path}", dir_path = dir_path.to_str().unwrap()); 8 | 9 | let pool = SqlitePool::connect(&database_url).await?; 10 | 11 | Ok(pool) 12 | } 13 | 14 | pub async fn insert( 15 | cpu: models::hardware_archive::HardwareData, 16 | ram: models::hardware_archive::HardwareData, 17 | ) -> Result<(), sqlx::Error> { 18 | let pool = get_pool().await?; 19 | 20 | sqlx::query( 21 | "INSERT INTO DATA_ARCHIVE (cpu_avg, cpu_max, cpu_min, ram_avg, ram_max, ram_min, timestamp) 22 | VALUES ($1, $2, $3, $4, $5, $6, $7)", 23 | ).bind(cpu.avg).bind(cpu.max).bind(cpu.min).bind(ram.avg).bind(ram.max).bind(ram.min).bind(chrono::Utc::now()).execute(&pool).await?; 24 | 25 | Ok(()) 26 | } 27 | 28 | pub async fn delete_old_data(refresh_interval_days: u32) -> Result<(), sqlx::Error> { 29 | let pool = get_pool().await?; 30 | 31 | sqlx::query("DELETE FROM DATA_ARCHIVE WHERE timestamp < $1") 32 | .bind(chrono::Utc::now() - chrono::Duration::days(refresh_interval_days as i64)) 33 | .execute(&pool) 34 | .await?; 35 | 36 | Ok(()) 37 | } 38 | -------------------------------------------------------------------------------- /src/test/unit/lib/math.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { randInt } from "@/lib/math"; 3 | 4 | describe("math utilities", () => { 5 | describe("randInt", () => { 6 | it("should return integer within given range", () => { 7 | const result = randInt(1, 10); 8 | expect(result).toBeGreaterThanOrEqual(1); 9 | expect(result).toBeLessThanOrEqual(10); 10 | expect(Number.isInteger(result)).toBe(true); 11 | }); 12 | 13 | it("should return min when min equals max", () => { 14 | const result = randInt(5, 5); 15 | expect(result).toBe(5); 16 | }); 17 | 18 | it("should handle negative ranges", () => { 19 | const result = randInt(-10, -5); 20 | expect(result).toBeGreaterThanOrEqual(-10); 21 | expect(result).toBeLessThanOrEqual(-5); 22 | expect(Number.isInteger(result)).toBe(true); 23 | }); 24 | 25 | it("should use Math.random and Math.floor correctly", () => { 26 | const mathRandomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); 27 | const mathFloorSpy = vi.spyOn(Math, "floor"); 28 | 29 | randInt(1, 10); 30 | 31 | expect(mathRandomSpy).toHaveBeenCalled(); 32 | expect(mathFloorSpy).toHaveBeenCalled(); 33 | 34 | mathRandomSpy.mockRestore(); 35 | mathFloorSpy.mockRestore(); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 2 | import { CheckIcon } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | function Checkbox({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /src/features/hardware/dashboard/hooks/useDashboardSelector.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useTauriStore } from "@/hooks/useTauriStore"; 3 | import { useTitleIconVisualSelector } from "@/hooks/useTitleIconVisualSelector"; 4 | import { 5 | type DashboardSelectItemType, 6 | dashBoardItems, 7 | } from "../types/dashboardItem"; 8 | 9 | const DEFAULT_VISIBLE_ITEMS: DashboardSelectItemType[] = [ 10 | ...dashBoardItems, 11 | "title", 12 | ] as const; 13 | 14 | export const useDashboardSelector = () => { 15 | const [visibleItems, setVisibleItems] = useTauriStore< 16 | DashboardSelectItemType[] 17 | >("dashboardVisibleItems", DEFAULT_VISIBLE_ITEMS); 18 | const { toggleTitleIconVisibility } = useTitleIconVisualSelector(); 19 | 20 | useEffect(() => { 21 | toggleTitleIconVisibility( 22 | "dashboard", 23 | visibleItems?.includes("title") || false, 24 | ); 25 | }, [visibleItems, toggleTitleIconVisibility]); 26 | 27 | const toggleItem = async (item: DashboardSelectItemType) => { 28 | if (!visibleItems) return; 29 | 30 | const newItems = visibleItems.includes(item) 31 | ? visibleItems.filter((i) => i !== item) 32 | : [...visibleItems, item]; 33 | 34 | // Prevent empty selection 35 | if (newItems.length === 0) return; 36 | 37 | await setVisibleItems(newItems); 38 | }; 39 | 40 | return { visibleItems, toggleItem }; 41 | }; 42 | -------------------------------------------------------------------------------- /src-tauri/src/services/gpu_service.rs: -------------------------------------------------------------------------------- 1 | use crate::enums; 2 | use crate::models::hardware::NameValue; 3 | use crate::platform::factory::PlatformFactory; 4 | 5 | /// 6 | /// Get GPU usage (%) and return as rounded integer 7 | /// For multiple GPUs, depends on Platform implementation policy 8 | /// 9 | pub async fn fetch_gpu_usage() -> Result { 10 | let platform = 11 | PlatformFactory::create().map_err(|e| format!("Failed to create platform: {e}"))?; 12 | let usage = platform.get_gpu_usage().await?; 13 | Ok(usage.round() as i32) 14 | } 15 | 16 | /// 17 | /// Get list of GPU temperatures 18 | /// `temperature_unit` assumes user setting (Celsius/Fahrenheit etc.) 19 | /// 20 | pub async fn fetch_gpu_temperature( 21 | temperature_unit: enums::settings::TemperatureUnit, 22 | ) -> Result, String> { 23 | let platform = 24 | PlatformFactory::create().map_err(|e| format!("Failed to create platform: {e}"))?; 25 | 26 | platform 27 | .get_gpu_temperature(temperature_unit) 28 | .await 29 | .map_err(|e| format!("Failed to get GPU temperature: {e:?}")) 30 | } 31 | 32 | /// 33 | /// Get NVIDIA GPU fan speed (not implemented) 34 | /// Always returns Err as planned for future implementation 35 | /// 36 | pub async fn fetch_nvidia_gpu_cooler() -> Result, String> { 37 | Err("Failed to get GPU cooler status: This function is not implemented".to_string()) 38 | } 39 | -------------------------------------------------------------------------------- /src-tauri/src/constants.rs: -------------------------------------------------------------------------------- 1 | /// Maximum number of data points to retain in hardware monitoring history. 2 | /// 3 | /// This controls the circular buffer size for real-time hardware metrics 4 | /// (CPU usage, memory usage, GPU usage). When the buffer reaches this capacity, 5 | /// the oldest data points are removed to make room for new ones. 6 | /// 7 | /// With 1-second sampling intervals, this provides 60 seconds (1 minute) 8 | /// of historical data for chart visualization. 9 | pub const HARDWARE_HISTORY_BUFFER_SIZE: usize = 60; 10 | 11 | /// Maximum time range in seconds for hardware history queries. 12 | /// 13 | /// This limits how far back in time users can request historical data 14 | /// from the monitoring service. Prevents excessive memory usage and 15 | /// ensures reasonable response times for history requests. 16 | /// 17 | /// Set to 3600 seconds (1 hour) as a reasonable upper bound for 18 | /// real-time monitoring use cases. 19 | pub const MAX_HISTORY_QUERY_DURATION_SECONDS: u32 = 3600; 20 | 21 | /// Archive interval in seconds for persisting hardware data to database. 22 | /// 23 | /// Determines how frequently hardware monitoring data is archived 24 | /// from memory to persistent storage. This interval balances between 25 | /// data granularity and storage efficiency. 26 | /// 27 | /// Set to 60 seconds to align with the history buffer size. 28 | pub const HARDWARE_ARCHIVE_INTERVAL_SECONDS: u64 = 60; 29 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /src-tauri/src/services/hardware_service.rs: -------------------------------------------------------------------------------- 1 | use crate::infrastructure; 2 | use crate::models::hardware::{HardwareMonitorState, SysInfo}; 3 | use crate::platform::factory::PlatformFactory; 4 | 5 | /// 6 | /// Collect hardware information in aggregate 7 | /// 8 | /// - Get CPU / GPU / Memory / Storage respectively 9 | /// - Continue with None (or empty) for individual failures 10 | /// - Return Err if all of CPU / GPU / Memory cannot be obtained 11 | /// 12 | pub async fn collect_hardware_info( 13 | state: &HardwareMonitorState, 14 | ) -> Result { 15 | let cpu = infrastructure::providers::sysinfo_provider::get_cpu_info( 16 | state.system.lock().unwrap(), 17 | ) 18 | .ok(); 19 | 20 | let platform = 21 | PlatformFactory::create().map_err(|e| format!("Failed to create platform: {e}"))?; 22 | 23 | // Execute GPU / Memory in parallel 24 | let (gpus_res, memory_res, storage_res) = 25 | tokio::join!(platform.get_gpu_info(), platform.get_memory_info(), async { 26 | infrastructure::providers::sysinfo_provider::get_storage_info() 27 | }); 28 | 29 | let gpus = gpus_res.ok(); 30 | let memory = memory_res.ok(); 31 | let storage = storage_res.map_err(|e| format!("Failed to get storage info: {e}"))?; 32 | 33 | if cpu.is_none() && gpus.is_none() && memory.is_none() { 34 | return Err("Failed to get any hardware info".to_string()); 35 | } 36 | 37 | Ok(SysInfo { 38 | cpu, 39 | memory, 40 | gpus, 41 | storage, 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /src/test/unit/components/ErrorFallback.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { describe, expect, it } from "vitest"; 3 | import ErrorFallback from "@/components/ErrorFallback"; 4 | 5 | describe("ErrorFallback", () => { 6 | it("should render error message when error occurs", () => { 7 | const testError = new Error("Test error message"); 8 | 9 | render( {}} />); 10 | 11 | expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( 12 | "An unexpected error has occurred.", 13 | ); 14 | expect(screen.getByText("Test error message")).toBeInTheDocument(); 15 | }); 16 | 17 | it("should render pre-formatted error message", () => { 18 | const testError = new Error("Formatted error"); 19 | 20 | render( {}} />); 21 | 22 | const preElement = screen.getByText("Formatted error"); 23 | expect(preElement.tagName.toLowerCase()).toBe("pre"); 24 | }); 25 | 26 | it("should handle errors with multiline messages", () => { 27 | const multilineError = new Error("Line 1\nLine 2\nLine 3"); 28 | 29 | render( 30 | {}} />, 31 | ); 32 | 33 | expect(screen.getByText(/Line 1/)).toBeInTheDocument(); 34 | expect(screen.getByText(/Line 2/)).toBeInTheDocument(); 35 | expect(screen.getByText(/Line 3/)).toBeInTheDocument(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/features/hardware/insights/components/SelectPeriod.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { 3 | Select, 4 | SelectContent, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from "@/components/ui/select"; 9 | import type { archivePeriods } from "../../consts/chart"; 10 | 11 | export const SelectPeriod = ({ 12 | options, 13 | selected, 14 | onChange, 15 | showDefaultOption, 16 | }: { 17 | options: { label: string; value: keyof typeof archivePeriods }[]; 18 | selected: keyof typeof archivePeriods | null; 19 | onChange: (value: (typeof archivePeriods)[number]) => void; 20 | showDefaultOption?: boolean; 21 | }) => { 22 | const { t } = useTranslation(); 23 | 24 | return ( 25 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/test/unit/lib/tauriStore.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; 2 | import { getStoreInstance } from "@/lib/tauriStore"; 3 | 4 | // Mock @tauri-apps/plugin-store 5 | vi.mock("@tauri-apps/plugin-store", () => ({ 6 | load: vi.fn(), 7 | })); 8 | 9 | describe("tauriStore", () => { 10 | beforeEach(() => { 11 | vi.clearAllMocks(); 12 | }); 13 | 14 | it("should create and return store instance", async () => { 15 | const mockStore = { 16 | get: vi.fn(), 17 | set: vi.fn(), 18 | save: vi.fn(), 19 | }; 20 | const { load } = await vi.importMock("@tauri-apps/plugin-store"); 21 | (load as Mock).mockResolvedValueOnce(mockStore); 22 | 23 | const store = await getStoreInstance(); 24 | 25 | expect(load).toHaveBeenCalledWith("store.json", { 26 | autoSave: true, 27 | defaults: {}, 28 | }); 29 | expect(store).toHaveProperty("get"); 30 | expect(store).toHaveProperty("set"); 31 | expect(store).toHaveProperty("save"); 32 | }); 33 | 34 | it("should return same instance on subsequent calls", async () => { 35 | const store1 = await getStoreInstance(); 36 | const store2 = await getStoreInstance(); 37 | 38 | expect(store1).toBe(store2); 39 | }); 40 | 41 | it("should handle store loading correctly", async () => { 42 | const store = await getStoreInstance(); 43 | 44 | expect(store).toHaveProperty("get"); 45 | expect(store).toHaveProperty("set"); 46 | expect(store).toHaveProperty("save"); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/GraphStyleToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Label } from "@/components/ui/label"; 3 | import { Switch } from "@/components/ui/switch"; 4 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 5 | import type { ClientSettings } from "@/rspc/bindings"; 6 | 7 | export const GraphStyleToggle = ({ 8 | type, 9 | }: { 10 | type: Extract< 11 | keyof ClientSettings, 12 | | "lineGraphBorder" 13 | | "lineGraphFill" 14 | | "lineGraphMix" 15 | | "lineGraphShowLegend" 16 | | "lineGraphShowScale" 17 | | "lineGraphShowTooltip" 18 | >; 19 | }) => { 20 | const { settings, updateSettingAtom } = useSettingsAtom(); 21 | const { t } = useTranslation(); 22 | 23 | const settingGraphSwitch = async (value: boolean) => { 24 | await updateSettingAtom(type, value); 25 | }; 26 | 27 | return ( 28 |
29 |
30 |
31 |
32 | 35 |
36 | 37 | 41 |
42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/database/gpu_archive.rs: -------------------------------------------------------------------------------- 1 | use crate::models; 2 | use crate::utils; 3 | use sqlx::sqlite::SqlitePool; 4 | 5 | pub async fn get_pool() -> Result { 6 | let dir_path = utils::file::get_app_data_dir("hv-database.db"); 7 | let database_url = format!("sqlite:{dir_path}", dir_path = dir_path.to_str().unwrap()); 8 | 9 | let pool = SqlitePool::connect(&database_url).await?; 10 | 11 | Ok(pool) 12 | } 13 | 14 | pub async fn insert(data: models::hardware_archive::GpuData) -> Result<(), sqlx::Error> { 15 | let pool = get_pool().await?; 16 | 17 | sqlx::query( 18 | "INSERT INTO GPU_DATA_ARCHIVE (gpu_name, usage_avg, usage_max, usage_min, temperature_avg, temperature_max, temperature_min, dedicated_memory_avg, dedicated_memory_max, dedicated_memory_min, timestamp) 19 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", 20 | ).bind(data.gpu_name).bind(data.usage_avg).bind(data.usage_max).bind(data.usage_min).bind(data.temperature_avg).bind(data.temperature_max).bind(data.temperature_min).bind(data.dedicated_memory_avg).bind(data.dedicated_memory_max).bind(data.dedicated_memory_min).bind(chrono::Utc::now()).execute(&pool).await?; 21 | 22 | Ok(()) 23 | } 24 | 25 | pub async fn delete_old_data(refresh_interval_days: u32) -> Result<(), sqlx::Error> { 26 | let pool = get_pool().await?; 27 | 28 | sqlx::query("DELETE FROM GPU_DATA_ARCHIVE WHERE timestamp < $1") 29 | .bind(chrono::Utc::now() - chrono::Duration::days(refresh_interval_days as i64)) 30 | .execute(&pool) 31 | .await?; 32 | 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /src-tauri/src/commands/background_image.rs: -------------------------------------------------------------------------------- 1 | use tauri::command; 2 | 3 | use crate::models::background_image::BackgroundImage; 4 | use crate::services::background_image_service; 5 | 6 | /// 7 | /// Get background image 8 | /// 9 | /// - `file_id`: Image file ID 10 | /// 11 | #[command] 12 | #[specta::specta] 13 | pub async fn get_background_image(file_id: String) -> Result { 14 | background_image_service::get_background_image(&file_id).await 15 | } 16 | 17 | /// 18 | /// Get list of background images in BG_IMG_DIR_NAME directory 19 | /// 20 | #[command] 21 | #[specta::specta] 22 | pub async fn get_background_images() -> Result, String> { 23 | background_image_service::get_background_images().await 24 | } 25 | 26 | /// 27 | /// Save background image 28 | /// 29 | /// - `image_data`: Base64 string of image data 30 | /// - returns: `file_id` 31 | /// 32 | /// ### TODO 33 | /// - Use JsImage https://docs.rs/tauri/2.1.1/tauri/image/enum.JsImage.html 34 | /// - Implemented with Base64 for now as type definition with specta was difficult 35 | /// 36 | /// 37 | #[command] 38 | #[specta::specta] 39 | pub async fn save_background_image(image_data: String) -> Result { 40 | background_image_service::save_background_image(&image_data).await 41 | } 42 | 43 | /// 44 | /// Delete background image 45 | /// - `file_id`: Image file ID 46 | /// 47 | #[tauri::command] 48 | #[specta::specta] 49 | pub async fn delete_background_image(file_id: String) -> Result<(), String> { 50 | background_image_service::delete_background_image(&file_id).await 51 | } 52 | -------------------------------------------------------------------------------- /src/features/settings/components/general/LanguageSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { Label } from "@/components/ui/label"; 3 | import { 4 | Select, 5 | SelectContent, 6 | SelectItem, 7 | SelectTrigger, 8 | SelectValue, 9 | } from "@/components/ui/select"; 10 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 11 | 12 | export const LanguageSelect = () => { 13 | const { settings, updateSettingAtom } = useSettingsAtom(); 14 | const { t, i18n } = useTranslation(); 15 | 16 | const supported = Object.keys(i18n.services.resourceStore.data); 17 | const displaySupported: Record = { 18 | en: t("lang.en"), 19 | ja: t("lang.ja"), 20 | }; 21 | 22 | const changeLanguage = async (value: string) => { 23 | await updateSettingAtom("language", value); 24 | }; 25 | 26 | return ( 27 |
28 | 31 | 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/test/unit/hooks/useStickyObserver.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; 3 | import { useStickyObserver } from "@/hooks/useStickyObserver"; 4 | 5 | // Simple mock for IntersectionObserver 6 | const mockObserve = vi.fn(); 7 | const mockUnobserve = vi.fn(); 8 | const mockDisconnect = vi.fn(); 9 | 10 | global.IntersectionObserver = class IntersectionObserver { 11 | observe = mockObserve; 12 | unobserve = mockUnobserve; 13 | disconnect = mockDisconnect; 14 | } as unknown as typeof IntersectionObserver; 15 | 16 | describe("useStickyObserver", () => { 17 | beforeEach(() => { 18 | mockObserve.mockClear(); 19 | mockUnobserve.mockClear(); 20 | mockDisconnect.mockClear(); 21 | }); 22 | 23 | it("should return sentinelRef and isStuck initially false", () => { 24 | const { result } = renderHook(() => useStickyObserver()); 25 | 26 | expect(result.current.isStuck).toBe(false); 27 | expect(result.current.sentinelRef).toBeDefined(); 28 | expect(result.current.sentinelRef.current).toBeNull(); 29 | }); 30 | 31 | it("should create IntersectionObserver and not call observe when ref is null", () => { 32 | renderHook(() => useStickyObserver()); 33 | 34 | // observe should not be called when sentinelRef.current is null 35 | expect(mockObserve).not.toHaveBeenCalled(); 36 | }); 37 | 38 | it("should not throw on unmount", () => { 39 | const { unmount } = renderHook(() => useStickyObserver()); 40 | 41 | expect(() => unmount()).not.toThrow(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src-tauri/src/services/ui_service.rs: -------------------------------------------------------------------------------- 1 | use tauri::{AppHandle, Manager}; 2 | use tauri_plugin_store::StoreExt; 3 | 4 | const STORE_FILENAME: &str = "store.json"; 5 | const KEY_WINDOW_DECORATED: &str = "window_decorated"; 6 | 7 | /// Apply saved decoration state on app startup 8 | pub fn apply_saved_window_decoration(app: &AppHandle) -> Result<(), String> { 9 | let store = app 10 | .store(STORE_FILENAME) 11 | .map_err(|e| format!("Failed to open store: {e}"))?; 12 | 13 | if let Some(is_decorated) = store.get(KEY_WINDOW_DECORATED).and_then(|v| v.as_bool()) { 14 | if let Some(window) = app.get_webview_window("main") { 15 | set_window_decoration(&window, is_decorated)?; 16 | } else { 17 | return Err("Main window not found".into()); 18 | } 19 | } 20 | Ok(()) 21 | } 22 | 23 | /// Apply window decoration state 24 | pub fn set_window_decoration( 25 | window: &tauri::WebviewWindow, 26 | is_decorated: bool, 27 | ) -> Result<(), String> { 28 | window 29 | .set_fullscreen(!is_decorated) 30 | .map_err(|e| format!("Failed to set fullscreen: {e}")) 31 | } 32 | 33 | /// Write current decoration state 34 | pub fn persist_window_decoration( 35 | app: &AppHandle, 36 | is_decorated: bool, 37 | ) -> Result<(), String> { 38 | let store = app 39 | .store(STORE_FILENAME) 40 | .map_err(|e| format!("Failed to open store: {e}"))?; 41 | 42 | // tauri-plugin-store v2: set + save 43 | // set does not return Result so call it directly 44 | store.set(KEY_WINDOW_DECORATED, is_decorated); 45 | store 46 | .save() 47 | .map_err(|e| format!("Failed to save store: {e}")) 48 | } 49 | -------------------------------------------------------------------------------- /src/test/unit/features/hardware/hooks/useExportToClipboard.test.ts: -------------------------------------------------------------------------------- 1 | import { writeText } from "@tauri-apps/plugin-clipboard-manager"; 2 | import { renderHook } from "@testing-library/react"; 3 | import { describe, expect, it, vi } from "vitest"; 4 | import { useExportToClipboard } from "@/features/hardware/dashboard/hooks/useExportToClipboard"; 5 | 6 | vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ 7 | writeText: vi.fn(), 8 | })); 9 | 10 | vi.mock("react-i18next", () => ({ 11 | useTranslation: () => ({ 12 | t: (key: string) => key, 13 | }), 14 | })); 15 | 16 | vi.mock("@tauri-apps/api", () => ({ 17 | invoke: vi.fn((cmd) => { 18 | if (cmd === "getProcessList") { 19 | return Promise.resolve([ 20 | { pid: 1, name: "Process1" }, 21 | { pid: 2, name: "Process2" }, 22 | ]); 23 | } 24 | return Promise.resolve(); 25 | }), 26 | })); 27 | 28 | // Mock window.__TAURI_INTERNALS__ 29 | Object.defineProperty(window, "__TAURI_INTERNALS__", { 30 | value: { 31 | invoke: vi.fn((cmd) => { 32 | if (cmd === "getProcessList") { 33 | return Promise.resolve([ 34 | { pid: 1, name: "Process1" }, 35 | { pid: 2, name: "Process2" }, 36 | ]); 37 | } 38 | return Promise.resolve(); 39 | }), 40 | }, 41 | }); 42 | 43 | describe("useExportToClipboard", () => { 44 | it("should export clipboard content correctly", async () => { 45 | const { result } = renderHook(() => useExportToClipboard()); 46 | 47 | await result.current.exportToClipboard(); 48 | 49 | expect(writeText).toHaveBeenCalledWith(expect.any(String)); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/GraphSizeSlider.tsx: -------------------------------------------------------------------------------- 1 | import { DotOutlineIcon } from "@phosphor-icons/react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Label } from "@/components/ui/label"; 4 | import { Slider } from "@/components/ui/slider"; 5 | import { sizeOptions } from "@/features/hardware/consts/chart"; 6 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 7 | import type { Settings } from "@/features/settings/types/settingsType"; 8 | 9 | export const GraphSizeSlider = () => { 10 | const { settings, updateSettingAtom } = useSettingsAtom(); 11 | const { t } = useTranslation(); 12 | 13 | const sizeIndex = sizeOptions.indexOf( 14 | settings.graphSize as Settings["graphSize"], 15 | ); 16 | 17 | const changeGraphSize = async (value: number[]) => { 18 | await updateSettingAtom("graphSize", sizeOptions[value[0]]); 19 | }; 20 | 21 | return ( 22 |
23 | 26 | 34 |
35 | {sizeOptions.map((size) => ( 36 | 41 | ))} 42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; 2 | import { CircleIcon } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | function RadioGroup({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 17 | ); 18 | } 19 | 20 | function RadioGroupItem({ 21 | className, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 33 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export { RadioGroup, RadioGroupItem }; 44 | -------------------------------------------------------------------------------- /src/hooks/useTauriStore.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import { getStoreInstance } from "@/lib/tauriStore"; 3 | 4 | type TauriStore = 5 | | [value: null, setValue: (newValue: T) => Promise, isPending: true] 6 | | [value: T, setValue: (newValue: T) => Promise, isPending: false]; 7 | 8 | export const useTauriStore = ( 9 | key: string, 10 | defaultValue: T, 11 | ): TauriStore => { 12 | const [value, setValueState] = useState(null); 13 | const [isPending, setIsPending] = useState(true); 14 | const isMountedRef = useRef(true); 15 | 16 | useEffect(() => { 17 | isMountedRef.current = true; 18 | 19 | const fetchValue = async () => { 20 | const store = await getStoreInstance(); 21 | 22 | const storedValue = (await store.has(key)) 23 | ? await store.get(key) 24 | : null; 25 | 26 | if (!storedValue) { 27 | await store.set(key, defaultValue); 28 | await store.save(); 29 | } 30 | 31 | if (isMountedRef.current) { 32 | setValueState(storedValue ?? defaultValue); 33 | setIsPending(false); 34 | } 35 | }; 36 | 37 | fetchValue(); 38 | 39 | return () => { 40 | isMountedRef.current = false; 41 | }; 42 | }, [key, defaultValue]); 43 | 44 | const setValue = useCallback( 45 | async (newValue: T) => { 46 | const store = await getStoreInstance(); 47 | await store.set(key, newValue); 48 | await store.save(); 49 | setValueState(newValue); 50 | }, 51 | [key], 52 | ); 53 | 54 | return isPending ? [null, setValue, true] : [value as T, setValue, false]; 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/ui/FullScreenExit.tsx: -------------------------------------------------------------------------------- 1 | import { XIcon } from "@phosphor-icons/react"; 2 | import { useState } from "react"; 3 | import { useTauriDialog } from "@/hooks/useTauriDialog"; 4 | import { cn } from "@/lib/utils"; 5 | import { commands } from "@/rspc/bindings"; 6 | 7 | export const FullscreenExitButton = ({ 8 | isDecorated, 9 | setDecorated, 10 | }: { 11 | isDecorated: boolean; 12 | setDecorated: (newValue: boolean) => Promise; 13 | }) => { 14 | const [hovered, setHovered] = useState(false); 15 | const { error } = useTauriDialog(); 16 | 17 | const handleDecoration = async () => { 18 | try { 19 | await commands.setDecoration(true); 20 | await setDecorated(true); 21 | } catch (e) { 22 | error(e as string); 23 | console.error("Failed to toggle window decoration:", e); 24 | } 25 | }; 26 | 27 | return ( 28 | !isDecorated && ( 29 |
30 |
36 | 46 |
47 |
48 | ) 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/lazyScreens.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { SelectedDisplayType } from "./types/ui"; 3 | 4 | // Lazy components (code-split per screen) 5 | export const Dashboard = React.lazy(() => 6 | import("./features/hardware/dashboard/Dashboard").then((m) => ({ 7 | default: m.Dashboard, 8 | })), 9 | ); 10 | 11 | export const ChartTemplate = React.lazy(() => 12 | import("./features/hardware/usage/Usage").then((m) => ({ 13 | default: m.ChartTemplate, 14 | })), 15 | ); 16 | 17 | export const CpuUsages = React.lazy(() => 18 | import("./features/hardware/usage/cpu/CpuUsage").then((m) => ({ 19 | default: m.CpuUsages, 20 | })), 21 | ); 22 | 23 | export const Insights = React.lazy(() => 24 | import("./features/hardware/insights/Insights").then((m) => ({ 25 | default: m.Insights, 26 | })), 27 | ); 28 | 29 | export const Settings = React.lazy(() => 30 | import("./features/settings/Settings").then((m) => ({ 31 | default: m.Settings, 32 | })), 33 | ); 34 | 35 | // Opportunistic prefetch on hover/focus 36 | export const prefetchScreen = async (type: SelectedDisplayType) => { 37 | switch (type) { 38 | case "dashboard": 39 | await import("./features/hardware/dashboard/Dashboard"); 40 | break; 41 | case "usage": 42 | await import("./features/hardware/usage/Usage"); 43 | break; 44 | case "cpuDetail": 45 | await import("./features/hardware/usage/cpu/CpuUsage"); 46 | break; 47 | case "insights": 48 | await import("./features/hardware/insights/Insights"); 49 | break; 50 | case "settings": 51 | await import("./features/settings/Settings"); 52 | break; 53 | default: 54 | break; 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /src/features/settings/components/general/AutoStartToggle.tsx: -------------------------------------------------------------------------------- 1 | import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart"; 2 | import { useEffect, useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Label } from "@/components/ui/label"; 5 | import { Skeleton } from "@/components/ui/skeleton"; 6 | import { Switch } from "@/components/ui/switch"; 7 | import { useTauriDialog } from "@/hooks/useTauriDialog"; 8 | 9 | export const AutoStartToggle = () => { 10 | const { t } = useTranslation(); 11 | const [autoStartEnabled, setAutoStartEnabled] = useState( 12 | null, 13 | ); 14 | const { error } = useTauriDialog(); 15 | 16 | const toggleAutoStart = async (value: boolean) => { 17 | setAutoStartEnabled(value); 18 | 19 | try { 20 | value ? await enable() : await disable(); 21 | } catch { 22 | error("Failed to set autostart"); 23 | setAutoStartEnabled(!value); 24 | } 25 | }; 26 | 27 | useEffect(() => { 28 | const checkAutoStart = async () => { 29 | const enabled = await isEnabled(); 30 | setAutoStartEnabled(enabled); 31 | }; 32 | 33 | checkAutoStart(); 34 | }, []); 35 | 36 | return ( 37 |
38 |
39 | 42 |
43 | 44 | {autoStartEnabled != null ? ( 45 | 46 | ) : ( 47 | 48 | )} 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | type BreakpointSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; 4 | 5 | const BREAKPOINT_ORDER: BreakpointSize[] = [ 6 | "xs", 7 | "sm", 8 | "md", 9 | "lg", 10 | "xl", 11 | "2xl", 12 | ]; 13 | 14 | const getBreakpoint = (): BreakpointSize => { 15 | const width = globalThis.innerWidth; 16 | 17 | if (width >= 1536) return "2xl"; 18 | if (width >= 1280) return "xl"; 19 | if (width >= 1024) return "lg"; 20 | if (width >= 768) return "md"; 21 | if (width >= 640) return "sm"; 22 | return "xs"; 23 | }; 24 | 25 | export const useWindowSize = () => { 26 | const [breakpoint, setBreakpoint] = useState(getBreakpoint()); 27 | 28 | useEffect(() => { 29 | let timeoutId: NodeJS.Timeout; 30 | 31 | const handleResize = () => { 32 | clearTimeout(timeoutId); 33 | timeoutId = setTimeout(() => { 34 | const newBreakpoint = getBreakpoint(); 35 | 36 | setBreakpoint((prev) => { 37 | if (prev === newBreakpoint) return prev; 38 | return newBreakpoint; 39 | }); 40 | }, 100); 41 | }; 42 | 43 | globalThis.addEventListener("resize", handleResize); 44 | 45 | return () => { 46 | clearTimeout(timeoutId); 47 | globalThis.removeEventListener("resize", handleResize); 48 | }; 49 | }, []); 50 | 51 | const isBreak = useCallback( 52 | (targetBreakpoint: BreakpointSize): boolean => { 53 | const currentIndex = BREAKPOINT_ORDER.indexOf(breakpoint); 54 | const targetIndex = BREAKPOINT_ORDER.indexOf(targetBreakpoint); 55 | 56 | return currentIndex >= targetIndex; 57 | }, 58 | [breakpoint], 59 | ); 60 | 61 | return { breakpoint, isBreak }; 62 | }; 63 | -------------------------------------------------------------------------------- /src-tauri/src/platform/factory.rs: -------------------------------------------------------------------------------- 1 | /// Platform detection error 2 | #[derive(Debug, Clone)] 3 | pub enum PlatformError { 4 | InitializationFailed(String), 5 | } 6 | 7 | impl std::fmt::Display for PlatformError { 8 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 9 | match self { 10 | PlatformError::InitializationFailed(reason) => { 11 | write!(f, "Platform initialization failed: {reason}") 12 | } 13 | } 14 | } 15 | } 16 | 17 | impl std::error::Error for PlatformError {} 18 | 19 | /// Factory that creates Platform instances 20 | pub struct PlatformFactory; 21 | 22 | impl PlatformFactory { 23 | /// Create a Platform trait object suitable for the current platform 24 | pub fn create() -> Result, PlatformError> { 25 | Self::create_platform() 26 | } 27 | 28 | /// Create a Platform trait object suitable for the current platform 29 | pub fn create_platform() 30 | -> Result, PlatformError> { 31 | #[cfg(target_os = "windows")] 32 | { 33 | let platform = crate::platform::windows::WindowsPlatform::new() 34 | .map_err(|e| PlatformError::InitializationFailed(e.to_string()))?; 35 | Ok(Box::new(platform)) 36 | } 37 | 38 | #[cfg(target_os = "linux")] 39 | { 40 | let platform = crate::platform::linux::LinuxPlatform::new() 41 | .map_err(|e| PlatformError::InitializationFailed(e.to_string()))?; 42 | Ok(Box::new(platform)) 43 | } 44 | 45 | #[cfg(target_os = "macos")] 46 | { 47 | let platform = crate::platform::macos::MacOSPlatform::new() 48 | .map_err(|e| PlatformError::InitializationFailed(e.to_string()))?; 49 | Ok(Box::new(platform)) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/hooks/useColorTheme.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentWindow } from "@tauri-apps/api/window"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { darkClasses } from "@/consts/style"; 4 | import type { Theme } from "@/rspc/bindings"; 5 | 6 | const defaultTheme = ["dark", "light"]; 7 | 8 | export const useColorTheme = (theme: Theme) => { 9 | const [systemTheme, setSystemTheme] = useState<"dark" | "light">("light"); 10 | 11 | const listenTheme = useCallback(async () => { 12 | const unlisten = await getCurrentWindow().onThemeChanged( 13 | ({ payload: theme }) => { 14 | setSystemTheme(theme === "dark" ? "dark" : "light"); 15 | }, 16 | ); 17 | 18 | return () => { 19 | unlisten(); 20 | }; 21 | }, []); 22 | 23 | useEffect(() => { 24 | getCurrentWindow() 25 | .theme() 26 | .then((t) => setSystemTheme(t === "dark" ? "dark" : "light")); 27 | 28 | const cleanup = listenTheme(); 29 | return () => { 30 | cleanup.then((f) => f()); 31 | }; 32 | }, [listenTheme]); 33 | 34 | useEffect(() => { 35 | document.documentElement.classList.remove(...defaultTheme); 36 | document.documentElement.dataset.theme = ""; 37 | 38 | if (theme === "system") { 39 | if (systemTheme === "dark") { 40 | document.documentElement.classList.add("dark"); 41 | } else { 42 | document.documentElement.classList.add("light"); 43 | } 44 | } 45 | 46 | if (defaultTheme.includes(theme)) { 47 | document.documentElement.classList.add(theme); 48 | return; 49 | } 50 | 51 | if (darkClasses.includes(theme)) { 52 | document.documentElement.classList.add("dark"); 53 | } 54 | 55 | document.documentElement.dataset.theme = theme; 56 | }, [theme, systemTheme]); 57 | }; 58 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude PR Assistant 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude-code-action: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && contains(github.event.issue.body, '@claude')) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v6 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude PR Action 33 | uses: anthropics/claude-code-action@beta 34 | with: 35 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 36 | # Or use OAuth token instead: 37 | # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | timeout_minutes: "60" 39 | # mode: tag # Default: responds to @claude mentions 40 | # Optional: Restrict network access to specific domains only 41 | # experimental_allowed_domains: | 42 | # .anthropic.com 43 | # .github.com 44 | # api.github.com 45 | # .githubusercontent.com 46 | # bun.sh 47 | # registry.npmjs.org 48 | # .blob.core.windows.net 49 | -------------------------------------------------------------------------------- /src/hooks/useTauriDialog.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type MessageDialogResult, 3 | ask as showAsk, 4 | confirm as showConfirm, 5 | message as showMessage, 6 | } from "@tauri-apps/plugin-dialog"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | type Kind = "info" | "warning" | "error"; 10 | type TitleType = 11 | | "info" 12 | | "confirm" 13 | | "success" 14 | | "warning" 15 | | "error" 16 | | "unexpected"; 17 | 18 | export const useTauriDialog = () => { 19 | const { t } = useTranslation(); 20 | 21 | const ask = async ({ 22 | title, 23 | message, 24 | kind, 25 | }: { 26 | title?: TitleType; 27 | message: string; 28 | kind?: Kind; 29 | }): Promise => { 30 | return await showAsk(message, { 31 | title: title ? t(`error.title.${title}`) : undefined, 32 | kind, 33 | }); 34 | }; 35 | 36 | const confirm = async ({ 37 | title, 38 | message, 39 | kind, 40 | }: { 41 | title?: TitleType; 42 | message: string; 43 | kind?: Kind; 44 | }): Promise => { 45 | return await showConfirm(message, { 46 | title: title ? t(`error.title.${title}`) : undefined, 47 | kind, 48 | }); 49 | }; 50 | 51 | const message = async ({ 52 | title, 53 | message, 54 | kind, 55 | }: { 56 | title?: TitleType; 57 | message: string; 58 | kind?: Kind; 59 | }): Promise => { 60 | return await showMessage(message, { 61 | title: title ? t(`error.title.${title}`) : undefined, 62 | kind, 63 | }); 64 | }; 65 | 66 | const error = async (errorMessage: string) => { 67 | return await message({ 68 | title: "error", 69 | message: errorMessage, 70 | kind: "error", 71 | }); 72 | }; 73 | 74 | return { ask, confirm, message, error }; 75 | }; 76 | -------------------------------------------------------------------------------- /.github/scripts/check-licenses.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | 3 | const licensesFile = process.argv[2]; 4 | if (!licensesFile) { 5 | console.error("Usage: ts-node check-licenses.ts "); 6 | process.exit(1); 7 | } 8 | 9 | const allowedLicenses = new Set([ 10 | "MIT", 11 | "Apache-2.0", 12 | "BSD-3-Clause", 13 | "BSD-2-Clause", 14 | "0BSD", 15 | "ISC", 16 | "BlueOak-1.0.0", 17 | "MPL-2.0", 18 | "CC-BY-4.0", 19 | ]); 20 | 21 | const licenses = JSON.parse(fs.readFileSync(licensesFile, "utf-8")); 22 | let errorCount = 0; 23 | 24 | const normalize = ( 25 | raw: string, 26 | ): { type: "OR" | "AND" | "SINGLE"; values: string[] } => { 27 | if (raw.includes(" OR ")) { 28 | return { type: "OR", values: raw.split(/\s+OR\s+/).map((s) => s.trim()) }; 29 | } 30 | if (raw.includes(" AND ")) { 31 | return { type: "AND", values: raw.split(/\s+AND\s+/).map((s) => s.trim()) }; 32 | } 33 | return { type: "SINGLE", values: [raw.trim()] }; 34 | }; 35 | 36 | for (const [pkg, info] of Object.entries(licenses)) { 37 | // Skip checking itself 38 | if (pkg.split("@")[0] === "hardware-visualizer") { 39 | continue; 40 | } 41 | 42 | const license = info.licenses; 43 | const { type, values } = normalize(license); 44 | 45 | const valid = 46 | type === "OR" 47 | ? values.some((l) => allowedLicenses.has(l)) 48 | : type === "AND" 49 | ? values.every((l) => allowedLicenses.has(l)) 50 | : allowedLicenses.has(values[0]); 51 | 52 | if (!valid) { 53 | console.error(`❌ Unsupported license detected: ${license} (${pkg})`); 54 | errorCount++; 55 | } 56 | } 57 | 58 | if (errorCount > 0) { 59 | console.error(`\n🚫 License check failed with ${errorCount} issue(s).`); 60 | process.exit(1); 61 | } 62 | 63 | console.log("✅ License check passed."); 64 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/utils/rounding_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use crate::utils::rounding::*; 4 | 5 | #[test] 6 | fn test_round1_positive_numbers() { 7 | assert_eq!(round1(1.23), 1.2); 8 | assert_eq!(round1(1.27), 1.3); 9 | assert_eq!(round1(1.25), 1.3); // Rounding 10 | assert_eq!(round1(1.24), 1.2); 11 | assert_eq!(round1(1.26), 1.3); 12 | } 13 | 14 | #[test] 15 | fn test_round1_negative_numbers() { 16 | assert_eq!(round1(-1.23), -1.2); 17 | assert_eq!(round1(-1.27), -1.3); 18 | assert_eq!(round1(-1.25), -1.3); 19 | assert_eq!(round1(-1.24), -1.2); 20 | assert_eq!(round1(-1.26), -1.3); 21 | } 22 | 23 | #[test] 24 | fn test_round1_zero_and_integers() { 25 | assert_eq!(round1(0.0), 0.0); 26 | assert_eq!(round1(1.0), 1.0); 27 | assert_eq!(round1(-1.0), -1.0); 28 | assert_eq!(round1(10.0), 10.0); 29 | } 30 | 31 | #[test] 32 | fn test_round1_edge_cases() { 33 | // Value already at first decimal place 34 | assert_eq!(round1(1.1), 1.1); 35 | assert_eq!(round1(1.9), 1.9); 36 | 37 | // When decimal part is 0 38 | assert_eq!(round1(5.05), 5.1); 39 | assert_eq!(round1(5.04), 5.0); 40 | 41 | // Very small values 42 | assert_eq!(round1(0.01), 0.0); 43 | assert_eq!(round1(0.05), 0.1); 44 | assert_eq!(round1(0.09), 0.1); 45 | } 46 | 47 | #[test] 48 | fn test_round1_large_numbers() { 49 | assert_eq!(round1(123.456), 123.5); 50 | assert_eq!(round1(999.99), 1000.0); 51 | assert_eq!(round1(1000.01), 1000.0); 52 | } 53 | 54 | #[test] 55 | fn test_round1_precision() { 56 | // Precision test: verify that the result is accurately represented to the first decimal place 57 | let result = round1(1.234567); 58 | assert!((result * 10.0).fract().abs() < f32::EPSILON); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/utils/ip_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use crate::utils::ip::*; 4 | use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; 5 | 6 | #[test] 7 | fn test_link_local_ipv6() { 8 | // Link-local address 9 | let ip = IpAddr::V6("fe80::1".parse::().unwrap()); 10 | assert!(is_unicast_link_local(&ip)); 11 | 12 | // Global IPv6 address 13 | let ip = IpAddr::V6("2400:4051::1".parse::().unwrap()); 14 | assert!(!is_unicast_link_local(&ip)); 15 | 16 | // IPv6 multicast 17 | let ip = IpAddr::V6("ff02::1".parse::().unwrap()); 18 | assert!(!is_unicast_link_local(&ip)); 19 | 20 | // Unspecified address 21 | let ip = IpAddr::V6("::".parse::().unwrap()); 22 | assert!(!is_unicast_link_local(&ip)); 23 | } 24 | 25 | #[test] 26 | fn test_ipv4_not_link_local() { 27 | // IPv4 address (not link-local) 28 | let ip = IpAddr::V4("192.168.1.1".parse::().unwrap()); 29 | assert!(!is_unicast_link_local(&ip)); 30 | 31 | // IPv4 address (not link-local) 32 | let ip = IpAddr::V4("127.0.0.1".parse::().unwrap()); 33 | assert!(!is_unicast_link_local(&ip)); 34 | } 35 | 36 | #[test] 37 | fn test_with_direct_ipv6() { 38 | // Test directly with Ipv6Addr type 39 | let ip = "fe80::1".parse::().unwrap(); 40 | assert!(is_unicast_link_local(&ip)); 41 | 42 | let ip = "2400:4051::1".parse::().unwrap(); 43 | assert!(!is_unicast_link_local(&ip)); 44 | } 45 | 46 | #[test] 47 | fn test_with_invalid_format() { 48 | // Invalid address strings cause parse errors so we don't test them here, 49 | // but here's an example that follows type constraints 50 | let ip = IpAddr::V4("255.255.255.255".parse::().unwrap()); 51 | assert!(!is_unicast_link_local(&ip)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/features/settings/components/general/TemperatureUnitSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useSetAtom } from "jotai"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Label } from "@/components/ui/label"; 4 | import { 5 | Select, 6 | SelectContent, 7 | SelectItem, 8 | SelectTrigger, 9 | SelectValue, 10 | } from "@/components/ui/select"; 11 | import { gpuTempAtom } from "@/features/hardware/store/chart"; 12 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 13 | import type { Settings } from "@/features/settings/types/settingsType"; 14 | 15 | export const TemperatureUnitSelect = () => { 16 | const { settings, updateSettingAtom } = useSettingsAtom(); 17 | const { t } = useTranslation(); 18 | const setData = useSetAtom(gpuTempAtom); 19 | 20 | const changeTemperatureUnit = async (value: Settings["temperatureUnit"]) => { 21 | await updateSettingAtom("temperatureUnit", value); 22 | setData([]); 23 | }; 24 | 25 | return ( 26 |
27 | 30 | 46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/features/settings/components/general/BurnInShiftModeRadio.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | import { Label } from "@/components/ui/label"; 4 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 5 | import type { BurnInShiftMode, ClientSettings } from "@/rspc/bindings"; 6 | 7 | export const BurnInShiftModeRadio = ({ 8 | settings, 9 | selectShiftMode, 10 | }: { 11 | settings: ClientSettings; 12 | selectShiftMode: (value: BurnInShiftMode) => Promise; 13 | }) => { 14 | const { t } = useTranslation(); 15 | const radioBurnInShiftJump = useId(); 16 | const radioBurnInShiftDrift = useId(); 17 | return ( 18 | <> 19 | 22 | 27 |
28 | 29 | 35 |
36 |
37 | 38 | 44 |
45 |
46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src-tauri/src/platform/linux/cache.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std; 3 | 4 | #[derive(Serialize, Deserialize)] 5 | pub struct CachedData { 6 | pub timestamp: u64, // UNIX time millis 7 | pub data: T, 8 | } 9 | 10 | const MAX_AGE_SECS: u64 = 60 * 60 * 24; 11 | 12 | /// Read data from cache 13 | pub fn read_cache(cache_path: &std::path::PathBuf) -> std::io::Result 14 | where 15 | T: for<'de> Deserialize<'de>, 16 | { 17 | let content = std::fs::read_to_string(cache_path)?; 18 | let wrapper: CachedData = serde_json::from_str(&content)?; 19 | 20 | let now = std::time::SystemTime::now() 21 | .duration_since(std::time::UNIX_EPOCH) 22 | .map_err(std::io::Error::other)? 23 | .as_secs(); 24 | 25 | let cache_time = wrapper.timestamp / 1000; 26 | 27 | if now - cache_time <= MAX_AGE_SECS { 28 | Ok(wrapper.data) 29 | } else { 30 | Err(std::io::Error::other("Cache expired")) 31 | } 32 | } 33 | 34 | /// Save data to cache 35 | pub fn write_cache(data: &T, cache_path: &std::path::PathBuf) -> std::io::Result<()> 36 | where 37 | T: Serialize + Clone, 38 | { 39 | if let Some(parent) = cache_path.parent() { 40 | std::fs::create_dir_all(parent)?; 41 | } 42 | 43 | let now = std::time::SystemTime::now() 44 | .duration_since(std::time::UNIX_EPOCH) 45 | .map_err(std::io::Error::other)? 46 | .as_millis() as u64; 47 | 48 | let wrapper = CachedData { 49 | timestamp: now, 50 | data: data.clone(), 51 | }; 52 | 53 | let json = serde_json::to_string_pretty(&wrapper)?; 54 | std::fs::write(cache_path, json) 55 | } 56 | 57 | /// Get cache path for memory information 58 | pub fn get_memory_cache_path() -> std::path::PathBuf { 59 | dirs::cache_dir() 60 | .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) 61 | .join("hardware_visualizer") 62 | .join("memory_info.json") 63 | } 64 | -------------------------------------------------------------------------------- /src-tauri/src/platform/linux/memory.rs: -------------------------------------------------------------------------------- 1 | use crate::infrastructure::providers; 2 | use crate::models; 3 | use crate::models::hardware::MemoryInfo; 4 | use crate::platform::linux; 5 | use crate::utils; 6 | use crate::{log_internal, log_warn}; 7 | use std; 8 | 9 | pub fn get_memory_info() -> std::pin::Pin< 10 | Box> + Send + 'static>, 11 | > { 12 | Box::pin(async { 13 | if let Ok(cached) = get_memory_info_cached_detail() { 14 | return Ok(cached); 15 | } 16 | 17 | // fallback: Only get memory capacity 18 | let mem_kb = providers::procfs::get_mem_total_kb() 19 | .map_err(|e| format!("Failed to read /proc/meminfo: {e}"))?; 20 | 21 | Ok(models::hardware::MemoryInfo { 22 | size: utils::formatter::format_size(mem_kb * 1024, 1), 23 | clock: 0, 24 | clock_unit: "MHz".into(), 25 | memory_count: 0, 26 | total_slots: 0, 27 | memory_type: "Unknown".into(), 28 | is_detailed: false, 29 | }) 30 | }) 31 | } 32 | 33 | pub fn get_memory_info_detail() -> std::pin::Pin< 34 | Box> + Send + 'static>, 35 | > { 36 | Box::pin(async { 37 | let raw = providers::dmidecode::get_raw_dmidecode().await?; 38 | let parsed = providers::dmidecode::parse_dmidecode_memory_info(&raw); 39 | 40 | if let Err(e) = 41 | linux::cache::write_cache(&parsed, &linux::cache::get_memory_cache_path()) 42 | { 43 | log_warn!( 44 | "Failed to cache memory info", 45 | "get_memory_info_detail", 46 | Some(e.to_string()) 47 | ); 48 | } 49 | 50 | Ok(parsed) 51 | }) 52 | } 53 | 54 | fn get_memory_info_cached_detail() -> std::io::Result { 55 | let cache_path = linux::cache::get_memory_cache_path(); 56 | linux::cache::read_cache(&cache_path) 57 | } 58 | -------------------------------------------------------------------------------- /src/components/shared/System.tsx: -------------------------------------------------------------------------------- 1 | import { useSetAtom } from "jotai"; 2 | import type { Dispatch, SetStateAction } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { commands } from "@/rspc/bindings"; 5 | import { settingAtoms } from "@/store/ui"; 6 | import { 7 | AlertDialog, 8 | AlertDialogAction, 9 | AlertDialogCancel, 10 | AlertDialogContent, 11 | AlertDialogDescription, 12 | AlertDialogFooter, 13 | AlertDialogHeader, 14 | AlertDialogTitle, 15 | } from "../ui/alert-dialog"; 16 | 17 | export const NeedRestart = ({ 18 | alertOpen, 19 | setAlertOpen, 20 | }: { 21 | alertOpen: boolean; 22 | setAlertOpen: Dispatch>; 23 | }) => { 24 | const { t } = useTranslation(); 25 | const setIsRequiredRestart = useSetAtom(settingAtoms.isRequiredRestart); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | {t("pages.settings.insights.needRestart.title")} 33 | 34 | 35 | {t("pages.settings.insights.needRestart.description")} 36 | 37 | 38 | 39 | { 41 | setAlertOpen(false); 42 | setIsRequiredRestart(true); 43 | }} 44 | > 45 | {t("pages.settings.insights.needRestart.cancel")} 46 | 47 | 48 | {t("pages.settings.insights.needRestart.restart")} 49 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )); 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = "vertical", ...props }, ref) => ( 28 | 41 | 42 | 43 | )); 44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 45 | 46 | export { ScrollArea, ScrollBar }; 47 | -------------------------------------------------------------------------------- /src-tauri/src/models/hardware_archive.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use specta::Type; 3 | use std::{ 4 | collections::{HashMap, VecDeque}, 5 | sync::{Arc, Mutex}, 6 | }; 7 | 8 | pub struct MonitorResources { 9 | pub system: Arc>, 10 | pub cpu_history: Arc>>, 11 | pub memory_history: Arc>>, 12 | pub process_cpu_histories: Arc>>>, 13 | pub process_memory_histories: Arc>>>, 14 | pub nv_gpu_usage_histories: Arc>>>, 15 | pub nv_gpu_temperature_histories: Arc>>>, 16 | pub nv_gpu_dedicated_memory_histories: Arc>>>, 17 | } 18 | 19 | #[derive(Debug, Serialize, Deserialize, Clone, Type)] 20 | #[serde(rename_all = "camelCase")] 21 | pub struct HardwareArchiveSettings { 22 | pub enabled: bool, 23 | pub scheduled_data_deletion: bool, 24 | pub refresh_interval_days: u32, 25 | } 26 | 27 | #[derive(Serialize, Deserialize, Debug)] 28 | pub struct HardwareData { 29 | pub avg: Option, 30 | pub max: Option, 31 | pub min: Option, 32 | } 33 | 34 | #[derive(Serialize, Deserialize, Debug)] 35 | pub struct GpuData { 36 | pub gpu_name: String, 37 | pub usage_avg: Option, 38 | pub usage_max: Option, 39 | pub usage_min: Option, 40 | pub temperature_avg: Option, 41 | pub temperature_max: Option, 42 | pub temperature_min: Option, 43 | pub dedicated_memory_avg: Option, 44 | pub dedicated_memory_max: Option, 45 | pub dedicated_memory_min: Option, 46 | } 47 | 48 | #[derive(Debug, Clone)] 49 | pub struct ProcessStatData { 50 | pub pid: i32, 51 | pub process_name: String, 52 | pub cpu_usage: f32, 53 | pub memory_usage: i32, 54 | pub execution_sec: i32, 55 | } 56 | -------------------------------------------------------------------------------- /src-tauri/src/platform/traits.rs: -------------------------------------------------------------------------------- 1 | use crate::enums; 2 | use crate::enums::error::BackendError; 3 | use crate::models; 4 | use std::future::Future; 5 | use std::pin::Pin; 6 | 7 | /// Trait that defines platform-specific memory operations 8 | pub trait MemoryPlatform: Send + Sync { 9 | /// Get basic memory information 10 | fn get_memory_info( 11 | &self, 12 | ) -> Pin< 13 | Box> + Send + '_>, 14 | >; 15 | 16 | /// Get detailed memory information (supported platforms only) 17 | fn get_memory_info_detail( 18 | &self, 19 | ) -> Pin< 20 | Box> + Send + '_>, 21 | >; 22 | } 23 | 24 | /// Trait that defines platform-specific GPU operations 25 | pub trait GpuPlatform: Send + Sync { 26 | /// Get GPU usage 27 | fn get_gpu_usage( 28 | &self, 29 | ) -> Pin> + Send + '_>>; 30 | 31 | /// Get GPU temperature 32 | fn get_gpu_temperature( 33 | &self, 34 | temperature_unit: enums::settings::TemperatureUnit, 35 | ) -> Pin< 36 | Box< 37 | dyn Future, String>> + Send + '_, 38 | >, 39 | >; 40 | 41 | /// Get GPU information 42 | fn get_gpu_info( 43 | &self, 44 | ) -> Pin< 45 | Box< 46 | dyn Future, String>> + Send + '_, 47 | >, 48 | >; 49 | } 50 | 51 | /// Trait that defines platform-specific network operations 52 | pub trait NetworkPlatform: Send + Sync { 53 | /// Get network information 54 | #[allow(dead_code)] 55 | fn get_network_info( 56 | &self, 57 | ) -> Result, BackendError>; 58 | } 59 | 60 | /// Trait that integrates all platform functionality 61 | pub trait Platform: MemoryPlatform + GpuPlatform + NetworkPlatform {} 62 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/database/migration.rs: -------------------------------------------------------------------------------- 1 | use tauri_plugin_sql::{Migration, MigrationKind}; 2 | 3 | pub fn get_migrations() -> Vec { 4 | vec![ 5 | // Up Migrations 6 | Migration { 7 | version: 1, 8 | description: "create_initial_tables", 9 | sql: "CREATE TABLE DATA_ARCHIVE (id INTEGER PRIMARY KEY, cpu_avg INTEGER, cpu_max INTEGER, cpu_min INTEGER, ram_avg INTEGER, ram_max INTEGER, ram_min INTEGER, timestamp DATETIME);", 10 | kind: MigrationKind::Up, 11 | }, 12 | Migration { 13 | version: 2, 14 | description: "create_gpu_tables", 15 | sql: "CREATE TABLE GPU_DATA_ARCHIVE (id INTEGER PRIMARY KEY, gpu_name TEXT, usage_avg INTEGER, usage_max INTEGER, usage_min INTEGER, temperature_avg INTEGER, temperature_max INTEGER, temperature_min INTEGER, timestamp DATETIME);", 16 | kind: MigrationKind::Up, 17 | }, 18 | Migration { 19 | version: 3, 20 | description: "add_gpu_memory_usage_columns", 21 | sql: r#" 22 | ALTER TABLE GPU_DATA_ARCHIVE ADD COLUMN dedicated_memory_avg INTEGER; 23 | ALTER TABLE GPU_DATA_ARCHIVE ADD COLUMN dedicated_memory_max INTEGER; 24 | ALTER TABLE GPU_DATA_ARCHIVE ADD COLUMN dedicated_memory_min INTEGER; 25 | "#, 26 | kind: MigrationKind::Up, 27 | }, 28 | Migration { 29 | version: 4, 30 | description: "create_process_stats", 31 | sql: "CREATE TABLE PROCESS_STATS (id INTEGER PRIMARY KEY AUTOINCREMENT, pid INTEGER NOT NULL, process_name TEXT NOT NULL, cpu_usage REAL NOT NULL, memory_usage INTEGER NOT NULL, execution_sec INTEGER NOT NULL, timestamp DATETIME NOT NULL);", 32 | kind: MigrationKind::Up, 33 | }, 34 | // Down Migrations 35 | Migration { 36 | version: 4, 37 | description: "drop_process_stats", 38 | sql: "DROP TABLE IF EXISTS PROCESS_STATS;", 39 | kind: MigrationKind::Down, 40 | }, 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src-tauri/src/_tests/commands/hardware_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use std::collections::{HashMap, VecDeque}; 4 | use std::sync::{Arc, Mutex}; 5 | use sysinfo::System; 6 | use tauri::Manager; 7 | 8 | use crate::commands::hardware::*; 9 | 10 | /// 11 | /// Test the get_process_list function 12 | /// 13 | #[test] 14 | fn test_get_process_list() { 15 | let app = tauri::test::mock_app(); 16 | 17 | // Mock 18 | let mut mock_system = System::new_all(); 19 | mock_system.refresh_all(); 20 | 21 | let mock_pid = 12345; 22 | let mock_process_name = "TestProcess".to_string(); 23 | let mock_cpu_usage = 50.0; 24 | let mock_memory_usage = 1024.0; 25 | 26 | let mut cpu_histories = HashMap::new(); 27 | cpu_histories.insert(mock_pid.into(), VecDeque::from(vec![mock_cpu_usage; 5])); 28 | 29 | let mut memory_histories = HashMap::new(); 30 | memory_histories.insert(mock_pid.into(), VecDeque::from(vec![mock_memory_usage; 5])); 31 | 32 | let app_state = AppState { 33 | system: Arc::new(Mutex::new(mock_system)), 34 | cpu_history: Arc::new(Mutex::new(VecDeque::new())), 35 | memory_history: Arc::new(Mutex::new(VecDeque::new())), 36 | gpu_history: Arc::new(Mutex::new(VecDeque::new())), 37 | gpu_usage: Arc::new(Mutex::new(0.0)), 38 | process_cpu_histories: Arc::new(Mutex::new(cpu_histories)), 39 | process_memory_histories: Arc::new(Mutex::new(memory_histories)), 40 | }; 41 | 42 | app.manage(app_state); 43 | 44 | // Act 45 | let process_list = get_process_list(app.state()); 46 | 47 | // Assert 48 | assert_eq!(process_list.len(), 1); 49 | let process = &process_list[0]; 50 | assert_eq!(process.pid, mock_pid as i32); 51 | assert_eq!(process.name, mock_process_name); 52 | assert_eq!(process.cpu_usage, mock_cpu_usage); 53 | assert_eq!(process.memory_usage, mock_memory_usage / 1024.0); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src-tauri/src/utils/file.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use tauri::generate_context; 3 | 4 | #[cfg(test)] 5 | use mockall::automock; 6 | 7 | // Trait to abstract environment variable access 8 | #[cfg_attr(test, automock)] 9 | pub trait EnvProvider { 10 | fn get_var(&self, key: &str) -> Result; 11 | } 12 | 13 | // Actual environment variable access 14 | pub struct RealEnvProvider; 15 | 16 | impl EnvProvider for RealEnvProvider { 17 | fn get_var(&self, key: &str) -> Result { 18 | std::env::var(key) 19 | } 20 | } 21 | 22 | /// 23 | /// Get directory name under `AppData/Roaming` 24 | /// 25 | #[cfg(target_os = "windows")] 26 | pub fn get_app_data_dir(sub_item: &str) -> PathBuf { 27 | get_app_data_dir_with_env(&RealEnvProvider, sub_item) 28 | } 29 | 30 | #[cfg(target_os = "windows")] 31 | pub fn get_app_data_dir_with_env(env: &E, sub_item: &str) -> PathBuf { 32 | let context: tauri::Context = generate_context!(); 33 | 34 | // Create directory based on identifier from tauri.conf.json 35 | let identifier = context.config().identifier.clone(); 36 | 37 | let app_data = PathBuf::from(env.get_var("APPDATA").unwrap()); 38 | app_data.join(identifier).join(sub_item) 39 | } 40 | 41 | /// 42 | /// Get directory name under `~/.config/` (Linux / macOS) 43 | /// 44 | #[cfg(not(target_os = "windows"))] 45 | pub fn get_app_data_dir(sub_item: &str) -> PathBuf { 46 | get_app_data_dir_with_env(&RealEnvProvider, sub_item) 47 | } 48 | 49 | #[cfg(not(target_os = "windows"))] 50 | pub fn get_app_data_dir_with_env(env: &E, sub_item: &str) -> PathBuf { 51 | use std::path::Path; 52 | 53 | let context: tauri::Context = generate_context!(); 54 | let identifier = context.config().identifier.clone(); 55 | 56 | let home = env.get_var("HOME").unwrap_or_else(|_| ".".to_string()); 57 | Path::new(&home) 58 | .join(".config") 59 | .join(identifier) 60 | .join(sub_item) 61 | } 62 | -------------------------------------------------------------------------------- /src/features/hardware/insights/process/hooks/useProcessStats.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from "react"; 2 | import { 3 | type archivePeriods, 4 | chartConfig, 5 | } from "@/features/hardware/consts/chart"; 6 | import { useTauriDialog } from "@/hooks/useTauriDialog"; 7 | import type { ProcessStat } from "../../types/processStats"; 8 | import { getProcessStats } from "../funcs/getProcessStatsRecord"; 9 | import { useProcessStatsAtom } from "./useProcessStatsAtom"; 10 | 11 | export const useProcessStats = ({ 12 | period, 13 | offset, 14 | }: { 15 | period: (typeof archivePeriods)[number]; 16 | offset: number; 17 | }) => { 18 | const [loading, setLoading] = useState(true); 19 | const { error } = useTauriDialog(); 20 | const { processStats, setProcessStatsAtom } = useProcessStatsAtom(); 21 | 22 | const step = 23 | { 24 | 10: 1, 25 | 30: 1, 26 | 60: 1, 27 | 180: 1, 28 | 720: 10, 29 | 1440: 30, 30 | 10080: 60, 31 | 20160: 180, 32 | 43200: 720, 33 | }[period] * chartConfig.archiveUpdateIntervalMilSec; 34 | 35 | const endAt = useMemo(() => { 36 | return new Date(Date.now() - offset * step); 37 | }, [offset, step]); 38 | 39 | const getData = useCallback( 40 | async (): Promise => getProcessStats(period, endAt), 41 | [period, endAt], 42 | ); 43 | 44 | useEffect(() => { 45 | const fetchStats = async () => { 46 | try { 47 | setLoading(true); 48 | const stats = await getData(); 49 | setProcessStatsAtom(stats); 50 | } catch (err) { 51 | console.error(err); 52 | error(String(err)); 53 | } finally { 54 | setLoading(false); 55 | } 56 | }; 57 | 58 | fetchStats(); 59 | 60 | const interval = setInterval(fetchStats, 60000); // Update every 1 minute 61 | 62 | return () => clearInterval(interval); 63 | }, [setProcessStatsAtom, getData, error]); 64 | 65 | return { processStats, loading }; 66 | }; 67 | -------------------------------------------------------------------------------- /src-tauri/src/infrastructure/providers/sysinfo_provider.rs: -------------------------------------------------------------------------------- 1 | use crate::enums; 2 | use crate::models; 3 | use crate::utils; 4 | use crate::utils::formatter::SizeUnit; 5 | 6 | use std::sync::MutexGuard; 7 | use sysinfo::{Disks, System}; 8 | 9 | /// 10 | /// ## Get CPU information 11 | /// 12 | pub fn get_cpu_info( 13 | system: MutexGuard<'_, System>, 14 | ) -> Result { 15 | let cpus = system.cpus(); 16 | 17 | if cpus.is_empty() { 18 | return Err("CPU information not available".to_string()); 19 | } 20 | 21 | // Collect CPU information 22 | let cpu_info = models::hardware::CpuInfo { 23 | name: cpus[0].brand().to_string(), 24 | vendor: utils::formatter::format_vendor_name(cpus[0].vendor_id()), 25 | core_count: sysinfo::System::physical_core_count().unwrap_or(0) as u32, 26 | clock: cpus[0].frequency() as u32, 27 | clock_unit: "MHz".to_string(), 28 | cpu_name: cpus[0].name().to_string(), 29 | }; 30 | 31 | Ok(cpu_info) 32 | } 33 | 34 | pub fn get_storage_info() -> Result, String> { 35 | let mut storage_info: Vec = Vec::new(); 36 | 37 | let disks = Disks::new_with_refreshed_list(); 38 | 39 | for disk in &disks { 40 | let size = utils::formatter::format_size_with_unit( 41 | disk.total_space(), 42 | 2, 43 | Some(SizeUnit::GBytes), 44 | ); 45 | let free = utils::formatter::format_size_with_unit( 46 | disk.available_space(), 47 | 2, 48 | Some(SizeUnit::GBytes), 49 | ); 50 | let storage = models::hardware::StorageInfo { 51 | name: disk.mount_point().to_string_lossy().into_owned(), 52 | size: size.value, 53 | size_unit: size.unit, 54 | free: free.value, 55 | free_unit: free.unit, 56 | storage_type: enums::hardware::DiskKind::from(disk.kind()), 57 | file_system: disk.file_system().to_string_lossy().into_owned(), 58 | }; 59 | 60 | storage_info.push(storage); 61 | } 62 | 63 | Ok(storage_info) 64 | } 65 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/GraphTypeSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | import { Checkbox } from "@/components/ui/checkbox"; 3 | import { Label } from "@/components/ui/label"; 4 | import type { ChartDataType } from "@/features/hardware/types/hardwareDataType"; 5 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 6 | 7 | export const GraphTypeSelector = () => { 8 | const { settings, toggleDisplayTarget } = useSettingsAtom(); 9 | const selectedGraphTypes = settings.displayTargets; 10 | 11 | const cpuId = useId(); 12 | const memoryId = useId(); 13 | const gpuId = useId(); 14 | 15 | const toggleGraphType = async (type: ChartDataType) => { 16 | await toggleDisplayTarget(type); 17 | }; 18 | 19 | return ( 20 |
21 |
22 | toggleGraphType("cpu")} 26 | /> 27 | 30 |
31 |
32 | toggleGraphType("memory")} 36 | /> 37 | 43 |
44 |
45 | toggleGraphType("gpu")} 49 | /> 50 | 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot Instructions for HardwareVisualizer 2 | 3 | ## Overview 4 | 5 | HardwareVisualizer は、リアルタイムでコンピュータのハードウェアパフォーマンスを監視するツールです。このプロジェクトは、フロントエンド(TypeScript/React)とバックエンド(Rust/Tauri)で構成されています。以下のガイドラインは、AI コーディングエージェントがこのコードベースで効率的に作業するための指針を提供します。 6 | 7 | ## プロジェクト構造 8 | 9 | - **`src/`**: フロントエンドの主要なコードが含まれています。 10 | - `components/`: 再利用可能な UI コンポーネント。 11 | - `features/`: 機能ごとのモジュール(例: hardware, menu, settings)。 12 | - `hooks/`: React カスタムフック。 13 | - `lib/`: ユーティリティ関数。 14 | - `store/`: 状態管理関連のコード。 15 | - `types/`: TypeScript 型定義。 16 | - **`src-tauri/`**: バックエンドの Rust コード。 17 | - `src/`: Rust の主要なコード。 18 | - `commands/`: フロントエンドと通信するための Tauri コマンド。 19 | - `database/`: データベース関連のロジック。 20 | - `services/`: サービス層のロジック。 21 | - `utils/`: ユーティリティ関数。 22 | - **`test/`**: 単体テスト。 23 | - **`coverage/`**: テストカバレッジレポート。 24 | 25 | ## 開発フロー 26 | 27 | ### 必要なツール 28 | 29 | - Node.js v22 30 | - Rust 1.85 31 | 32 | ### コマンド 33 | 34 | - **依存関係のインストール**: 35 | ```bash 36 | npm ci 37 | ``` 38 | - **開発モードでの起動**: 39 | ```bash 40 | npm run tauri dev 41 | ``` 42 | - **本番ビルド**: 43 | ```bash 44 | npm run tauri build 45 | ``` 46 | - **コードのリント**: 47 | ```bash 48 | npm run lint 49 | cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings 50 | ``` 51 | - **コードのフォーマット**: 52 | ```bash 53 | npm run format 54 | cargo fmt --manifest-path src-tauri/Cargo.toml -- --check 55 | ``` 56 | - **テストの実行**: 57 | 58 | ```bash 59 | npm test 60 | cargo test --manifest-path src-tauri/Cargo.toml -- --test-threads=1 --nocapture 61 | ``` 62 | 63 | ## プロジェクト固有のパターン 64 | 65 | - **Tauri コマンド**: フロントエンドとバックエンド間の通信は、`src-tauri/src/commands/` 内のコマンドを使用して行われます。フロントエンドからは、自動生成された `rspc/bindings.ts` を通じて Tauri コマンドを呼び出します。 66 | - **ユーティリティ関数**: 再利用可能なロジックは `lib/` または `src-tauri/src/utils/` に配置されています。 67 | 68 | ## 参考ファイル 69 | 70 | - `README.md`: プロジェクトの全体像とセットアップ手順。 71 | - `vite.config.ts`: フロントエンドのビルド設定。 72 | - `tauri.conf.json`: Tauri アプリケーションの設定。 73 | 74 | このガイドラインに基づいて作業を進めてください。不明点があれば、README.md やコードベースを参照してください。 75 | -------------------------------------------------------------------------------- /src-tauri/src/enums/hardware.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 2 | use specta::Type; 3 | use std::fmt; 4 | 5 | #[derive(Debug, PartialEq, Eq, Clone, Type)] 6 | #[serde(rename_all = "camelCase")] 7 | pub enum HardwareType { 8 | #[serde(rename = "cpu")] 9 | Cpu, 10 | Memory, 11 | #[serde(rename = "gpu")] 12 | Gpu, 13 | } 14 | 15 | impl Serialize for HardwareType { 16 | fn serialize(&self, serializer: S) -> Result 17 | where 18 | S: Serializer, 19 | { 20 | let s = match *self { 21 | HardwareType::Cpu => "cpu", 22 | HardwareType::Memory => "memory", 23 | HardwareType::Gpu => "gpu", 24 | }; 25 | serializer.serialize_str(s) 26 | } 27 | } 28 | 29 | impl<'de> Deserialize<'de> for HardwareType { 30 | fn deserialize(deserializer: D) -> Result 31 | where 32 | D: Deserializer<'de>, 33 | { 34 | let s = String::deserialize(deserializer)?.to_lowercase(); 35 | match s.as_str() { 36 | "cpu" => Ok(HardwareType::Cpu), 37 | "memory" => Ok(HardwareType::Memory), 38 | "gpu" => Ok(HardwareType::Gpu), 39 | _ => Err(serde::de::Error::unknown_variant( 40 | &s, 41 | &["cpu", "memory", "gpu"], 42 | )), 43 | } 44 | } 45 | } 46 | 47 | impl From for DiskKind { 48 | fn from(kind: sysinfo::DiskKind) -> Self { 49 | match kind { 50 | sysinfo::DiskKind::HDD => DiskKind::Hdd, 51 | 52 | sysinfo::DiskKind::SSD => DiskKind::Ssd, 53 | 54 | _ => DiskKind::Unknown, 55 | } 56 | } 57 | } 58 | 59 | #[derive(Serialize, Deserialize, Type, Debug, PartialEq, Eq, Clone)] 60 | pub enum DiskKind { 61 | #[serde(rename = "hdd")] 62 | Hdd, 63 | #[serde(rename = "ssd")] 64 | Ssd, 65 | #[serde(rename = "other")] 66 | Unknown, 67 | } 68 | 69 | impl fmt::Display for DiskKind { 70 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 71 | f.write_str(match *self { 72 | DiskKind::Hdd => "HDD", 73 | DiskKind::Ssd => "SSD", 74 | _ => "Other", 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | time: "08:30" 13 | timezone: "Asia/Tokyo" 14 | cooldown: 15 | default-days: 5 16 | open-pull-requests-limit: 40 17 | assignees: 18 | - "shm11C3" 19 | 20 | groups: 21 | default: 22 | patterns: 23 | - "*" 24 | update-types: 25 | - "patch" 26 | - "minor" 27 | exclude-patterns: 28 | - "react*" 29 | - "@types/react*" 30 | - "typescript" 31 | tauri: 32 | patterns: 33 | - "tauri" 34 | - "@tauri-apps/*" 35 | react: 36 | patterns: 37 | - "react" 38 | - "react-dom" 39 | biome: 40 | patterns: 41 | - "@biomejs/biome" 42 | 43 | - package-ecosystem: "cargo" 44 | directory: "/src-tauri" 45 | schedule: 46 | interval: "weekly" 47 | day: "monday" 48 | time: "08:30" 49 | timezone: "Asia/Tokyo" 50 | cooldown: 51 | default-days: 5 52 | open-pull-requests-limit: 40 53 | assignees: 54 | - "shm11C3" 55 | 56 | - package-ecosystem: "rust-toolchain" 57 | directory: "/" 58 | schedule: 59 | interval: "weekly" 60 | day: "monday" 61 | time: "08:30" 62 | timezone: "Asia/Tokyo" 63 | cooldown: 64 | default-days: 5 65 | 66 | - package-ecosystem: "github-actions" 67 | directory: "/" 68 | schedule: 69 | interval: "weekly" 70 | day: "monday" 71 | time: "07:00" 72 | timezone: "Asia/Tokyo" 73 | cooldown: 74 | default-days: 5 75 | assignees: 76 | - "shm11C3" 77 | -------------------------------------------------------------------------------- /src-tauri/src/platform/linux/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::enums; 2 | use crate::enums::error::BackendError; 3 | use crate::models::hardware::{GraphicInfo, NetworkInfo}; 4 | use crate::platform::traits::{GpuPlatform, MemoryPlatform, NetworkPlatform, Platform}; 5 | use std::future::Future; 6 | use std::pin::Pin; 7 | 8 | pub mod cache; 9 | pub mod gpu; 10 | pub mod memory; 11 | pub mod network; 12 | 13 | pub struct LinuxPlatform; 14 | 15 | impl LinuxPlatform { 16 | pub fn new() -> Result { 17 | Ok(Self) 18 | } 19 | } 20 | 21 | impl MemoryPlatform for LinuxPlatform { 22 | fn get_memory_info( 23 | &self, 24 | ) -> Pin< 25 | Box< 26 | dyn Future> 27 | + Send 28 | + '_, 29 | >, 30 | > { 31 | memory::get_memory_info() 32 | } 33 | 34 | fn get_memory_info_detail( 35 | &self, 36 | ) -> Pin< 37 | Box< 38 | dyn Future> 39 | + Send 40 | + '_, 41 | >, 42 | > { 43 | memory::get_memory_info_detail() 44 | } 45 | } 46 | 47 | impl GpuPlatform for LinuxPlatform { 48 | fn get_gpu_usage( 49 | &self, 50 | ) -> Pin> + Send + '_>> { 51 | Box::pin(gpu::get_gpu_usage()) 52 | } 53 | 54 | fn get_gpu_temperature( 55 | &self, 56 | _temperature_unit: enums::settings::TemperatureUnit, 57 | ) -> Pin< 58 | Box< 59 | dyn Future, String>> 60 | + Send 61 | + '_, 62 | >, 63 | > { 64 | Box::pin(async { Err("Not implemented".to_string()) }) 65 | } 66 | 67 | fn get_gpu_info( 68 | &self, 69 | ) -> Pin, String>> + Send + '_>> { 70 | Box::pin(gpu::get_gpu_info()) 71 | } 72 | } 73 | 74 | impl NetworkPlatform for LinuxPlatform { 75 | fn get_network_info(&self) -> Result, BackendError> { 76 | network::get_network_info() 77 | } 78 | } 79 | 80 | impl Platform for LinuxPlatform {} 81 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # HardwareVisualizer Contributing Guide 2 | 3 | Thank you are interested in contributing to HardwareVisualizer! 4 | HardwareVisualizer is an open-source project, and we welcome improvements from the community. 5 | 6 | ## Pull Request Guidelines 7 | 8 | - For new features, we recommend creating an Issue before implementation. 9 | - For bug fixes, creating an Issue is optional. 10 | 11 | Branch naming convention: 12 | 13 | - Features: `feat/` 14 | - Bug fixes: `fix/` 15 | 16 | When submitting a Pull Request (PR), please: 17 | 18 | - Provide a concise description of the change 19 | - Link any related Issue 20 | - Ensure CI checks pass 21 | 22 | ## Development Guide 23 | 24 | ### Setup 25 | 26 | Development requires the following tools: 27 | 28 | - [Node.js v22](https://nodejs.org/) 29 | - [Rust 1.89](https://www.rust-lang.org/) 30 | 31 | If you are using Linux, you may need to install additional dependencies: 32 | 33 | ```bash 34 | sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 35 | ``` 36 | 37 | Next, install the dependencies: 38 | 39 | ```bash 40 | npm ci 41 | ``` 42 | 43 | ### Run lint & tests locally before opening a PR 44 | 45 | If there are errors in linting, formatting, or tests, the PR cannot be merged. 46 | Run these before opening a PR: 47 | 48 | For JavaScript/TypeScript: 49 | 50 | ```bash 51 | npm run lint 52 | npm run format 53 | npm test 54 | ``` 55 | 56 | For Rust: 57 | 58 | ```bash 59 | cargo fmt --manifest-path src-tauri/Cargo.toml -- --check 60 | cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings 61 | cargo test --manifest-path src-tauri/Cargo.toml -- --test-threads=1 --nocapture 62 | ``` 63 | 64 | ## Security 65 | 66 | If you discover a vulnerability, do not open a public issue. Instead, please contact the maintainer directly via GitHub or via email at: `m11c3.sh@gmail.com`. 67 | 68 | ## License 69 | 70 | By contributing to HardwareVisualizer, you agree that your contributions will be licensed under the [MIT License](LICENSE). 71 | -------------------------------------------------------------------------------- /src-tauri/src/platform/windows/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::enums::error::BackendError; 2 | use crate::enums::settings::TemperatureUnit; 3 | use crate::models::hardware::{GraphicInfo, NetworkInfo}; 4 | use crate::platform::traits::{GpuPlatform, MemoryPlatform, NetworkPlatform, Platform}; 5 | 6 | use std::future::Future; 7 | use std::pin::Pin; 8 | 9 | pub mod gpu; 10 | pub mod memory; 11 | pub mod network; 12 | 13 | pub struct WindowsPlatform; 14 | 15 | impl WindowsPlatform { 16 | pub fn new() -> Result { 17 | Ok(Self) 18 | } 19 | } 20 | 21 | impl MemoryPlatform for WindowsPlatform { 22 | fn get_memory_info( 23 | &self, 24 | ) -> Pin< 25 | Box< 26 | dyn Future> 27 | + Send 28 | + '_, 29 | >, 30 | > { 31 | memory::get_memory_info() 32 | } 33 | 34 | fn get_memory_info_detail( 35 | &self, 36 | ) -> Pin< 37 | Box< 38 | dyn Future> 39 | + Send 40 | + '_, 41 | >, 42 | > { 43 | memory::get_memory_info_detail() 44 | } 45 | } 46 | 47 | impl GpuPlatform for WindowsPlatform { 48 | fn get_gpu_usage( 49 | &self, 50 | ) -> Pin> + Send + '_>> { 51 | Box::pin(gpu::get_gpu_usage()) 52 | } 53 | 54 | fn get_gpu_temperature( 55 | &self, 56 | temperature_unit: TemperatureUnit, 57 | ) -> Pin< 58 | Box< 59 | dyn Future, String>> 60 | + Send 61 | + '_, 62 | >, 63 | > { 64 | Box::pin(gpu::get_gpu_temperature(temperature_unit)) 65 | } 66 | 67 | fn get_gpu_info( 68 | &self, 69 | ) -> Pin, String>> + Send + '_>> { 70 | Box::pin(gpu::get_gpu_info()) 71 | } 72 | } 73 | 74 | impl NetworkPlatform for WindowsPlatform { 75 | fn get_network_info(&self) -> Result, BackendError> { 76 | network::get_network_info() 77 | } 78 | } 79 | 80 | impl Platform for WindowsPlatform {} 81 | -------------------------------------------------------------------------------- /src/features/hardware/hooks/useHardwareInfoAtom.ts: -------------------------------------------------------------------------------- 1 | import { atom, useAtom } from "jotai"; 2 | import { useTauriDialog } from "@/hooks/useTauriDialog"; 3 | import { commands, type NetworkInfo, type SysInfo } from "@/rspc/bindings"; 4 | import { isError } from "@/types/result"; 5 | 6 | const hardInfoAtom = atom({ 7 | cpu: null, 8 | memory: null, 9 | gpus: null, 10 | storage: [], 11 | }); 12 | 13 | const networkInfoAtom = atom([]); 14 | 15 | export const useHardwareInfoAtom = () => { 16 | const [hardwareInfo, setHardInfo] = useAtom(hardInfoAtom); 17 | const [networkInfo, setNetworkInfo] = useAtom(networkInfoAtom); 18 | const { error } = useTauriDialog(); 19 | 20 | const init = async () => { 21 | const fetchedHardwareInfo = await commands.getHardwareInfo(); 22 | if (isError(fetchedHardwareInfo)) { 23 | error(fetchedHardwareInfo.error); 24 | console.error("Failed to fetch hardware info:", fetchedHardwareInfo); 25 | return; 26 | } 27 | 28 | setHardInfo(fetchedHardwareInfo.data); 29 | }; 30 | 31 | const initNetwork = async () => { 32 | const fetchedNetworkInfo = await commands.getNetworkInfo(); 33 | if (isError(fetchedNetworkInfo)) { 34 | error(fetchedNetworkInfo.error); 35 | console.error("Failed to fetch network info:", fetchedNetworkInfo); 36 | return; 37 | } 38 | 39 | setNetworkInfo(fetchedNetworkInfo.data); 40 | }; 41 | 42 | const fetchMemoryInfoDetail = async () => { 43 | const backup = hardwareInfo.memory; 44 | setHardInfo({ ...hardwareInfo, memory: null }); 45 | 46 | const result = await commands.getMemoryInfoDetail(); 47 | 48 | if (isError(result)) { 49 | error(result.error); 50 | console.error("Failed to fetch memory info detail:", result); 51 | setHardInfo({ ...hardwareInfo, memory: backup }); 52 | return; 53 | } 54 | 55 | setHardInfo({ ...hardwareInfo, memory: result.data }); 56 | }; 57 | 58 | return { 59 | hardwareInfo, 60 | networkInfo, 61 | init, 62 | initNetwork, 63 | fetchMemoryInfoDetail, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/icons/LineChartIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { LineGraphType } from "@/rspc/bindings"; 2 | 3 | const MonotoneChartIcon = ({ className }: { className?: string }) => ( 4 | 11 | Default Chart Icon 12 | 17 | 18 | ); 19 | 20 | const StepChartIcon = ({ className }: { className?: string }) => ( 21 | 28 | Step Chart Icon 29 | 34 | 35 | ); 36 | 37 | const LinearChartIcon = ({ className }: { className?: string }) => ( 38 | 45 | Linear Chart Icon 46 | 47 | 48 | ); 49 | 50 | const BasisChartIcon = ({ className }: { className?: string }) => ( 51 | 58 | Basis Chart Icon 59 | 64 | 65 | ); 66 | 67 | export const LineChartIcon = ({ 68 | type, 69 | className, 70 | }: { 71 | type: LineGraphType; 72 | className?: string; 73 | }) => { 74 | return { 75 | default: , 76 | step: , 77 | linear: , 78 | basis: , 79 | }[type]; 80 | }; 81 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return ; 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 0, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 60 | -------------------------------------------------------------------------------- /src/features/settings/components/graph/LineChartTypeSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { LineChartIcon } from "@/components/icons/LineChartIcon"; 3 | import { Label } from "@/components/ui/label"; 4 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 5 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 6 | import type { ClientSettings, LineGraphType } from "@/rspc/bindings"; 7 | 8 | export const LineChartTypeSelector = () => { 9 | const { settings, updateSettingAtom } = useSettingsAtom(); 10 | const { t } = useTranslation(); 11 | 12 | const changeLineGraphType = async ( 13 | value: ClientSettings["lineGraphType"], 14 | ) => { 15 | await updateSettingAtom("lineGraphType", value); 16 | }; 17 | 18 | const lineGraphTypes: LineGraphType[] = [ 19 | "default", 20 | "step", 21 | "linear", 22 | "basis", 23 | ] as const; 24 | 25 | const lineGraphLabels: Record = { 26 | default: "Default", 27 | step: "Step", 28 | linear: "Linear", 29 | basis: "Soft", 30 | }; 31 | 32 | return ( 33 |
34 | 37 | 42 | {lineGraphTypes.map((type) => { 43 | const id = `radio-line-chart-type-${type}`; 44 | 45 | return ( 46 |
47 | 48 | 55 |
56 | ); 57 | })} 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/features/settings/components/insights/InsightsToggle.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircleIcon, ProhibitInsetIcon } from "@phosphor-icons/react"; 2 | import { useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { NeedRestart } from "@/components/shared/System"; 5 | import { Label } from "@/components/ui/label"; 6 | import { Switch } from "@/components/ui/switch"; 7 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 8 | 9 | export const InsightsToggle = () => { 10 | const [alertOpen, setAlertOpen] = useState(false); 11 | const { t } = useTranslation(); 12 | const { settings, toggleHardwareArchiveAtom } = useSettingsAtom(); 13 | 14 | const handleCheckedChange = async (value: boolean) => { 15 | await toggleHardwareArchiveAtom(value); 16 | setAlertOpen(true); 17 | }; 18 | 19 | return ( 20 | <> 21 |
22 |
23 |
24 |
25 | 41 |
42 | 43 | 47 |
48 |
49 |
50 | 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/features/settings/components/about/AboutSection.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowSquareOutIcon, GithubLogoIcon } from "@phosphor-icons/react"; 2 | import { getVersion } from "@tauri-apps/api/app"; 3 | import { useEffect, useState } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { Button } from "@/components/ui/button"; 6 | import { openURL } from "@/lib/openUrl"; 7 | 8 | export const AboutSection = ({ 9 | onShowLicense, 10 | }: { 11 | onShowLicense: () => void; 12 | }) => { 13 | const { t } = useTranslation(); 14 | const [version, setVersion] = useState(""); 15 | 16 | useEffect(() => { 17 | getVersion().then((v) => setVersion(v)); 18 | }, []); 19 | 20 | return ( 21 |
22 |

23 | {t("pages.settings.about.version", { version })} 24 |

25 |

26 | {t("pages.settings.about.author", { author: "shm11C3" })} 27 |

28 |
29 | 40 | 54 | 61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function Tabs({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function TabsList({ 20 | className, 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 32 | ); 33 | } 34 | 35 | function TabsTrigger({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ); 49 | } 50 | 51 | function TabsContent({ 52 | className, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 | ); 62 | } 63 | 64 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 65 | -------------------------------------------------------------------------------- /src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as AccordionPrimitive from "@radix-ui/react-accordion"; 4 | import { ChevronDown } from "lucide-react"; 5 | import * as React from "react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Accordion = AccordionPrimitive.Root; 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )); 21 | AccordionItem.displayName = "AccordionItem"; 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className, 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )); 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )); 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; 59 | -------------------------------------------------------------------------------- /src/features/settings/components/Preview.tsx: -------------------------------------------------------------------------------- 1 | import { LineChartComponent as LineChart } from "@/components/charts/LineChart"; 2 | import { chartConfig } from "@/features/hardware/consts/chart"; 3 | import { useSettingsAtom } from "@/features/settings/hooks/useSettingsAtom"; 4 | 5 | export const PreviewChart = () => { 6 | const { settings } = useSettingsAtom(); 7 | 8 | const labels = Array(chartConfig.historyLengthSec).fill(""); 9 | 10 | const cpuValues = [ 11 | 7, 7, 8, 9, 11, 10, 5, 9, 7, 9, 7, 5, 4, 6, 6, 6, 10, 6, 8, 9, 8, 7, 7, 4, 12 | 7, 6, 7, 6, 6, 6, 8, 10, 8, 5, 5, 6, 6, 7, 24, 20, 19, 24, 18, 7, 8, 17, 11, 13 | 15, 22, 10, 22, 12, 6, 6, 6, 8, 10, 12, 10, 7, 14 | ]; 15 | 16 | const memoryValues = [ 17 | 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 18 | 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 19 | 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 60, 60, 60, 60, 60, 60, 60, 20 | 60, 60, 60, 21 | ]; 22 | 23 | const gpuValues = [ 24 | 9, 31, 3, 4, 0, 6, 0, 0, 3, 0, 1, 0, 0, 0, 0, 3, 8, 2, 2, 0, 8, 1, 0, 0, 0, 25 | 4, 1, 0, 1, 3, 7, 0, 2, 0, 0, 4, 2, 6, 23, 25, 31, 22, 25, 27, 3, 12, 2, 30, 26 | 17, 4, 1, 2, 0, 0, 1, 3, 3, 8, 4, 1, 27 | ]; 28 | 29 | return settings.lineGraphMix ? ( 30 | 40 | ) : ( 41 | <> 42 | 49 | 56 | 63 | 64 | ); 65 | }; 66 | --------------------------------------------------------------------------------