feat: add standalone OpsLog Reader

This commit is contained in:
2026-09-18 13:07:00 +08:00
parent 6b1cc28c89
commit 3a69e64cad
84 changed files with 1059 additions and 33 deletions

View File

@@ -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:

View File

@@ -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
View 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
View File

@@ -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",

View File

@@ -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
View File

@@ -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]]

View File

@@ -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"

View 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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View 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>

View File

@@ -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();
}
}
});
}

View File

@@ -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();
}

View 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");
}
}

View 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());
}
}

View 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());
}
}

View File

@@ -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": {

View 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
View 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_/);
});

View File

@@ -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
View 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
View 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>;
}

View File

@@ -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", {

View File

@@ -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>
);

View File

@@ -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.
}

View File

@@ -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;

View 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>;

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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
View 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();

View 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.
}
};

View File

@@ -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; }