Compare commits
16 Commits
2776d39ac1
...
95fb8d8698
| Author | SHA1 | Date | |
|---|---|---|---|
| 95fb8d8698 | |||
| bf91d18c4f | |||
| ff7d9e1e46 | |||
| ef75631889 | |||
| 0b6a076d78 | |||
| 36168f26d7 | |||
| 116286f80f | |||
| ecf3c73715 | |||
| 37d7ec49eb | |||
| 6e94db0c2b | |||
| 5c4b2a53d1 | |||
| a49f47c8a6 | |||
| c4c490b881 | |||
| 7553dcf405 | |||
| 81a4196e43 | |||
| c74e59485d |
4
.gitignore
vendored
4
.gitignore
vendored
@ -9,6 +9,7 @@ others/
|
||||
public/style.css
|
||||
public/highlight.css
|
||||
public/tiptap
|
||||
public/codemirror
|
||||
public/lightbox
|
||||
public/yggdrasil-core
|
||||
public/doc
|
||||
@ -21,6 +22,9 @@ uploads/*
|
||||
!uploads/.gitkeep
|
||||
uploads/.cache/
|
||||
|
||||
# Database backups
|
||||
backups/
|
||||
|
||||
profile.json.gz
|
||||
|
||||
# Git worktrees
|
||||
|
||||
24
AGENTS.md
24
AGENTS.md
@ -162,6 +162,30 @@ Core JavaScript bundle in `libs/yggdrasil-core/`, built as an IIFE library expos
|
||||
|
||||
Do not edit `public/yggdrasil-core/` — they are build artifacts.
|
||||
|
||||
## CodeMirror Editor Subproject
|
||||
|
||||
CodeMirror 6 code editor in `libs/codemirror-editor/`, built as an IIFE library exposing `window.CodeMirrorEditor` (object literal `{ create(containerId, options) }`) and `window.EditorOptions` (a class so `new EditorOptions()` survives TS erasure from wasm). Mirrors the tiptap-editor packaging pattern exactly.
|
||||
|
||||
- Output: `public/codemirror/` (`editor.js`, `editor.js.map`). No CSS file — themes are JS `Extension`s from `@catppuccin/codemirror` (Latte light / Mocha dark, matching the syntax-highlighting `themes/`).
|
||||
- `make build` runs `pnpm ci --include=dev && pnpm run build` inside `libs/codemirror-editor`; the `build` script is `tsc --noEmit && vite build` (Vite 8 / Rolldown, type-check before bundle). Output is IIFE (`formats: ['iife']`, `exports: 'default'`).
|
||||
- Features: `@codemirror/lang-sql` with live schema autocomplete (schema injected via `set_schema` from `get_db_schema` server function), `@replit/codemirror-vim` keymap (injected before other keymaps), Catppuccin theme hot-swap via `Compartment.reconfigure` (no instance rebuild — preserves Vim state/cursor/undo).
|
||||
- Rust bridge: `src/codemirror_bridge.rs` mirrors `src/tiptap_bridge.rs` — `get_module()` uses `js_sys::Reflect::get` + `unchecked_into` (object literal, not a constructor, so NOT an extern fn call and NOT `dyn_into`); `EditorHandle` holds the instance + all `Closure`s; `impl Drop` calls `destroy()`.
|
||||
- Shared data types `SqlSchema`/`SqlTable` (serde) compile on both targets; the wasm-bindgen externs live in a `#[cfg(target_arch = "wasm32")] mod wasm`.
|
||||
- Unit tests: `pnpm test` (Vitest 4 + happy-dom), covering getValue/setValue, setTheme (Compartment), setSchema, vim toggle, onChange.
|
||||
- Injected via `Dioxus.toml` `script` array (`/codemirror/editor.js`).
|
||||
|
||||
Do not edit `public/codemirror/` — they are build artifacts.
|
||||
|
||||
## Database Management (`/admin/system`)
|
||||
|
||||
Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服务器状态 / SQL 控制台 / 数据导出 / 备份恢复. All gated by `get_current_admin_user` (admin-only). Backend in `src/api/database/` (status/system_status/sql_console/schema/export/backup/tasks), page in `src/pages/admin/system.rs`.
|
||||
|
||||
- **SQL 控制台** is full read-write with 4 guards: (1) `sqlparser` AST gates — `DROP DATABASE`/`DROP SCHEMA`/`CREATE DATABASE` absolutely forbidden (string pre-check); `DROP`/`TRUNCATE`/`ALTER` require a `confirm_dangerous` checkbox; (2) `UPDATE`/`DELETE` without `WHERE` rejected; (3) `STATEMENT_TIMEOUT_SECS` query timeout (pool-level GUC); (4) frontend write-confirm dialog. Multi-statement disabled by default. Results capped at 500 rows.
|
||||
- **备份恢复** uses `dashmap` task-progress table; `create_backup`/`restore_backup` return a task_id immediately and poll `get_task_progress`. Backup prefers `pg_dump` (full, incl. schema), falls back to per-table `COPY TO STDOUT` (data only) when `pg_dump` is unavailable. Backup files carry a `-- YGGDRASIL BACKUP v1` signature header; restore rejects non-system files. `backups/` is gitignored and served only via `GET /api/database/backups/{filename}` (admin-gated, path-allowlist).
|
||||
- **服务器状态** uses `sysinfo` (optional, server feature) with a background sampler (`SYSINFO_SAMPLE_SECS`, default 0.5s) writing to a `RwLock<SystemSnapshot>`; server functions read the snapshot (zero sampling cost), so frontend can poll high-frequency. `src/cache.rs` exposes moka hit-rate via `AtomicU64` hit/miss counters per cache + `cache_stats()`.
|
||||
- New env var: `SYSINFO_SAMPLE_SECS` (sysinfo sampling interval in seconds, supports decimals).
|
||||
- New deps (all `optional = true`, server feature): `sysinfo`, `sqlparser`, `dashmap`.
|
||||
|
||||
## Syntax Highlighting Pipeline
|
||||
|
||||
- `themes/` contains Catppuccin Latte (light) and Mocha (dark) `.tmTheme` files
|
||||
|
||||
98
Cargo.lock
generated
98
Cargo.lock
generated
@ -2248,7 +2248,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2908,6 +2908,15 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@ -4008,6 +4017,15 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparser"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7bbffee862a796d67959a89859d6b1046bb5016d63e23835ad0da182777bbe0"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@ -4111,6 +4129,19 @@ dependencies = [
|
||||
"yaml-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.34.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
@ -4962,19 +4993,52 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@ -4986,6 +5050,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@ -5010,10 +5085,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@ -5395,6 +5479,7 @@ dependencies = [
|
||||
"axum",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"deadpool-postgres",
|
||||
"dioxus",
|
||||
"dotenvy",
|
||||
@ -5411,10 +5496,13 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"sha2 0.10.9",
|
||||
"sqlparser",
|
||||
"syntect",
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tower-http",
|
||||
|
||||
15
Cargo.toml
15
Cargo.toml
@ -3,6 +3,13 @@ name = "yggdrasil"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
# 构建时工具二进制:用 syntect(server-only 依赖)生成 highlight.css。
|
||||
# 仅在启用 server feature 时编译——WASM 前端构建(--no-default-features --features web)
|
||||
# 不会拉入 syntect,此二进制若也参与编译会因找不到 syntect 报错。
|
||||
[[bin]]
|
||||
name = "generate_highlight_css"
|
||||
required-features = ["server"]
|
||||
|
||||
[dependencies]
|
||||
dioxus = { version = "0.7.9", features = ["fullstack", "router"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@ -35,6 +42,9 @@ image = { version = "0.25", optional = true, default-features = false, features
|
||||
zenwebp = { version = "0.3", optional = true }
|
||||
moka = { version = "0.12", features = ["future", "sync"], optional = true }
|
||||
governor = { version = "0.8", optional = true }
|
||||
sysinfo = { version = "0.34", optional = true }
|
||||
sqlparser = { version = "0.45", optional = true }
|
||||
dashmap = { version = "6.2", optional = true }
|
||||
md-5 = { version = "0.10", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
@ -44,6 +54,8 @@ web-sys = { version = "0.3", features = ["Document", "Window", "Storage", "Eleme
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
# 把 serde 类型(如 SqlSchema)序列化为 JsValue,供 wasm-bindgen extern 传递。
|
||||
serde-wasm-bindgen = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.52", features = ["rt-multi-thread", "macros", "fs", "time", "sync"] }
|
||||
@ -87,6 +99,9 @@ server = [
|
||||
"dep:zenwebp",
|
||||
"dep:moka",
|
||||
"dep:governor",
|
||||
"dep:sysinfo",
|
||||
"dep:sqlparser",
|
||||
"dep:dashmap",
|
||||
"dep:md-5",
|
||||
"dep:futures",
|
||||
"dep:bytes",
|
||||
|
||||
@ -11,8 +11,8 @@ watch_path = ["src", "Cargo.toml"]
|
||||
|
||||
[web.resource]
|
||||
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css"]
|
||||
script = ["/tiptap/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
|
||||
script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
|
||||
|
||||
[web.resource.dev]
|
||||
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css", "/yggdrasil-core/yggdrasil-core.css"]
|
||||
script = ["/tiptap/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
|
||||
script = ["/tiptap/editor.js", "/codemirror/editor.js", "/lightbox/lightbox.js", "/yggdrasil-core/yggdrasil-core.js"]
|
||||
|
||||
16
Makefile
16
Makefile
@ -1,9 +1,10 @@
|
||||
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental build-lightbox build-lightbox-incremental build-core build-core-incremental highlight-css test doc doc-open start clippy fix
|
||||
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental build-lightbox build-lightbox-incremental build-core build-core-incremental build-codemirror build-codemirror-incremental highlight-css test doc doc-open start clippy fix
|
||||
|
||||
build:
|
||||
@$(MAKE) build-editor
|
||||
@$(MAKE) build-lightbox
|
||||
@$(MAKE) build-core
|
||||
@$(MAKE) build-codemirror
|
||||
@$(MAKE) highlight-css
|
||||
@tailwindcss -i input.css -o public/style.css --minify
|
||||
@$(MAKE) doc
|
||||
@ -13,6 +14,7 @@ build-linux:
|
||||
@$(MAKE) build-editor
|
||||
@$(MAKE) build-lightbox
|
||||
@$(MAKE) build-core
|
||||
@$(MAKE) build-codemirror
|
||||
@$(MAKE) highlight-css
|
||||
@tailwindcss -i input.css -o public/style.css --minify
|
||||
@dx build @client --release --debug-symbols=false --wasm-js-cfg false
|
||||
@ -53,7 +55,16 @@ build-core:
|
||||
build-core-incremental:
|
||||
@cd libs/yggdrasil-core && pnpm run build
|
||||
|
||||
dev: build-editor-incremental build-lightbox-incremental build-core-incremental
|
||||
build-codemirror:
|
||||
@echo "Building CodeMirror editor..."
|
||||
@cd libs/codemirror-editor && pnpm ci --include=dev && pnpm run build
|
||||
@echo "CodeMirror editor built."
|
||||
|
||||
# dev 用的增量构建:跳过 pnpm ci(假设 node_modules 已存在),仅 vite build。
|
||||
build-codemirror-incremental:
|
||||
@cd libs/codemirror-editor && pnpm run build
|
||||
|
||||
dev: build-editor-incremental build-lightbox-incremental build-core-incremental build-codemirror-incremental
|
||||
@echo "Cleaning static/..."
|
||||
@rm -rf static/
|
||||
@echo "Building Tiptap editor (incremental)..."
|
||||
@ -74,6 +85,7 @@ test:
|
||||
@cd libs/tiptap-editor && pnpm test
|
||||
@cd libs/lightbox && pnpm test
|
||||
@cd libs/yggdrasil-core && pnpm test
|
||||
@cd libs/codemirror-editor && pnpm test
|
||||
|
||||
clippy:
|
||||
@cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
30
libs/codemirror-editor/package.json
Normal file
30
libs/codemirror-editor/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@yggdrasil/codemirror-editor",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@catppuccin/codemirror": "^1.0.3",
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/commands": "^6.8.0",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/view": "^6.36.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"codemirror": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"happy-dom": "^20.10.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
1041
libs/codemirror-editor/pnpm-lock.yaml
generated
Normal file
1041
libs/codemirror-editor/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
libs/codemirror-editor/src/__tests__/editor.test.ts
Normal file
67
libs/codemirror-editor/src/__tests__/editor.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { EditorOptions, CodeMirrorInstance } from '../editor';
|
||||
|
||||
describe('CodeMirrorInstance', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
container.id = 'test-cm';
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
it('getValue/setValue 往返', () => {
|
||||
const inst = new CodeMirrorInstance(container, new EditorOptions());
|
||||
inst.setValue('SELECT 1');
|
||||
expect(inst.getValue()).toBe('SELECT 1');
|
||||
inst.destroy();
|
||||
});
|
||||
|
||||
it('初始 value 正确', () => {
|
||||
const opts = new EditorOptions();
|
||||
opts.value = 'SELECT * FROM posts';
|
||||
const inst = new CodeMirrorInstance(container, opts);
|
||||
expect(inst.getValue()).toBe('SELECT * FROM posts');
|
||||
inst.destroy();
|
||||
});
|
||||
|
||||
it('setTheme 不抛错(走 Compartment reconfigure)', () => {
|
||||
const inst = new CodeMirrorInstance(container, new EditorOptions());
|
||||
expect(() => inst.setTheme('dark')).not.toThrow();
|
||||
expect(() => inst.setTheme('light')).not.toThrow();
|
||||
inst.destroy();
|
||||
});
|
||||
|
||||
it('setSchema 更新 lang-sql 配置', () => {
|
||||
const inst = new CodeMirrorInstance(container, new EditorOptions());
|
||||
expect(() =>
|
||||
inst.setSchema({ tables: [{ name: 'posts', columns: ['id', 'title'] }] }),
|
||||
).not.toThrow();
|
||||
inst.destroy();
|
||||
});
|
||||
|
||||
it('vim 开关:vim:true 注入,false 不注入', () => {
|
||||
const optsOn = new EditorOptions();
|
||||
optsOn.vim = true;
|
||||
const instOn = new CodeMirrorInstance(container, optsOn);
|
||||
instOn.destroy();
|
||||
|
||||
const optsOff = new EditorOptions();
|
||||
optsOff.vim = false;
|
||||
const instOff = new CodeMirrorInstance(container, optsOff);
|
||||
instOff.destroy();
|
||||
// happy-dom 无法验证 keymap 行为,仅验证配置加载不抛错
|
||||
});
|
||||
|
||||
it('onChange 在内容变更时触发', () => {
|
||||
let captured = '';
|
||||
const opts = new EditorOptions();
|
||||
opts.onChange = (v) => {
|
||||
captured = v;
|
||||
};
|
||||
const inst = new CodeMirrorInstance(container, opts);
|
||||
inst.setValue('hello');
|
||||
expect(captured).toBe('hello');
|
||||
inst.destroy();
|
||||
});
|
||||
});
|
||||
127
libs/codemirror-editor/src/editor.ts
Normal file
127
libs/codemirror-editor/src/editor.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { basicSetup } from 'codemirror';
|
||||
import { sql, PostgreSQL } from '@codemirror/lang-sql';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { themeExtension, type ThemeName } from './themes';
|
||||
|
||||
/** SQL 补全用 schema 数据(由 Rust 侧从实时库拉取注入)。 */
|
||||
export interface SqlSchema {
|
||||
tables: { name: string; columns: string[] }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 传给 CodeMirrorEditor.create 的配置。
|
||||
* 必须是 class(非 interface),以便 TS 擦除后存活,
|
||||
* wasm 侧能用 `new EditorOptions()` 构造,并通过 setter 填充字段。
|
||||
*/
|
||||
export class EditorOptions {
|
||||
language?: string;
|
||||
theme?: ThemeName;
|
||||
vim?: boolean;
|
||||
schema?: SqlSchema;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodeMirror 实例封装。
|
||||
* create() 时用三个 Compartment 注入 theme/schema/vim,
|
||||
* 支持后续热切换(reconfigure)而不重建实例——保留 Vim 状态、光标、撤销栈。
|
||||
*/
|
||||
export class CodeMirrorInstance {
|
||||
private view: EditorView;
|
||||
private themeCompartment = new Compartment();
|
||||
private schemaCompartment = new Compartment();
|
||||
private vimCompartment = new Compartment();
|
||||
|
||||
constructor(container: HTMLElement, options: EditorOptions) {
|
||||
const theme: ThemeName = options.theme ?? 'light';
|
||||
const schema = options.schema ?? { tables: [] };
|
||||
|
||||
this.view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: options.value ?? '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
// vim 必须在 keymap 最前(@replit/codemirror-vim 仓库要求)
|
||||
this.vimCompartment.of(options.vim ? [vim()] : []),
|
||||
this.themeCompartment.of(themeExtension(theme)),
|
||||
this.schemaCompartment.of(
|
||||
sql({
|
||||
dialect: PostgreSQL,
|
||||
schema: schemaToCompletions(schema),
|
||||
upperCaseKeywords: true,
|
||||
}),
|
||||
),
|
||||
EditorView.updateListener.of((v) => {
|
||||
if (v.docChanged) {
|
||||
options.onChange?.(this.view.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
parent: container,
|
||||
});
|
||||
|
||||
options.onReady?.();
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.view.state.doc.toString();
|
||||
}
|
||||
|
||||
setValue(s: string): void {
|
||||
this.view.dispatch({
|
||||
changes: { from: 0, to: this.view.state.doc.length, insert: s },
|
||||
});
|
||||
}
|
||||
|
||||
/** 热切换主题,不重建实例(Compartment.reconfigure)。 */
|
||||
setTheme(theme: ThemeName): void {
|
||||
this.view.dispatch({
|
||||
effects: this.themeCompartment.reconfigure(themeExtension(theme)),
|
||||
});
|
||||
}
|
||||
|
||||
/** 更新 SQL 补全 schema(Compartment.reconfigure)。 */
|
||||
setSchema(schema: SqlSchema): void {
|
||||
this.view.dispatch({
|
||||
effects: this.schemaCompartment.reconfigure(
|
||||
sql({
|
||||
dialect: PostgreSQL,
|
||||
schema: schemaToCompletions(schema),
|
||||
upperCaseKeywords: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.view.focus();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.view.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 SqlSchema 转成 @codemirror/lang-sql 期望的补全结构。 */
|
||||
function schemaToCompletions(schema: SqlSchema) {
|
||||
return schema.tables.map((t) => ({
|
||||
label: t.name,
|
||||
type: 'table',
|
||||
detail: 'table',
|
||||
columns: t.columns.map((c) => ({ label: c, type: 'column' })),
|
||||
}));
|
||||
}
|
||||
|
||||
// 暴露 EditorOptions 到 window,供 wasm-bindgen 用 new EditorOptions()。
|
||||
// IIFE 的 name 只能挂一个全局(CodeMirrorEditor),故手动 hoist EditorOptions。
|
||||
declare global {
|
||||
interface Window {
|
||||
EditorOptions: typeof EditorOptions;
|
||||
}
|
||||
}
|
||||
window.EditorOptions = EditorOptions;
|
||||
31
libs/codemirror-editor/src/index.ts
Normal file
31
libs/codemirror-editor/src/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { CodeMirrorInstance, EditorOptions } from './editor';
|
||||
|
||||
/**
|
||||
* 模块入口:暴露对象字面量 { create } 作为默认导出。
|
||||
* IIFE 产物挂在 window.CodeMirrorEditor 上,由 Rust 侧用 Reflect::get 取
|
||||
* (对象字面量,不能用 wasm-bindgen 的 extern fn——那会被编成函数调用而失败)。
|
||||
*/
|
||||
const CodeMirrorEditor = {
|
||||
_instances: new Map<string, CodeMirrorInstance>(),
|
||||
|
||||
create(
|
||||
containerId: string,
|
||||
options: EditorOptions = new EditorOptions(),
|
||||
): CodeMirrorInstance | null {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
|
||||
// 销毁同 id 的旧实例
|
||||
const existing = this._instances.get(containerId);
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
this._instances.delete(containerId);
|
||||
}
|
||||
|
||||
const instance = new CodeMirrorInstance(container, options);
|
||||
this._instances.set(containerId, instance);
|
||||
return instance;
|
||||
},
|
||||
};
|
||||
|
||||
export default CodeMirrorEditor;
|
||||
11
libs/codemirror-editor/src/themes.ts
Normal file
11
libs/codemirror-editor/src/themes.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// @catppuccin/codemirror 提供现成的 Catppuccin 主题 Extension,
|
||||
// 与项目 themes/ 下的 Catppuccin Latte/Mocha .tmTheme 视觉一致。
|
||||
import { catppuccinLatte, catppuccinMocha } from '@catppuccin/codemirror';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
|
||||
export type ThemeName = 'light' | 'dark';
|
||||
|
||||
/** 根据主题名返回对应的 CodeMirror 主题 Extension。 */
|
||||
export function themeExtension(name: ThemeName): Extension {
|
||||
return name === 'light' ? catppuccinLatte : catppuccinMocha;
|
||||
}
|
||||
19
libs/codemirror-editor/tsconfig.json
Normal file
19
libs/codemirror-editor/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
27
libs/codemirror-editor/vite.config.ts
Normal file
27
libs/codemirror-editor/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
// 输出直写 public/codemirror/,Dioxus 直接托管,无需拷贝步骤。
|
||||
outDir: resolve(__dirname, '../../public/codemirror'),
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
// IIFE 产物挂在 window.CodeMirrorEditor 上,Rust 侧用 Reflect::get 取。
|
||||
name: 'CodeMirrorEditor',
|
||||
fileName: () => 'editor.js',
|
||||
formats: ['iife'],
|
||||
},
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
// 默认导出(对象字面量 { create() })成为 window.CodeMirrorEditor。
|
||||
exports: 'default',
|
||||
assetFileNames: 'editor.[ext]',
|
||||
},
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
8
libs/codemirror-editor/vitest.config.ts
Normal file
8
libs/codemirror-editor/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
590
src/api/database/backup.rs
Normal file
590
src/api/database/backup.rs
Normal file
@ -0,0 +1,590 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! 备份与恢复(读写,最高风险)。
|
||||
//!
|
||||
//! 备份:探测 pg_dump 可用性——可用则子进程生成完整 .sql,不可用则回退纯 SQL
|
||||
//! (仅数据)。备份文件含签名头。
|
||||
//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护。
|
||||
//! 长耗时操作走后台任务 + 进度轮询(见 [`super::tasks`])。
|
||||
|
||||
// Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。
|
||||
#[cfg(feature = "server")]
|
||||
use std::path::{Component, PathBuf};
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use chrono::Utc;
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// admin 鉴权 + AppError + tasks 进度表仅在 server 构建里被 server function 体引用。
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::database::tasks::{self, TaskKind, TaskStatus};
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
|
||||
/// 备份目录(项目根,与 uploads/ 平级,gitignored)。
|
||||
const BACKUP_DIR: &str = "backups";
|
||||
/// 文件名白名单正则:仅字母数字下划线点连字符(防路径穿越)。
|
||||
const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$";
|
||||
/// 备份文件签名头(恢复时校验,拒绝非本系统文件)。
|
||||
const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1";
|
||||
|
||||
/// 备份文件元信息(列表展示用)。
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct BackupInfo {
|
||||
pub filename: String,
|
||||
pub size_bytes: u64,
|
||||
/// 备份模式:pg_dump / sql-fallback(从签名头解析)。
|
||||
pub mode: String,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 发起备份,立即返回 task_id,后台任务执行。
|
||||
#[server(CreateBackup, "/api")]
|
||||
pub async fn create_backup() -> Result<String, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
tasks::insert(task_id.clone(), TaskKind::Backup);
|
||||
let tid = task_id.clone();
|
||||
tokio::spawn(async move {
|
||||
run_backup(&tid).await;
|
||||
});
|
||||
Ok(task_id)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台执行备份:pg_dump 优先,不可用回退纯 SQL。
|
||||
#[cfg(feature = "server")]
|
||||
async fn run_backup(task_id: &str) {
|
||||
let _ = std::fs::create_dir_all(BACKUP_DIR);
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
|
||||
// 探测 pg_dump
|
||||
let pg_dump_ok = std::process::Command::new("pg_dump")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok();
|
||||
|
||||
if pg_dump_ok {
|
||||
run_pg_dump_backup(task_id, ×tamp).await;
|
||||
} else {
|
||||
run_sql_fallback_backup(task_id, ×tamp).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。
|
||||
#[cfg(feature = "server")]
|
||||
async fn run_pg_dump_backup(task_id: &str, timestamp: &str) {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"正在用 pg_dump 导出",
|
||||
10,
|
||||
TaskStatus::Running,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let filename = format!("backup_{}.sql", timestamp);
|
||||
let path = backup_path(&filename);
|
||||
let db_url = match std::env::var("DATABASE_URL") {
|
||||
Ok(u) if !u.is_empty() => u,
|
||||
_ => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"DATABASE_URL 未配置",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some("pg_dump 备份需要 DATABASE_URL".to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut header = String::new();
|
||||
header.push_str(&format!("{}\n", BACKUP_SIGNATURE));
|
||||
header.push_str(&format!("-- created_at: {}\n", Utc::now()));
|
||||
header.push_str("-- mode: pg_dump\n");
|
||||
|
||||
// 先写签名头,再追加 pg_dump 输出。
|
||||
let write_header = std::fs::write(&path, &header);
|
||||
if write_header.is_err() {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"写入备份文件失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some("无法写入备份目录".to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 启动失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let child = match std::process::Command::new("pg_dump")
|
||||
.arg(&db_url)
|
||||
.stdout(std::process::Stdio::from(stdout_file))
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 启动失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let output = child.wait_with_output();
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"完成",
|
||||
100,
|
||||
TaskStatus::Done,
|
||||
None,
|
||||
None,
|
||||
Some(filename),
|
||||
);
|
||||
}
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(stderr),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 执行失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 纯 SQL 回退:仅备份数据(不含 schema),按表计数精确进度。
|
||||
#[cfg(feature = "server")]
|
||||
async fn run_sql_fallback_backup(task_id: &str, timestamp: &str) {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"pg_dump 不可用,使用纯 SQL 回退(仅数据)",
|
||||
10,
|
||||
TaskStatus::Running,
|
||||
Some("仅备份数据,不含 schema/索引/触发器".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let filename = format!("backup_{}_sqlfallback.sql", timestamp);
|
||||
let path = backup_path(&filename);
|
||||
|
||||
let client = match crate::db::pool::get_conn().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"数据库连接失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 取 public schema 下所有表名
|
||||
let tables: Vec<String> = match client
|
||||
.query(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(),
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"读取表清单失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let total = tables.len().max(1);
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!("{}\n", BACKUP_SIGNATURE));
|
||||
out.push_str(&format!("-- created_at: {}\n", Utc::now()));
|
||||
out.push_str("-- mode: sql-fallback\n\n");
|
||||
|
||||
for (i, table) in tables.iter().enumerate() {
|
||||
out.push_str(&format!("\n-- table: {}\n", table));
|
||||
let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table);
|
||||
match client.copy_out(©_stmt).await {
|
||||
Ok(stream) => {
|
||||
use futures::StreamExt;
|
||||
// CopyOutStream 是 !Unpin,必须 pin 才能调 next。
|
||||
tokio::pin!(stream);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
if let Ok(bytes) = chunk {
|
||||
out.push_str(&String::from_utf8_lossy(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
out.push_str(&format!("-- 导出失败: {}\n", e));
|
||||
}
|
||||
}
|
||||
// 按表更新进度(用 u32 避免大 schema 下的截断/溢出)
|
||||
tasks::update(
|
||||
task_id,
|
||||
&format!("导出表 {}/{}", i + 1, total),
|
||||
(10 + (i + 1) as u32 * 90 / total as u32).min(99) as u8,
|
||||
TaskStatus::Running,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
if std::fs::write(&path, out).is_err() {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"写入备份文件失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some("无法写入备份目录".to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
tasks::update(
|
||||
task_id,
|
||||
"完成",
|
||||
100,
|
||||
TaskStatus::Done,
|
||||
None,
|
||||
None,
|
||||
Some(filename),
|
||||
);
|
||||
}
|
||||
|
||||
/// 发起恢复:校验签名 + 路径穿越防护 + 二次确认,立即返回 task_id。
|
||||
#[server(RestoreBackup, "/api")]
|
||||
pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
// 全部校验都在 server cfg 块内:confirm/regex/backup_path/std::fs 都是 server-only。
|
||||
// WASM 侧的 server-function 客户端桩只返回 Ok(String::new())。
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
if !confirm {
|
||||
return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into());
|
||||
}
|
||||
// 路径穿越防护
|
||||
let re = regex::Regex::new(FILENAME_RE).unwrap();
|
||||
if !re.is_match(&filename) {
|
||||
return Err(AppError::BadRequest("无效的文件名".to_string()).into());
|
||||
}
|
||||
let path = backup_path(&filename);
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("备份文件不存在").into());
|
||||
}
|
||||
|
||||
// 签名校验:首行需含签名
|
||||
let head = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
|
||||
.unwrap_or_default();
|
||||
if !head.contains(BACKUP_SIGNATURE) {
|
||||
return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into());
|
||||
}
|
||||
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
tasks::insert(task_id.clone(), TaskKind::Restore);
|
||||
let tid = task_id.clone();
|
||||
let f = filename;
|
||||
tokio::spawn(async move {
|
||||
run_restore(&tid, &f).await;
|
||||
});
|
||||
Ok(task_id)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
// WASM 客户端桩:忽略参数,返回空 task_id。
|
||||
let _ = (filename, confirm);
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台执行恢复:探测 psql,可用则 psql -f,不可用则报告。
|
||||
#[cfg(feature = "server")]
|
||||
async fn run_restore(task_id: &str, filename: &str) {
|
||||
let path = backup_path(filename);
|
||||
let db_url = match std::env::var("DATABASE_URL") {
|
||||
Ok(u) if !u.is_empty() => u,
|
||||
_ => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"DATABASE_URL 未配置",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some("恢复需要 DATABASE_URL".to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let psql_ok = std::process::Command::new("psql")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok();
|
||||
if !psql_ok {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"psql 不可用",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some("恢复需要 psql,但当前环境未安装 psql".to_string()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
tasks::update(
|
||||
task_id,
|
||||
"正在用 psql 恢复",
|
||||
50,
|
||||
TaskStatus::Running,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let output = std::process::Command::new("psql")
|
||||
.arg(&db_url)
|
||||
.arg("-f")
|
||||
.arg(&path)
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output();
|
||||
match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None);
|
||||
}
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
|
||||
tasks::update(
|
||||
task_id,
|
||||
"恢复失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(stderr),
|
||||
None,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tasks::update(
|
||||
task_id,
|
||||
"psql 启动失败",
|
||||
100,
|
||||
TaskStatus::Failed,
|
||||
None,
|
||||
Some(e.to_string()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出 backups/ 目录下的备份文件元信息。
|
||||
#[server(ListBackups, "/api")]
|
||||
pub async fn list_backups() -> Result<Vec<BackupInfo>, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let mut infos: Vec<BackupInfo> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".sql") {
|
||||
continue;
|
||||
}
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mode = std::fs::read_to_string(entry.path())
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("-- mode:"))
|
||||
.map(|l| l.trim_start_matches("-- mode: ").trim().to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let created_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| {
|
||||
chrono::DateTime::<Utc>::from_timestamp(d.as_secs() as i64, 0)
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
infos.push(BackupInfo {
|
||||
filename: name,
|
||||
size_bytes: meta.len(),
|
||||
mode,
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 按创建时间降序(新的在前)
|
||||
infos.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(infos)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除备份文件。
|
||||
#[server(DeleteBackup, "/api")]
|
||||
pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let re = regex::Regex::new(FILENAME_RE).unwrap();
|
||||
if !re.is_match(&filename) {
|
||||
return Err(AppError::BadRequest("无效的文件名".to_string()).into());
|
||||
}
|
||||
let path = backup_path(&filename);
|
||||
if !path.exists() {
|
||||
return Err(AppError::NotFound("备份文件不存在").into());
|
||||
}
|
||||
std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。
|
||||
#[cfg(feature = "server")]
|
||||
fn backup_path(filename: &str) -> PathBuf {
|
||||
let raw: PathBuf = [BACKUP_DIR, filename].iter().collect();
|
||||
// 确保规范化后首两段仍是 BACKUP_DIR/filename(防任何路径穿越残留)
|
||||
let mut it = raw.components();
|
||||
let _ = it.next(); // BACKUP_DIR
|
||||
if it.all(|c| !matches!(c, Component::ParentDir | Component::RootDir)) {
|
||||
raw
|
||||
} else {
|
||||
PathBuf::from(BACKUP_DIR)
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum 处理器:下载备份文件(admin 鉴权 + 路径白名单)。
|
||||
/// 仅 server 构建:纯 Axum 路由(在 main.rs 注册),无 WASM 消费者。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn download_backup(
|
||||
axum::extract::Path(filename): axum::extract::Path<String>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<impl axum::response::IntoResponse, (axum::http::StatusCode, String)> {
|
||||
use axum::http::{header, StatusCode};
|
||||
|
||||
// 鉴权
|
||||
let cookie_header = headers
|
||||
.get("cookie")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string);
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
|
||||
};
|
||||
let user = match crate::api::auth::get_user_by_token(&token).await {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
|
||||
};
|
||||
if user.role != crate::models::user::UserRole::Admin {
|
||||
return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
|
||||
}
|
||||
|
||||
// 路径白名单
|
||||
let re = regex::Regex::new(FILENAME_RE).unwrap();
|
||||
if !re.is_match(&filename) {
|
||||
return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string()));
|
||||
}
|
||||
let path = backup_path(&filename);
|
||||
let bytes = tokio::fs::read(&path)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?;
|
||||
let disposition = format!("attachment; filename=\"{}\"", filename);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
axum::http::HeaderValue::from_static("application/sql; charset=utf-8"),
|
||||
),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
axum::http::HeaderValue::from_str(&disposition)
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")),
|
||||
),
|
||||
],
|
||||
axum::body::Body::from(bytes),
|
||||
))
|
||||
}
|
||||
233
src/api/database/export.rs
Normal file
233
src/api/database/export.rs
Normal file
@ -0,0 +1,233 @@
|
||||
//! 数据导出:Axum 流式路由(大文件不走 JSON 序列化)。
|
||||
//!
|
||||
//! 鉴权镜像 [`crate::api::upload`]:从 cookie 取 session,校验管理员。
|
||||
//! 两种来源:按表导出(表名白名单)/ 按查询导出(强制只读,AST 校验)。
|
||||
//! 两种格式:CSV(COPY TO STDOUT 流式)/ SQL(逐行拼 INSERT)。
|
||||
|
||||
#![allow(clippy::unused_unit)]
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::Query;
|
||||
use axum::http::{header, HeaderMap, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::session::parse_session_token;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExportParams {
|
||||
/// `table:<name>` 或 `query:<SELECT ...>`。
|
||||
pub source: String,
|
||||
/// `sql` 或 `csv`。
|
||||
pub format: String,
|
||||
/// 是否带表头(CSV)/ 列名(SQL INSERT)。默认 true。
|
||||
pub include_columns: Option<bool>,
|
||||
}
|
||||
|
||||
/// POST/GET /api/database/export —— 流式导出。
|
||||
pub async fn export_data(
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<ExportParams>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
// 1. 鉴权:cookie → session → admin
|
||||
let cookie_header = headers
|
||||
.get("cookie")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let token = parse_session_token(cookie_header).map(str::to_string);
|
||||
let token = match token {
|
||||
Some(t) => t,
|
||||
None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())),
|
||||
};
|
||||
let user = match crate::api::auth::get_user_by_token(&token).await {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())),
|
||||
};
|
||||
if user.role != crate::models::user::UserRole::Admin {
|
||||
return Err((StatusCode::FORBIDDEN, "权限不足".to_string()));
|
||||
}
|
||||
|
||||
// 2. 解析来源 + 白名单/只读校验
|
||||
let include_columns = params.include_columns.unwrap_or(true);
|
||||
let (source_sql, table_name) = parse_source(¶ms.source)?;
|
||||
|
||||
match params.format.as_str() {
|
||||
"csv" => export_csv(source_sql, include_columns).await,
|
||||
"sql" => export_sql(source_sql, include_columns, table_name).await,
|
||||
_ => Err((StatusCode::BAD_REQUEST, "不支持的格式".to_string())),
|
||||
}
|
||||
.map(|(body, content_type, filename)| {
|
||||
let disposition = format!("attachment; filename=\"{}\"", filename);
|
||||
let disposition_value = axum::http::HeaderValue::from_str(&disposition)
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment"));
|
||||
let content_type_value = axum::http::HeaderValue::from_str(content_type)
|
||||
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("application/octet-stream"));
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type_value),
|
||||
(header::CONTENT_DISPOSITION, disposition_value),
|
||||
],
|
||||
body,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析导出来源,返回(内部 SQL,表名)。
|
||||
/// - `table:posts` → 校验表名合法后,`SELECT * FROM "posts"`(只读)+ 表名 "posts"。
|
||||
/// - `query:SELECT ...` → 校验为只读语句后原样返回,表名为 "export"。
|
||||
fn parse_source(source: &str) -> Result<(String, String), (StatusCode, String)> {
|
||||
if let Some(table) = source.strip_prefix("table:") {
|
||||
// 表名白名单:仅允许标识符字符,防注入
|
||||
let t = table.trim();
|
||||
if t.is_empty() || !is_simple_ident(t) {
|
||||
return Err((StatusCode::BAD_REQUEST, "无效的表名".to_string()));
|
||||
}
|
||||
Ok((format!("SELECT * FROM \"{}\"", t), t.to_string()))
|
||||
} else if let Some(query) = source.strip_prefix("query:") {
|
||||
// 只读校验:sqlparser 解析后所有语句均为 Query/Explain
|
||||
let dialect = sqlparser::dialect::PostgreSqlDialect {};
|
||||
let parsed = sqlparser::parser::Parser::parse_sql(&dialect, query)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "SQL 解析失败".to_string()))?;
|
||||
if parsed.iter().any(|s| !is_read_only_ast(s)) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"导出查询必须是只读(SELECT/EXPLAIN)".to_string(),
|
||||
));
|
||||
}
|
||||
Ok((query.to_string(), "export".to_string()))
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"source 必须是 table:<name> 或 query:<sql>".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单标识符校验(字母数字下划线,防 SQL 注入与路径穿越)。
|
||||
fn is_simple_ident(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// 判断 AST 是否只读(SELECT/EXPLAIN)。
|
||||
fn is_read_only_ast(stmt: &sqlparser::ast::Statement) -> bool {
|
||||
use sqlparser::ast::Statement;
|
||||
matches!(stmt, Statement::Query(_) | Statement::Explain { .. })
|
||||
}
|
||||
|
||||
/// CSV 导出:用 COPY ... TO STDOUT WITH CSV 流式(大表不 OOM)。
|
||||
async fn export_csv(
|
||||
source_sql: String,
|
||||
include_columns: bool,
|
||||
) -> Result<(Body, &'static str, String), (StatusCode, String)> {
|
||||
let client = crate::db::pool::get_conn()
|
||||
.await
|
||||
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "数据库不可用".to_string()))?;
|
||||
|
||||
let header_clause = if include_columns { "HEADER" } else { "" };
|
||||
let copy_stmt = format!("COPY ({}) TO STDOUT WITH CSV {}", source_sql, header_clause);
|
||||
let stream = client
|
||||
.copy_out(©_stmt)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("COPY 失败:{e}")))?;
|
||||
|
||||
// tokio_postgres 的 copy_out 流产出 Bytes,直接转 axum Body。
|
||||
let mapped = stream.map(|res| res.map_err(std::io::Error::other));
|
||||
let body = Body::from_stream(mapped);
|
||||
Ok((body, "text/csv; charset=utf-8", "export.csv".to_string()))
|
||||
}
|
||||
|
||||
/// SQL 导出:逐行拼 INSERT(纯数据,不含 DDL)。
|
||||
async fn export_sql(
|
||||
source_sql: String,
|
||||
include_columns: bool,
|
||||
table_name: String,
|
||||
) -> Result<(Body, &'static str, String), (StatusCode, String)> {
|
||||
let client = crate::db::pool::get_conn()
|
||||
.await
|
||||
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "数据库不可用".to_string()))?;
|
||||
|
||||
let rows = client
|
||||
.query(&source_sql, &[])
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("查询失败:{e}")))?;
|
||||
|
||||
let columns: Vec<String> = rows
|
||||
.first()
|
||||
.map(|r| r.columns().iter().map(|c| c.name().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str("-- Yggdrasil 数据导出(仅数据,不含 schema)\n");
|
||||
let col_clause = if include_columns && !columns.is_empty() {
|
||||
format!("({})", columns.join(", "))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
for r in &rows {
|
||||
let vals: Vec<String> = (0..r.len())
|
||||
.map(|i| sql_quote_cell(r, i))
|
||||
.collect();
|
||||
out.push_str(&format!(
|
||||
"INSERT INTO {} {} VALUES ({});\n",
|
||||
&table_name,
|
||||
col_clause,
|
||||
vals.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
let body = Body::from(out);
|
||||
Ok((
|
||||
body,
|
||||
"application/sql; charset=utf-8",
|
||||
"export.sql".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 把一个单元格值转成 SQL 字面量(字符串单引号转义,数字原样,NULL)。
|
||||
fn sql_quote_cell(row: &tokio_postgres::Row, idx: usize) -> String {
|
||||
let ty = row.columns().get(idx).map(|c| c.type_().name()).unwrap_or("");
|
||||
match ty {
|
||||
"int2" => row
|
||||
.try_get::<_, Option<i16>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "NULL".into()),
|
||||
"int4" => row
|
||||
.try_get::<_, Option<i32>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "NULL".into()),
|
||||
"int8" => row
|
||||
.try_get::<_, Option<i64>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "NULL".into()),
|
||||
"float4" | "float8" => row
|
||||
.try_get::<_, Option<f64>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| "NULL".into()),
|
||||
"bool" => row
|
||||
.try_get::<_, Option<bool>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| if v { "TRUE" } else { "FALSE" }.to_string())
|
||||
.unwrap_or_else(|| "NULL".into()),
|
||||
_ => {
|
||||
// 字符串/时间戳等:单引号转义
|
||||
match row.try_get::<_, Option<String>>(idx) {
|
||||
Ok(Some(s)) => format!("'{}'", s.replace('\'', "''")),
|
||||
_ => "NULL".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/api/database/mod.rs
Normal file
23
src/api/database/mod.rs
Normal file
@ -0,0 +1,23 @@
|
||||
//! 数据库管理 server functions 与 Axum 处理器。
|
||||
//!
|
||||
//! 按功能拆分子模块:
|
||||
//! - [`status`]:数据库运行状态聚合(表/连接/死元组/迁移版本)。
|
||||
//!
|
||||
//! 后续 task 会新增:`system_status`(服务器状态)、`sql_console`(SQL 执行+护栏)、
|
||||
//! `schema`(SQL 补全数据)、`export`(流式导出)、`backup`/`tasks`(备份恢复+进度)。
|
||||
|
||||
/// 数据库运行状态聚合查询。
|
||||
pub mod status;
|
||||
/// 服务器状态聚合查询(应用内 + 主机层)。
|
||||
pub mod system_status;
|
||||
/// SQL 控制台执行(全读写 + 4 护栏)。
|
||||
pub mod sql_console;
|
||||
/// SQL 补全用 schema 拉取。
|
||||
pub mod schema;
|
||||
/// 数据导出 Axum 流式处理器(仅 server:纯 Axum 路由,无 WASM 消费者)。
|
||||
#[cfg(feature = "server")]
|
||||
pub mod export;
|
||||
/// 备份/恢复(双模式 + 任务进度)。
|
||||
pub mod backup;
|
||||
/// 备份/恢复异步任务进度表。
|
||||
pub mod tasks;
|
||||
53
src/api/database/schema.rs
Normal file
53
src/api/database/schema.rs
Normal file
@ -0,0 +1,53 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! SQL 补全用 schema 拉取(供 CodeMirror lang-sql 表/列补全)。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
// admin 鉴权 + DB 查询仅在 server 构建里被 server function 体引用。
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
// SqlSchema/SqlTable 是两端共享的纯数据类型(定义在 codemirror_bridge)。
|
||||
use crate::codemirror_bridge::SqlSchema;
|
||||
|
||||
/// 拉取数据库 schema(表名 + 列名),供 CodeMirror SQL 补全。
|
||||
#[server(GetDbSchema, "/api")]
|
||||
pub async fn get_db_schema() -> Result<SqlSchema, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT t.table_name, \
|
||||
string_agg(c.column_name, ',' ORDER BY c.ordinal_position) \
|
||||
FROM information_schema.tables t \
|
||||
JOIN information_schema.columns c USING (table_schema, table_name) \
|
||||
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' \
|
||||
GROUP BY t.table_name ORDER BY t.table_name",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let tables = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let cols: String = r.get(1);
|
||||
crate::codemirror_bridge::SqlTable {
|
||||
name: r.get(0),
|
||||
columns: cols.split(',').map(|s| s.to_string()).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(SqlSchema { tables })
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(SqlSchema::default())
|
||||
}
|
||||
}
|
||||
344
src/api/database/sql_console.rs
Normal file
344
src/api/database/sql_console.rs
Normal file
@ -0,0 +1,344 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! SQL 控制台执行(全读写 + 4 道护栏)。
|
||||
//!
|
||||
//! 护栏:
|
||||
//! 1. 高危语句闸门:`DROP DATABASE`/`DROP SCHEMA`(字符串预检)绝禁;
|
||||
//! `DROP`/`TRUNCATE`/`ALTER` 需 `confirm_dangerous`。
|
||||
//! 2. 无 WHERE 拦截:`UPDATE`/`DELETE` 无 `selection` 拒绝。
|
||||
//! 3. 查询超时上限:复用 `STATEMENT_TIMEOUT_SECS`(pool 层已注入 GUC)。
|
||||
//! 4. 前端二次确认(前端实现)。
|
||||
//!
|
||||
//! 默认禁止多语句(`allow_multi` 放开)。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// admin 鉴权 + DB 查询仅在 server 构建里被 server function 体引用。
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy)]
|
||||
pub struct ExecuteSqlOpts {
|
||||
/// 是否允许多语句(`;` 分隔),默认 false。
|
||||
pub allow_multi: bool,
|
||||
/// 是否勾选「我了解后果」(放开 DROP/TRUNCATE/ALTER 等高危)。
|
||||
pub confirm_dangerous: bool,
|
||||
/// 是否带 EXPLAIN 执行计划。
|
||||
pub with_explain: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
pub struct SqlResult {
|
||||
pub columns: Vec<String>,
|
||||
/// 每格用 JSON 表示(text/int/timestamp/bool/null)。
|
||||
pub rows: Vec<Vec<serde_json::Value>>,
|
||||
pub affected_rows: u64,
|
||||
pub elapsed_ms: u64,
|
||||
/// 语句类型(来自 AST,如 "Select"/"Update"/"CreateTable")。
|
||||
pub statement_type: String,
|
||||
pub explain: Option<String>,
|
||||
/// 是否因 500 行上限截断。
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// 结果行数上限(超出截断 + 提示)。
|
||||
const MAX_ROWS: usize = 500;
|
||||
|
||||
/// 绝对禁止的语句关键词(字符串预检,sqlparser 无 ObjectType::Database/Schema)。
|
||||
/// 命中即拒,不可放行。
|
||||
const ABSOLUTELY_FORBIDDEN: &[&str] = &["drop database", "drop schema", "create database"];
|
||||
|
||||
/// 护栏检查返回值。
|
||||
#[derive(Debug)]
|
||||
enum GuardResult {
|
||||
Allowed,
|
||||
/// 需 confirm_dangerous 才放行。
|
||||
NeedsConfirm,
|
||||
/// 不可放行(附带原因)。
|
||||
Forbidden(String),
|
||||
}
|
||||
|
||||
/// 护栏 1+2:sqlparser 解析后遍历 AST,检查高危语句与无 WHERE 的 UPDATE/DELETE。
|
||||
#[cfg(feature = "server")]
|
||||
fn check_guards(
|
||||
asts: &[sqlparser::ast::Statement],
|
||||
confirm_dangerous: bool,
|
||||
) -> GuardResult {
|
||||
use sqlparser::ast::{ObjectType, Statement};
|
||||
|
||||
for stmt in asts {
|
||||
match stmt {
|
||||
// 护栏 1(绝禁):DROP SCHEMA 永远禁止(ObjectType 无 Database 变体,
|
||||
// DROP DATABASE 由字符串预检 + AST 双重拦截——见上方 ABSOLUTELY_FORBIDDEN)。
|
||||
// 这里在 AST 层结构性禁止 DROP SCHEMA,防 SQL 注释/空白绕过字符串预检。
|
||||
Statement::Drop {
|
||||
object_type: ObjectType::Schema,
|
||||
..
|
||||
} => {
|
||||
return GuardResult::Forbidden("禁止 DROP SCHEMA".to_string());
|
||||
}
|
||||
// 护栏 1:需确认的高危语句(DROP TABLE/VIEW/INDEX 等、TRUNCATE、ALTER)
|
||||
Statement::Drop { .. } | Statement::Truncate { .. } | Statement::AlterTable { .. } => {
|
||||
if !confirm_dangerous {
|
||||
return GuardResult::NeedsConfirm;
|
||||
}
|
||||
}
|
||||
// 护栏 2:UPDATE 无 WHERE
|
||||
Statement::Update { selection: None, .. } => {
|
||||
return GuardResult::Forbidden(
|
||||
"UPDATE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
|
||||
);
|
||||
}
|
||||
// 护栏 2:DELETE 无 WHERE
|
||||
Statement::Delete { selection: None, .. } => {
|
||||
return GuardResult::Forbidden(
|
||||
"DELETE 缺少 WHERE 子句,将影响全表。请加 WHERE 条件。".to_string(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
GuardResult::Allowed
|
||||
}
|
||||
|
||||
/// 提取语句类型名(AST 变体名,如 "Select"/"Insert"/"Update")。
|
||||
#[cfg(feature = "server")]
|
||||
fn statement_type_name(stmt: &sqlparser::ast::Statement) -> String {
|
||||
use sqlparser::ast::Statement;
|
||||
let name = match stmt {
|
||||
Statement::Query(_) => "Select",
|
||||
Statement::Insert { .. } => "Insert",
|
||||
Statement::Update { .. } => "Update",
|
||||
Statement::Delete { .. } => "Delete",
|
||||
Statement::CreateTable { .. } => "CreateTable",
|
||||
Statement::AlterTable { .. } => "AlterTable",
|
||||
Statement::Drop { .. } => "Drop",
|
||||
Statement::Truncate { .. } => "Truncate",
|
||||
Statement::Explain { .. } => "Explain",
|
||||
_ => "Other",
|
||||
};
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// 判断语句是否只读(SELECT/EXPLAIN/SHOW/WITH...SELECT)。
|
||||
#[cfg(feature = "server")]
|
||||
fn is_read_only(stmt: &sqlparser::ast::Statement) -> bool {
|
||||
use sqlparser::ast::Statement;
|
||||
matches!(
|
||||
stmt,
|
||||
Statement::Query(_) | Statement::Explain { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// 把一列的值转成 JSON(按 PG 类型名分发)。
|
||||
#[cfg(feature = "server")]
|
||||
fn col_to_json(row: &tokio_postgres::Row, idx: usize) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
let ty = row.columns().get(idx).map(|c| c.type_().name()).unwrap_or("");
|
||||
match ty {
|
||||
"int2" => row
|
||||
.try_get::<_, Option<i16>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"int4" => row
|
||||
.try_get::<_, Option<i32>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"int8" => row
|
||||
.try_get::<_, Option<i64>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"float4" => row
|
||||
.try_get::<_, Option<f32>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"float8" => row
|
||||
.try_get::<_, Option<f64>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
"bool" => row
|
||||
.try_get::<_, Option<bool>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
// 其余(text/varchar/timestamp/jsonb/...)一律按字符串取,失败则 null
|
||||
_ => row
|
||||
.try_get::<_, Option<String>>(idx)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 SQL(全读写,管理员)。护栏见模块文档。
|
||||
#[server(ExecuteSql, "/api")]
|
||||
pub async fn execute_sql(sql: String, opts: ExecuteSqlOpts) -> Result<SqlResult, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use sqlparser::dialect::PostgreSqlDialect;
|
||||
use sqlparser::parser::Parser;
|
||||
|
||||
// 护栏 1(绝禁):字符串预检 DROP/CREATE DATABASE、DROP SCHEMA
|
||||
let normalized = sql.to_lowercase();
|
||||
for forbidden in ABSOLUTELY_FORBIDDEN {
|
||||
if normalized.contains(forbidden) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"禁止的操作:{}",
|
||||
forbidden.to_uppercase()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 SQL
|
||||
let dialect = PostgreSqlDialect {};
|
||||
let asts = Parser::parse_sql(&dialect, &sql)
|
||||
.map_err(|e| AppError::BadRequest(format!("SQL 解析失败:{e}")))?;
|
||||
if asts.is_empty() {
|
||||
return Err(AppError::BadRequest("空的 SQL 语句".into()).into());
|
||||
}
|
||||
|
||||
// 多语句检查(默认禁止)
|
||||
if asts.len() > 1 && !opts.allow_multi {
|
||||
return Err(AppError::BadRequest(
|
||||
"检测到多条语句,请勾选「允许多语句」后再执行".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// 护栏 1+2:AST 检查
|
||||
match check_guards(&asts, opts.confirm_dangerous) {
|
||||
GuardResult::Forbidden(msg) => {
|
||||
return Err(AppError::BadRequest(msg).into());
|
||||
}
|
||||
GuardResult::NeedsConfirm => {
|
||||
return Err(AppError::BadRequest(
|
||||
"高危操作(DROP/TRUNCATE/ALTER),需勾选「我了解后果」".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
GuardResult::Allowed => {}
|
||||
}
|
||||
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let start = std::time::Instant::now;
|
||||
|
||||
// 逐条执行:每条用其 AST 重序列化的形式(stmt.to_string()),
|
||||
// 而非原始整段 SQL——保证执行的语句与护栏检查的 AST 完全一致,
|
||||
// 避免 allow_multi 时整段 SQL 被重复执行,也杜绝读/写分类与实际执行解耦。
|
||||
let mut last_result = SqlResult::default();
|
||||
for stmt in &asts {
|
||||
// 重序列化单条语句为可执行 SQL(去掉末尾分号,避免与 execute 的隐式分号冲突)
|
||||
let stmt_sql = stmt.to_string();
|
||||
last_result = execute_one(&client, stmt, &stmt_sql, opts.with_explain, start).await?;
|
||||
}
|
||||
Ok(last_result)
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
let _ = (sql, opts);
|
||||
Ok(SqlResult::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行单条语句,返回结果。
|
||||
///
|
||||
/// `stmt_sql` 必须是 `stmt` 重序列化后的**单条**语句 SQL(不含其他语句),
|
||||
/// 保证护栏检查的 AST 与实际执行的语句一致。
|
||||
#[cfg(feature = "server")]
|
||||
async fn execute_one(
|
||||
client: &deadpool_postgres::Object,
|
||||
stmt: &sqlparser::ast::Statement,
|
||||
stmt_sql: &str,
|
||||
with_explain: bool,
|
||||
start: impl Fn() -> std::time::Instant + Copy,
|
||||
) -> Result<SqlResult, ServerFnError> {
|
||||
let statement_type = statement_type_name(stmt);
|
||||
let read_only = is_read_only(stmt);
|
||||
|
||||
if with_explain && read_only {
|
||||
// EXPLAIN 模式:包裹单条语句取执行计划,取首列文本拼接
|
||||
let explain_sql = format!("EXPLAIN {}", stmt_sql.trim_end_matches(';'));
|
||||
let rows = client
|
||||
.query(&explain_sql, &[])
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let explain = rows
|
||||
.iter()
|
||||
.filter_map(|r| r.try_get::<_, String>(0).ok())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
return Ok(SqlResult {
|
||||
statement_type,
|
||||
explain: Some(explain),
|
||||
elapsed_ms: start().elapsed().as_millis() as u64,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
if read_only {
|
||||
// 只读:取结果集。列名从第一行取(空结果集时无列名,前端容错)。
|
||||
let rows = client
|
||||
.query(stmt_sql, &[])
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let columns: Vec<String> = rows
|
||||
.first()
|
||||
.map(|r| {
|
||||
r.columns()
|
||||
.iter()
|
||||
.map(|c| c.name().to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut data: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||
let mut truncated = false;
|
||||
for r in &rows {
|
||||
if data.len() >= MAX_ROWS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let row: Vec<serde_json::Value> = (0..r.len()).map(|i| col_to_json(r, i)).collect();
|
||||
data.push(row);
|
||||
}
|
||||
Ok(SqlResult {
|
||||
columns,
|
||||
rows: data,
|
||||
truncated,
|
||||
statement_type,
|
||||
elapsed_ms: start().elapsed().as_millis() as u64,
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
// 写操作:返回影响行数
|
||||
let affected = client
|
||||
.execute(stmt_sql, &[])
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
Ok(SqlResult {
|
||||
affected_rows: affected,
|
||||
statement_type,
|
||||
elapsed_ms: start().elapsed().as_millis() as u64,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
240
src/api/database/status.rs
Normal file
240
src/api/database/status.rs
Normal file
@ -0,0 +1,240 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! 数据库运行状态聚合查询(只读)。
|
||||
//!
|
||||
//! 全部查询走 `pg_catalog` / `pg_stat_*` / `schema_migrations`,零写、零风险。
|
||||
//! [`get_db_status`] 在一次 server function 调用里聚合多组数据返回。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// 仅 server 构建用到:admin 鉴权 + DB 查询。WASM 侧的 server-function 客户端桩
|
||||
// 不解析这些符号,必须 gate 以避免在非 server 构建里找不到 server-only 符号。
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::db::pool::get_conn;
|
||||
|
||||
/// 数据库状态聚合数据。
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
pub struct DbStatus {
|
||||
/// 当前数据库总大小(字节)。
|
||||
pub db_size_bytes: i64,
|
||||
/// 当前数据库的活跃连接数。
|
||||
pub total_connections: i32,
|
||||
/// PG 配置的最大连接数(`max_connections`)。
|
||||
pub max_connections: i32,
|
||||
/// 已应用的最新迁移版本(`schema_migrations.version`)。
|
||||
pub migration_version: Option<String>,
|
||||
/// 最新迁移的应用时间。
|
||||
pub migration_applied_at: Option<DateTime<Utc>>,
|
||||
/// 用户表清单(按总大小降序)。
|
||||
pub tables: Vec<TableInfo>,
|
||||
/// 索引占用 Top N。
|
||||
pub top_indexes: Vec<IndexInfo>,
|
||||
/// 活跃连接列表(已过滤掉自身这条查询)。
|
||||
pub active_connections: Vec<ConnInfo>,
|
||||
}
|
||||
|
||||
/// 单张表的统计信息。
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TableInfo {
|
||||
pub name: String,
|
||||
/// 行数:total_size 小于阈值时为真实 COUNT(*),否则回退 reltuples 估算(见 row_count_estimated)。
|
||||
pub row_count: i64,
|
||||
/// true 表示 row_count 是 reltuples 估算值(大表回退,未 ANALYZE 时 reltuples=-1 会显示为估算)。
|
||||
pub row_count_estimated: bool,
|
||||
pub table_size_bytes: i64,
|
||||
pub index_size_bytes: i64,
|
||||
pub total_size_bytes: i64,
|
||||
pub last_vacuum: Option<DateTime<Utc>>,
|
||||
pub last_analyze: Option<DateTime<Utc>>,
|
||||
pub dead_tuples: i64,
|
||||
pub live_tuples: i64,
|
||||
}
|
||||
|
||||
/// 索引占用信息。
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct IndexInfo {
|
||||
pub name: String,
|
||||
pub table_name: String,
|
||||
pub size_bytes: i64,
|
||||
}
|
||||
|
||||
/// 单条活跃连接信息。
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ConnInfo {
|
||||
pub pid: i32,
|
||||
pub user: String,
|
||||
pub state: Option<String>,
|
||||
pub query: Option<String>,
|
||||
/// 当前查询已运行秒数(无查询时为 None)。
|
||||
pub query_duration_secs: Option<f64>,
|
||||
}
|
||||
|
||||
/// 获取数据库运行状态(只读,管理员)。
|
||||
#[server(GetDbStatus, "/api")]
|
||||
pub async fn get_db_status() -> Result<DbStatus, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
|
||||
// 数据库总大小
|
||||
let db_size: i64 = client
|
||||
.query_one("SELECT pg_database_size(current_database())", &[])
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
// 当前库连接数 + 全局最大连接数。
|
||||
// count(*) 原生返回 bigint(int8),与下方 setting::int 一并显式转 int4,
|
||||
// 以匹配 total_connections/max_connections 的 i32 类型(否则 FromSql 反序列化失败)。
|
||||
let conn_row = client
|
||||
.query_one(
|
||||
"SELECT count(*)::int, \
|
||||
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') \
|
||||
FROM pg_stat_activity WHERE datname = current_database()",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let total_conn: i32 = conn_row.get(0);
|
||||
let max_conn: i32 = conn_row.get(1);
|
||||
|
||||
// 最新迁移版本(schema_migrations 由 migrate.rs 创建)
|
||||
let migration = client
|
||||
.query_opt(
|
||||
"SELECT version, applied_at FROM schema_migrations \
|
||||
ORDER BY applied_at DESC LIMIT 1",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let (migration_version, migration_applied_at) = match migration {
|
||||
Some(row) => (Some(row.get(0)), Some(row.get(1))),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
// 表清单:大小/统计走 catalog;行数对小表用真实 COUNT(*),大表回退 reltuples 估算。
|
||||
// 阈值 100MB:超过则 COUNT(*) 成本过高(全表扫描),降级为估算值并标注。
|
||||
const COUNT_SIZE_THRESHOLD: i64 = 100 * 1024 * 1024;
|
||||
let table_rows = client
|
||||
.query(
|
||||
"SELECT c.relname, c.reltuples::bigint, pg_relation_size(c.oid), \
|
||||
pg_total_relation_size(c.oid) - pg_relation_size(c.oid), \
|
||||
pg_total_relation_size(c.oid), s.last_vacuum, s.last_analyze, \
|
||||
s.n_dead_tup, s.n_live_tup \
|
||||
FROM pg_class c \
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace \
|
||||
LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid \
|
||||
WHERE c.relkind = 'r' AND n.nspname = 'public' \
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let tables = {
|
||||
let mut out = Vec::with_capacity(table_rows.len());
|
||||
for r in table_rows {
|
||||
let name: String = r.get(0);
|
||||
let reltuples: i64 = r.get(1);
|
||||
let total_size: i64 = r.get(4);
|
||||
// 小表:真实 COUNT(*);大表:回退 reltuples 估算。
|
||||
// relname 来自 pg_class 可信,但表名可能含大写/特殊字符,需 PG 标识符转义(双引号包裹,内部双引号双写)。
|
||||
let (row_count, estimated) = if total_size < COUNT_SIZE_THRESHOLD {
|
||||
let ident = format!("\"{}\"", name.replace('"', "\"\""));
|
||||
let cnt: i64 = client
|
||||
.query_one(&format!("SELECT count(*)::bigint FROM {}", ident), &[])
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
(cnt, false)
|
||||
} else {
|
||||
(reltuples, true)
|
||||
};
|
||||
out.push(TableInfo {
|
||||
name,
|
||||
row_count,
|
||||
row_count_estimated: estimated,
|
||||
table_size_bytes: r.get(2),
|
||||
index_size_bytes: r.get(3),
|
||||
total_size_bytes: total_size,
|
||||
last_vacuum: r.get(5),
|
||||
last_analyze: r.get(6),
|
||||
dead_tuples: r.get(7),
|
||||
live_tuples: r.get(8),
|
||||
});
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
// 索引占用 Top 10
|
||||
let index_rows = client
|
||||
.query(
|
||||
"SELECT c.relname AS index_name, cl.relname AS table_name, \
|
||||
pg_relation_size(c.oid) \
|
||||
FROM pg_class c \
|
||||
JOIN pg_index i ON i.indexrelid = c.oid \
|
||||
JOIN pg_class cl ON cl.oid = i.indrelid \
|
||||
JOIN pg_namespace n ON n.oid = cl.relnamespace \
|
||||
WHERE n.nspname = 'public' \
|
||||
ORDER BY pg_relation_size(c.oid) DESC LIMIT 10",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let top_indexes = index_rows
|
||||
.into_iter()
|
||||
.map(|r| IndexInfo {
|
||||
name: r.get(0),
|
||||
table_name: r.get(1),
|
||||
size_bytes: r.get(2),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 活跃连接(过滤自身 pid,避免循环显示)。
|
||||
// extract(epoch FROM ...) 原生返回 numeric(decimal),tokio-postgres 无 FromSql<f64>
|
||||
// 实现该类型;显式 ::double precision 转 float8 以匹配 query_duration_secs 的 f64。
|
||||
let conn_rows = client
|
||||
.query(
|
||||
"SELECT pid, usename, state, query, \
|
||||
extract(epoch FROM now() - query_start)::double precision \
|
||||
FROM pg_stat_activity \
|
||||
WHERE datname = current_database() AND pid <> pg_backend_pid() \
|
||||
ORDER BY query_start DESC NULLS LAST LIMIT 50",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::query)?;
|
||||
let active_connections = conn_rows
|
||||
.into_iter()
|
||||
.map(|r| ConnInfo {
|
||||
pid: r.get(0),
|
||||
user: r.get::<_, Option<String>>(1).unwrap_or_default(),
|
||||
state: r.get(2),
|
||||
query: r.get(3),
|
||||
query_duration_secs: r.get(4),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(DbStatus {
|
||||
db_size_bytes: db_size,
|
||||
total_connections: total_conn,
|
||||
max_connections: max_conn,
|
||||
migration_version,
|
||||
migration_applied_at,
|
||||
tables,
|
||||
top_indexes,
|
||||
active_connections,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(DbStatus::default())
|
||||
}
|
||||
}
|
||||
121
src/api/database/system_status.rs
Normal file
121
src/api/database/system_status.rs
Normal file
@ -0,0 +1,121 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! 服务器状态聚合查询(只读):应用内指标 + 主机层指标。
|
||||
//!
|
||||
//! 应用内:DB 连接池、moka 缓存命中率、活跃会话数、进程运行时间。
|
||||
//! 主机层:sysinfo 后台采样快照(CPU/内存/磁盘/load),由 [`sysinfo_sampler`] 维护。
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// admin 鉴权 + AppError 仅在 server 构建里被 server function 体引用。
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::error::AppError;
|
||||
|
||||
/// 单个缓存的统计快照(前端展示用)。
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct CacheStat {
|
||||
pub name: String,
|
||||
pub entry_count: u64,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
/// 命中率(0.0–1.0)。
|
||||
pub hit_rate: f64,
|
||||
}
|
||||
|
||||
/// 服务器状态聚合数据。
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ServerStatus {
|
||||
/// 进程运行时间(秒)。
|
||||
pub uptime_secs: u64,
|
||||
/// DB 连接池总大小(已创建连接数)。
|
||||
pub pool_size: usize,
|
||||
/// 连接池最大容量。
|
||||
pub pool_max_size: usize,
|
||||
/// 当前空闲可用连接数。
|
||||
pub pool_available: usize,
|
||||
/// 正在等待获取连接的请求数。
|
||||
pub pool_waiting: usize,
|
||||
/// 有效会话数(sessions 表 expires_at > now())。
|
||||
pub active_sessions: i64,
|
||||
/// 主机层指标快照(CPU/内存/磁盘等)。
|
||||
pub host: crate::sysinfo_sampler::SystemSnapshot,
|
||||
/// 各缓存命中率与条目数。
|
||||
pub caches: Vec<CacheStat>,
|
||||
}
|
||||
|
||||
/// 进程启动时刻(LazyLock),用于计算运行时间。
|
||||
#[cfg(feature = "server")]
|
||||
static STARTED_AT: std::sync::LazyLock<std::time::Instant> =
|
||||
std::sync::LazyLock::new(std::time::Instant::now);
|
||||
|
||||
/// 获取服务器状态(只读,管理员)。
|
||||
#[server(GetServerStatus, "/api")]
|
||||
pub async fn get_server_status() -> Result<ServerStatus, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
use crate::cache::cache_stats;
|
||||
use crate::db::pool::{get_conn, DB_POOL};
|
||||
|
||||
// 连接池状态
|
||||
let pool_status = DB_POOL.status();
|
||||
let pool_size = pool_status.size;
|
||||
let pool_max_size = pool_status.max_size;
|
||||
let pool_available = pool_status.available;
|
||||
let pool_waiting = pool_status.waiting;
|
||||
|
||||
// 活跃会话数
|
||||
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||
let active_sessions: i64 = client
|
||||
.query_one("SELECT count(*) FROM sessions WHERE expires_at > now()", &[])
|
||||
.await
|
||||
.map_err(AppError::query)?
|
||||
.get(0);
|
||||
|
||||
// 进程运行时间
|
||||
let uptime_secs = STARTED_AT.elapsed().as_secs();
|
||||
|
||||
// 主机层快照
|
||||
let host = crate::sysinfo_sampler::read_snapshot().await;
|
||||
|
||||
// 缓存统计
|
||||
let caches = cache_stats()
|
||||
.into_iter()
|
||||
.map(|s| CacheStat {
|
||||
name: s.name.to_string(),
|
||||
entry_count: s.entry_count,
|
||||
hits: s.hits,
|
||||
misses: s.misses,
|
||||
hit_rate: s.hit_rate,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ServerStatus {
|
||||
uptime_secs,
|
||||
pool_size,
|
||||
pool_max_size,
|
||||
pool_available,
|
||||
pool_waiting,
|
||||
active_sessions,
|
||||
host,
|
||||
caches,
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(ServerStatus {
|
||||
uptime_secs: 0,
|
||||
pool_size: 0,
|
||||
pool_max_size: 0,
|
||||
pool_available: 0,
|
||||
pool_waiting: 0,
|
||||
active_sessions: 0,
|
||||
host: crate::sysinfo_sampler::read_snapshot().await,
|
||||
caches: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
124
src/api/database/tasks.rs
Normal file
124
src/api/database/tasks.rs
Normal file
@ -0,0 +1,124 @@
|
||||
#![allow(clippy::unused_unit, deprecated)]
|
||||
|
||||
//! 备份/恢复的异步任务进度表(DashMap)。
|
||||
//!
|
||||
//! create_backup/restore_backup 立即返回 task_id,后台任务跑时通过
|
||||
//! [`update`] 更新进度,前端轮询 [`get_task_progress`]。
|
||||
//! 已完成超过 1 小时的任务惰性清理,避免内存累积。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// admin 鉴权仅 server 构建用到。
|
||||
#[cfg(feature = "server")]
|
||||
use crate::api::auth::get_current_admin_user;
|
||||
// DashMap / LazyLock 仅 server 构建持有任务进度表;WASM 端只序列化 TaskProgress。
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::LazyLock;
|
||||
#[cfg(feature = "server")]
|
||||
use dashmap::DashMap;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum TaskKind {
|
||||
Backup,
|
||||
Restore,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub enum TaskStatus {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct TaskProgress {
|
||||
pub id: String,
|
||||
pub kind: TaskKind,
|
||||
pub stage: String,
|
||||
pub percent: u8,
|
||||
pub detail: Option<String>,
|
||||
pub status: TaskStatus,
|
||||
pub error: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 完成后的备份文件名(下载/恢复用)。
|
||||
pub result_filename: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
static TASKS: LazyLock<DashMap<String, TaskProgress>> = LazyLock::new(DashMap::new);
|
||||
|
||||
/// 注册新任务(初始 Running,0%)。
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) fn insert(id: String, kind: TaskKind) {
|
||||
TASKS.insert(
|
||||
id.clone(),
|
||||
TaskProgress {
|
||||
id,
|
||||
kind,
|
||||
stage: "排队中".to_string(),
|
||||
percent: 0,
|
||||
detail: None,
|
||||
status: TaskStatus::Running,
|
||||
error: None,
|
||||
created_at: Utc::now(),
|
||||
result_filename: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新任务进度(后台任务调用)。
|
||||
#[cfg(feature = "server")]
|
||||
pub(super) fn update(
|
||||
id: &str,
|
||||
stage: &str,
|
||||
percent: u8,
|
||||
status: TaskStatus,
|
||||
detail: Option<String>,
|
||||
error: Option<String>,
|
||||
result_filename: Option<String>,
|
||||
) {
|
||||
if let Some(mut p) = TASKS.get_mut(id) {
|
||||
p.stage = stage.to_string();
|
||||
p.percent = percent;
|
||||
p.status = status;
|
||||
p.detail = detail;
|
||||
p.error = error;
|
||||
p.result_filename = result_filename;
|
||||
}
|
||||
}
|
||||
|
||||
/// 惰性清理已完成超过 1 小时的任务(查询时顺便清)。
|
||||
#[cfg(feature = "server")]
|
||||
fn gc_old() {
|
||||
let cutoff = Utc::now() - chrono::Duration::hours(1);
|
||||
TASKS.retain(|_, p| !(matches!(p.status, TaskStatus::Done | TaskStatus::Failed) && p.created_at < cutoff));
|
||||
}
|
||||
|
||||
/// 查询任务进度(轮询用)。
|
||||
#[server(GetTaskProgress, "/api")]
|
||||
pub async fn get_task_progress(task_id: String) -> Result<TaskProgress, ServerFnError> {
|
||||
let _user = get_current_admin_user().await?;
|
||||
#[cfg(feature = "server")]
|
||||
{
|
||||
gc_old();
|
||||
TASKS.get(&task_id)
|
||||
.map(|p| p.clone())
|
||||
.ok_or_else(|| crate::api::error::AppError::NotFound("任务不存在").into())
|
||||
}
|
||||
#[cfg(not(feature = "server"))]
|
||||
{
|
||||
Ok(TaskProgress {
|
||||
id: task_id,
|
||||
kind: TaskKind::Backup,
|
||||
stage: String::new(),
|
||||
percent: 0,
|
||||
detail: None,
|
||||
status: TaskStatus::Done,
|
||||
error: None,
|
||||
created_at: Utc::now(),
|
||||
result_filename: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,8 @@ pub enum AppError {
|
||||
Forbidden(&'static str),
|
||||
/// 资源不存在(404)。
|
||||
NotFound(&'static str),
|
||||
/// 客户端请求错误(400)——业务规则拒绝,消息原样透传给用户。
|
||||
BadRequest(String),
|
||||
/// 数据库连接失败。
|
||||
DbConn(String),
|
||||
/// SQL 查询执行失败。
|
||||
@ -54,6 +56,8 @@ impl From<AppError> for ServerFnError {
|
||||
AppError::Unauthorized(m) => m.to_string(),
|
||||
AppError::Forbidden(m) => m.to_string(),
|
||||
AppError::NotFound(m) => m.to_string(),
|
||||
// BadRequest 是业务规则拒绝(如 SQL 护栏拦截),消息原样透传给用户。
|
||||
AppError::BadRequest(m) => m.to_string(),
|
||||
AppError::DbConn(_) => "服务暂时不可用".to_string(),
|
||||
AppError::Query(_) => "操作失败".to_string(),
|
||||
AppError::Transaction(_) => "操作失败".to_string(),
|
||||
|
||||
@ -10,6 +10,8 @@ pub mod auth;
|
||||
pub mod comments;
|
||||
/// CSRF 防护中间件。
|
||||
pub mod csrf;
|
||||
/// 数据库管理接口(运行状态 / SQL 控制台 / 导出 / 备份恢复)。
|
||||
pub mod database;
|
||||
/// 应用错误类型与转换。
|
||||
pub mod error;
|
||||
/// 健康检查端点(liveness / readiness)。
|
||||
|
||||
190
src/cache.rs
190
src/cache.rs
@ -203,6 +203,98 @@ static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
|
||||
.build()
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 命中率统计(供系统状态面板展示)
|
||||
// ============================================================================
|
||||
|
||||
/// 单个缓存的命中/未命中计数。用 AtomicU64 在 get_* 路径上记录。
|
||||
#[cfg(feature = "server")]
|
||||
pub struct CacheStats {
|
||||
pub name: &'static str,
|
||||
hits: std::sync::atomic::AtomicU64,
|
||||
misses: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl CacheStats {
|
||||
pub const fn new(name: &'static str) -> Self {
|
||||
Self {
|
||||
name,
|
||||
hits: std::sync::atomic::AtomicU64::new(0),
|
||||
misses: std::sync::atomic::AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
fn record_hit(&self) {
|
||||
self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fn record_miss(&self) {
|
||||
self.misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// 每个缓存一份统计实例(const,启动时零开销初始化)。
|
||||
#[cfg(feature = "server")]
|
||||
static POST_LIST_STATS: CacheStats = CacheStats::new("文章列表");
|
||||
#[cfg(feature = "server")]
|
||||
static TAG_STATS: CacheStats = CacheStats::new("标签");
|
||||
#[cfg(feature = "server")]
|
||||
static SINGLE_POST_STATS: CacheStats = CacheStats::new("单篇文章");
|
||||
#[cfg(feature = "server")]
|
||||
static POST_STATS_STATS: CacheStats = CacheStats::new("文章统计");
|
||||
#[cfg(feature = "server")]
|
||||
static TAG_POSTS_STATS: CacheStats = CacheStats::new("标签文章");
|
||||
#[cfg(feature = "server")]
|
||||
static COMMENT_STATS: CacheStats = CacheStats::new("评论");
|
||||
#[cfg(feature = "server")]
|
||||
static PENDING_COUNT_STATS: CacheStats = CacheStats::new("待审评论数");
|
||||
#[cfg(feature = "server")]
|
||||
static SESSION_STATS: CacheStats = CacheStats::new("会话用户");
|
||||
#[cfg(feature = "server")]
|
||||
static SEARCH_STATS: CacheStats = CacheStats::new("搜索");
|
||||
|
||||
/// 缓存统计快照项(序列化给前端展示)。
|
||||
#[cfg(feature = "server")]
|
||||
#[derive(Debug)]
|
||||
pub struct CacheStatSnapshot {
|
||||
pub name: &'static str,
|
||||
pub entry_count: u64,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub hit_rate: f64,
|
||||
}
|
||||
|
||||
/// 聚合所有缓存的统计快照(供 get_server_status 调用)。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn cache_stats() -> Vec<CacheStatSnapshot> {
|
||||
fn snap(
|
||||
stats: &CacheStats,
|
||||
entry_count: u64,
|
||||
) -> CacheStatSnapshot {
|
||||
let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let total = hits + misses;
|
||||
let hit_rate = if total == 0 { 0.0 } else { hits as f64 / total as f64 };
|
||||
CacheStatSnapshot {
|
||||
name: stats.name,
|
||||
entry_count,
|
||||
hits,
|
||||
misses,
|
||||
hit_rate,
|
||||
}
|
||||
}
|
||||
vec![
|
||||
snap(&POST_LIST_STATS, POST_LIST_CACHE.entry_count()),
|
||||
snap(&TAG_STATS, TAG_LIST_CACHE.entry_count()),
|
||||
snap(&SINGLE_POST_STATS, SINGLE_POST_CACHE.entry_count()),
|
||||
snap(&POST_STATS_STATS, POST_STATS_CACHE.entry_count()),
|
||||
snap(&TAG_POSTS_STATS, TAG_POSTS_CACHE.entry_count()),
|
||||
snap(&COMMENT_STATS, COMMENT_CACHE.entry_count()),
|
||||
snap(&PENDING_COUNT_STATS, PENDING_COUNT_CACHE.entry_count()),
|
||||
snap(&SESSION_STATS, SESSION_CACHE.entry_count()),
|
||||
snap(&SEARCH_STATS, SEARCH_CACHE.entry_count()),
|
||||
]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 公共缓存 API
|
||||
// ============================================================================
|
||||
@ -210,7 +302,13 @@ static SEARCH_CACHE: LazyLock<SearchCache> = LazyLock::new(|| {
|
||||
/// 读取文章分页列表缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
|
||||
POST_LIST_CACHE.get(key).await
|
||||
let v = POST_LIST_CACHE.get(key).await;
|
||||
if v.is_some() {
|
||||
POST_LIST_STATS.record_hit();
|
||||
} else {
|
||||
POST_LIST_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 写入文章分页列表缓存。
|
||||
@ -222,10 +320,16 @@ pub async fn set_post_list(key: &CacheKey, posts: Vec<PostListItem>, total: i64)
|
||||
/// 读取已发布文章总数缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_total_published_posts() -> Option<i64> {
|
||||
POST_LIST_CACHE
|
||||
let v = POST_LIST_CACHE
|
||||
.get(&CacheKey::TotalPublishedPosts)
|
||||
.await
|
||||
.map(|(_, total)| total)
|
||||
.map(|(_, total)| total);
|
||||
if v.is_some() {
|
||||
POST_LIST_STATS.record_hit();
|
||||
} else {
|
||||
POST_LIST_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 写入已发布文章总数缓存,文章列表部分置空以节省内存。
|
||||
@ -239,7 +343,13 @@ pub async fn set_total_published_posts(total: i64) {
|
||||
/// 读取全部标签缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_tag_list() -> Option<Vec<Tag>> {
|
||||
TAG_LIST_CACHE.get(&CacheKey::AllTags).await
|
||||
let v = TAG_LIST_CACHE.get(&CacheKey::AllTags).await;
|
||||
if v.is_some() {
|
||||
TAG_STATS.record_hit();
|
||||
} else {
|
||||
TAG_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 写入全部标签缓存。
|
||||
@ -251,9 +361,15 @@ pub async fn set_tag_list(tags: Vec<Tag>) {
|
||||
/// 按 slug 读取单篇文章缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
|
||||
SINGLE_POST_CACHE
|
||||
let v = SINGLE_POST_CACHE
|
||||
.get(&CacheKey::PostBySlug(slug.to_string()))
|
||||
.await
|
||||
.await;
|
||||
if v.is_some() {
|
||||
SINGLE_POST_STATS.record_hit();
|
||||
} else {
|
||||
SINGLE_POST_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 按 slug 写入单篇文章缓存,None 表示文章不存在。
|
||||
@ -267,9 +383,15 @@ pub async fn set_post_by_slug(slug: &str, post: Option<Post>) {
|
||||
/// 按标签读取文章列表缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, i64)> {
|
||||
TAG_POSTS_CACHE
|
||||
let v = TAG_POSTS_CACHE
|
||||
.get(&CacheKey::PostsByTag(tag.to_string()))
|
||||
.await
|
||||
.await;
|
||||
if v.is_some() {
|
||||
TAG_POSTS_STATS.record_hit();
|
||||
} else {
|
||||
TAG_POSTS_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 按标签写入文章列表缓存。
|
||||
@ -283,7 +405,13 @@ pub async fn set_posts_by_tag(tag: &str, posts: Vec<PostListItem>, total: i64) {
|
||||
/// 按标签+分页读取文章列表缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec<PostListItem>, i64)> {
|
||||
TAG_POSTS_CACHE.get(key).await
|
||||
let v = TAG_POSTS_CACHE.get(key).await;
|
||||
if v.is_some() {
|
||||
TAG_POSTS_STATS.record_hit();
|
||||
} else {
|
||||
TAG_POSTS_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 按标签+分页写入文章列表缓存。
|
||||
@ -295,7 +423,13 @@ pub async fn set_posts_by_tag_paged(key: &CacheKey, posts: Vec<PostListItem>, to
|
||||
/// 读取文章统计缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_post_stats() -> Option<PostStats> {
|
||||
POST_STATS_CACHE.get(&CacheKey::PostStats).await
|
||||
let v = POST_STATS_CACHE.get(&CacheKey::PostStats).await;
|
||||
if v.is_some() {
|
||||
POST_STATS_STATS.record_hit();
|
||||
} else {
|
||||
POST_STATS_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 写入文章统计缓存。
|
||||
@ -370,9 +504,15 @@ pub fn invalidate_all_post_caches() {
|
||||
/// 按文章主键读取评论列表缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_comments_by_post(post_id: i32) -> Option<Vec<PublicComment>> {
|
||||
COMMENT_CACHE
|
||||
let v = COMMENT_CACHE
|
||||
.get(&CacheKey::CommentsByPost { post_id })
|
||||
.await
|
||||
.await;
|
||||
if v.is_some() {
|
||||
COMMENT_STATS.record_hit();
|
||||
} else {
|
||||
COMMENT_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 按文章主键写入评论列表缓存。
|
||||
@ -386,9 +526,15 @@ pub async fn set_comments_by_post(post_id: i32, comments: Vec<PublicComment>) {
|
||||
/// 读取待审核评论总数缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_pending_count() -> Option<i64> {
|
||||
PENDING_COUNT_CACHE
|
||||
let v = PENDING_COUNT_CACHE
|
||||
.get(&CacheKey::PendingCommentCount)
|
||||
.await
|
||||
.await;
|
||||
if v.is_some() {
|
||||
PENDING_COUNT_STATS.record_hit();
|
||||
} else {
|
||||
PENDING_COUNT_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 写入待审核评论总数缓存。
|
||||
@ -408,7 +554,13 @@ pub fn normalize_search_key(query: &str) -> String {
|
||||
/// 读取会话用户缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_session_user(token_hash: &str) -> Option<SessionUser> {
|
||||
SESSION_CACHE.get(token_hash).await
|
||||
let v = SESSION_CACHE.get(token_hash).await;
|
||||
if v.is_some() {
|
||||
SESSION_STATS.record_hit();
|
||||
} else {
|
||||
SESSION_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 写入会话用户缓存。
|
||||
@ -426,7 +578,13 @@ pub async fn invalidate_session_user(token_hash: &str) {
|
||||
/// 读取搜索结果缓存。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn get_search_results(query: &str) -> Option<(Vec<PostListItem>, i64)> {
|
||||
SEARCH_CACHE.get(&normalize_search_key(query)).await
|
||||
let v = SEARCH_CACHE.get(&normalize_search_key(query)).await;
|
||||
if v.is_some() {
|
||||
SEARCH_STATS.record_hit();
|
||||
} else {
|
||||
SEARCH_STATS.record_miss();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// 写入搜索结果缓存。
|
||||
|
||||
188
src/codemirror_bridge.rs
Normal file
188
src/codemirror_bridge.rs
Normal file
@ -0,0 +1,188 @@
|
||||
//! CodeMirror 编辑器的 wasm-bindgen 绑定层。
|
||||
//!
|
||||
//! 封装与 `window.CodeMirrorEditor`(IIFE 暴露的全局对象字面量)的全部交互,
|
||||
//! 严格镜像 [`crate::tiptap_bridge`] 的结构:共享纯数据类型双目标编译,
|
||||
//! wasm-bindgen extern + [`EditorHandle`] 仅在 WASM 前端编译(server 构建无 window)。
|
||||
//!
|
||||
//! 与 tiptap 一样,`CodeMirrorEditor` 是 IIFE 挂在 window 上的**对象字面量**
|
||||
//! (`{ create }`),不是函数——因此用 `js_sys::Reflect::get` 做属性访问拿到,
|
||||
//! 不能用 wasm-bindgen 的 extern fn(那会被编成函数调用,"not a function")。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SQL 补全用 schema 数据,由 `get_db_schema` server function 填充。
|
||||
// 暂未被消费(Task 5 的 get_db_schema 会用到),先 allow dead_code 避免 -D warnings 阻断 clippy。
|
||||
#[allow(dead_code)]
|
||||
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
|
||||
pub struct SqlSchema {
|
||||
pub tables: Vec<SqlTable>,
|
||||
}
|
||||
|
||||
/// 单张表的补全数据:表名 + 列名列表。
|
||||
#[allow(dead_code)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SqlTable {
|
||||
pub name: String,
|
||||
pub columns: Vec<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 以下全部仅在 WASM 前端编译:wasm-bindgen extern + EditorHandle + 闭包。
|
||||
// 放在 #[cfg] 子模块内,避免 server 构建尝试编译引用 JS 对象的 extern。
|
||||
// ============================================================================
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod wasm {
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
// —— window.CodeMirrorEditor 模块对象 ——
|
||||
//
|
||||
// CodeMirrorEditor 是 IIFE 产物挂在 window 上的对象字面量(含 create 方法),
|
||||
// 不是函数。wasm-bindgen 对 `fn get_module() -> T` 形式的 extern 会生成
|
||||
// `window.CodeMirrorEditor()`(函数调用),会因 "not a function" 失败。
|
||||
// 因此用 js_sys::Reflect::get 做属性访问拿到模块对象,再 unchecked_into。
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
/// `window.CodeMirrorEditor` 模块对象的 Rust 映射(IIFE 产物挂在 window 上的对象字面量)。
|
||||
/// 不是函数——通过 [`get_module`] 用 Reflect::get 取属性而非 extern fn 调用拿到。
|
||||
pub type CodeMirrorEditorModule;
|
||||
|
||||
/// 调用 `CodeMirrorEditor.create(containerId, opts)`。
|
||||
/// 找不到容器返回 null(被 Option 捕获);构造失败抛异常(被 catch 捕获)。
|
||||
#[wasm_bindgen(method, catch)]
|
||||
pub fn create(
|
||||
this: &CodeMirrorEditorModule,
|
||||
container_id: &str,
|
||||
opts: &EditorOptions,
|
||||
) -> Result<Option<EditorInstance>, JsValue>;
|
||||
}
|
||||
|
||||
/// 读取 `window.CodeMirrorEditor`(IIFE 默认导出,顶层 var 即 window 属性)。
|
||||
/// 用 Reflect::get 做属性访问——extern fn 形式会被 wasm-bindgen 编成函数调用。
|
||||
///
|
||||
/// 用 unchecked_into 而非 dyn_into:CodeMirrorEditor 是 JS 对象字面量,
|
||||
/// 不是 wasm-bindgen 注册的构造函数实例,dyn_into 的 instanceof 检查必然失败。
|
||||
/// unchecked_into 只做编译期类型标注,不做运行时校验
|
||||
/// (Reflect.get 已保证拿到的是目标对象)。
|
||||
pub fn get_module() -> CodeMirrorEditorModule {
|
||||
let window = web_sys::window().expect("no window");
|
||||
let val = js_sys::Reflect::get(&window, &"CodeMirrorEditor".into())
|
||||
.expect("window.CodeMirrorEditor missing");
|
||||
val.unchecked_into::<CodeMirrorEditorModule>()
|
||||
}
|
||||
|
||||
// —— 编辑器实例(CodeMirrorInstance)——
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
/// `CodeMirrorEditor.create` 返回的编辑器实例对象,承载 CodeMirror EditorView。
|
||||
pub type EditorInstance;
|
||||
|
||||
/// 返回当前文档全文。
|
||||
#[wasm_bindgen(method, js_name = getValue)]
|
||||
pub fn get_value(this: &EditorInstance) -> String;
|
||||
|
||||
/// 替换整个文档内容(dispatch changes,触发 onChange)。
|
||||
#[wasm_bindgen(method, js_name = setValue)]
|
||||
pub fn set_value(this: &EditorInstance, s: &str);
|
||||
|
||||
/// 热切换主题(Compartment.reconfigure,不重建实例)。
|
||||
#[wasm_bindgen(method, js_name = setTheme)]
|
||||
pub fn set_theme(this: &EditorInstance, theme: &str);
|
||||
|
||||
/// 更新 SQL 补全 schema(Compartment.reconfigure)。
|
||||
/// 参数为 serde_wasm_bindgen::to_value 序列化后的 JsValue
|
||||
///(SqlSchema 是 serde 类型,非 wasm-bindgen 类型,故不能直接传 &SqlSchema)。
|
||||
#[wasm_bindgen(method, js_name = setSchema)]
|
||||
pub fn set_schema(this: &EditorInstance, schema: &wasm_bindgen::JsValue);
|
||||
|
||||
/// 让编辑器获取焦点。
|
||||
#[wasm_bindgen(method)]
|
||||
pub fn focus(this: &EditorInstance);
|
||||
|
||||
/// 销毁编辑器,释放 JS 侧资源。
|
||||
#[wasm_bindgen(method)]
|
||||
pub fn destroy(this: &EditorInstance);
|
||||
}
|
||||
|
||||
// —— EditorOptions:用 builder 模式(setter)构造 JS 对象 ——
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
/// 传给 `CodeMirrorEditor.create` 的配置对象,对应 JS 侧的 EditorOptions。
|
||||
/// 用 `new()` 创建空对象后通过 setter 链式设置字段。
|
||||
pub type EditorOptions;
|
||||
|
||||
/// 构造一个空的 EditorOptions,随后用各 setter 填充。
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> EditorOptions;
|
||||
|
||||
/// 语言(默认 'sql')。
|
||||
#[wasm_bindgen(method, setter, js_name = language)]
|
||||
pub fn set_language(this: &EditorOptions, v: &str);
|
||||
|
||||
/// 主题:'light'(Catppuccin Latte)或 'dark'(Catppuccin Mocha)。
|
||||
#[wasm_bindgen(method, setter, js_name = theme)]
|
||||
pub fn set_theme(this: &EditorOptions, v: &str);
|
||||
|
||||
/// 是否启用 Vim keymap。
|
||||
#[wasm_bindgen(method, setter, js_name = vim)]
|
||||
pub fn set_vim(this: &EditorOptions, v: bool);
|
||||
|
||||
/// SQL 补全 schema(表/列数据)。v 为 serde_wasm_bindgen::to_value 序列化结果。
|
||||
#[wasm_bindgen(method, setter, js_name = schema)]
|
||||
pub fn set_schema(this: &EditorOptions, v: &wasm_bindgen::JsValue);
|
||||
|
||||
/// 初始文档内容。
|
||||
#[wasm_bindgen(method, setter, js_name = value)]
|
||||
pub fn set_value(this: &EditorOptions, v: &str);
|
||||
|
||||
/// 文档变更回调(参数为最新全文)。
|
||||
#[wasm_bindgen(method, setter, js_name = onChange)]
|
||||
pub fn set_on_change(this: &EditorOptions, cb: &Closure<dyn FnMut(String)>);
|
||||
|
||||
/// 编辑器就绪回调(构造末尾同步触发一次)。
|
||||
#[wasm_bindgen(method, setter, js_name = onReady)]
|
||||
pub fn set_on_ready(this: &EditorOptions, cb: &Closure<dyn FnMut()>);
|
||||
}
|
||||
|
||||
/// 编辑器实例句柄:持有 instance + 所有 Closure,Drop 时销毁实例并释放闭包。
|
||||
///
|
||||
/// 闭包字段 `_` 前缀表示仅用于保持生命周期——它们被注入 JS 后,JS 侧持有
|
||||
/// 函数引用;只要 [`EditorHandle`] 存活,闭包就不会被回收。Drop 时随结构释放。
|
||||
pub struct EditorHandle {
|
||||
instance: EditorInstance,
|
||||
_on_change: Closure<dyn FnMut(String)>,
|
||||
_on_ready: Closure<dyn FnMut()>,
|
||||
}
|
||||
|
||||
impl EditorHandle {
|
||||
/// 调用方须先把各 closure set 进 EditorOptions,再 create,
|
||||
/// 然后把返回的 instance + 同名 closure 一起传入 new。
|
||||
pub fn new(
|
||||
instance: EditorInstance,
|
||||
on_change: Closure<dyn FnMut(String)>,
|
||||
on_ready: Closure<dyn FnMut()>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance,
|
||||
_on_change: on_change,
|
||||
_on_ready: on_ready,
|
||||
}
|
||||
}
|
||||
|
||||
/// 借用底层实例,供宿主调 getValue/setTheme/setSchema 等。
|
||||
pub fn instance(&self) -> &EditorInstance {
|
||||
&self.instance
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EditorHandle {
|
||||
fn drop(&mut self) {
|
||||
// 销毁 JS 侧编辑器;随后 _on_change/_on_ready 字段按声明顺序释放,
|
||||
// 释放 wasm-bindgen 函数表槽位。
|
||||
let _ = self.instance.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use wasm::*;
|
||||
@ -72,6 +72,11 @@ pub fn AdminLayout() -> Element {
|
||||
label: "回收站",
|
||||
is_active: matches!(route, Route::Trash {}) || matches!(route, Route::TrashPage { .. }),
|
||||
},
|
||||
NavItemConfig {
|
||||
route: Route::System {},
|
||||
label: "系统",
|
||||
is_active: matches!(route, Route::System {}),
|
||||
},
|
||||
];
|
||||
|
||||
// 右侧操作区:主题切换 + 登出按钮
|
||||
|
||||
@ -274,3 +274,46 @@ pub fn FilterTabs(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面级导航 Tab 组件。
|
||||
///
|
||||
/// 与 [`FilterTabs`] 的区别:
|
||||
/// - [`FilterTabs`]:**列表筛选**语义,带平滑滑动底部指示条动画(靠 WASM 端测量
|
||||
/// `offsetLeft/offsetWidth`)。适合「全部 / 待审核 / 已通过」这类同质数据过滤。
|
||||
/// - `Tabs`:**页面导航**语义,选中态用按钮自身 `border-b-2` 下划线(无动画)。
|
||||
/// 适合「数据库状态 / 服务器状态 / SQL 控制台」这类切换到结构完全不同的面板。
|
||||
///
|
||||
/// 因 Dioxus 0.7 `#[component]` 宏不支持泛型类型参数,value 用 `String`(与
|
||||
/// `FilterTabs` 一致);调用方负责枚举 ↔ String 的桥接(见 `SystemTab::as_str`)。
|
||||
///
|
||||
/// Props:
|
||||
/// - `items`:`(value, label)` 列表,`value` 是稳定字符串 key,`label` 是展示文案
|
||||
/// - `active_value`:当前选中项的 value
|
||||
/// - `on_change`:切换时回调,传入新选中项的 value
|
||||
#[component]
|
||||
pub fn Tabs(
|
||||
items: Vec<(&'static str, &'static str)>,
|
||||
active_value: String,
|
||||
on_change: EventHandler<String>,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
div { class: "flex flex-wrap gap-1 border-b border-paper-border",
|
||||
for (value, label) in items {
|
||||
button {
|
||||
key: "{value}",
|
||||
class: "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
class: if active_value == *value {
|
||||
"border-paper-accent text-paper-accent"
|
||||
} else {
|
||||
"border-transparent text-paper-secondary hover:text-paper-primary"
|
||||
},
|
||||
onclick: {
|
||||
let v = value.to_string();
|
||||
move |_| on_change.call(v.clone())
|
||||
},
|
||||
{label}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
src/main.rs
32
src/main.rs
@ -23,6 +23,10 @@ mod hooks;
|
||||
mod models;
|
||||
mod pages;
|
||||
mod router;
|
||||
// sysinfo_sampler:主机指标快照。
|
||||
// SystemSnapshot 结构体两端都编译(被 system_status 的 ServerStatus 字段引用);
|
||||
// 真正的采样任务 / RwLock / read_snapshot 实现在本模块内部自行 #[cfg(feature = "server")] gate。
|
||||
mod sysinfo_sampler;
|
||||
// ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。
|
||||
#[cfg(feature = "server")]
|
||||
mod ssr_cache;
|
||||
@ -31,6 +35,9 @@ mod theme;
|
||||
// tiptap_bridge:共享类型(UploadsInFlight/UploadErrorEntry)两端都编译;
|
||||
// wasm-bindgen extern 与 EditorHandle 在内部的 #[cfg(wasm32)] 子模块里。
|
||||
mod tiptap_bridge;
|
||||
// codemirror_bridge:SQL 编辑器的 wasm-bindgen 绑定,结构镜像 tiptap_bridge。
|
||||
// 共享类型(SqlSchema/SqlTable)两端都编译;extern 与 EditorHandle 在 #[cfg(wasm32)] 子模块里。
|
||||
mod codemirror_bridge;
|
||||
mod utils;
|
||||
mod webp;
|
||||
|
||||
@ -322,6 +329,9 @@ fn main() {
|
||||
tasks::image_cache_cleanup::run_cleanup().await;
|
||||
});
|
||||
|
||||
// 启动后台采样任务:sysinfo 主机指标(CPU/内存/磁盘),server function 只读快照。
|
||||
sysinfo_sampler::spawn_sampler();
|
||||
|
||||
// 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。
|
||||
// 注意:src/ssr_cache.rs 中的世代号是未来就绪基础设施,当前并不会使
|
||||
// Dioxus 0.7 的 SSR 缓存实际失效(Dioxus 未暴露相应 API)。在 API 可用
|
||||
@ -379,6 +389,24 @@ fn main() {
|
||||
))
|
||||
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
|
||||
|
||||
// 数据导出:流式响应,走 GET + query(参数较短)。
|
||||
// 鉴权在 handler 内部从 cookie 校验 admin;CSRF 最外层拦截非法来源。
|
||||
let export_route = axum::Router::new()
|
||||
.route(
|
||||
"/api/database/export",
|
||||
axum::routing::get(crate::api::database::export::export_data),
|
||||
)
|
||||
// 备份下载:admin 鉴权 + 路径白名单(backups/ 不直接暴露静态目录)
|
||||
.route(
|
||||
"/api/database/backups/{filename}",
|
||||
axum::routing::get(crate::api::database::backup::download_backup),
|
||||
)
|
||||
.layer(TimeoutLayer::with_status_code(
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
Duration::from_secs(120),
|
||||
))
|
||||
.layer(axum::middleware::from_fn(crate::api::csrf::csrf_middleware));
|
||||
|
||||
// Dioxus 应用路由:自动挂载所有 server function 并渲染前端组件
|
||||
let dioxus_app =
|
||||
axum::Router::new().serve_dioxus_application(config, router::AppRouter);
|
||||
@ -416,8 +444,8 @@ fn main() {
|
||||
axum::routing::get(|| async { StatusCode::NOT_FOUND }),
|
||||
);
|
||||
|
||||
// 合并:upload 路由保持自己独立的 300s 超时;app routes 加可选压缩/30s;static routes 无任何中间件
|
||||
let router = upload_route.merge(app_routes).merge(static_routes);
|
||||
// 合并:upload 路由保持自己独立的 300s 超时;export 路由 120s;app routes 加可选压缩/30s;static routes 无任何中间件
|
||||
let router = upload_route.merge(export_route).merge(app_routes).merge(static_routes);
|
||||
|
||||
Ok(router)
|
||||
});
|
||||
|
||||
@ -8,6 +8,8 @@ pub mod comments;
|
||||
pub mod dashboard;
|
||||
/// 文章管理列表页面模块。
|
||||
pub mod posts;
|
||||
/// 系统管理页面模块(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)。
|
||||
pub mod system;
|
||||
/// 回收站管理页面模块。
|
||||
pub mod trash;
|
||||
/// 文章编辑器页面模块(基于 Tiptap 富文本编辑器)。
|
||||
@ -19,6 +21,8 @@ pub use comments::{AdminComments, AdminCommentsPage};
|
||||
pub use dashboard::Admin;
|
||||
/// 文章管理列表组件(带默认分页)。
|
||||
pub use posts::{Posts, PostsPage};
|
||||
/// 系统管理入口组件。
|
||||
pub use system::System;
|
||||
/// 回收站管理组件(带默认分页)。
|
||||
pub use trash::{Trash, TrashPage};
|
||||
/// 文章编辑器组件(新建与编辑模式)。
|
||||
|
||||
1453
src/pages/admin/system.rs
Normal file
1453
src/pages/admin/system.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,7 +12,8 @@ use crate::components::frontend_layout::FrontendLayout;
|
||||
use crate::context::UserContext;
|
||||
use crate::pages::about::About;
|
||||
use crate::pages::admin::{
|
||||
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, Trash, TrashPage, Write, WriteEdit,
|
||||
Admin, AdminComments, AdminCommentsPage, Posts, PostsPage, System, Trash, TrashPage, Write,
|
||||
WriteEdit,
|
||||
};
|
||||
use crate::pages::archives::Archives;
|
||||
use crate::pages::home::{Home, HomePage};
|
||||
@ -90,6 +91,9 @@ pub enum Route {
|
||||
/// 回收站分页
|
||||
#[route("/trash/:page")]
|
||||
TrashPage { page: i32 },
|
||||
/// 系统管理(数据库 + 服务器状态 + SQL 控制台 + 导出 + 备份)
|
||||
#[route("/system")]
|
||||
System {},
|
||||
#[end_layout]
|
||||
#[end_nest]
|
||||
|
||||
|
||||
106
src/sysinfo_sampler.rs
Normal file
106
src/sysinfo_sampler.rs
Normal file
@ -0,0 +1,106 @@
|
||||
//! sysinfo 主机指标后台采样。
|
||||
//!
|
||||
//! sysinfo 的 CPU% **不是即时值**,需要两次采样间的 delta。因此用后台任务周期
|
||||
//! 刷新,server function 只读 [`RwLock`] 快照(毫秒级返回,零采样成本)。
|
||||
//!
|
||||
//! 采样间隔由环境变量 `SYSINFO_SAMPLE_SECS` 配置(默认 0.5 秒,支持小数如 0.1)。
|
||||
|
||||
// LazyLock / Duration 仅 server 构建的采样任务用到;WASM 端只序列化 SystemSnapshot。
|
||||
#[cfg(feature = "server")]
|
||||
use std::sync::LazyLock;
|
||||
#[cfg(feature = "server")]
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 主机指标快照(由后台采样任务周期更新)。
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Debug)]
|
||||
pub struct SystemSnapshot {
|
||||
/// 总体 CPU 使用率(百分比)。
|
||||
pub cpu_usage: f32,
|
||||
/// 系统 1 分钟平均负载。
|
||||
pub load_avg_1: f64,
|
||||
/// 总物理内存(字节)。
|
||||
pub total_memory: u64,
|
||||
/// 已用物理内存(字节)。
|
||||
pub used_memory: u64,
|
||||
/// 主磁盘总空间(字节,取根分区或最大盘)。
|
||||
pub disk_total: u64,
|
||||
/// 主磁盘可用空间(字节)。
|
||||
pub disk_available: u64,
|
||||
/// 操作系统版本(如 "macOS 15.5")。
|
||||
pub os_name: String,
|
||||
/// 内核版本。
|
||||
pub kernel_version: String,
|
||||
/// 系统启动后秒数。
|
||||
pub uptime_secs: u64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
static SNAPSHOT: LazyLock<tokio::sync::RwLock<SystemSnapshot>> =
|
||||
LazyLock::new(|| tokio::sync::RwLock::new(SystemSnapshot::default()));
|
||||
|
||||
/// 采样间隔,由环境变量 `SYSINFO_SAMPLE_SECS` 配置,默认 0.5 秒,下限 0.05 秒。
|
||||
#[cfg(feature = "server")]
|
||||
fn sample_interval() -> Duration {
|
||||
let secs = std::env::var("SYSINFO_SAMPLE_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.5);
|
||||
Duration::from_secs_f64(secs.max(0.05))
|
||||
}
|
||||
|
||||
/// 启动后台采样任务。应在 main 启动流程(migrate 之后、serve 之前)调用一次。
|
||||
///
|
||||
/// CPU% 需两次采样 delta,故循环里先 refresh 再 sleep 再 refresh 才有有效值。
|
||||
#[cfg(feature = "server")]
|
||||
pub fn spawn_sampler() {
|
||||
tokio::spawn(async move {
|
||||
use sysinfo::{Disks, System};
|
||||
|
||||
let mut sys = System::new();
|
||||
let interval = sample_interval();
|
||||
// 首次 refresh 建立基线,CPU% 在下一轮才有意义。
|
||||
sys.refresh_cpu_usage();
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
let load = System::load_average();
|
||||
|
||||
// 主磁盘:取空间最大的盘(通常是数据盘)。
|
||||
let (disk_total, disk_available) = disks
|
||||
.list()
|
||||
.iter()
|
||||
.max_by_key(|d| d.total_space())
|
||||
.map(|d| (d.total_space(), d.available_space()))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
let snap = SystemSnapshot {
|
||||
cpu_usage: sys.global_cpu_usage(),
|
||||
load_avg_1: load.one,
|
||||
total_memory: sys.total_memory(),
|
||||
used_memory: sys.used_memory(),
|
||||
disk_total,
|
||||
disk_available,
|
||||
os_name: System::long_os_version().unwrap_or_default(),
|
||||
kernel_version: System::kernel_version().unwrap_or_default(),
|
||||
uptime_secs: System::uptime(),
|
||||
};
|
||||
*SNAPSHOT.write().await = snap;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取最新快照(只读,毫秒级返回,不触发采样)。
|
||||
#[cfg(feature = "server")]
|
||||
pub async fn read_snapshot() -> SystemSnapshot {
|
||||
SNAPSHOT.read().await.clone()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "server"))]
|
||||
pub async fn read_snapshot() -> SystemSnapshot {
|
||||
SystemSnapshot::default()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user