Add: Webui Support
This commit is contained in:
parent
ddd4a55bf6
commit
ce59d807f9
23 changed files with 3454 additions and 34 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
/build
|
||||
template/webroot
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "magic_mount_rs"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -11,6 +11,8 @@ log = "0.4"
|
|||
simplelog = "0.12"
|
||||
rustix = { version = "0.38", features = ["fs", "mount"] }
|
||||
libc = "0.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
|
|
|||
116
src/src/config.rs
Normal file
116
src/src/config.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const CONFIG_FILE_DEFAULT: &str = "/data/adb/magic_mount/config.toml";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_moduledir")]
|
||||
pub moduledir: PathBuf,
|
||||
|
||||
#[serde(default)]
|
||||
pub tempdir: Option<PathBuf>,
|
||||
|
||||
#[serde(default = "default_mountsource")]
|
||||
pub mountsource: String,
|
||||
|
||||
#[serde(default = "default_logfile")]
|
||||
pub logfile: PathBuf,
|
||||
|
||||
#[serde(default)]
|
||||
pub verbose: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub partitions: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_moduledir() -> PathBuf {
|
||||
PathBuf::from("/data/adb/modules/")
|
||||
}
|
||||
|
||||
fn default_mountsource() -> String {
|
||||
"MaGIcMounT".to_string()
|
||||
}
|
||||
|
||||
fn default_logfile() -> PathBuf {
|
||||
PathBuf::from("/data/adb/magic_mount/mm.log")
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
moduledir: default_moduledir(),
|
||||
tempdir: None,
|
||||
mountsource: default_mountsource(),
|
||||
logfile: default_logfile(),
|
||||
verbose: false,
|
||||
partitions: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let content = fs::read_to_string(path.as_ref())
|
||||
.context("failed to read config file")?;
|
||||
|
||||
let config: Config = toml::from_str(&content)
|
||||
.context("failed to parse config file")?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn load_default() -> Option<Self> {
|
||||
Self::from_file(CONFIG_FILE_DEFAULT).ok()
|
||||
}
|
||||
|
||||
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
|
||||
let content = toml::to_string_pretty(self)
|
||||
.context("failed to serialize config")?;
|
||||
|
||||
if let Some(parent) = path.as_ref().parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.context("failed to create config directory")?;
|
||||
}
|
||||
|
||||
fs::write(path.as_ref(), content)
|
||||
.context("failed to write config file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn example() -> String {
|
||||
let example = Self::default();
|
||||
toml::to_string_pretty(&example).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn merge_with_cli(&mut self,
|
||||
moduledir: Option<PathBuf>,
|
||||
tempdir: Option<PathBuf>,
|
||||
mountsource: Option<String>,
|
||||
logfile: Option<PathBuf>,
|
||||
verbose: bool,
|
||||
partitions: Vec<String>,
|
||||
) {
|
||||
if let Some(dir) = moduledir {
|
||||
self.moduledir = dir;
|
||||
}
|
||||
if tempdir.is_some() {
|
||||
self.tempdir = tempdir;
|
||||
}
|
||||
if let Some(source) = mountsource {
|
||||
self.mountsource = source;
|
||||
}
|
||||
if let Some(log) = logfile {
|
||||
self.logfile = log;
|
||||
}
|
||||
if verbose {
|
||||
self.verbose = true;
|
||||
}
|
||||
if !partitions.is_empty() {
|
||||
self.partitions = partitions;
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/src/main.rs
137
src/src/main.rs
|
|
@ -1,32 +1,36 @@
|
|||
mod config;
|
||||
mod magic_mount;
|
||||
mod utils;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use config::Config;
|
||||
|
||||
const MODULE_DIR_DEFAULT: &str = "/data/adb/modules/";
|
||||
const MOUNT_SOURCE_DEFAULT: &str = "MaGIcMounT";
|
||||
const LOG_FILE_DEFAULT: &str = "/data/adb/mm.log";
|
||||
const CONFIG_FILE_DEFAULT: &str = "/data/adb/magic_mount/config.toml";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "magic_mount", version, about = "Magic Mount Metamodule")]
|
||||
struct Cli {
|
||||
/// Config file path
|
||||
#[arg(short = 'c', long = "config")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Module directory path
|
||||
#[arg(short = 'm', long = "moduledir", default_value = MODULE_DIR_DEFAULT)]
|
||||
moduledir: PathBuf,
|
||||
#[arg(short = 'm', long = "moduledir")]
|
||||
moduledir: Option<PathBuf>,
|
||||
|
||||
/// Temporary directory path (auto-selected if not specified)
|
||||
#[arg(short = 't', long = "tempdir")]
|
||||
tempdir: Option<PathBuf>,
|
||||
|
||||
/// Mount source name
|
||||
#[arg(short = 's', long = "mountsource", default_value = MOUNT_SOURCE_DEFAULT)]
|
||||
mountsource: String,
|
||||
#[arg(short = 's', long = "mountsource")]
|
||||
mountsource: Option<String>,
|
||||
|
||||
/// Log file path
|
||||
#[arg(short = 'l', long = "logfile", default_value = LOG_FILE_DEFAULT)]
|
||||
logfile: PathBuf,
|
||||
#[arg(short = 'l', long = "logfile")]
|
||||
logfile: Option<PathBuf>,
|
||||
|
||||
/// Enable verbose (debug) logging
|
||||
#[arg(short = 'v', long = "verbose")]
|
||||
|
|
@ -35,18 +39,79 @@ struct Cli {
|
|||
/// Extra partitions, comma-separated, eg: -p mi_ext,my_stock
|
||||
#[arg(short = 'p', long = "partitions", value_delimiter = ',')]
|
||||
partitions: Vec<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Generate example config file
|
||||
GenConfig {
|
||||
/// Output path for config file
|
||||
#[arg(short = 'o', long = "output", default_value = CONFIG_FILE_DEFAULT)]
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Show current effective configuration
|
||||
ShowConfig,
|
||||
}
|
||||
|
||||
fn load_config(cli: &Cli) -> Result<Config> {
|
||||
// 1. 尝试从指定的配置文件加载
|
||||
if let Some(config_path) = &cli.config {
|
||||
log::info!("Loading config from: {}", config_path.display());
|
||||
return Config::from_file(config_path)
|
||||
.context("failed to load specified config file");
|
||||
}
|
||||
|
||||
// 2. 尝试从默认位置加载
|
||||
if let Some(config) = Config::load_default() {
|
||||
log::info!("Loaded config from default location: {}", CONFIG_FILE_DEFAULT);
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
// 3. 使用默认配置
|
||||
log::info!("Using default configuration (no config file found)");
|
||||
Ok(Config::default())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
utils::init_logger(&cli.logfile, cli.verbose)?;
|
||||
// 处理子命令
|
||||
if let Some(command) = &cli.command {
|
||||
match command {
|
||||
Commands::GenConfig { output } => {
|
||||
return generate_config(output);
|
||||
}
|
||||
Commands::ShowConfig => {
|
||||
let config = load_config(&cli)?;
|
||||
return show_config(&config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
let mut config = load_config(&cli)?;
|
||||
|
||||
// 命令行参数覆盖配置文件
|
||||
config.merge_with_cli(
|
||||
cli.moduledir,
|
||||
cli.tempdir,
|
||||
cli.mountsource,
|
||||
cli.logfile.clone(),
|
||||
cli.verbose,
|
||||
cli.partitions,
|
||||
);
|
||||
|
||||
// 初始化日志
|
||||
utils::init_logger(&config.logfile, config.verbose)?;
|
||||
|
||||
log::info!("Magic Mount Starting");
|
||||
log::info!("module dir : {}", cli.moduledir.display());
|
||||
log::info!("module dir : {}", config.moduledir.display());
|
||||
|
||||
let tempdir = if let Some(temp) = cli.tempdir {
|
||||
log::info!("temp dir (user) : {}", temp.display());
|
||||
let tempdir = if let Some(temp) = config.tempdir {
|
||||
log::info!("temp dir (cfg) : {}", temp.display());
|
||||
temp
|
||||
} else {
|
||||
let temp = utils::select_temp_dir()
|
||||
|
|
@ -55,16 +120,21 @@ fn main() -> Result<()> {
|
|||
temp
|
||||
};
|
||||
|
||||
log::info!("mount source : {}", cli.mountsource);
|
||||
log::info!("log file : {}", cli.logfile.display());
|
||||
log::info!("verbose mode : {}", cli.verbose);
|
||||
if !cli.partitions.is_empty() {
|
||||
log::info!("extra partitions: {:?}", cli.partitions);
|
||||
log::info!("mount source : {}", config.mountsource);
|
||||
log::info!("log file : {}", config.logfile.display());
|
||||
log::info!("verbose mode : {}", config.verbose);
|
||||
if !config.partitions.is_empty() {
|
||||
log::info!("extra partitions: {:?}", config.partitions);
|
||||
}
|
||||
|
||||
utils::ensure_temp_dir(&tempdir)?;
|
||||
|
||||
let result = magic_mount::magic_mount(&tempdir, &cli.moduledir, &cli.mountsource, &cli.partitions);
|
||||
let result = magic_mount::magic_mount(
|
||||
&tempdir,
|
||||
&config.moduledir,
|
||||
&config.mountsource,
|
||||
&config.partitions,
|
||||
);
|
||||
|
||||
utils::cleanup_temp_dir(&tempdir);
|
||||
|
||||
|
|
@ -80,3 +150,30 @@ fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_config(output: &PathBuf) -> Result<()> {
|
||||
let config = Config::default();
|
||||
config.save_to_file(output)
|
||||
.context("failed to generate config file")?;
|
||||
|
||||
println!("✓ Config file generated at: {}", output.display());
|
||||
println!("\nExample content:");
|
||||
println!("{}", Config::example());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_config(config: &Config) -> Result<()> {
|
||||
println!("Current Configuration:");
|
||||
println!("=====================");
|
||||
println!("Module Dir : {}", config.moduledir.display());
|
||||
println!("Temp Dir : {}",
|
||||
config.tempdir.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "(auto)".to_string())
|
||||
);
|
||||
println!("Mount Source : {}", config.mountsource);
|
||||
println!("Log File : {}", config.logfile.display());
|
||||
println!("Verbose : {}", config.verbose);
|
||||
println!("Partitions : {:?}", config.partitions);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
5
template/config.toml
Normal file
5
template/config.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
moduledir = "/data/adb/modules/"
|
||||
mountsource = "KSU"
|
||||
logfile = "/data/adb/magic_mount/mm.log"
|
||||
verbose = false
|
||||
partitions = []
|
||||
|
|
@ -43,5 +43,12 @@ chmod 755 "$MODPATH/meta-mm" || abort "! Failed to set permissions"
|
|||
|
||||
ui_print "- Architecture-specific binary installed successfully"
|
||||
|
||||
mkdir -p /data/adb/magic_mount
|
||||
|
||||
if [ ! -f /data/adb/magic_mount/config.toml ] ; then
|
||||
ui_print "- Add default config"
|
||||
cat "$MODPATH/config.toml" > /data/adb/magic_mount/config.toml
|
||||
fi
|
||||
|
||||
ui_print "- Installation complete"
|
||||
ui_print "- Image is ready for module installations"
|
||||
|
|
|
|||
|
|
@ -12,17 +12,7 @@ if [ ! -f "$BINARY" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Set dual-directory environment variables
|
||||
MODULE_METADATA_DIR="/data/adb/modules"
|
||||
MODULE_METADATA_SOURCE="KSU"
|
||||
MODULE_METADATA_LOGFILE="/data/adb/mm.log"
|
||||
|
||||
if [ -f "$MODULE_METADATA_LOGFILE" ]; then
|
||||
mv "$MODULE_METADATA_LOGFILE" "$MODULE_METADATA_LOGFILE".old
|
||||
fi
|
||||
|
||||
# Execute the mount binary
|
||||
"$BINARY" -s "$MODULE_METADATA_SOURCE" -m "$MODULE_METADATA_DIR" -v -l "$MODULE_METADATA_LOGFILE"
|
||||
$BINARY
|
||||
|
||||
EXIT_CODE=$?
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
MODDIR="${0%/*}"
|
||||
|
||||
rm -rf /data/adb/mm.log
|
||||
rm -rf /data/adb/mm.log.bak
|
||||
rm -rf /data/adb/magic_mount
|
||||
|
||||
exit 0
|
||||
|
|
|
|||
24
webui/.gitignore
vendored
Normal file
24
webui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
webui/README.md
Normal file
73
webui/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
webui/eslint.config.js
Normal file
23
webui/eslint.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
webui/index.html
Normal file
13
webui/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Magic Mount</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
webui/package.json
Normal file
31
webui/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "mm-webui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"kernelsu": "^2.1.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
2157
webui/pnpm-lock.yaml
generated
Normal file
2157
webui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
1
webui/public/vite.svg
Normal file
1
webui/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
473
webui/src/App.tsx
Normal file
473
webui/src/App.tsx
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { exec } from "kernelsu";
|
||||
import locate from "./locate.json";
|
||||
import "./index.css";
|
||||
|
||||
type Lang = keyof typeof locate; // "en" | "zh"
|
||||
type Tab = "config" | "log";
|
||||
|
||||
type ConfigState = {
|
||||
moduledir: string;
|
||||
tempdir: string;
|
||||
mountsource: string;
|
||||
logfile: string;
|
||||
verbose: boolean;
|
||||
partitions: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: ConfigState = {
|
||||
moduledir: "/data/adb/modules/",
|
||||
tempdir: "",
|
||||
mountsource: "KSU",
|
||||
logfile: "/data/adb/magic_mount/mm.log",
|
||||
verbose: false,
|
||||
partitions: [],
|
||||
};
|
||||
|
||||
const CONFIG_PATH = "/data/adb/magic_mount/config.toml";
|
||||
const LOG_BASE = "/data/adb/magic_mount";
|
||||
|
||||
const App: React.FC = () => {
|
||||
// ---------- i18n ----------
|
||||
const [lang, setLang] = useState<Lang>(() => {
|
||||
if (typeof window === "undefined") return "en";
|
||||
const saved = window.localStorage.getItem("mm-lang");
|
||||
return saved === "zh" || saved === "en" ? (saved as Lang) : "en";
|
||||
});
|
||||
const L = locate[lang];
|
||||
|
||||
const handleLangChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value as Lang;
|
||||
setLang(value);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("mm-lang", value);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- tabs ----------
|
||||
const [activeTab, setActiveTab] = useState<Tab>("config");
|
||||
|
||||
// ---------- config state ----------
|
||||
const [config, setConfig] = useState<ConfigState>(DEFAULT_CONFIG);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [configMessage, setConfigMessage] = useState<string | null>(null);
|
||||
|
||||
// ---------- log state ----------
|
||||
const [logFile, setLogFile] = useState<"mm.log" | "mm.log.old">("mm.log");
|
||||
const [logContent, setLogContent] = useState("");
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [logError, setLogError] = useState<string | null>(null);
|
||||
|
||||
// ---------- partitions input ----------
|
||||
const [partitionInput, setPartitionInput] = useState("");
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
const parseTOMLConfig = (text: string): ConfigState | null => {
|
||||
try {
|
||||
const result: ConfigState = { ...DEFAULT_CONFIG };
|
||||
const lines = text.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
const match = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, key, value] = match;
|
||||
const cleanValue = value.replace(/^["']|["']$/g, "").trim();
|
||||
|
||||
switch (key) {
|
||||
case "moduledir":
|
||||
case "mountsource":
|
||||
case "logfile":
|
||||
result[key] = cleanValue;
|
||||
break;
|
||||
case "tempdir":
|
||||
result.tempdir = cleanValue;
|
||||
break;
|
||||
case "verbose":
|
||||
result.verbose = cleanValue === "true";
|
||||
break;
|
||||
case "partitions":
|
||||
// 解析数组格式: ["item1", "item2"] 或 []
|
||||
const arrayMatch = cleanValue.match(/^\[(.*)\]$/);
|
||||
if (arrayMatch) {
|
||||
const items = arrayMatch[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
||||
.filter((s) => s);
|
||||
result.partitions = items;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse TOML:", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const serializeTOMLConfig = (cfg: ConfigState): string => {
|
||||
const lines: string[] = [
|
||||
"# Magic Mount Configuration File",
|
||||
"# Generated by Web UI",
|
||||
"",
|
||||
`moduledir = "${cfg.moduledir}"`,
|
||||
];
|
||||
|
||||
// 只有非空值才写入
|
||||
if (cfg.tempdir) {
|
||||
lines.push(`tempdir = "${cfg.tempdir}"`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`mountsource = "${cfg.mountsource}"`,
|
||||
`logfile = "${cfg.logfile}"`,
|
||||
`verbose = ${cfg.verbose}`
|
||||
);
|
||||
|
||||
// 分区数组
|
||||
if (cfg.partitions.length > 0) {
|
||||
const partStr = cfg.partitions.map((p) => `"${p}"`).join(", ");
|
||||
lines.push(`partitions = [${partStr}]`);
|
||||
} else {
|
||||
lines.push("partitions = []");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
// ---------- read config ----------
|
||||
const loadConfig = async () => {
|
||||
setConfigLoading(true);
|
||||
setConfigMessage(null);
|
||||
|
||||
try {
|
||||
const { errno, stdout, stderr } = await exec(
|
||||
`[ -f "${CONFIG_PATH}" ] && cat "${CONFIG_PATH}" || echo ""`
|
||||
);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
setConfig(DEFAULT_CONFIG);
|
||||
setPartitionInput("");
|
||||
setConfigMessage(L.configReadError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
setConfig(DEFAULT_CONFIG);
|
||||
setPartitionInput("");
|
||||
setConfigMessage(L.configDefaultUsed);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseTOMLConfig(stdout);
|
||||
if (parsed) {
|
||||
setConfig(parsed);
|
||||
setPartitionInput(parsed.partitions.join(", "));
|
||||
setConfigMessage(L.configLoaded);
|
||||
} else {
|
||||
setConfig(DEFAULT_CONFIG);
|
||||
setPartitionInput("");
|
||||
setConfigMessage("Invalid TOML format");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setConfig(DEFAULT_CONFIG);
|
||||
setPartitionInput("");
|
||||
setConfigMessage(L.configReadError);
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- save config ----------
|
||||
const saveConfig = async () => {
|
||||
setConfigSaving(true);
|
||||
setConfigMessage(null);
|
||||
|
||||
try {
|
||||
const partitions = partitionInput
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s);
|
||||
|
||||
const configToSave = { ...config, partitions };
|
||||
|
||||
const content = serializeTOMLConfig(configToSave);
|
||||
const escapedContent = content.replace(/'/g, "'\\''");
|
||||
|
||||
const shell = `
|
||||
mkdir -p "$(dirname "${CONFIG_PATH}")" && \
|
||||
printf '%s\n' '${escapedContent}' > "${CONFIG_PATH}"
|
||||
`;
|
||||
|
||||
const { errno, stderr } = await exec(shell);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
setConfigMessage(L.configSaveFailed);
|
||||
} else {
|
||||
setConfig(configToSave);
|
||||
setConfigMessage(L.configSaved);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setConfigMessage(L.configSaveError);
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- read log ----------
|
||||
const loadLog = async () => {
|
||||
setLogLoading(true);
|
||||
setLogError(null);
|
||||
setLogContent("");
|
||||
try {
|
||||
const fullPath = `${LOG_BASE}/${logFile}`;
|
||||
const { errno, stdout, stderr } = await exec(
|
||||
`[ -f "${fullPath}" ] && cat "${fullPath}" || echo "Log file not found: ${fullPath}"`
|
||||
);
|
||||
|
||||
if (errno !== 0) {
|
||||
console.error(stderr);
|
||||
setLogError(`${L.logReadFailed} (errno=${errno})`);
|
||||
} else {
|
||||
setLogContent(stdout || "");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLogError(L.logReadException);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// initial load config
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// reload log when tab or file changed
|
||||
useEffect(() => {
|
||||
if (activeTab === "log") {
|
||||
loadLog();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, logFile]);
|
||||
|
||||
// ---------- render tabs ----------
|
||||
|
||||
const renderConfigTab = () => (
|
||||
<div className="card">
|
||||
<h2>{L.config}</h2>
|
||||
|
||||
{configLoading && <p className="hint">Loading...</p>}
|
||||
{configMessage && (
|
||||
<p className={configMessage.includes("Failed") || configMessage.includes("Invalid") ? "error" : "hint"}>
|
||||
{configMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Verbose switch */}
|
||||
<div className="field">
|
||||
<label>Verbose Logging</label>
|
||||
<div className="loglevel-switch">
|
||||
<button
|
||||
className={!config.verbose ? "lv-btn active" : "lv-btn"}
|
||||
onClick={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
verbose: false,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Off
|
||||
</button>
|
||||
<button
|
||||
className={config.verbose ? "lv-btn active" : "lv-btn"}
|
||||
onClick={() =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
verbose: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
On
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Directory */}
|
||||
<div className="field">
|
||||
<label>moduledir</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.moduledir}
|
||||
placeholder={DEFAULT_CONFIG.moduledir}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
moduledir: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory */}
|
||||
<div className="field">
|
||||
<label>tempdir</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.tempdir}
|
||||
placeholder="(auto-select if empty)"
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
tempdir: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mount Source */}
|
||||
<div className="field">
|
||||
<label>mountsource</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.mountsource}
|
||||
placeholder={DEFAULT_CONFIG.mountsource}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
mountsource: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Log File */}
|
||||
<div className="field">
|
||||
<label>logfile</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.logfile}
|
||||
placeholder={DEFAULT_CONFIG.logfile}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
logfile: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Partitions */}
|
||||
<div className="field">
|
||||
<label>partitions</label>
|
||||
<input
|
||||
type="text"
|
||||
value={partitionInput}
|
||||
placeholder="e.g., mi_ext,my_stock"
|
||||
onChange={(e) => setPartitionInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button onClick={loadConfig} disabled={configLoading || configSaving}>
|
||||
{L.reload}
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={saveConfig}
|
||||
disabled={configSaving || configLoading}
|
||||
>
|
||||
{configSaving ? "Saving..." : L.saveConfig}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="path">
|
||||
Config Path: {CONFIG_PATH}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLogTab = () => (
|
||||
<div className="card">
|
||||
<h2>{L.logView}</h2>
|
||||
|
||||
<div className="field">
|
||||
<label>{L.selectLog}</label>
|
||||
<div className="log-select-row">
|
||||
<select
|
||||
value={logFile}
|
||||
onChange={(e) =>
|
||||
setLogFile(e.target.value as "mm.log" | "mm.log.old")
|
||||
}
|
||||
>
|
||||
<option value="mm.log">mm.log</option>
|
||||
<option value="mm.log.old">mm.log.old</option>
|
||||
</select>
|
||||
|
||||
<button className="refresh-btn" onClick={loadLog} disabled={logLoading}>
|
||||
{logLoading ? "Loading..." : L.refreshLog}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logError && <p className="error">{logError}</p>}
|
||||
|
||||
<pre className="log-view">
|
||||
{logLoading && !logContent ? "Loading..." : logContent || L.emptyLog}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app-root">
|
||||
{/* top bar with language selector */}
|
||||
<div className="top-bar">
|
||||
<div className="title">Magic Mount</div>
|
||||
|
||||
<select
|
||||
className="lang-select"
|
||||
value={lang}
|
||||
onChange={handleLangChange}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="app-main">
|
||||
{activeTab === "config" ? renderConfigTab() : renderLogTab()}
|
||||
</div>
|
||||
|
||||
{/* bottom tabs */}
|
||||
<div className="bottom-bar">
|
||||
<button
|
||||
className={activeTab === "config" ? "tab-btn active" : "tab-btn"}
|
||||
onClick={() => setActiveTab("config")}
|
||||
>
|
||||
{L.configTab}
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === "log" ? "tab-btn active" : "tab-btn"}
|
||||
onClick={() => setActiveTab("log")}
|
||||
>
|
||||
{L.logTab}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
278
webui/src/index.css
Normal file
278
webui/src/index.css
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/* ------- Nord Base Colors ------- */
|
||||
:root {
|
||||
--nord0: #2e3440;
|
||||
--nord1: #3b4252;
|
||||
--nord2: #434c5e;
|
||||
--nord3: #4c566a;
|
||||
|
||||
--nord4: #d8dee9;
|
||||
--nord5: #e5e9f0;
|
||||
--nord6: #eceff4;
|
||||
|
||||
--nord7: #8fbcbb;
|
||||
--nord8: #88c0d0;
|
||||
--nord9: #81a1c1;
|
||||
--nord10: #5e81ac;
|
||||
|
||||
--nord11: #bf616a;
|
||||
--nord12: #d08770;
|
||||
--nord13: #ebcb8b;
|
||||
--nord14: #a3be8c;
|
||||
--nord15: #b48ead;
|
||||
}
|
||||
|
||||
/* ---------- Base Layout ---------- */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--nord0);
|
||||
color: var(--nord6);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter,
|
||||
"Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Top bar for language selector */
|
||||
.top-bar {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
background: var(--nord1);
|
||||
border-bottom: 1px solid var(--nord2);
|
||||
}
|
||||
|
||||
.lang-select {
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--nord2);
|
||||
background: var(--nord0);
|
||||
color: var(--nord6);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lang-select:focus {
|
||||
border-color: var(--nord8);
|
||||
box-shadow: 0 0 0 2px rgba(136, 192, 208, 0.25);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 16px 16px 64px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ---------- Card ---------- */
|
||||
.card {
|
||||
background: var(--nord1);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
/* ---------- Form fields ---------- */
|
||||
.field {
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 13px;
|
||||
color: var(--nord4);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--nord2);
|
||||
background: var(--nord0);
|
||||
color: var(--nord6);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
border-color: var(--nord8);
|
||||
box-shadow: 0 0 0 2px rgba(136, 192, 208, 0.2);
|
||||
}
|
||||
|
||||
/* ---------- Buttons ---------- */
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--nord2);
|
||||
color: var(--nord6);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--nord3);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--nord8);
|
||||
color: var(--nord0);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--nord9);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---------- Actions (centered) ---------- */
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ---------- Text helpers ---------- */
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--nord4);
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 12px;
|
||||
color: var(--nord11);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-size: 11px;
|
||||
color: var(--nord4);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Log viewer ---------- */
|
||||
.log-view {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--nord0);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--nord2);
|
||||
color: var(--nord6);
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- Bottom Tabs ---------- */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 52px;
|
||||
background: var(--nord1);
|
||||
display: flex;
|
||||
border-top: 1px solid var(--nord2);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
color: var(--nord6);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
/* ---------- loglevel Switch ---------- */
|
||||
.loglevel-switch {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--nord2);
|
||||
background: var(--nord0);
|
||||
}
|
||||
|
||||
.lv-btn {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
background: var(--nord1);
|
||||
color: var(--nord6);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
font-size: 14px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.lv-btn:first-child {
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
.lv-btn:last-child {
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
|
||||
.lv-btn:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
|
||||
.lv-btn.active {
|
||||
background: var(--nord8);
|
||||
color: var(--nord0);
|
||||
}
|
||||
|
||||
/* ---------- Log select row ---------- */
|
||||
.log-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-select-row select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 8px 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* top bar title */
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--nord6);
|
||||
margin-right: auto;
|
||||
}
|
||||
48
webui/src/locate.json
Normal file
48
webui/src/locate.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"en": {
|
||||
"config": "Configuration",
|
||||
"reload": "Reload",
|
||||
"saveConfig": "Save",
|
||||
"loglevel": "Log Level",
|
||||
"debug": "debug",
|
||||
"info": "info",
|
||||
"logView": "Log Viewer",
|
||||
"selectLog": "Log File",
|
||||
"refreshLog": "Refresh Log",
|
||||
"emptyLog": "(empty)",
|
||||
"configTab": "Config",
|
||||
"logTab": "Logs",
|
||||
"configLoaded": "Config loaded from file.",
|
||||
"configDefaultUsed": "No config file found, using defaults.",
|
||||
"configReadError": "Failed to read config, using defaults.",
|
||||
"configSaved": "Config saved.",
|
||||
"configSaveFailed": "Failed to save config. Check logs or permissions.",
|
||||
"configSaveError": "Error occurred while saving config.",
|
||||
"logReadFailed": "Failed to read log",
|
||||
"logReadException": "Exception occurred while reading log.",
|
||||
"configPathLabel": "Config file path"
|
||||
},
|
||||
"zh": {
|
||||
"config": "配置",
|
||||
"reload": "重载",
|
||||
"saveConfig": "保存",
|
||||
"loglevel": "日志级别",
|
||||
"debug": "调试",
|
||||
"info": "信息",
|
||||
"logView": "日志查看",
|
||||
"selectLog": "日志文件",
|
||||
"refreshLog": "刷新日志",
|
||||
"emptyLog": "(空日志)",
|
||||
"configTab": "配置",
|
||||
"logTab": "日志",
|
||||
"configLoaded": "已从配置文件加载。",
|
||||
"configDefaultUsed": "未找到配置文件,已使用默认配置。",
|
||||
"configReadError": "读取配置失败,已使用默认配置。",
|
||||
"configSaved": "配置已保存",
|
||||
"configSaveFailed": "保存失败,请查看日志或检查权限。",
|
||||
"configSaveError": "保存配置时发生错误。",
|
||||
"logReadFailed": "读取日志失败",
|
||||
"logReadException": "读取日志时发生异常。",
|
||||
"configPathLabel": "配置文件路径"
|
||||
}
|
||||
}
|
||||
10
webui/src/main.tsx
Normal file
10
webui/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
28
webui/tsconfig.app.json
Normal file
28
webui/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue