feat: add standalone OpsLog Reader
43
.github/workflows/build-windows.yml
vendored
@@ -77,6 +77,30 @@ jobs:
|
||||
Copy-Item $installer.FullName "release/OpsLog_${packageVersion}_windows_x64_setup.exe"
|
||||
Copy-Item $installerSignature "release/OpsLog_${packageVersion}_windows_x64_setup.exe.sig"
|
||||
|
||||
- name: Build OpsLog Reader for Windows
|
||||
env:
|
||||
CARGO_TARGET_DIR: src-tauri/target/reader
|
||||
run: npm run reader:windows:setup
|
||||
|
||||
- name: Create OpsLog Reader Windows packages
|
||||
shell: pwsh
|
||||
run: |
|
||||
$packageVersion = (Get-Content package.json | ConvertFrom-Json).version
|
||||
$portableRoot = "release/OpsLog Reader"
|
||||
$executable = "src-tauri/target/reader/release/opslog-reader.exe"
|
||||
$installer = Get-ChildItem "src-tauri/target/reader/release/bundle/nsis" -Filter "*.exe" | Select-Object -First 1
|
||||
if (!(Test-Path $executable)) {
|
||||
throw "OpsLog Reader executable was not found: $executable"
|
||||
}
|
||||
if ($null -eq $installer) {
|
||||
throw "OpsLog Reader NSIS installer was not found"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $portableRoot | Out-Null
|
||||
Copy-Item $executable "$portableRoot/OpsLog Reader.exe"
|
||||
Copy-Item README.md "$portableRoot/README.md"
|
||||
Compress-Archive -Path $portableRoot -DestinationPath "release/OpsLog_Reader_${packageVersion}_windows_x64_portable.zip" -Force
|
||||
Copy-Item $installer.FullName "release/OpsLog_Reader_${packageVersion}_windows_x64_setup.exe"
|
||||
|
||||
- name: Upload Windows packages
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -160,6 +184,25 @@ jobs:
|
||||
cp "$updater_archive" "release/OpsLog_${package_version}_macos_${{ matrix.arch }}.app.tar.gz"
|
||||
cp "$updater_archive.sig" "release/OpsLog_${package_version}_macos_${{ matrix.arch }}.app.tar.gz.sig"
|
||||
|
||||
- name: Build OpsLog Reader for macOS
|
||||
env:
|
||||
CARGO_TARGET_DIR: src-tauri/target/reader
|
||||
run: npm run reader:macos -- --target ${{ matrix.target }}
|
||||
|
||||
- name: Create OpsLog Reader macOS portable archive
|
||||
shell: bash
|
||||
run: |
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
bundle_root="src-tauri/target/reader/${{ matrix.target }}/release/bundle"
|
||||
dmg="$(find "$bundle_root/dmg" -type f -name '*.dmg' -print -quit)"
|
||||
test -n "$dmg"
|
||||
app_path="$bundle_root/macos/OpsLog Reader.app"
|
||||
test -d "$app_path"
|
||||
executable="$(find "$app_path/Contents/MacOS" -type f -perm -111 -print -quit)"
|
||||
file "$executable" | grep -q "${{ matrix.expected_mach_o }}"
|
||||
cp "$dmg" "release/OpsLog_Reader_${package_version}_macos_${{ matrix.arch }}.dmg"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app_path" "release/OpsLog_Reader_${package_version}_macos_${{ matrix.arch }}_portable.zip"
|
||||
|
||||
- name: Upload macOS packages
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -51,6 +51,10 @@ OpsLog 直接连接现有 Kibana,查询、下载和导出均由本机完成,
|
||||
<img src="docs/images/opslog-custom-markers.webp" alt="OpsLog 自定义标记编辑器" width="78%">
|
||||
</p>
|
||||
|
||||
### 独立 TRC 阅读器
|
||||
|
||||
Release 同时提供独立的 OpsLog Reader。首次启动可自行选择是否关联 `.trc` 文件;不关联也可手动打开。阅读、搜索、折叠、结构预览和自定义标记沿用 OpsLog 的同一套实现,两端共享阅读偏好与标记配置。
|
||||
|
||||
### 可交互 HTML
|
||||
|
||||
日志可导出为单个只读 HTML 文件,在浏览器中离线打开。文件保留语义高亮、结构折叠、普通及正则搜索、内置标记、自定义标记和 Outline 跳转,无需安装 OpsLog。
|
||||
@@ -136,6 +140,7 @@ macOS 与 Windows 桌面端支持自动更新。更新窗口展示完整 Changel
|
||||
```bash
|
||||
npm install
|
||||
npm run desktop:dev
|
||||
npm run reader:dev
|
||||
```
|
||||
|
||||
浏览器模式:
|
||||
@@ -162,15 +167,17 @@ npm run dev:lan
|
||||
```bash
|
||||
# macOS
|
||||
npm run desktop:macos
|
||||
npm run reader:macos
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows x64
|
||||
npm install
|
||||
npm run desktop:windows:setup
|
||||
npm run reader:windows:setup
|
||||
```
|
||||
|
||||
GitHub Release 包含 Windows x64 安装版及绿色 ZIP、macOS Apple Silicon / Intel DMG 及便携 ZIP、更新包、签名、`latest.json` 和 `SHA256SUMS`。
|
||||
GitHub Release 同时生成 OpsLog 与 OpsLog Reader 的 Windows x64 安装版、绿色 ZIP、macOS Apple Silicon / Intel DMG 及便携 ZIP。OpsLog 的更新包、签名、`latest.json` 与全部资产校验汇总在同一 Release 中。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
8
app-icon-reader.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<rect x="32" y="32" width="960" height="960" rx="220" fill="#071523"/>
|
||||
<rect x="72" y="72" width="880" height="880" rx="180" fill="none" stroke="#f2a65a" stroke-width="28"/>
|
||||
<circle cx="512" cy="512" r="394" fill="none" stroke="#4b3728" stroke-width="12" stroke-dasharray="26 34"/>
|
||||
<path d="M305 220h294l120 120v464H305z" fill="#16191d" stroke="#f2a65a" stroke-width="34" stroke-linejoin="round"/>
|
||||
<path d="M599 220v120h120" fill="none" stroke="#f2a65a" stroke-width="34" stroke-linejoin="round"/>
|
||||
<path d="M375 555h86l39-118 84 236 45-118h90" fill="none" stroke="#f2a65a" stroke-width="42" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 724 B |
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "opslog-web",
|
||||
"version": "3.0.27",
|
||||
"version": "3.0.28",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "opslog-web",
|
||||
"version": "3.0.27",
|
||||
"version": "3.0.28",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opslog-web",
|
||||
"version": "3.0.27",
|
||||
"version": "3.0.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,8 +16,13 @@
|
||||
"desktop:windows": "tauri build --no-bundle",
|
||||
"desktop:portable": "npm run desktop:windows",
|
||||
"desktop:windows:setup": "tauri build --bundles nsis",
|
||||
"reader:dev": "tauri dev --features reader-app --config src-tauri/tauri.reader.conf.json",
|
||||
"reader:build": "tauri build --features reader-app --config src-tauri/tauri.reader.conf.json",
|
||||
"reader:macos": "tauri build --features reader-app --bundles app,dmg --config src-tauri/tauri.reader.conf.json",
|
||||
"reader:windows": "tauri build --features reader-app --no-bundle --config src-tauri/tauri.reader.conf.json",
|
||||
"reader:windows:setup": "tauri build --features reader-app --bundles nsis --config src-tauri/tauri.reader.conf.json",
|
||||
"desktop:info": "tauri info",
|
||||
"test": "npm run build && node --import tsx --test tests/environment-store.test.mjs tests/keyboard-shortcuts.test.ts tests/log-reader-preferences.test.ts tests/query-builders.test.mjs tests/routes.test.mjs tests/trace-model.test.ts tests/transaction-log-analysis.test.ts tests/transaction-log-fetch.test.ts tests/update-release-notes.test.ts",
|
||||
"test": "npm run build && node --import tsx --test tests/environment-store.test.mjs tests/keyboard-shortcuts.test.ts tests/log-reader-preferences.test.ts tests/query-builders.test.mjs tests/reader-app.test.ts tests/routes.test.mjs tests/trace-model.test.ts tests/transaction-log-analysis.test.ts tests/transaction-log-fetch.test.ts tests/update-release-notes.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
4
src-tauri/Cargo.lock
generated
@@ -2493,10 +2493,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opslog"
|
||||
version = "3.0.27"
|
||||
version = "3.0.28"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"core-foundation 0.10.1",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
@@ -2511,6 +2512,7 @@ dependencies = [
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
[package]
|
||||
name = "opslog"
|
||||
version = "3.0.27"
|
||||
version = "3.0.28"
|
||||
description = "Portable M5/Faulu log query console"
|
||||
authors = ["MuRong Technology"]
|
||||
license = "Proprietary"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
reader-app = []
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
@@ -32,3 +36,9 @@ tauri-plugin-log = "2"
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
tokio = { version = "1", features = ["fs", "net", "sync"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
||||
12
src-tauri/capabilities/reader.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "reader",
|
||||
"description": "enables the standalone TRC reader window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/reader-icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/reader-icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
src-tauri/reader-icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/reader-icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/reader-icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/reader-icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
src-tauri/reader-icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src-tauri/reader-icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/reader-icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/reader-icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/reader-icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/reader-icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/reader-icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/reader-icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/reader-icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src-tauri/reader-icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/reader-icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/reader-icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/reader-icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src-tauri/reader-icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/reader-icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
src-tauri/reader-icons/icon.icns
Normal file
BIN
src-tauri/reader-icons/icon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/reader-icons/icon.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 864 B |
BIN
src-tauri/reader-icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
src-tauri/reader-icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
48
src-tauri/reader.Info.plist
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeExtensions</key>
|
||||
<array>
|
||||
<string>trc</string>
|
||||
</array>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>OpsLog TRC Log</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.murong.opslog.trc</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.text</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>OpsLog TRC 日志文件</string>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.murong.opslog.trc</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>trc</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<string>text/plain</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,3 +1,5 @@
|
||||
#![cfg_attr(feature = "reader-app", allow(dead_code))]
|
||||
|
||||
mod commands;
|
||||
mod domain;
|
||||
mod environment_store;
|
||||
@@ -5,8 +7,17 @@ mod export_files;
|
||||
mod kibana_client;
|
||||
mod lan_server;
|
||||
mod query_builders;
|
||||
#[cfg(feature = "reader-app")]
|
||||
mod reader_association;
|
||||
#[cfg(feature = "reader-app")]
|
||||
mod reader_files;
|
||||
mod reader_settings;
|
||||
mod update_release;
|
||||
|
||||
#[cfg(feature = "reader-app")]
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
#[cfg(not(feature = "reader-app"))]
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -34,6 +45,8 @@ pub fn run() {
|
||||
commands::save_custom_log_markers,
|
||||
commands::save_portable_log,
|
||||
commands::load_trace,
|
||||
reader_settings::load_reader_settings,
|
||||
reader_settings::save_reader_setting,
|
||||
update_release::load_update_release_notes,
|
||||
lan_server::get_lan_share_status,
|
||||
lan_server::set_lan_share_enabled,
|
||||
@@ -41,3 +54,48 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("OpsLog application failed");
|
||||
}
|
||||
|
||||
#[cfg(feature = "reader-app")]
|
||||
pub fn run_reader() {
|
||||
let pending_file =
|
||||
reader_files::PendingTrcFile(std::sync::Mutex::new(reader_files::startup_trc_path()));
|
||||
let app = tauri::Builder::default()
|
||||
.manage(pending_file)
|
||||
.setup(|app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build(),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
reader_association::get_trc_association_status,
|
||||
reader_association::associate_trc_files,
|
||||
reader_files::load_startup_trc_file,
|
||||
reader_settings::load_reader_settings,
|
||||
reader_settings::save_reader_setting,
|
||||
commands::save_custom_log_markers,
|
||||
commands::save_portable_log,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("OpsLog Reader application failed to build");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Opened { urls } = event {
|
||||
for path in urls.into_iter().filter_map(|url| url.to_file_path().ok()) {
|
||||
if let Some(state) = app_handle.try_state::<reader_files::PendingTrcFile>() {
|
||||
reader_files::remember_pending_file(&state, path.clone());
|
||||
}
|
||||
let _ = app_handle.emit("reader://open-file", path.to_string_lossy().into_owned());
|
||||
}
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
#[cfg(feature = "reader-app")]
|
||||
opslog_lib::run_reader();
|
||||
|
||||
#[cfg(not(feature = "reader-app"))]
|
||||
opslog_lib::run();
|
||||
}
|
||||
|
||||
178
src-tauri/src/reader_association.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use serde::Serialize;
|
||||
|
||||
const READER_BUNDLE_ID: &str = "com.murong.opslog.reader";
|
||||
const TRC_CONTENT_TYPE: &str = "com.murong.opslog.trc";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrcAssociationStatus {
|
||||
supported: bool,
|
||||
associated: bool,
|
||||
platform: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
|
||||
use super::{READER_BUNDLE_ID, TRC_CONTENT_TYPE};
|
||||
|
||||
const LS_ROLES_VIEWER: u32 = 0x0000_0002;
|
||||
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn LSCopyDefaultRoleHandlerForContentType(
|
||||
content_type: CFStringRef,
|
||||
role: u32,
|
||||
) -> CFStringRef;
|
||||
fn LSSetDefaultRoleHandlerForContentType(
|
||||
content_type: CFStringRef,
|
||||
role: u32,
|
||||
handler_bundle_id: CFStringRef,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
pub fn is_associated() -> bool {
|
||||
let content_type = CFString::new(TRC_CONTENT_TYPE);
|
||||
let handler = unsafe {
|
||||
LSCopyDefaultRoleHandlerForContentType(
|
||||
content_type.as_concrete_TypeRef(),
|
||||
LS_ROLES_VIEWER,
|
||||
)
|
||||
};
|
||||
if handler.is_null() {
|
||||
return false;
|
||||
}
|
||||
let handler = unsafe { CFString::wrap_under_create_rule(handler) };
|
||||
handler.to_string() == READER_BUNDLE_ID
|
||||
}
|
||||
|
||||
pub fn associate() -> Result<(), String> {
|
||||
let content_type = CFString::new(TRC_CONTENT_TYPE);
|
||||
let bundle_id = CFString::new(READER_BUNDLE_ID);
|
||||
let status = unsafe {
|
||||
LSSetDefaultRoleHandlerForContentType(
|
||||
content_type.as_concrete_TypeRef(),
|
||||
LS_ROLES_VIEWER,
|
||||
bundle_id.as_concrete_TypeRef(),
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
return Err(format!("macOS 无法设置 .trc 默认应用(错误码 {status})"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use std::ffi::c_void;
|
||||
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
|
||||
const TRC_PROG_ID: &str = "OpsLogReader.trc";
|
||||
|
||||
#[link(name = "shell32")]
|
||||
unsafe extern "system" {
|
||||
fn SHChangeNotify(event_id: i32, flags: u32, item1: *const c_void, item2: *const c_void);
|
||||
}
|
||||
|
||||
pub fn is_associated() -> bool {
|
||||
let current_user = RegKey::predef(HKEY_CURRENT_USER);
|
||||
current_user
|
||||
.open_subkey(r"Software\Classes\.trc")
|
||||
.and_then(|key| key.get_value::<String, _>(""))
|
||||
.is_ok_and(|value| value == TRC_PROG_ID)
|
||||
}
|
||||
|
||||
pub fn associate() -> Result<(), String> {
|
||||
let executable =
|
||||
std::env::current_exe().map_err(|error| format!("无法定位 Reader 程序:{error}"))?;
|
||||
let current_user = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let (classes, _) = current_user
|
||||
.create_subkey(r"Software\Classes")
|
||||
.map_err(|error| format!("无法打开用户文件关联配置:{error}"))?;
|
||||
|
||||
let (extension, _) = classes
|
||||
.create_subkey(".trc")
|
||||
.map_err(|error| format!("无法创建 .trc 文件关联:{error}"))?;
|
||||
extension
|
||||
.set_value("", &TRC_PROG_ID)
|
||||
.map_err(|error| format!("无法保存 .trc 文件关联:{error}"))?;
|
||||
extension
|
||||
.set_value("Content Type", &"text/plain")
|
||||
.map_err(|error| format!("无法保存 .trc 文件类型:{error}"))?;
|
||||
|
||||
let (file_type, _) = classes
|
||||
.create_subkey(TRC_PROG_ID)
|
||||
.map_err(|error| format!("无法创建 Reader 文件类型:{error}"))?;
|
||||
file_type
|
||||
.set_value("", &"OpsLog TRC 日志文件")
|
||||
.map_err(|error| format!("无法保存 Reader 文件类型:{error}"))?;
|
||||
let (icon, _) = file_type
|
||||
.create_subkey("DefaultIcon")
|
||||
.map_err(|error| format!("无法配置 Reader 文件图标:{error}"))?;
|
||||
icon.set_value("", &format!("\"{}\",0", executable.display()))
|
||||
.map_err(|error| format!("无法保存 Reader 文件图标:{error}"))?;
|
||||
let (open_command, _) = file_type
|
||||
.create_subkey(r"shell\open\command")
|
||||
.map_err(|error| format!("无法配置 Reader 打开命令:{error}"))?;
|
||||
open_command
|
||||
.set_value("", &format!("\"{}\" \"%1\"", executable.display()))
|
||||
.map_err(|error| format!("无法保存 Reader 打开命令:{error}"))?;
|
||||
|
||||
unsafe {
|
||||
SHChangeNotify(0x0800_0000, 0, std::ptr::null(), std::ptr::null());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_trc_association_status() -> TrcAssociationStatus {
|
||||
#[cfg(target_os = "macos")]
|
||||
return TrcAssociationStatus {
|
||||
supported: true,
|
||||
associated: platform::is_associated(),
|
||||
platform: "macOS",
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return TrcAssociationStatus {
|
||||
supported: true,
|
||||
associated: platform::is_associated(),
|
||||
platform: "Windows",
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
TrcAssociationStatus {
|
||||
supported: false,
|
||||
associated: false,
|
||||
platform: "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn associate_trc_files() -> Result<TrcAssociationStatus, String> {
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
{
|
||||
platform::associate()?;
|
||||
return Ok(get_trc_association_status());
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
Err("当前平台暂不支持自动关联 .trc 文件".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn uses_reader_specific_identifiers() {
|
||||
assert_eq!(READER_BUNDLE_ID, "com.murong.opslog.reader");
|
||||
assert_eq!(TRC_CONTENT_TYPE, "com.murong.opslog.trc");
|
||||
}
|
||||
}
|
||||
96
src-tauri/src/reader_files.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
const MAX_TRC_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PendingTrcFile(pub Mutex<Option<PathBuf>>);
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrcDocument {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
fn validate_trc_path(path: &Path) -> Result<(), String> {
|
||||
let is_trc = path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("trc"));
|
||||
if !is_trc {
|
||||
return Err("只支持打开 .trc 日志文件".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn startup_trc_path() -> Option<PathBuf> {
|
||||
std::env::args_os()
|
||||
.skip(1)
|
||||
.map(PathBuf::from)
|
||||
.find(|path| validate_trc_path(path).is_ok() && path.is_file())
|
||||
}
|
||||
|
||||
pub fn remember_pending_file(state: &PendingTrcFile, path: PathBuf) {
|
||||
if validate_trc_path(&path).is_ok()
|
||||
&& let Ok(mut pending) = state.0.lock()
|
||||
{
|
||||
*pending = Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_document(path: PathBuf) -> Result<TrcDocument, String> {
|
||||
validate_trc_path(&path)?;
|
||||
let metadata = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.map_err(|error| format!("无法读取日志文件信息:{error}"))?;
|
||||
if !metadata.is_file() {
|
||||
return Err("选择的路径不是日志文件".to_string());
|
||||
}
|
||||
if metadata.len() > MAX_TRC_BYTES {
|
||||
return Err("日志文件超过 64 MB 安全上限".to_string());
|
||||
}
|
||||
let content = tokio::fs::read_to_string(&path)
|
||||
.await
|
||||
.map_err(|error| format!("无法读取 TRC 日志:{error}"))?;
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("log.trc")
|
||||
.to_string();
|
||||
Ok(TrcDocument {
|
||||
name,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_startup_trc_file(
|
||||
state: tauri::State<'_, PendingTrcFile>,
|
||||
) -> Result<Option<TrcDocument>, String> {
|
||||
let path = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "无法读取待打开的日志文件".to_string())?
|
||||
.take();
|
||||
match path {
|
||||
Some(path) => read_document(path).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_trc_extension_case_insensitively() {
|
||||
assert!(validate_trc_path(Path::new("sample.trc")).is_ok());
|
||||
assert!(validate_trc_path(Path::new("sample.TRC")).is_ok());
|
||||
assert!(validate_trc_path(Path::new("sample.log")).is_err());
|
||||
}
|
||||
}
|
||||
109
src-tauri/src/reader_settings.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const SETTINGS_DIRECTORY: &str = "OpsLog/shared-reader-settings";
|
||||
const ALLOWED_SETTINGS: [(&str, &str); 5] = [
|
||||
(
|
||||
"opslog.transaction-log.custom-markers.v1",
|
||||
"custom-markers.json",
|
||||
),
|
||||
(
|
||||
"opslog.transaction-log-reader.preferences.v1",
|
||||
"reader-preferences.json",
|
||||
),
|
||||
(
|
||||
"opslog.transaction-log.custom-marker-width-ratio.v1",
|
||||
"custom-marker-width.txt",
|
||||
),
|
||||
(
|
||||
"opslog.transaction-log-reader.width-ratio.v1",
|
||||
"reader-width.txt",
|
||||
),
|
||||
(
|
||||
"opslog.transaction-log-outline.geometry.v1",
|
||||
"outline-geometry.json",
|
||||
),
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReaderSettingInput {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
fn settings_directory() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|directory| directory.join(SETTINGS_DIRECTORY))
|
||||
.ok_or_else(|| "无法定位阅读器共享配置目录".to_string())
|
||||
}
|
||||
|
||||
fn filename_for(key: &str) -> Result<&'static str, String> {
|
||||
ALLOWED_SETTINGS
|
||||
.iter()
|
||||
.find_map(|(candidate, filename)| (*candidate == key).then_some(*filename))
|
||||
.ok_or_else(|| "不支持的阅读器配置项".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_reader_settings() -> Result<HashMap<String, String>, String> {
|
||||
let directory = settings_directory()?;
|
||||
let mut settings = HashMap::new();
|
||||
for (key, filename) in ALLOWED_SETTINGS {
|
||||
let path = directory.join(filename);
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(value) => {
|
||||
settings.insert(key.to_string(), value);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"无法读取共享阅读器配置 {}:{error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_reader_setting(input: ReaderSettingInput) -> Result<(), String> {
|
||||
if input.value.len() > 1024 * 1024 {
|
||||
return Err("单个阅读器配置项不能超过 1 MB".to_string());
|
||||
}
|
||||
let filename = filename_for(&input.key)?;
|
||||
let directory = settings_directory()?;
|
||||
tokio::fs::create_dir_all(&directory)
|
||||
.await
|
||||
.map_err(|error| format!("无法创建阅读器共享配置目录:{error}"))?;
|
||||
let path = directory.join(filename);
|
||||
tokio::fs::write(&path, input.value)
|
||||
.await
|
||||
.map_err(|error| format!("无法保存阅读器共享配置:{error}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.await
|
||||
.map_err(|error| format!("无法保护阅读器共享配置权限:{error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_known_reader_settings_are_persisted() {
|
||||
assert_eq!(
|
||||
filename_for("opslog.transaction-log.custom-markers.v1").unwrap(),
|
||||
"custom-markers.json"
|
||||
);
|
||||
assert!(filename_for("unexpected.setting").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "OpsLog",
|
||||
"version": "3.0.27",
|
||||
"version": "3.0.28",
|
||||
"identifier": "com.murong.opslog",
|
||||
"build": {
|
||||
"frontendDist": "../dist-web",
|
||||
@@ -29,7 +29,10 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'",
|
||||
"capabilities": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
53
src-tauri/tauri.reader.conf.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "OpsLog Reader",
|
||||
"mainBinaryName": "opslog-reader",
|
||||
"identifier": "com.murong.opslog.reader",
|
||||
"build": {
|
||||
"frontendDist": "../dist-web",
|
||||
"devUrl": "http://127.0.0.1:5173",
|
||||
"beforeDevCommand": "npm run web:dev",
|
||||
"beforeBuildCommand": "npm run web:build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "OpsLog Reader · TRC 日志阅读器",
|
||||
"url": "reader.html",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 1080,
|
||||
"minHeight": 700,
|
||||
"center": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"capabilities": [
|
||||
"reader"
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": false,
|
||||
"icon": [
|
||||
"reader-icons/32x32.png",
|
||||
"reader-icons/128x128.png",
|
||||
"reader-icons/128x128@2x.png",
|
||||
"reader-icons/icon.icns",
|
||||
"reader-icons/icon.ico"
|
||||
],
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "独立的 TRC 日志阅读器",
|
||||
"longDescription": "与 OpsLog 共享阅读能力和偏好配置的本地 TRC 日志阅读器",
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "12.0",
|
||||
"infoPlist": "reader.Info.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
45
tests/reader-app.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const readJson = async (path: string): Promise<Record<string, unknown>> =>
|
||||
JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
|
||||
|
||||
test("standalone reader keeps a distinct app identity without forcing a global association", async () => {
|
||||
const config = await readJson("src-tauri/tauri.reader.conf.json");
|
||||
assert.equal(config.productName, "OpsLog Reader");
|
||||
assert.equal(config.identifier, "com.murong.opslog.reader");
|
||||
assert.equal(config.mainBinaryName, "opslog-reader");
|
||||
|
||||
const app = config.app as { windows?: Array<{ decorations?: boolean; titleBarStyle?: string }> };
|
||||
assert.equal(app.windows?.[0]?.decorations, true);
|
||||
assert.equal(app.windows?.[0]?.titleBarStyle, undefined);
|
||||
|
||||
const bundle = config.bundle as { fileAssociations?: unknown; macOS?: { infoPlist?: string } };
|
||||
assert.equal(bundle.fileAssociations, undefined);
|
||||
assert.equal(bundle.macOS?.infoPlist, "reader.Info.plist");
|
||||
|
||||
const macInfo = await readFile("src-tauri/reader.Info.plist", "utf8");
|
||||
assert.match(macInfo, /<string>trc<\/string>/);
|
||||
assert.match(macInfo, /<key>LSHandlerRank<\/key>\s*<string>Alternate<\/string>/);
|
||||
assert.doesNotMatch(macInfo, /<string>Owner<\/string>/);
|
||||
});
|
||||
|
||||
test("reader asks before setting the trc default application", async () => {
|
||||
const readerApp = await readFile("web/src/ReaderApp.tsx", "utf8");
|
||||
const associationGuide = await readFile("web/src/components/TrcAssociationGuide.tsx", "utf8");
|
||||
const association = await readFile("src-tauri/src/reader_association.rs", "utf8");
|
||||
assert.match(associationGuide, /是否关联 TRC 文件/);
|
||||
assert.match(associationGuide, /暂不关联/);
|
||||
assert.match(readerApp, /ASSOCIATION_PROMPT_KEY/);
|
||||
assert.match(association, /associate_trc_files/);
|
||||
});
|
||||
|
||||
test("release workflow builds both OpsLog applications", async () => {
|
||||
const workflow = await readFile(".github/workflows/build-windows.yml", "utf8");
|
||||
assert.match(workflow, /npm run desktop:windows:setup/);
|
||||
assert.match(workflow, /npm run reader:windows:setup/);
|
||||
assert.match(workflow, /npm run desktop:macos/);
|
||||
assert.match(workflow, /npm run reader:macos/);
|
||||
assert.match(workflow, /OpsLog_Reader_/);
|
||||
});
|
||||
@@ -1,12 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
root: "web",
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../dist-web",
|
||||
emptyOutDir: true
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(process.cwd(), "web/index.html"),
|
||||
reader: resolve(process.cwd(), "web/reader.html")
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
13
web/reader.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#06111f" />
|
||||
<title>OpsLog Reader · TRC 日志阅读器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/reader-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
176
web/src/ReaderApp.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { associateTrcFiles, desktopMode, errorMessage, getTrcAssociationStatus, loadStartupTrcFile, type TrcAssociationStatus, type TrcDocument } from "./api";
|
||||
import { isQueryFocusShortcut } from "./keyboard-shortcuts";
|
||||
import { ImportIcon } from "./components/Icons";
|
||||
import { LogReaderWorkspace, type TransactionLogDrawerHandle } from "./components/TransactionLogDrawer";
|
||||
import { TrcAssociationGuide } from "./components/TrcAssociationGuide";
|
||||
|
||||
const MAX_TRC_BYTES = 64 * 1024 * 1024;
|
||||
const ASSOCIATION_PROMPT_KEY = "opslog.reader.trc-association-prompt.v1";
|
||||
|
||||
const ReaderFamilyMark = () => <svg className="reader-family-mark" viewBox="0 0 1024 1024" aria-hidden="true">
|
||||
<rect x="32" y="32" width="960" height="960" rx="220" />
|
||||
<rect x="72" y="72" width="880" height="880" rx="180" />
|
||||
<path d="M305 220h294l120 120v464H305z" />
|
||||
<path d="M599 220v120h120M375 555h86l39-118 84 236 45-118h90" />
|
||||
<circle cx="512" cy="512" r="394" />
|
||||
</svg>;
|
||||
|
||||
export default function ReaderApp() {
|
||||
const [document, setDocument] = useState<TrcDocument>();
|
||||
const [notice, setNotice] = useState<string>();
|
||||
const [associationStatus, setAssociationStatus] = useState<TrcAssociationStatus>();
|
||||
const [showAssociationGuide, setShowAssociationGuide] = useState(false);
|
||||
const [associating, setAssociating] = useState(false);
|
||||
const readerRef = useRef<TransactionLogDrawerHandle>(null);
|
||||
|
||||
const openPendingFile = useCallback(async () => {
|
||||
try {
|
||||
setNotice(undefined);
|
||||
const pendingDocument = await loadStartupTrcFile();
|
||||
if (pendingDocument) setDocument(pendingDocument);
|
||||
} catch (error) {
|
||||
setNotice(errorMessage(error));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktopMode) return;
|
||||
let disposed = false;
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
void (async () => {
|
||||
unlisten = await listen<string>("reader://open-file", () => {
|
||||
if (!disposed) void openPendingFile();
|
||||
});
|
||||
if (!disposed) await openPendingFile();
|
||||
})().catch((error) => {
|
||||
if (!disposed) setNotice(errorMessage(error));
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [openPendingFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!desktopMode) return;
|
||||
void getTrcAssociationStatus().then((status) => {
|
||||
setAssociationStatus(status);
|
||||
const choice = window.localStorage.getItem(ASSOCIATION_PROMPT_KEY);
|
||||
setShowAssociationGuide(status.supported && !status.associated && choice !== "dismissed");
|
||||
}).catch((error) => setNotice(errorMessage(error)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyboard = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.isComposing || !document) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
readerRef.current?.closeTopLayer();
|
||||
} else if (isQueryFocusShortcut(event)) {
|
||||
event.preventDefault();
|
||||
readerRef.current?.focusSearch();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyboard);
|
||||
return () => window.removeEventListener("keydown", handleKeyboard);
|
||||
}, [document]);
|
||||
|
||||
const openBrowserFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.name.toLocaleLowerCase().endsWith(".trc")) {
|
||||
setNotice("只支持打开 .trc 日志文件");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_TRC_BYTES) {
|
||||
setNotice("日志文件超过 64 MB 安全上限");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setNotice(undefined);
|
||||
setDocument({ name: file.name, path: file.name, content: await file.text() });
|
||||
} catch (error) {
|
||||
setNotice(errorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const openControl = <label className="reader-open-file">
|
||||
<ImportIcon />
|
||||
<span>打开 TRC</span>
|
||||
<input type="file" accept=".trc" onChange={(event) => void openBrowserFile(event)} />
|
||||
</label>;
|
||||
|
||||
const associateFiles = async () => {
|
||||
setAssociating(true);
|
||||
try {
|
||||
setNotice(undefined);
|
||||
const status = await associateTrcFiles();
|
||||
setAssociationStatus(status);
|
||||
if (!status.associated) throw new Error("系统未确认 .trc 文件关联,请稍后重试");
|
||||
window.localStorage.setItem(ASSOCIATION_PROMPT_KEY, "associated");
|
||||
setShowAssociationGuide(false);
|
||||
} catch (error) {
|
||||
setNotice(errorMessage(error));
|
||||
} finally {
|
||||
setAssociating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissAssociationGuide = () => {
|
||||
window.localStorage.setItem(ASSOCIATION_PROMPT_KEY, "dismissed");
|
||||
setShowAssociationGuide(false);
|
||||
};
|
||||
|
||||
const associationSummary = associationStatus?.supported && <section className={`trc-association-summary${associationStatus.associated ? " is-associated" : ""}`}>
|
||||
<div><span>文件关联</span><strong>{associationStatus.associated ? ".trc 已关联" : ".trc 尚未关联"}</strong></div>
|
||||
{!associationStatus.associated && <button type="button" onClick={() => setShowAssociationGuide(true)}>设置关联</button>}
|
||||
</section>;
|
||||
|
||||
if (!document) return <div className="reader-empty-state">
|
||||
<main className="reader-welcome-layout">
|
||||
<section className="reader-welcome-primary">
|
||||
<ReaderFamilyMark />
|
||||
<span className="eyebrow">OPSLOG FAMILY · LOCAL TRACE</span>
|
||||
<h1>OpsLog Reader</h1>
|
||||
<p>独立的 TRC 日志阅读工具。保留 OpsLog 的折叠、搜索、结构预览和自定义标记能力。</p>
|
||||
{openControl}
|
||||
<small>文件只在本机读取,不会上传到网络</small>
|
||||
</section>
|
||||
<aside className="reader-welcome-side">
|
||||
{showAssociationGuide && associationStatus
|
||||
? <TrcAssociationGuide status={associationStatus} pending={associating} onAssociate={() => void associateFiles()} onDismiss={dismissAssociationGuide} />
|
||||
: <>
|
||||
<span className="eyebrow">OPEN WORKFLOW</span>
|
||||
<h2>打开方式</h2>
|
||||
<ol><li><b>01</b><span>点击“打开 TRC”选择本地日志</span></li><li><b>02</b><span>可选关联扩展名,之后直接双击打开</span></li></ol>
|
||||
{associationSummary}
|
||||
</>}
|
||||
</aside>
|
||||
</main>
|
||||
{notice && <div className="reader-file-notice" role="alert">{notice}<button onClick={() => setNotice(undefined)}>×</button></div>}
|
||||
</div>;
|
||||
|
||||
const headerActions = <>
|
||||
{openControl}
|
||||
{associationStatus?.supported && !associationStatus.associated && <button type="button" className="reader-association-shortcut" disabled={associating} onClick={() => void associateFiles()}>{associating ? "正在关联…" : "关联 .trc"}</button>}
|
||||
</>;
|
||||
|
||||
return <div className="reader-app">
|
||||
<LogReaderWorkspace
|
||||
ref={readerRef}
|
||||
logId={document.name}
|
||||
content={document.content}
|
||||
loading={false}
|
||||
onClose={() => setDocument(undefined)}
|
||||
presentation="standalone"
|
||||
eyebrow="TRC LOG · LOCAL FILE"
|
||||
title="OpsLog Reader"
|
||||
sourceLabel="TRC"
|
||||
headerAction={headerActions}
|
||||
/>
|
||||
{notice && <div className="reader-file-notice" role="alert">{notice}<button onClick={() => setNotice(undefined)}>×</button></div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -5,6 +5,18 @@ interface SavedFile {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface TrcDocument {
|
||||
name: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TrcAssociationStatus {
|
||||
supported: boolean;
|
||||
associated: boolean;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface LanShareStatus {
|
||||
enabled: boolean;
|
||||
url?: string | null;
|
||||
@@ -210,6 +222,17 @@ export const loadTrace = async (
|
||||
return (await response.json()).rows;
|
||||
};
|
||||
|
||||
export const loadStartupTrcFile = async (): Promise<TrcDocument | undefined> =>
|
||||
(await desktopInvoke<TrcDocument | null>("load_startup_trc_file")) ?? undefined;
|
||||
|
||||
export const getTrcAssociationStatus = async (): Promise<TrcAssociationStatus> => {
|
||||
if (!desktopMode) return { supported: false, associated: false, platform: "browser" };
|
||||
return desktopInvoke<TrcAssociationStatus>("get_trc_association_status");
|
||||
};
|
||||
|
||||
export const associateTrcFiles = async (): Promise<TrcAssociationStatus> =>
|
||||
desktopInvoke<TrcAssociationStatus>("associate_trc_files");
|
||||
|
||||
export const importEnvironmentConfig = async (contents: string): Promise<string> => {
|
||||
if (desktopMode) return (await desktopInvoke<SavedFile>("save_environment_config", { contents })).path;
|
||||
const response = await webFetch("/api/environments/import", {
|
||||
|
||||
@@ -63,8 +63,8 @@ export const Header = ({ environments, selected, onSelect, loading, desktopMode,
|
||||
</div>
|
||||
{desktopMode && <LanShareControl controller={lanShare} />}
|
||||
{desktopMode
|
||||
? <button className={`version-chip is-interactive${updateAvailable ? " has-update" : ""}`} disabled={updateBusy} title={updateAvailable ? "有新版本可安装" : "检查更新"} onClick={onCheckForUpdates}>APP · 3.0.27<span aria-hidden="true" /></button>
|
||||
: <div className="version-chip">WEB · 3.0.27</div>}
|
||||
? <button className={`version-chip is-interactive${updateAvailable ? " has-update" : ""}`} disabled={updateBusy} title={updateAvailable ? "有新版本可安装" : "检查更新"} onClick={onCheckForUpdates}>APP · 3.0.28<span aria-hidden="true" /></button>
|
||||
: <div className="version-chip">WEB · 3.0.28</div>}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type LogOutlineResizeDirection
|
||||
} from "../log-outline-geometry";
|
||||
import type { LogHighlight, LogOutlineCategory, LogOutlineItem } from "../transaction-log-model";
|
||||
import { readerSettingsStorage } from "../reader-settings-storage";
|
||||
import { CloseIcon } from "./Icons";
|
||||
import { LogOutlineList } from "./LogOutlineList";
|
||||
|
||||
@@ -48,7 +49,7 @@ interface OutlineInteraction {
|
||||
|
||||
const readStoredGeometry = (): LogOutlineGeometry | undefined => {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(OUTLINE_GEOMETRY_KEY) ?? "null") as Partial<LogOutlineGeometry> | null;
|
||||
const parsed = JSON.parse(readerSettingsStorage.getItem(OUTLINE_GEOMETRY_KEY) ?? "null") as Partial<LogOutlineGeometry> | null;
|
||||
if (!parsed || ![parsed.x, parsed.y, parsed.width, parsed.height].every(Number.isFinite)) return undefined;
|
||||
return parsed as LogOutlineGeometry;
|
||||
} catch {
|
||||
@@ -58,7 +59,7 @@ const readStoredGeometry = (): LogOutlineGeometry | undefined => {
|
||||
|
||||
const storeGeometry = (geometry: LogOutlineGeometry) => {
|
||||
try {
|
||||
localStorage.setItem(OUTLINE_GEOMETRY_KEY, JSON.stringify(geometry));
|
||||
readerSettingsStorage.setItem(OUTLINE_GEOMETRY_KEY, JSON.stringify(geometry));
|
||||
} catch {
|
||||
// Moving and resizing remain available when local storage is disabled.
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useCallback, useDeferredValue, useEffect, useImperativeHandle, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { forwardRef, useCallback, useDeferredValue, useEffect, useImperativeHandle, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
|
||||
import { readCustomMarkerWidthRatio, storeCustomMarkerWidthRatio } from "../custom-marker-layout";
|
||||
import { buildCustomLogMarkerOutline, createCustomLogMarker, MAX_CUSTOM_LOG_MARKERS, readCustomLogMarkers, storeCustomLogMarkers, type CustomLogMarker } from "../custom-log-markers";
|
||||
import { readLogReaderPreferences, storeLogReaderPreferences, type LogReaderPreferences } from "../log-reader-preferences";
|
||||
@@ -9,6 +9,7 @@ import { errorMessage, savePortableLogHtml } from "../api";
|
||||
import { findPlainLogMatchesInLowercase, findRegexLogMatches, MAX_LOG_SEARCH_MATCHES } from "../transaction-log-search";
|
||||
import type { InspectableLogStructureKind } from "../structured-log-preview";
|
||||
import type { LogOutlineCategory } from "../transaction-log-model";
|
||||
import { readerSettingsStorage } from "../reader-settings-storage";
|
||||
import { CloseIcon, DownloadIcon, MarkerAddIcon, SearchIcon } from "./Icons";
|
||||
import { CustomLogMarkerShelf, type CustomLogMarkerShelfHandle } from "./CustomLogMarkerShelf";
|
||||
import { CustomMarkerSectionResizeHandle } from "./CustomMarkerSectionResizeHandle";
|
||||
@@ -30,20 +31,25 @@ const clampReaderWidthRatio = (ratio: number): number => {
|
||||
|
||||
const readReaderWidthRatio = (): number => {
|
||||
try {
|
||||
const saved = Number.parseFloat(localStorage.getItem(READER_WIDTH_KEY) ?? "");
|
||||
const saved = Number.parseFloat(readerSettingsStorage.getItem(READER_WIDTH_KEY) ?? "");
|
||||
return clampReaderWidthRatio(Number.isFinite(saved) ? saved : DEFAULT_READER_WIDTH_RATIO);
|
||||
} catch {
|
||||
return DEFAULT_READER_WIDTH_RATIO;
|
||||
}
|
||||
};
|
||||
|
||||
interface TransactionLogDrawerProps {
|
||||
export interface LogReaderWorkspaceProps {
|
||||
logId?: string;
|
||||
content: string;
|
||||
loading: boolean;
|
||||
remoteDurationMs?: number;
|
||||
cached?: boolean;
|
||||
onClose: () => void;
|
||||
presentation?: "drawer" | "standalone";
|
||||
eyebrow?: string;
|
||||
title?: string;
|
||||
sourceLabel?: string;
|
||||
headerAction?: ReactNode;
|
||||
}
|
||||
|
||||
export interface TransactionLogDrawerHandle {
|
||||
@@ -51,7 +57,7 @@ export interface TransactionLogDrawerHandle {
|
||||
focusSearch: () => void;
|
||||
}
|
||||
|
||||
export const TransactionLogDrawer = forwardRef<TransactionLogDrawerHandle, TransactionLogDrawerProps>(({ logId, content, loading, remoteDurationMs, cached, onClose }, ref) => {
|
||||
export const LogReaderWorkspace = forwardRef<TransactionLogDrawerHandle, LogReaderWorkspaceProps>(({ logId, content, loading, remoteDurationMs, cached, onClose, presentation = "drawer", eyebrow = "TRANSACTION LOG", title = "日志阅读器", sourceLabel, headerAction }, ref) => {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [activeMatch, setActiveMatch] = useState(0);
|
||||
const [readerPreferences, setReaderPreferences] = useState(readLogReaderPreferences);
|
||||
@@ -150,7 +156,7 @@ export const TransactionLogDrawer = forwardRef<TransactionLogDrawerHandle, Trans
|
||||
resizeStart.current = null;
|
||||
setIsResizing(false);
|
||||
try {
|
||||
localStorage.setItem(READER_WIDTH_KEY, String(widthRatioRef.current));
|
||||
readerSettingsStorage.setItem(READER_WIDTH_KEY, String(widthRatioRef.current));
|
||||
} catch {
|
||||
// The reader remains resizable if storage is disabled.
|
||||
}
|
||||
@@ -191,7 +197,7 @@ export const TransactionLogDrawer = forwardRef<TransactionLogDrawerHandle, Trans
|
||||
else return;
|
||||
event.preventDefault();
|
||||
try {
|
||||
localStorage.setItem(READER_WIDTH_KEY, String(widthRatioRef.current));
|
||||
readerSettingsStorage.setItem(READER_WIDTH_KEY, String(widthRatioRef.current));
|
||||
} catch {
|
||||
// Keyboard resizing still applies for this view.
|
||||
}
|
||||
@@ -267,10 +273,10 @@ export const TransactionLogDrawer = forwardRef<TransactionLogDrawerHandle, Trans
|
||||
};
|
||||
|
||||
if (!logId) return null;
|
||||
return <div className="drawer-backdrop" onMouseDown={onClose}>
|
||||
<aside className="drawer log-reader-drawer" style={{ width: `${widthRatio * 100}vw` }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="log-reader-resize-handle" role="separator" aria-orientation="vertical" aria-label="调整日志阅读器宽度" aria-valuemin={Math.round(Math.min(520 / window.innerWidth, .88) * 100)} aria-valuemax={88} aria-valuenow={Math.round(widthRatio * 100)} tabIndex={0} onPointerDown={startResize} onKeyDown={adjustReaderWidth} />
|
||||
<div className="drawer-heading"><div><span className="eyebrow">TRANSACTION LOG</span><h2>日志阅读器</h2><div className="log-reader-title-line"><code>{logId}</code><button type="button" className="portable-log-export" disabled={loading || !content || exportingPortable} aria-busy={exportingPortable} title="导出可在浏览器中离线打开的只读日志页面" onClick={() => void exportPortableReader()}>{exportingPortable ? <span className="button-spinner" aria-hidden="true" /> : <DownloadIcon />}<span>{exportingPortable ? "正在生成阅读页…" : "导出阅读页"}</span></button></div>{portableExportNotice && <span className="portable-log-export-notice" role="status">{portableExportNotice}</span>}</div><button title="关闭阅读器" aria-label="关闭阅读器" onClick={onClose}><CloseIcon /></button></div>
|
||||
return <div className={presentation === "standalone" ? "standalone-reader-shell" : "drawer-backdrop"} onMouseDown={presentation === "drawer" ? onClose : undefined}>
|
||||
<aside className={presentation === "standalone" ? "standalone-log-reader" : "drawer log-reader-drawer"} style={presentation === "drawer" ? { width: `${widthRatio * 100}vw` } : undefined} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{presentation === "drawer" && <div className="log-reader-resize-handle" role="separator" aria-orientation="vertical" aria-label="调整日志阅读器宽度" aria-valuemin={Math.round(Math.min(520 / window.innerWidth, .88) * 100)} aria-valuemax={88} aria-valuenow={Math.round(widthRatio * 100)} tabIndex={0} onPointerDown={startResize} onKeyDown={adjustReaderWidth} />}
|
||||
<div className="drawer-heading"><div><span className="eyebrow">{eyebrow}</span><h2>{title}</h2><div className="log-reader-title-line"><code>{logId}</code><button type="button" className="portable-log-export" disabled={loading || !content || exportingPortable} aria-busy={exportingPortable} title="导出可在浏览器中离线打开的只读日志页面" onClick={() => void exportPortableReader()}>{exportingPortable ? <span className="button-spinner" aria-hidden="true" /> : <DownloadIcon />}<span>{exportingPortable ? "正在生成阅读页…" : "导出阅读页"}</span></button>{headerAction}</div>{portableExportNotice && <span className="portable-log-export-notice" role="status">{portableExportNotice}</span>}</div>{presentation === "drawer" && <button title="关闭当前日志" aria-label="关闭当前日志" onClick={onClose}><CloseIcon /></button>}</div>
|
||||
<div className="log-reader-controls">
|
||||
<div className={`log-reader-search-group${searchResult.error ? " has-error" : ""}`}>
|
||||
<label className="log-reader-search"><SearchIcon /><input ref={searchInputRef} autoFocus value={keyword} onChange={(event) => setKeyword(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); moveMatch(event.shiftKey ? -1 : 1); } }} placeholder={regexSearch ? "输入正则表达式查询日志" : "查询日志内容"} aria-label="查询日志内容" aria-keyshortcuts="Meta+F Alt+F" aria-invalid={Boolean(searchResult.error)} aria-describedby={searchResult.error ? "log-reader-search-error" : undefined} /></label>
|
||||
@@ -285,7 +291,7 @@ export const TransactionLogDrawer = forwardRef<TransactionLogDrawerHandle, Trans
|
||||
{!loading && content && <div className="log-reader-insights-shell" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div className="log-reader-insights" aria-label="日志分析摘要">
|
||||
<div className="built-in-marker-track">
|
||||
<span className="reader-performance" title="远程时间包含 Kibana 查询、VPN 传输和正文接收">{cached ? <><b>缓存</b> 即时加载</> : <><b>{((remoteDurationMs ?? 0) / 1000).toFixed(2)}s</b> 远程</>} · <b>{analyzed.durationMs.toFixed(0)}ms</b> 解析</span>
|
||||
<span className="reader-performance" title={sourceLabel ? "本地文件读取与正文解析耗时" : "远程时间包含 Kibana 查询、VPN 传输和正文接收"}>{sourceLabel ? <><b>{sourceLabel}</b> 本地</> : cached ? <><b>缓存</b> 即时加载</> : <><b>{((remoteDurationMs ?? 0) / 1000).toFixed(2)}s</b> 远程</>} · <b>{analyzed.durationMs.toFixed(0)}ms</b> 解析</span>
|
||||
<span><b>{analysis.stats.lines.toLocaleString()}</b> 行</span>
|
||||
<button type="button" data-log-outline-trigger className={outlineCategory === "service" ? "is-active" : undefined} title="查看微服务入口 Outline" onClick={() => toggleOutline("service")}><b>{analysis.stats.services}</b> 微服务</button>
|
||||
<button type="button" data-log-outline-trigger className={outlineCategory === "call" ? "is-active" : undefined} title="查看调用标记 Outline" onClick={() => toggleOutline("call")}><b>{analysis.stats.calls}</b> 调用标记</button>
|
||||
@@ -338,4 +344,5 @@ export const TransactionLogDrawer = forwardRef<TransactionLogDrawerHandle, Trans
|
||||
</div>;
|
||||
});
|
||||
|
||||
TransactionLogDrawer.displayName = "TransactionLogDrawer";
|
||||
LogReaderWorkspace.displayName = "LogReaderWorkspace";
|
||||
export const TransactionLogDrawer = LogReaderWorkspace;
|
||||
|
||||
21
web/src/components/TrcAssociationGuide.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { TrcAssociationStatus } from "../api";
|
||||
|
||||
interface TrcAssociationGuideProps {
|
||||
status: TrcAssociationStatus;
|
||||
pending: boolean;
|
||||
onAssociate: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const TrcAssociationGuide = ({ status, pending, onAssociate, onDismiss }: TrcAssociationGuideProps) => <section className="trc-association-guide" aria-labelledby="trc-association-title">
|
||||
<div className="trc-association-icon" aria-hidden="true">.trc</div>
|
||||
<div>
|
||||
<span>首次使用</span>
|
||||
<h2 id="trc-association-title">是否关联 TRC 文件?</h2>
|
||||
<p>关联后,双击 <code>.trc</code> 文件会直接使用 OpsLog Reader 打开。暂不关联也可以继续手动选择文件。</p>
|
||||
</div>
|
||||
<div className="trc-association-actions">
|
||||
<button type="button" className="secondary" disabled={pending} onClick={onDismiss}>暂不关联</button>
|
||||
<button type="button" className="primary" disabled={pending || !status.supported} onClick={onAssociate}>{pending ? "正在关联…" : `关联到 ${status.platform}`}</button>
|
||||
</div>
|
||||
</section>;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { findPlainLogMatchesInLowercase, findRegexLogMatches, MAX_LOG_SEARCH_MATCHES, type LogSearchMatch } from "./transaction-log-search";
|
||||
import type { LogHighlight, LogOutlineItem } from "./transaction-log-model";
|
||||
import { readerSettingsStorage } from "./reader-settings-storage";
|
||||
|
||||
export interface CustomLogMarkerRule {
|
||||
id: string;
|
||||
@@ -76,7 +77,7 @@ export const normalizeCustomLogMarkers = (value: unknown, maximum = MAX_CUSTOM_L
|
||||
.slice(0, Math.max(0, maximum));
|
||||
};
|
||||
|
||||
export const readCustomLogMarkers = (storage: MarkerStorage = localStorage): CustomLogMarker[] => {
|
||||
export const readCustomLogMarkers = (storage: MarkerStorage = readerSettingsStorage): CustomLogMarker[] => {
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(CUSTOM_LOG_MARKERS_KEY) ?? "[]") as unknown;
|
||||
return normalizeCustomLogMarkers(value);
|
||||
@@ -85,7 +86,7 @@ export const readCustomLogMarkers = (storage: MarkerStorage = localStorage): Cus
|
||||
}
|
||||
};
|
||||
|
||||
export const storeCustomLogMarkers = (markers: CustomLogMarker[], storage: MarkerStorage = localStorage): void => {
|
||||
export const storeCustomLogMarkers = (markers: CustomLogMarker[], storage: MarkerStorage = readerSettingsStorage): void => {
|
||||
try {
|
||||
storage.setItem(CUSTOM_LOG_MARKERS_KEY, JSON.stringify(markers.slice(0, MAX_CUSTOM_LOG_MARKERS)));
|
||||
} catch {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const MAX_CUSTOM_MARKER_WIDTH_RATIO = .58;
|
||||
export const clampCustomMarkerWidthRatio = (ratio: number): number =>
|
||||
Math.min(MAX_CUSTOM_MARKER_WIDTH_RATIO, Math.max(MIN_CUSTOM_MARKER_WIDTH_RATIO, ratio));
|
||||
|
||||
export const readCustomMarkerWidthRatio = (storage: MarkerLayoutStorage = localStorage): number => {
|
||||
export const readCustomMarkerWidthRatio = (storage: MarkerLayoutStorage = readerSettingsStorage): number => {
|
||||
try {
|
||||
const ratio = Number.parseFloat(storage.getItem(CUSTOM_MARKER_WIDTH_RATIO_KEY) ?? "");
|
||||
return clampCustomMarkerWidthRatio(Number.isFinite(ratio) ? ratio : DEFAULT_CUSTOM_MARKER_WIDTH_RATIO);
|
||||
@@ -20,10 +20,11 @@ export const readCustomMarkerWidthRatio = (storage: MarkerLayoutStorage = localS
|
||||
}
|
||||
};
|
||||
|
||||
export const storeCustomMarkerWidthRatio = (ratio: number, storage: MarkerLayoutStorage = localStorage): void => {
|
||||
export const storeCustomMarkerWidthRatio = (ratio: number, storage: MarkerLayoutStorage = readerSettingsStorage): void => {
|
||||
try {
|
||||
storage.setItem(CUSTOM_MARKER_WIDTH_RATIO_KEY, String(clampCustomMarkerWidthRatio(ratio)));
|
||||
} catch {
|
||||
// Resizing remains available for the current session when storage is unavailable.
|
||||
}
|
||||
};
|
||||
import { readerSettingsStorage } from "./reader-settings-storage";
|
||||
|
||||
@@ -37,7 +37,7 @@ const normalizePreferences = (value: unknown): LogReaderPreferences => {
|
||||
};
|
||||
};
|
||||
|
||||
export const readLogReaderPreferences = (storage: ReaderPreferenceStorage = localStorage): LogReaderPreferences => {
|
||||
export const readLogReaderPreferences = (storage: ReaderPreferenceStorage = readerSettingsStorage): LogReaderPreferences => {
|
||||
try {
|
||||
return normalizePreferences(JSON.parse(storage.getItem(LOG_READER_PREFERENCES_KEY) ?? "null"));
|
||||
} catch {
|
||||
@@ -47,7 +47,7 @@ export const readLogReaderPreferences = (storage: ReaderPreferenceStorage = loca
|
||||
|
||||
export const storeLogReaderPreferences = (
|
||||
preferences: LogReaderPreferences,
|
||||
storage: ReaderPreferenceStorage = localStorage
|
||||
storage: ReaderPreferenceStorage = readerSettingsStorage
|
||||
) => {
|
||||
try {
|
||||
storage.setItem(LOG_READER_PREFERENCES_KEY, JSON.stringify(preferences));
|
||||
@@ -55,3 +55,4 @@ export const storeLogReaderPreferences = (
|
||||
// Reader controls remain usable when local storage is disabled.
|
||||
}
|
||||
};
|
||||
import { readerSettingsStorage } from "./reader-settings-storage";
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { hydrateReaderSettings } from "./reader-settings-storage";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
const start = async () => {
|
||||
await hydrateReaderSettings();
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
12
web/src/reader-main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ReaderApp from "./ReaderApp";
|
||||
import { hydrateReaderSettings } from "./reader-settings-storage";
|
||||
import "./styles.css";
|
||||
|
||||
const start = async () => {
|
||||
await hydrateReaderSettings();
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><ReaderApp /></StrictMode>);
|
||||
};
|
||||
|
||||
void start();
|
||||
53
web/src/reader-settings-storage.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
export const READER_SETTINGS_KEYS = [
|
||||
"opslog.transaction-log.custom-markers.v1",
|
||||
"opslog.transaction-log-reader.preferences.v1",
|
||||
"opslog.transaction-log.custom-marker-width-ratio.v1",
|
||||
"opslog.transaction-log-reader.width-ratio.v1",
|
||||
"opslog.transaction-log-outline.geometry.v1"
|
||||
] as const;
|
||||
|
||||
const isReaderSettingKey = (key: string): boolean =>
|
||||
READER_SETTINGS_KEYS.some((candidate) => candidate === key);
|
||||
|
||||
const browserStorage = (): Storage | undefined => {
|
||||
try {
|
||||
return typeof window === "undefined" ? undefined : window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const readerSettingsStorage = {
|
||||
getItem: (key: string): string | null => browserStorage()?.getItem(key) ?? null,
|
||||
setItem: (key: string, value: string): void => {
|
||||
browserStorage()?.setItem(key, value);
|
||||
if (!isTauri() || !isReaderSettingKey(key)) return;
|
||||
void invoke("save_reader_setting", { input: { key, value } }).catch(() => {
|
||||
// Local settings remain available if the shared native store cannot be written.
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const hydrateReaderSettings = async (): Promise<void> => {
|
||||
if (!isTauri()) return;
|
||||
const storage = browserStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
const shared = await invoke<Record<string, string>>("load_reader_settings");
|
||||
await Promise.all(READER_SETTINGS_KEYS.map(async (key) => {
|
||||
const sharedValue = shared[key];
|
||||
if (typeof sharedValue === "string") {
|
||||
storage.setItem(key, sharedValue);
|
||||
return;
|
||||
}
|
||||
const legacyValue = storage.getItem(key);
|
||||
if (legacyValue !== null) {
|
||||
await invoke("save_reader_setting", { input: { key, value: legacyValue } });
|
||||
}
|
||||
}));
|
||||
} catch {
|
||||
// Existing local preferences continue to work if native hydration fails.
|
||||
}
|
||||
};
|
||||
@@ -311,6 +311,20 @@ td.success { color: var(--green); } td.error { color: var(--red); } td.neutral {
|
||||
.log-outline-resize-handle { position: absolute; z-index: 3; display: block; touch-action: none; }.log-outline-resize-handle.is-n, .log-outline-resize-handle.is-s { left: 10px; right: 10px; height: 8px; cursor: ns-resize; }.log-outline-resize-handle.is-n { top: -2px; }.log-outline-resize-handle.is-s { bottom: -2px; }.log-outline-resize-handle.is-e, .log-outline-resize-handle.is-w { top: 10px; bottom: 10px; width: 8px; cursor: ew-resize; }.log-outline-resize-handle.is-e { right: -2px; }.log-outline-resize-handle.is-w { left: -2px; }.log-outline-resize-handle.is-ne, .log-outline-resize-handle.is-nw, .log-outline-resize-handle.is-se, .log-outline-resize-handle.is-sw { width: 14px; height: 14px; }.log-outline-resize-handle.is-ne { top: -2px; right: -2px; cursor: nesw-resize; }.log-outline-resize-handle.is-nw { top: -2px; left: -2px; cursor: nwse-resize; }.log-outline-resize-handle.is-se { right: -2px; bottom: -2px; cursor: nwse-resize; }.log-outline-resize-handle.is-sw { left: -2px; bottom: -2px; cursor: nesw-resize; }.log-outline-resize-handle.is-se::after { content: ""; position: absolute; right: 4px; bottom: 4px; width: 7px; height: 7px; opacity: .45; border-right: 2px solid #61a9c4; border-bottom: 2px solid #61a9c4; }.log-outline-popover:hover .log-outline-resize-handle.is-se::after, .log-outline-popover.is-adjusting .log-outline-resize-handle.is-se::after { opacity: .9; border-color: var(--cyan); }
|
||||
.log-reader-status { flex: 1; min-height: 220px; display: grid; place-items: center; color: var(--muted); font-size: 12px; }.log-reader-loading { display: flex; flex-direction: column; justify-content: center; gap: 8px; border: 1px dashed rgba(42, 212, 224, .18); margin-top: 14px; }.log-reader-loading strong { color: #91b6cc; font-size: 13px; letter-spacing: .04em; }.log-reader-loading > span { color: #587a92; font-size: 10px; }.log-reader-loading-visual { position: relative; width: 176px; height: 68px; overflow: hidden; border: 1px solid rgba(55, 131, 169, .42); padding: 11px; background: #071827; }.log-reader-loading-visual i { display: block; height: 5px; margin: 5px 0; background: #17445e; transform-origin: left; animation: reader-log-line 1.3s ease-in-out infinite; }.log-reader-loading-visual i:nth-child(1) { width: 92%; }.log-reader-loading-visual i:nth-child(2) { width: 66%; animation-delay: .16s; }.log-reader-loading-visual i:nth-child(3) { width: 81%; animation-delay: .32s; }.log-reader-loading-visual i:nth-child(4) { width: 46%; animation-delay: .48s; }.log-reader-loading-visual b { position: absolute; top: 8px; bottom: 8px; left: -12px; width: 2px; background: var(--cyan); box-shadow: 0 0 10px var(--cyan); animation: reader-log-scan 1.55s linear infinite; }
|
||||
.log-reader-body { position: relative; flex: 1; min-height: 0; overflow: hidden; margin-top: 10px; border: 1px solid var(--line); background: #061421; }.structured-log-viewer { height: 100%; }
|
||||
.reader-app, .reader-empty-state { --reader-accent: #f2a65a; --reader-accent-soft: rgba(242, 166, 90, .12); }
|
||||
.reader-app, .standalone-reader-shell { position: fixed; inset: 0; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.standalone-reader-shell { padding: 0; background: #061421; }
|
||||
.standalone-log-reader { width: 100%; height: 100%; display: flex; flex-direction: column; overflow: hidden; padding: 0; border: 0; border-radius: 0; background: #071523; box-shadow: none; }
|
||||
.standalone-log-reader .drawer-heading { flex: 0 0 auto; align-items: center; padding: 16px 22px 14px; border-bottom-color: #17374c; background: #081a2a; }.standalone-log-reader .drawer-heading > div { min-width: 0; }.standalone-log-reader .drawer-heading h2 { color: #f0d1af; }.standalone-log-reader .drawer-heading .eyebrow { color: #9b704c; }.standalone-log-reader .log-reader-title-line code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--reader-accent); }.standalone-log-reader .log-reader-controls { margin: 0 22px; }.standalone-log-reader .log-reader-insights-shell { margin: 0 22px; }.standalone-log-reader .log-reader-body { margin: 10px 22px 18px; }
|
||||
.reader-open-file, .reader-association-shortcut { min-height: 25px; display: inline-flex; align-items: center; gap: 5px; padding: 0 9px; border: 1px solid #725234; background: #211a18; color: #d7ae85; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; cursor: pointer; }.reader-open-file:hover, .reader-association-shortcut:hover:not(:disabled) { color: var(--reader-accent); border-color: var(--reader-accent); background: var(--reader-accent-soft); }.reader-open-file svg { width: 13px; height: 13px; }.reader-open-file input { display: none; }.reader-association-shortcut:disabled { opacity: .55; cursor: wait; }
|
||||
.reader-empty-state { position: fixed; inset: 0; overflow: auto; background-color: #061421; background-image: radial-gradient(circle at 29% 47%, rgba(242, 166, 90, .09), transparent 28%), linear-gradient(rgba(31, 80, 110, .08) 1px, transparent 1px), linear-gradient(90deg, rgba(31, 80, 110, .08) 1px, transparent 1px); background-size: auto, 36px 36px, 36px 36px; }
|
||||
.reader-welcome-layout { width: min(1180px, calc(100vw - 96px)); min-height: 100%; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(360px, .85fr); align-items: center; gap: clamp(56px, 7vw, 104px); margin: 0 auto; padding: 64px 0; }.reader-welcome-primary { display: flex; flex-direction: column; align-items: flex-start; }.reader-welcome-primary h1 { margin: 10px 0 0; color: #e8edf1; font: 700 clamp(34px, 4vw, 52px) ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .055em; }.reader-welcome-primary p { max-width: 560px; margin: 18px 0 27px; color: #7898ad; font-size: 13px; line-height: 1.9; }.reader-welcome-primary .reader-open-file { min-height: 42px; padding: 0 20px; border-color: var(--reader-accent); background: var(--reader-accent); color: #21150b; font-size: 11px; font-weight: 850; }.reader-welcome-primary .reader-open-file:hover { background: #ffc17d; border-color: #ffc17d; color: #1a110a; }.reader-welcome-primary small { margin-top: 14px; color: #58778c; font-size: 9px; }
|
||||
.reader-welcome-side { min-width: 0; padding: 34px 0 34px clamp(36px, 5vw, 64px); border-left: 1px solid #22445a; }.reader-welcome-side > h2 { margin: 8px 0 24px; color: #d9e6ed; font-size: 19px; }.reader-welcome-side ol { display: grid; gap: 18px; margin: 0 0 28px; padding: 0; list-style: none; }.reader-welcome-side li { display: grid; grid-template-columns: 32px 1fr; align-items: center; gap: 12px; color: #7f9aae; font-size: 11px; }.reader-welcome-side li b { color: var(--reader-accent); font: 700 9px ui-monospace, monospace; }
|
||||
.reader-family-mark { width: 124px; height: 124px; margin-bottom: 24px; filter: drop-shadow(0 0 22px rgba(242, 166, 90, .08)); }.reader-family-mark rect:first-child { fill: #071523; }.reader-family-mark rect:nth-child(2), .reader-family-mark path { fill: none; stroke: var(--reader-accent); stroke-width: 30; stroke-linecap: round; stroke-linejoin: round; }.reader-family-mark path:nth-of-type(1) { fill: #16191d; }.reader-family-mark circle { fill: none; stroke: #4b3728; stroke-width: 12; stroke-dasharray: 26 34; }
|
||||
.trc-association-guide { display: grid; grid-template-columns: 50px 1fr; gap: 4px 16px; }.trc-association-icon { grid-row: 1 / span 2; width: 50px; height: 50px; display: grid; place-items: center; border: 1px solid #78583a; background: var(--reader-accent-soft); color: var(--reader-accent); font: 800 11px ui-monospace, monospace; }.trc-association-guide span { color: #9b704c; font: 700 8px ui-monospace, monospace; letter-spacing: .16em; text-transform: uppercase; }.trc-association-guide h2 { margin: 5px 0 8px; color: #dce7ed; font-size: 18px; }.trc-association-guide p { grid-column: 2; max-width: 420px; margin: 0; color: #7390a3; font-size: 11px; line-height: 1.8; }.trc-association-guide code { color: var(--reader-accent); }.trc-association-actions { grid-column: 2; display: flex; gap: 8px; margin-top: 20px; }.trc-association-actions button { min-height: 32px; padding: 0 12px; border: 1px solid #31536a; background: #0a1c2b; color: #8ca9bb; font-size: 10px; cursor: pointer; }.trc-association-actions button.primary { border-color: var(--reader-accent); background: var(--reader-accent); color: #21150b; font-weight: 800; }.trc-association-actions button:disabled { opacity: .5; cursor: wait; }
|
||||
.trc-association-summary { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-top: 18px; border-top: 1px solid #19364a; }.trc-association-summary div { display: grid; gap: 5px; }.trc-association-summary span { color: #58778c; font: 8px ui-monospace, monospace; letter-spacing: .12em; }.trc-association-summary strong { color: #d2a16f; font-size: 11px; }.trc-association-summary.is-associated strong { color: #55c99b; }.trc-association-summary button { border: 0; background: transparent; color: var(--reader-accent); font-size: 10px; cursor: pointer; }
|
||||
@media (max-width: 850px) { .reader-welcome-layout { width: min(640px, calc(100vw - 48px)); grid-template-columns: 1fr; align-content: center; gap: 44px; }.reader-welcome-side { padding: 34px 0 0; border-top: 1px solid #22445a; border-left: 0; } }
|
||||
.reader-file-notice { position: fixed; z-index: 80; right: 28px; bottom: 26px; max-width: min(520px, calc(100vw - 56px)); display: flex; align-items: center; gap: 14px; padding: 11px 13px; border: 1px solid rgba(255, 107, 122, .55); background: #231520; color: #f0a9b2; font-size: 10px; box-shadow: 0 16px 40px rgba(0,0,0,.4); }.reader-file-notice button { border: 0; background: transparent; color: #d97a87; cursor: pointer; }
|
||||
.is-adjusting-log-outline { user-select: none; }.is-adjusting-log-outline * { user-select: none !important; }
|
||||
.cm-log-timestamp { color: #789bb1; }.cm-log-source { color: #8eb6ca; }.cm-log-level-info { color: #5f8aa2; }.cm-log-level-warn { color: #f7c86a; font-weight: 700; }.cm-log-level-error, .cm-log-exception { color: #ff687a; font-weight: 700; text-shadow: 0 0 10px rgba(255, 87, 107, .22); }
|
||||
.cm-log-sql-keyword { color: #48d9e3; font-weight: 700; }.cm-log-sql-table { color: #d2a0ff; font-weight: 800; background: rgba(182, 116, 255, .1); border-bottom: 1px solid rgba(196, 146, 255, .58); }.cm-log-sql-muted { color: #456477; }
|
||||
|
||||