feat: expand custom markers and web configuration

This commit is contained in:
2026-09-09 21:42:00 +08:00
parent fb4bcd88a6
commit b7a4e0a793
21 changed files with 579 additions and 70 deletions

View File

@@ -43,6 +43,14 @@ npm run dev
浏览器模式访问 `http://127.0.0.1:5173`,本地查询网关只监听 `127.0.0.1`
需要从局域网设备访问时,使用下面的命令;它会同时启动 Vite 页面和本地查询网关:
```bash
npm run dev:lan
```
随后通过 `http://<本机局域网 IP>:5173` 访问即可。不要单独运行 `npm run web:dev -- --host 0.0.0.0`,该命令只启动页面,无法提供查询或导入接口。
## 构建和分发
必须在目标操作系统上生成正式安装包。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "opslog-web",
"version": "3.0.13",
"version": "3.0.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opslog-web",
"version": "3.0.13",
"version": "3.0.14",
"dependencies": {
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.4",

View File

@@ -1,10 +1,11 @@
{
"name": "opslog-web",
"version": "3.0.13",
"version": "3.0.14",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -k -n api,web -c cyan,blue \"tsx watch server/index.ts\" \"npm run web:dev\"",
"dev:lan": "concurrently -k -n api,web -c cyan,blue \"tsx watch server/index.ts\" \"vite --host 0.0.0.0\"",
"build": "tsc -p tsconfig.server.json && vite build",
"web:dev": "vite",
"web:build": "vite build",

View File

@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import type { EnvironmentConfig, PublicEnvironment } from "./domain.js";
@@ -25,11 +25,30 @@ const configPath = path.resolve(process.cwd(), "opslog-envs.json");
export const normalizeKibanaUrl = (url: string): string =>
LEGACY_KIBANA_URLS.get(url.toLowerCase()) ?? url;
export const parseEnvironmentConfig = (contents: string): EnvironmentConfig[] => {
try {
const parsed = JSON.parse(contents) as unknown;
const environments = z.array(environmentSchema).min(1).parse(parsed);
return environments.map((environment) => ({
...environment,
kibanaUrl: normalizeKibanaUrl(environment.kibanaUrl)
}));
} catch (error) {
if (error instanceof SyntaxError) throw new Error(`环境配置 JSON 不合法:${error.message}`);
if (error instanceof z.ZodError) throw new Error("环境配置内容不完整或字段格式不合法");
throw error;
}
};
export const loadEnvironments = async (): Promise<EnvironmentConfig[]> => {
const raw = await readFile(configPath, "utf8");
return z.array(environmentSchema)
.parse(JSON.parse(raw))
.map((environment) => ({ ...environment, kibanaUrl: normalizeKibanaUrl(environment.kibanaUrl) }));
return parseEnvironmentConfig(raw);
};
export const saveEnvironmentConfig = async (contents: string): Promise<string> => {
parseEnvironmentConfig(contents);
await writeFile(configPath, contents, { encoding: "utf8", mode: 0o600 });
return configPath;
};
export const findEnvironment = async (name: string): Promise<EnvironmentConfig> => {

View File

@@ -2,7 +2,7 @@ import { Router, type Response } from "express";
import { z } from "zod";
import { toCsv } from "./csv.js";
import { DISPLAY_FIELDS, type LogKind, type SearchInput } from "./domain.js";
import { findEnvironment, loadEnvironments, toPublicEnvironment } from "./environment-store.js";
import { findEnvironment, loadEnvironments, saveEnvironmentConfig, toPublicEnvironment } from "./environment-store.js";
import { runEsql } from "./kibana-client.js";
import { buildSearchQuery, buildTraceQuery, buildTrcQuery, pageRows } from "./query-builders.js";
@@ -48,6 +48,10 @@ const downloadSchema = z.object({
endTime: dateTime
});
const environmentImportSchema = z.object({
contents: z.string().min(2).max(256 * 1024)
});
const asyncRoute = (
handler: (request: Parameters<Router["get"]>[1] extends (...args: infer A) => unknown ? A[0] : never, response: Response) => Promise<void>
) => (request: Parameters<typeof handler>[0], response: Response, next: (error?: unknown) => void) => {
@@ -85,6 +89,12 @@ apiRouter.get("/environments", asyncRoute(async (_request, response) => {
response.json(environments.map(toPublicEnvironment));
}));
apiRouter.post("/environments/import", asyncRoute(async (request, response) => {
const { contents } = environmentImportSchema.parse(request.body);
const path = await saveEnvironmentConfig(contents);
response.json({ path });
}));
apiRouter.post("/search", asyncRoute(async (request, response) => {
const input = searchSchema.parse(request.body) as SearchInput;
const environment = await findEnvironment(input.environment);
@@ -158,6 +168,6 @@ export const errorHandler = (error: unknown, _request: unknown, response: Respon
return;
}
const message = error instanceof Error ? error.message : "未知服务端错误";
const isConfigurationError = message.startsWith("未知环境") || message.includes("索引");
const isConfigurationError = message.startsWith("未知环境") || message.startsWith("环境配置") || message.includes("索引");
response.status(isConfigurationError ? 400 : 502).json({ error: message });
};

2
src-tauri/Cargo.lock generated
View File

@@ -2305,7 +2305,7 @@ dependencies = [
[[package]]
name = "opslog"
version = "3.0.13"
version = "3.0.14"
dependencies = [
"chrono",
"dirs",

View File

@@ -1,6 +1,6 @@
[package]
name = "opslog"
version = "3.0.13"
version = "3.0.14"
description = "Portable M5/Faulu log query console"
authors = ["MuRong Technology"]
license = "Proprietary"

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "OpsLog",
"version": "3.0.13",
"version": "3.0.14",
"identifier": "com.murong.opslog",
"build": {
"frontendDist": "../dist-web",

View File

@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeKibanaUrl } from "../dist-server/server/environment-store.js";
import { normalizeKibanaUrl, parseEnvironmentConfig } from "../dist-server/server/environment-store.js";
test("Web 网关将遗留 Kibana IP 迁移为证书域名", () => {
assert.equal(
@@ -16,3 +16,19 @@ test("Web 网关将遗留 Kibana IP 迁移为证书域名", () => {
"https://kibana.example.test/kibana"
);
});
test("Web 导入配置沿用环境校验和地址迁移规则", () => {
const environments = parseEnvironmentConfig(JSON.stringify([{
name: "test",
kibanaUrl: "https://10.1.6.10/kibana",
username: "user",
password: "secret",
txnlstIndex: "txn-list-*",
txntrcIndex: "txn-trace-*",
applogIndex: "app-*"
}]));
assert.equal(environments[0].kibanaUrl, "https://nexus.faulukenya.com/kibana");
assert.throws(() => parseEnvironmentConfig("[]"), /环境配置内容不完整/);
assert.throws(() => parseEnvironmentConfig("not-json"), /环境配置 JSON 不合法/);
});

View File

@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import { clampLogOutlineGeometry, moveLogOutlineGeometry, resizeLogOutlineGeometry } from "../web/src/log-outline-geometry";
import { createSqlOutlinePreviews } from "../web/src/log-outline-preview";
import { buildCustomLogMarkerOutline, combineCustomLogMarkers, CUSTOM_LOG_MARKERS_KEY, readCustomLogMarkers, reorderCustomLogMarkers, storeCustomLogMarkers, type CustomLogMarker } from "../web/src/custom-log-markers";
import { clampCustomMarkerWidthRatio, CUSTOM_MARKER_WIDTH_RATIO_KEY, readCustomMarkerWidthRatio, storeCustomMarkerWidthRatio } from "../web/src/custom-marker-layout";
import { buildCustomLogMarkerOutline, cloneCustomLogMarker, combineCustomLogMarkers, createEmptyCustomLogMarker, CUSTOM_LOG_MARKERS_KEY, readCustomLogMarkers, reorderCustomLogMarkerRules, reorderCustomLogMarkers, storeCustomLogMarkers, type CustomLogMarker } from "../web/src/custom-log-markers";
import { analyzeTransactionLog } from "../web/src/transaction-log-analysis";
import { findPlainLogMatchesInLowercase, findRegexLogMatches } from "../web/src/transaction-log-search";
@@ -35,15 +36,68 @@ test("custom markers reorder and combine with the drop target rules first", () =
const combined = combineCustomLogMarkers(markers, "gamma", "alpha");
assert.equal(combined[0].kind, "combine");
assert.equal(combined[0].label, "Alpha");
assert.deepEqual(combined[0].rules.map(({ label }) => label), ["Alpha", "Gamma"]);
assert.equal(combined[1].id, "beta");
const sourceBeforeTarget = combineCustomLogMarkers(markers, "alpha", "gamma");
assert.equal(sourceBeforeTarget[0].id, "beta");
assert.equal(sourceBeforeTarget[1].kind, "combine");
assert.equal(sourceBeforeTarget[1].label, "Gamma");
assert.deepEqual(sourceBeforeTarget[1].rules.map(({ label }) => label), ["Gamma", "Alpha"]);
});
test("combining markers preserves the target name and every child rule unchanged", () => {
const target: CustomLogMarker = {
id: "request",
label: "请求报文",
kind: "single",
rules: [{ id: "request-rule", label: "RequestBO 入参", query: "RequestBO >>>", regex: false }]
};
const source: CustomLogMarker = {
id: "response",
label: "响应报文",
kind: "single",
rules: [{ id: "response-rule", label: "RequestBO 出参", query: String.raw`RequestBO\s+<<<`, regex: true }]
};
const [combined] = combineCustomLogMarkers([target, source], source.id, target.id);
assert.equal(combined.label, target.label);
assert.deepEqual(combined.rules, [target.rules[0], source.rules[0]]);
});
test("custom marker creation, cloning, and child rule reordering preserve independent identities", () => {
const draft = createEmptyCustomLogMarker("combine");
assert.equal(draft.kind, "combine");
assert.equal(draft.rules.length, 2);
const reordered = reorderCustomLogMarkerRules(draft.rules, draft.rules[1].id, draft.rules[0].id, false);
assert.deepEqual(reordered.map(({ id }) => id), [draft.rules[1].id, draft.rules[0].id]);
const source = marker("source", "Source", "ERROR");
const cloned = cloneCustomLogMarker([source], source.id);
assert.equal(cloned.length, 2);
assert.equal(cloned[1].label, "Source 副本");
assert.notEqual(cloned[1].id, source.id);
assert.notEqual(cloned[1].rules[0].id, source.rules[0].id);
assert.equal(cloned[1].rules[0].query, source.rules[0].query);
});
test("custom marker section width is clamped and restored from storage", () => {
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); }
};
assert.equal(clampCustomMarkerWidthRatio(.05), .16);
assert.equal(clampCustomMarkerWidthRatio(.9), .58);
storeCustomMarkerWidthRatio(.41, storage);
assert.equal(values.get(CUSTOM_MARKER_WIDTH_RATIO_KEY), "0.41");
assert.equal(readCustomMarkerWidthRatio(storage), .41);
});
test("custom marker outline merges text and regex hits with their source aliases", () => {
const content = ["first ERROR E1001", "second warning W2002", "third error E3003"].join("\n");
const combined: CustomLogMarker = {

View File

@@ -144,6 +144,12 @@ export const loadTrace = async (
};
export const importEnvironmentConfig = async (contents: string): Promise<string> => {
if (!desktopMode) throw new Error("仅桌面版支持导入环境配置");
return (await desktopInvoke<SavedFile>("save_environment_config", { contents })).path;
if (desktopMode) return (await desktopInvoke<SavedFile>("save_environment_config", { contents })).path;
const response = await fetch("/api/environments/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contents })
});
if (!response.ok) return parseError(response);
return ((await response.json()) as SavedFile).path;
};

View File

@@ -0,0 +1,51 @@
import { useLayoutEffect, useRef, useState } from "react";
interface CustomLogMarkerContextMenuProps {
x: number;
y: number;
markerLabel?: string;
cloneDisabled?: boolean;
onCreateSingle: () => void;
onCreateCombine: () => void;
onEdit: () => void;
onClone: () => void;
onDelete: () => void;
}
export const CustomLogMarkerContextMenu = ({
x, y, markerLabel, cloneDisabled, onCreateSingle, onCreateCombine, onEdit, onClone, onDelete
}: CustomLogMarkerContextMenuProps) => {
const hostRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x, y });
useLayoutEffect(() => {
const bounds = hostRef.current?.getBoundingClientRect();
if (!bounds) return;
setPosition({
x: Math.max(8, Math.min(x, window.innerWidth - bounds.width - 8)),
y: Math.max(8, Math.min(y, window.innerHeight - bounds.height - 8))
});
hostRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
}, [x, y]);
return <div
className="custom-marker-context-menu"
ref={hostRef}
role="menu"
aria-label={markerLabel ? `${markerLabel}操作菜单` : "创建自定义标记"}
style={{ left: position.x, top: position.y }}
onPointerDown={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()}
>
{markerLabel ? <>
<header><span>MARKER ACTIONS</span><strong>{markerLabel}</strong></header>
<button type="button" role="menuitem" onClick={onEdit}><i></i><span><b></b><small></small></span></button>
<button type="button" role="menuitem" disabled={cloneDisabled} onClick={onClone}><i></i><span><b></b><small></small></span></button>
<button type="button" role="menuitem" className="danger" onClick={onDelete}><i>×</i><span><b></b><small></small></span></button>
</> : <>
<header><span>CREATE MARKER</span><strong></strong></header>
<button type="button" role="menuitem" onClick={onCreateSingle}><i>#</i><span><b></b><small></small></span></button>
<button type="button" role="menuitem" onClick={onCreateCombine}><i></i><span><b>Combine </b><small></small></span></button>
</>}
</div>;
};

View File

@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from "react";
import type { CustomLogMarker, CustomLogMarkerRule } from "../custom-log-markers";
import { CloseIcon } from "./Icons";
import { CustomLogMarkerRuleEditor } from "./CustomLogMarkerRuleEditor";
interface CustomLogMarkerEditorProps {
marker: CustomLogMarker;
aliasOnly: boolean;
creating?: boolean;
onCancel: () => void;
onDelete: () => void;
onDelete?: () => void;
onSave: (marker: CustomLogMarker) => void;
}
@@ -21,7 +23,7 @@ const validateRule = (rule: CustomLogMarkerRule): string | undefined => {
}
};
export const CustomLogMarkerEditor = ({ marker, aliasOnly, onCancel, onDelete, onSave }: CustomLogMarkerEditorProps) => {
export const CustomLogMarkerEditor = ({ marker, aliasOnly, creating = false, onCancel, onDelete, onSave }: CustomLogMarkerEditorProps) => {
const hostRef = useRef<HTMLDivElement>(null);
const [draft, setDraft] = useState(marker);
const error = aliasOnly ? undefined : draft.rules.map(validateRule).find(Boolean);
@@ -34,27 +36,18 @@ export const CustomLogMarkerEditor = ({ marker, aliasOnly, onCancel, onDelete, o
return () => window.removeEventListener("pointerdown", closeOutside);
}, [onCancel]);
const updateRule = (id: string, change: Partial<CustomLogMarkerRule>) => setDraft((current) => ({
...current,
rules: current.rules.map((rule) => rule.id === id ? { ...rule, ...change } : rule)
}));
const save = () => {
const label = draft.label.trim();
if (!label || error) return;
onSave({ ...draft, label, rules: draft.rules.map((rule) => ({ ...rule, label: rule.label.trim() || rule.query.trim(), query: rule.query.trim() })) });
const rules = draft.rules.map((rule) => ({ ...rule, label: rule.label.trim() || rule.query.trim(), query: rule.query.trim() }));
onSave({ ...draft, label, kind: rules.length > 1 ? "combine" : "single", rules });
};
return <div className="custom-marker-editor" ref={hostRef} role="dialog" aria-label={aliasOnly ? "修改标记别名" : "编辑自定义标记"}>
<header><div><span>CUSTOM MARKER</span><strong>{aliasOnly ? "修改别名" : draft.kind === "combine" ? "编辑组合模板" : "编辑标记"}</strong></div><button type="button" aria-label="关闭编辑器" onClick={onCancel}><CloseIcon /></button></header>
<header><div><span>{creating ? "NEW CUSTOM MARKER" : "CUSTOM MARKER"}</span><strong>{aliasOnly ? "修改别名" : creating ? draft.kind === "combine" ? "创建 Combine 标签" : "创建普通标签" : draft.kind === "combine" ? "编辑组合模板" : "编辑标记"}</strong></div><button type="button" aria-label="关闭编辑器" onClick={onCancel}><CloseIcon /></button></header>
<label className="custom-marker-name"><span></span><input autoFocus value={draft.label} maxLength={40} onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") save(); }} /></label>
{!aliasOnly && <div className="custom-marker-rules">
{draft.rules.map((rule, index) => <div className="custom-marker-rule" key={rule.id}>
<div className="custom-marker-rule-heading"><b>{String(index + 1).padStart(2, "0")}</b><input value={rule.label} maxLength={40} aria-label={`条件 ${index + 1} 别名`} onChange={(event) => updateRule(rule.id, { label: event.target.value })} /><button type="button" className={rule.regex ? "is-active" : undefined} aria-pressed={rule.regex} onClick={() => updateRule(rule.id, { regex: !rule.regex })}>.*</button></div>
<textarea value={rule.query} rows={2} spellCheck={false} aria-label={`条件 ${index + 1} 查询内容`} onChange={(event) => updateRule(rule.id, { query: event.target.value })} />
</div>)}
</div>}
{!aliasOnly && <CustomLogMarkerRuleEditor rules={draft.rules} onChange={(rules) => setDraft((current) => ({ ...current, rules }))} />}
{error && <p className="custom-marker-error">{error}</p>}
<footer><button type="button" className="danger" onClick={onDelete}></button><div><button type="button" onClick={onCancel}></button><button type="button" className="primary" disabled={!draft.label.trim() || Boolean(error)} onClick={save}></button></div></footer>
<footer>{onDelete ? <button type="button" className="danger" onClick={onDelete}></button> : <span />}<div><button type="button" onClick={onCancel}></button><button type="button" className="primary" disabled={!draft.label.trim() || Boolean(error)} onClick={save}></button></div></footer>
</div>;
};

View File

@@ -0,0 +1,99 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { createCustomLogMarkerRule, MAX_CUSTOM_LOG_MARKERS, reorderCustomLogMarkerRules, type CustomLogMarkerRule } from "../custom-log-markers";
interface CustomLogMarkerRuleEditorProps {
rules: CustomLogMarkerRule[];
onChange: (rules: CustomLogMarkerRule[]) => void;
}
interface RuleDrag {
id: string;
startY: number;
y: number;
active: boolean;
targetId?: string;
after?: boolean;
}
export const CustomLogMarkerRuleEditor = ({ rules, onChange }: CustomLogMarkerRuleEditorProps) => {
const rulesRef = useRef(rules);
const onChangeRef = useRef(onChange);
const dragRef = useRef<RuleDrag | undefined>(undefined);
const [drag, setDrag] = useState<RuleDrag>();
const draggingId = drag?.id;
rulesRef.current = rules;
onChangeRef.current = onChange;
useEffect(() => {
if (!draggingId) return;
const move = (event: PointerEvent) => {
const current = dragRef.current;
if (!current) return;
const active = current.active || Math.abs(event.clientY - current.startY) > 4;
const target = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>("[data-custom-rule-id]");
const targetId = target?.dataset.customRuleId !== current.id ? target?.dataset.customRuleId : undefined;
const after = targetId && target ? event.clientY > target.getBoundingClientRect().top + target.offsetHeight / 2 : undefined;
const next = { ...current, y: event.clientY, active, targetId, after };
dragRef.current = next;
setDrag(next);
};
const stop = () => {
const current = dragRef.current;
if (current?.active && current.targetId) {
onChangeRef.current(reorderCustomLogMarkerRules(rulesRef.current, current.id, current.targetId, Boolean(current.after)));
}
dragRef.current = undefined;
setDrag(undefined);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop, { once: true });
window.addEventListener("pointercancel", stop, { once: true });
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [draggingId]);
const beginDrag = (event: ReactPointerEvent, rule: CustomLogMarkerRule) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const next = { id: rule.id, startY: event.clientY, y: event.clientY, active: false };
dragRef.current = next;
setDrag(next);
};
const updateRule = (id: string, change: Partial<CustomLogMarkerRule>) => onChange(
rules.map((rule) => rule.id === id ? { ...rule, ...change } : rule)
);
const removeRule = (id: string) => {
if (rules.length <= 1) return;
onChange(rules.filter((rule) => rule.id !== id));
};
return <div className="custom-marker-rules">
{rules.map((rule, index) => <div
className={`custom-marker-rule${drag?.id === rule.id && drag.active ? " is-dragging" : ""}${drag?.targetId === rule.id ? ` is-drop-${drag.after ? "after" : "before"}` : ""}`}
data-custom-rule-id={rule.id}
key={rule.id}
>
<div className="custom-marker-rule-heading">
<button type="button" className="custom-marker-rule-grip" aria-label={`拖动条件 ${index + 1} 调整顺序`} title="拖动调整顺序" onPointerDown={(event) => beginDrag(event, rule)}><i aria-hidden="true" /></button>
<b>{String(index + 1).padStart(2, "0")}</b>
<input value={rule.label} maxLength={40} aria-label={`条件 ${index + 1} 别名`} onChange={(event) => updateRule(rule.id, { label: event.target.value })} />
<button type="button" className={`custom-marker-rule-regex${rule.regex ? " is-active" : ""}`} aria-label={`条件 ${index + 1} 使用正则表达式`} aria-pressed={rule.regex} onClick={() => updateRule(rule.id, { regex: !rule.regex })}>.*</button>
<button type="button" className="custom-marker-rule-remove" disabled={rules.length <= 1} aria-label={`删除条件 ${index + 1}`} title="删除子标签" onClick={() => removeRule(rule.id)}>×</button>
</div>
<textarea value={rule.query} rows={2} spellCheck={false} aria-label={`条件 ${index + 1} 查询内容`} onChange={(event) => updateRule(rule.id, { query: event.target.value })} />
</div>)}
<button
type="button"
className="custom-marker-rule-add"
disabled={rules.length >= MAX_CUSTOM_LOG_MARKERS}
onClick={() => onChange([...rules, createCustomLogMarkerRule(rules.length)])}
><i aria-hidden="true"></i></button>
{drag?.active && <div className="custom-marker-rule-drag-ghost" style={{ top: drag.y }}>{rules.find(({ id }) => id === drag.id)?.label || "未命名条件"}</div>}
</div>;
};

View File

@@ -1,11 +1,20 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { combineCustomLogMarkers, reorderCustomLogMarkers, type CustomLogMarker } from "../custom-log-markers";
import { MoreIcon } from "./Icons";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import {
cloneCustomLogMarker,
combineCustomLogMarkers,
createEmptyCustomLogMarker,
MAX_CUSTOM_LOG_MARKERS,
reorderCustomLogMarkers,
type CustomLogMarker
} from "../custom-log-markers";
import { CustomLogMarkerContextMenu } from "./CustomLogMarkerContextMenu";
import { CustomLogMarkerEditor } from "./CustomLogMarkerEditor";
import { MoreIcon } from "./Icons";
interface CustomLogMarkerShelfProps {
markers: CustomLogMarker[];
activeMarkerId?: string;
widthRatio: number;
onChange: (markers: CustomLogMarker[]) => void;
onOpen: (marker: CustomLogMarker) => void;
}
@@ -24,18 +33,42 @@ interface MarkerDrag {
action?: "before" | "combine" | "after" | "delete";
}
export const CustomLogMarkerShelf = forwardRef<CustomLogMarkerShelfHandle, CustomLogMarkerShelfProps>(({ markers, activeMarkerId, onChange, onOpen }, ref) => {
interface MarkerEditorState {
marker: CustomLogMarker;
aliasOnly: boolean;
isNew: boolean;
}
interface MarkerMenuState {
x: number;
y: number;
markerId?: string;
}
export const CustomLogMarkerShelf = forwardRef<CustomLogMarkerShelfHandle, CustomLogMarkerShelfProps>(({
markers, activeMarkerId, widthRatio, onChange, onOpen
}, ref) => {
const hostRef = useRef<HTMLDivElement>(null);
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const dragRef = useRef<MarkerDrag | undefined>(undefined);
const markersRef = useRef(markers);
const onChangeRef = useRef(onChange);
const suppressClickRef = useRef(false);
const [capacity, setCapacity] = useState(3);
const [overflowOpen, setOverflowOpen] = useState(false);
const [editing, setEditing] = useState<{ marker: CustomLogMarker; aliasOnly: boolean }>();
const [editing, setEditing] = useState<MarkerEditorState>();
const [menu, setMenu] = useState<MarkerMenuState>();
const [drag, setDrag] = useState<MarkerDrag>();
const draggingId = drag?.id;
markersRef.current = markers;
onChangeRef.current = onChange;
useImperativeHandle(ref, () => ({
closeTopLayer: () => {
if (menu) {
setMenu(undefined);
return true;
}
if (editing) {
setEditing(undefined);
return true;
@@ -46,7 +79,7 @@ export const CustomLogMarkerShelf = forwardRef<CustomLogMarkerShelfHandle, Custo
}
return false;
}
}), [editing, overflowOpen]);
}), [editing, menu, overflowOpen]);
useEffect(() => {
const host = hostRef.current;
@@ -56,22 +89,23 @@ export const CustomLogMarkerShelf = forwardRef<CustomLogMarkerShelfHandle, Custo
return () => observer.disconnect();
}, []);
useEffect(() => () => {
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
}, []);
useEffect(() => {
if (!drag) return;
if (!draggingId) return;
const move = (event: PointerEvent) => {
const current = dragRef.current;
const host = hostRef.current;
if (!current || !host) return;
const moved = Math.hypot(event.clientX - current.startX, event.clientY - current.startY) > 6;
if (!moved) return;
if (Math.hypot(event.clientX - current.startX, event.clientY - current.startY) <= 6) return;
suppressClickRef.current = true;
const hostBounds = host.getBoundingClientRect();
const overflowBounds = host.querySelector<HTMLElement>(".custom-marker-overflow-menu")?.getBoundingClientRect();
const bounds = overflowBounds ? {
left: Math.min(hostBounds.left, overflowBounds.left),
right: Math.max(hostBounds.right, overflowBounds.right),
top: Math.min(hostBounds.top, overflowBounds.top),
bottom: Math.max(hostBounds.bottom, overflowBounds.bottom)
left: Math.min(hostBounds.left, overflowBounds.left), right: Math.max(hostBounds.right, overflowBounds.right),
top: Math.min(hostBounds.top, overflowBounds.top), bottom: Math.max(hostBounds.bottom, overflowBounds.bottom)
} : hostBounds;
const outside = event.clientX < bounds.left - 48 || event.clientX > bounds.right + 48 || event.clientY < bounds.top - 48 || event.clientY > bounds.bottom + 48;
const target = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>("[data-custom-marker-id]");
@@ -89,9 +123,12 @@ export const CustomLogMarkerShelf = forwardRef<CustomLogMarkerShelfHandle, Custo
};
const stop = () => {
const current = dragRef.current;
if (current?.action === "delete") onChange(markers.filter(({ id }) => id !== current.id));
else if (current?.targetId && current.action === "combine") onChange(combineCustomLogMarkers(markers, current.id, current.targetId));
else if (current?.targetId && (current.action === "before" || current.action === "after")) onChange(reorderCustomLogMarkers(markers, current.id, current.targetId, current.action === "after"));
const currentMarkers = markersRef.current;
if (current?.action === "delete") onChangeRef.current(currentMarkers.filter(({ id }) => id !== current.id));
else if (current?.targetId && current.action === "combine") onChangeRef.current(combineCustomLogMarkers(currentMarkers, current.id, current.targetId));
else if (current?.targetId && (current.action === "before" || current.action === "after")) {
onChangeRef.current(reorderCustomLogMarkers(currentMarkers, current.id, current.targetId, current.action === "after"));
}
dragRef.current = undefined;
setDrag(undefined);
window.setTimeout(() => { suppressClickRef.current = false; }, 0);
@@ -104,19 +141,24 @@ export const CustomLogMarkerShelf = forwardRef<CustomLogMarkerShelfHandle, Custo
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [drag, markers, onChange]);
}, [draggingId]);
useEffect(() => {
if (!overflowOpen) return;
if (!overflowOpen && !menu) return;
const close = (event: PointerEvent) => {
if (event.target instanceof Node && !hostRef.current?.contains(event.target)) setOverflowOpen(false);
if (!(event.target instanceof Element)) return;
if (menu && !event.target.closest(".custom-marker-context-menu")) {
setMenu(undefined);
}
if (overflowOpen && !event.target.closest(".custom-marker-overflow")) setOverflowOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, [overflowOpen]);
}, [menu, overflowOpen]);
const beginDrag = (event: ReactPointerEvent, marker: CustomLogMarker) => {
if (event.button !== 0) return;
setMenu(undefined);
const next = { id: marker.id, x: event.clientX, y: event.clientY, startX: event.clientX, startY: event.clientY };
dragRef.current = next;
setDrag(next);
@@ -130,28 +172,68 @@ export const CustomLogMarkerShelf = forwardRef<CustomLogMarkerShelfHandle, Custo
const editAlias = (marker: CustomLogMarker) => {
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
setEditing({ marker, aliasOnly: true });
setEditing({ marker, aliasOnly: true, isNew: false });
};
const openEditor = (marker: CustomLogMarker, isNew = false) => {
setMenu(undefined);
setOverflowOpen(false);
setEditing({ marker, aliasOnly: false, isNew });
};
const createMarker = (kind: CustomLogMarker["kind"]) => openEditor(createEmptyCustomLogMarker(kind), true);
const menuMarker = menu?.markerId ? markers.find(({ id }) => id === menu.markerId) : undefined;
const visible = markers.slice(0, capacity);
const overflow = markers.slice(capacity);
const markerButton = (marker: CustomLogMarker, inOverflow = false) => <button
type="button"
className={`custom-marker-tag${marker.kind === "combine" ? " is-combine" : ""}${activeMarkerId === marker.id ? " is-active" : ""}${drag?.id === marker.id ? " is-dragging" : ""}${drag?.targetId === marker.id ? ` is-drop-${drag.action}` : ""}`}
data-custom-marker-id={marker.id}
key={marker.id}
title={`${marker.kind === "combine" ? `${marker.rules.length} 个组合条件` : marker.rules[0].regex ? "正则标记" : "普通标记"} · 双击改名 · 右键编辑`}
title={`${marker.kind === "combine" ? `${marker.rules.length} 个组合条件` : marker.rules[0].regex ? "正则标记" : "普通标记"} · 双击改名 · 右键操作`}
onPointerDown={(event) => beginDrag(event, marker)}
onClick={() => { setOverflowOpen(false); chooseMarker(marker); }}
onDoubleClick={() => editAlias(marker)}
onContextMenu={(event) => { event.preventDefault(); setOverflowOpen(false); setEditing({ marker, aliasOnly: false }); }}
><i aria-hidden="true">{marker.kind === "combine" ? marker.rules.length : marker.rules[0].regex ? ".*" : "#"}</i><span>{marker.label}</span>{inOverflow && <small>{marker.kind === "combine" ? "组合" : marker.rules[0].regex ? "正则" : "文本"}</small>}</button>;
return <div className="custom-marker-shelf" ref={hostRef}>
<div className="custom-marker-track" aria-label="自定义日志标记">{visible.map((marker) => markerButton(marker))}</div>
const openContextMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
if (event.target instanceof Element && event.target.closest(".custom-marker-editor")) return;
if (event.target instanceof Element && event.target.closest(".custom-marker-overflow > button")) {
event.preventDefault();
return;
}
event.preventDefault();
const markerId = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-custom-marker-id]")?.dataset.customMarkerId : undefined;
setOverflowOpen(false);
setMenu({ x: event.clientX, y: event.clientY, markerId });
};
const saveEditor = (updated: CustomLogMarker) => {
if (!editing) return;
onChange(editing.isNew ? [...markers, updated] : markers.map((marker) => marker.id === updated.id ? updated : marker));
setEditing(undefined);
};
return <div className="custom-marker-shelf" ref={hostRef} style={{ flexBasis: `${widthRatio * 100}%` }} onContextMenu={openContextMenu}>
<div className="custom-marker-track" aria-label="自定义日志标记">
{visible.map((marker) => markerButton(marker))}
{!markers.length && <span className="custom-marker-empty"></span>}
</div>
{overflow.length > 0 && <div className="custom-marker-overflow"><button type="button" className={overflowOpen ? "is-active" : undefined} aria-label={`查看其余 ${overflow.length} 个自定义标记`} onClick={() => setOverflowOpen((open) => !open)}><MoreIcon /><b>+{overflow.length}</b></button>{overflowOpen && <div className="custom-marker-overflow-menu">{overflow.map((marker) => markerButton(marker, true))}</div>}</div>}
{drag && suppressClickRef.current && <div className={`custom-marker-drag-ghost${drag.action === "delete" ? " is-delete" : ""}`} style={{ left: drag.x, top: drag.y }}>{drag.action === "delete" ? "松开删除" : markers.find(({ id }) => id === drag.id)?.label}</div>}
{editing && <CustomLogMarkerEditor marker={editing.marker} aliasOnly={editing.aliasOnly} onCancel={() => setEditing(undefined)} onDelete={() => { onChange(markers.filter(({ id }) => id !== editing.marker.id)); setEditing(undefined); }} onSave={(updated) => { onChange(markers.map((marker) => marker.id === updated.id ? updated : marker)); setEditing(undefined); }} />}
{menu && <CustomLogMarkerContextMenu
x={menu.x} y={menu.y} markerLabel={menuMarker?.label} cloneDisabled={markers.length >= MAX_CUSTOM_LOG_MARKERS}
onCreateSingle={() => createMarker("single")} onCreateCombine={() => createMarker("combine")}
onEdit={() => menuMarker && openEditor(menuMarker)}
onClone={() => { if (menuMarker) onChange(cloneCustomLogMarker(markers, menuMarker.id)); setMenu(undefined); }}
onDelete={() => { if (menuMarker) onChange(markers.filter(({ id }) => id !== menuMarker.id)); setMenu(undefined); }}
/>}
{editing && <CustomLogMarkerEditor
marker={editing.marker} aliasOnly={editing.aliasOnly} creating={editing.isNew} onCancel={() => setEditing(undefined)}
onDelete={editing.isNew ? undefined : () => { onChange(markers.filter(({ id }) => id !== editing.marker.id)); setEditing(undefined); }}
onSave={saveEditor}
/>}
</div>;
});

View File

@@ -0,0 +1,78 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
import { clampCustomMarkerWidthRatio } from "../custom-marker-layout";
interface CustomMarkerSectionResizeHandleProps {
ratio: number;
onChange: (ratio: number) => void;
onCommit: (ratio: number) => void;
}
interface ResizeStart {
x: number;
ratio: number;
width: number;
}
export const CustomMarkerSectionResizeHandle = ({ ratio, onChange, onCommit }: CustomMarkerSectionResizeHandleProps) => {
const startRef = useRef<ResizeStart | undefined>(undefined);
const ratioRef = useRef(ratio);
const [resizing, setResizing] = useState(false);
ratioRef.current = ratio;
useEffect(() => {
if (!resizing) return;
const move = (event: PointerEvent) => {
const start = startRef.current;
if (!start) return;
const next = clampCustomMarkerWidthRatio(start.ratio + (start.x - event.clientX) / start.width);
ratioRef.current = next;
onChange(next);
};
const stop = () => {
startRef.current = undefined;
setResizing(false);
onCommit(ratioRef.current);
};
document.body.classList.add("is-resizing-custom-marker-zone");
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop, { once: true });
window.addEventListener("pointercancel", stop, { once: true });
return () => {
document.body.classList.remove("is-resizing-custom-marker-zone");
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [onChange, onCommit, resizing]);
const beginResize = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const width = event.currentTarget.parentElement?.getBoundingClientRect().width ?? window.innerWidth;
event.preventDefault();
startRef.current = { x: event.clientX, ratio, width };
setResizing(true);
};
const resizeWithKeyboard = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const direction = event.key === "ArrowLeft" ? 1 : event.key === "ArrowRight" ? -1 : 0;
if (!direction) return;
event.preventDefault();
const next = clampCustomMarkerWidthRatio(ratio + direction * .03);
onChange(next);
onCommit(next);
};
return <div
className={`custom-marker-section-resize${resizing ? " is-resizing" : ""}`}
role="separator"
aria-label="调整自定义标记区域宽度"
aria-orientation="vertical"
aria-valuemin={16}
aria-valuemax={58}
aria-valuenow={Math.round(ratio * 100)}
tabIndex={0}
title="左右拖动调整自定义标记区域宽度"
onPointerDown={beginResize}
onKeyDown={resizeWithKeyboard}
><i aria-hidden="true" /><span></span></div>;
};

View File

@@ -43,10 +43,8 @@ export const Header = ({ environments, selected, onSelect, loading, desktopMode,
</div>
</div>
<div className="topbar-context">
{desktopMode && <>
<button className="config-import" type="button" disabled={loading} onClick={() => configInput.current?.click()}><ImportIcon /></button>
<input ref={configInput} className="config-file-input" type="file" accept="application/json,.json" onChange={(event) => void selectConfig(event.currentTarget.files)} />
</>}
<button className="config-import" type="button" disabled={loading} onClick={() => configInput.current?.click()}><ImportIcon /></button>
<input ref={configInput} className="config-file-input" type="file" accept="application/json,.json" onChange={(event) => void selectConfig(event.currentTarget.files)} />
<div className="environment-control">
<label htmlFor="environment"></label>
<select id="environment" value={selected} onChange={(event) => onSelect(event.target.value)}>
@@ -58,8 +56,8 @@ export const Header = ({ environments, selected, onSelect, loading, desktopMode,
{loading ? "正在查询" : environment?.insecureTls ? "TLS 兼容模式" : "查询网关就绪"}
</div>
{desktopMode
? <button className={`version-chip is-interactive${updateAvailable ? " has-update" : ""}`} disabled={updateBusy} title={updateAvailable ? "有新版本可安装" : "检查更新"} onClick={onCheckForUpdates}>APP · 3.0.13<span aria-hidden="true" /></button>
: <div className="version-chip">WEB · 3.0.13</div>}
? <button className={`version-chip is-interactive${updateAvailable ? " has-update" : ""}`} disabled={updateBusy} title={updateAvailable ? "有新版本可安装" : "检查更新"} onClick={onCheckForUpdates}>APP · 3.0.14<span aria-hidden="true" /></button>
: <div className="version-chip">WEB · 3.0.14</div>}
</div>
</header>
);

View File

@@ -1,4 +1,5 @@
import { forwardRef, useCallback, useDeferredValue, useEffect, useImperativeHandle, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } 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";
import { analyzeTransactionLog } from "../transaction-log-analysis";
@@ -6,6 +7,7 @@ import { findPlainLogMatchesInLowercase, findRegexLogMatches, MAX_LOG_SEARCH_MAT
import type { LogOutlineCategory } from "../transaction-log-model";
import { CloseIcon, MarkerAddIcon, SearchIcon } from "./Icons";
import { CustomLogMarkerShelf, type CustomLogMarkerShelfHandle } from "./CustomLogMarkerShelf";
import { CustomMarkerSectionResizeHandle } from "./CustomMarkerSectionResizeHandle";
import { LogOutlinePopover } from "./LogOutlinePopover";
import { StructuredLogViewer, type StructuredLogViewerHandle } from "./StructuredLogViewer";
@@ -50,6 +52,7 @@ export const TransactionLogDrawer = forwardRef<TransactionLogDrawerHandle, Trans
const [isResizing, setIsResizing] = useState(false);
const [outlineCategory, setOutlineCategory] = useState<LogOutlineCategory>();
const [customMarkers, setCustomMarkers] = useState(readCustomLogMarkers);
const [customMarkerWidthRatio, setCustomMarkerWidthRatio] = useState(readCustomMarkerWidthRatio);
const [activeCustomMarkerId, setActiveCustomMarkerId] = useState<string>();
const viewerRef = useRef<StructuredLogViewerHandle>(null);
const customMarkerShelfRef = useRef<CustomLogMarkerShelfHandle>(null);
@@ -250,8 +253,8 @@ export const TransactionLogDrawer = forwardRef<TransactionLogDrawerHandle, Trans
<button type="button" data-log-outline-trigger className={`${analysis.stats.exceptions ? "has-errors" : ""}${outlineCategory === "exception" ? " is-active" : ""}`} title="查看异常 Outline" onClick={() => toggleOutline("exception")}><b>{analysis.stats.exceptions}</b> ERROR/</button>
<button type="button" data-log-outline-trigger className={outlineCategory === "structured" ? "is-active" : undefined} title="查看结构块 Outline" onClick={() => toggleOutline("structured")}><b>{analysis.stats.structured}</b> </button>
</div>
<i className="marker-track-separator" aria-hidden="true" />
<CustomLogMarkerShelf ref={customMarkerShelfRef} markers={customMarkers} activeMarkerId={activeCustomMarkerId} onChange={replaceCustomMarkers} onOpen={openCustomMarker} />
<CustomMarkerSectionResizeHandle ratio={customMarkerWidthRatio} onChange={setCustomMarkerWidthRatio} onCommit={storeCustomMarkerWidthRatio} />
<CustomLogMarkerShelf ref={customMarkerShelfRef} markers={customMarkers} activeMarkerId={activeCustomMarkerId} widthRatio={customMarkerWidthRatio} onChange={replaceCustomMarkers} onOpen={openCustomMarker} />
<div className="log-reader-fold-actions"><button title="折叠全部结构,并在下次打开日志时继续使用" onClick={() => applyFoldMode("folded")}></button><button title="展开全部结构,并在下次打开日志时继续使用" onClick={() => applyFoldMode("expanded")}></button></div>
</div>
</div>}

View File

@@ -33,6 +33,13 @@ const createId = (): string => globalThis.crypto?.randomUUID?.() ?? `${Date.now(
const cleanText = (value: unknown, maximum: number): string => typeof value === "string" ? value.trim().slice(0, maximum) : "";
const defaultLabel = (query: string): string => query.length > 18 ? `${query.slice(0, 17)}` : query;
export const createCustomLogMarkerRule = (index = 0): CustomLogMarkerRule => ({
id: createId(),
label: `条件 ${index + 1}`,
query: "",
regex: false
});
const normalizeRule = (value: unknown): CustomLogMarkerRule | undefined => {
if (!value || typeof value !== "object") return undefined;
const candidate = value as Partial<CustomLogMarkerRule>;
@@ -86,6 +93,28 @@ export const createCustomLogMarker = (query: string, regex: boolean): CustomLogM
return { id: createId(), label: rule.label, kind: "single", rules: [rule] };
};
export const createEmptyCustomLogMarker = (kind: CustomLogMarker["kind"]): CustomLogMarker => {
const rules = Array.from({ length: kind === "combine" ? 2 : 1 }, (_, index) => createCustomLogMarkerRule(index));
return { id: createId(), label: kind === "combine" ? "新建组合标记" : "新建标记", kind, rules };
};
export const cloneCustomLogMarker = (markers: CustomLogMarker[], markerId: string): CustomLogMarker[] => {
if (markers.length >= MAX_CUSTOM_LOG_MARKERS) return markers;
const markerIndex = markers.findIndex(({ id }) => id === markerId);
if (markerIndex < 0) return markers;
const marker = markers[markerIndex];
const label = `${marker.label} 副本`.slice(0, 40);
const clone: CustomLogMarker = {
...marker,
id: createId(),
label,
rules: marker.rules.map((rule) => ({ ...rule, id: createId() }))
};
const next = [...markers];
next.splice(markerIndex + 1, 0, clone);
return next;
};
export const reorderCustomLogMarkers = (markers: CustomLogMarker[], sourceId: string, targetId: string, after: boolean): CustomLogMarker[] => {
const sourceIndex = markers.findIndex(({ id }) => id === sourceId);
const targetIndex = markers.findIndex(({ id }) => id === targetId);
@@ -97,6 +126,17 @@ export const reorderCustomLogMarkers = (markers: CustomLogMarker[], sourceId: st
return next;
};
export const reorderCustomLogMarkerRules = (rules: CustomLogMarkerRule[], sourceId: string, targetId: string, after: boolean): CustomLogMarkerRule[] => {
const sourceIndex = rules.findIndex(({ id }) => id === sourceId);
const targetIndex = rules.findIndex(({ id }) => id === targetId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return rules;
const next = [...rules];
const [source] = next.splice(sourceIndex, 1);
const adjustedTarget = next.findIndex(({ id }) => id === targetId);
next.splice(adjustedTarget + (after ? 1 : 0), 0, source);
return next;
};
export const combineCustomLogMarkers = (markers: CustomLogMarker[], sourceId: string, targetId: string): CustomLogMarker[] => {
if (sourceId === targetId) return markers;
const source = markers.find(({ id }) => id === sourceId);
@@ -110,7 +150,7 @@ export const combineCustomLogMarkers = (markers: CustomLogMarker[], sourceId: st
const rules = [...target.rules, ...source.rules].slice(0, MAX_RULES);
const combined: CustomLogMarker = {
id: createId(),
label: `组合 · ${rules.length}`,
label: target.label,
kind: "combine",
rules
};

View File

@@ -0,0 +1,29 @@
interface MarkerLayoutStorage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
}
export const CUSTOM_MARKER_WIDTH_RATIO_KEY = "opslog.transaction-log.custom-marker-width-ratio.v1";
export const DEFAULT_CUSTOM_MARKER_WIDTH_RATIO = .28;
export const MIN_CUSTOM_MARKER_WIDTH_RATIO = .16;
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 => {
try {
const ratio = Number.parseFloat(storage.getItem(CUSTOM_MARKER_WIDTH_RATIO_KEY) ?? "");
return clampCustomMarkerWidthRatio(Number.isFinite(ratio) ? ratio : DEFAULT_CUSTOM_MARKER_WIDTH_RATIO);
} catch {
return DEFAULT_CUSTOM_MARKER_WIDTH_RATIO;
}
};
export const storeCustomMarkerWidthRatio = (ratio: number, storage: MarkerLayoutStorage = localStorage): void => {
try {
storage.setItem(CUSTOM_MARKER_WIDTH_RATIO_KEY, String(clampCustomMarkerWidthRatio(ratio)));
} catch {
// Resizing remains available for the current session when storage is unavailable.
}
};

View File

@@ -230,10 +230,32 @@ td.success { color: var(--green); } td.error { color: var(--red); } td.neutral {
.log-reader-insights b { color: #c0d7e5; font-weight: 650; }.log-reader-insights .has-errors b { color: #ff7888; }
.log-reader-insights .reader-performance { color: #7f9bae; }.log-reader-insights .reader-performance b { color: var(--cyan); }
.log-reader-fold-actions { display: flex; gap: 4px; margin-left: auto; }.log-reader-fold-actions button { border: 1px solid var(--line); background: #091a2a; color: #8baabe; padding: 5px 8px; font-size: 9px; cursor: pointer; }.log-reader-fold-actions button:hover { color: var(--cyan); border-color: var(--line-bright); }
.marker-track-separator { flex: 0 0 1px; align-self: stretch; min-height: 24px; border-left: 1px dashed rgba(75, 142, 171, .64); }
.custom-marker-shelf { position: relative; flex: 1 1 180px; min-width: 104px; display: flex; align-items: center; gap: 5px; }.custom-marker-track { flex: 1; min-width: 0; display: flex; gap: 5px; overflow: hidden; }.custom-marker-tag { position: relative; flex: 0 0 111px; min-width: 0; height: 25px; display: flex; align-items: center; gap: 5px; padding: 0 7px; overflow: hidden; border: 1px solid rgba(44, 91, 117, .72); background: rgba(8, 28, 44, .86); color: #83a6bb; font: 9px ui-monospace, monospace; cursor: grab; animation: custom-marker-arrive .2s ease-out; transition: opacity .14s ease, transform .16s ease, color .14s ease, border-color .14s ease, background .14s ease, box-shadow .14s ease; }.custom-marker-tag i { flex: 0 0 auto; color: #5cbfd1; font-style: normal; font-weight: 800; }.custom-marker-tag span { overflow: hidden; text-overflow: ellipsis; }.custom-marker-tag:hover, .custom-marker-tag.is-active { color: #c1dce8; border-color: rgba(42, 212, 224, .62); background: rgba(16, 54, 73, .9); }.custom-marker-tag.is-combine { border-style: double; background: rgba(32, 34, 61, .82); }.custom-marker-tag.is-combine i { color: #c399ef; }.custom-marker-tag.is-dragging { opacity: .26; }.custom-marker-tag.is-drop-before { box-shadow: inset 3px 0 var(--cyan); transform: translateX(3px); }.custom-marker-tag.is-drop-after { box-shadow: inset -3px 0 var(--cyan); transform: translateX(-3px); }.custom-marker-tag.is-drop-combine { color: #e4c9ff; border-color: #bc86ed; background: rgba(126, 73, 164, .2); box-shadow: 0 0 0 2px rgba(185, 126, 232, .14); transform: translateY(-2px); }.custom-marker-overflow { position: relative; flex: 0 0 auto; }.custom-marker-overflow > button { height: 25px; display: flex; align-items: center; gap: 3px; padding: 0 5px; border: 1px solid rgba(44, 91, 117, .72); background: #091a2a; color: #7799ae; cursor: pointer; }.custom-marker-overflow > button:hover, .custom-marker-overflow > button.is-active { color: var(--cyan); border-color: var(--line-bright); }.custom-marker-overflow > button svg { width: 13px; height: 13px; }.custom-marker-overflow > button b { font-size: 8px; }.custom-marker-overflow-menu { position: absolute; z-index: 15; top: 31px; right: 0; width: 260px; max-height: min(360px, 54vh); padding: 6px; overflow-y: auto; border: 1px solid #28627c; background: #071725; box-shadow: 0 16px 44px rgba(0,0,0,.52); }.custom-marker-overflow-menu .custom-marker-tag { width: 100%; height: 34px; margin-bottom: 4px; cursor: pointer; animation: none; }.custom-marker-overflow-menu .custom-marker-tag small { margin-left: auto; color: #55788d; font-size: 8px; }.custom-marker-drag-ghost { position: fixed; z-index: 50; max-width: 180px; height: 27px; display: flex; align-items: center; padding: 0 10px; overflow: hidden; border: 1px solid var(--cyan); background: rgba(8, 31, 47, .94); color: #c4e3ec; font: 9px ui-monospace, monospace; box-shadow: 0 8px 26px rgba(0,0,0,.46); pointer-events: none; transform: translate(12px, 12px); }.custom-marker-drag-ghost.is-delete { border-color: var(--red); color: #ff94a0; background: rgba(64, 18, 30, .94); }
.custom-marker-section-resize { flex: 0 0 auto; align-self: stretch; min-width: 76px; display: flex; align-items: center; gap: 7px; color: #587b90; cursor: ew-resize; user-select: none; touch-action: none; outline: 0; }
.custom-marker-section-resize i { align-self: stretch; width: 7px; border-left: 1px dashed rgba(75, 142, 171, .64); transition: border-color .15s ease, box-shadow .15s ease; }
.custom-marker-section-resize span { font-size: 8px; letter-spacing: .08em; }
.custom-marker-section-resize:hover, .custom-marker-section-resize:focus-visible, .is-resizing-custom-marker-zone .custom-marker-section-resize { color: var(--cyan); }
.custom-marker-section-resize:hover i, .custom-marker-section-resize:focus-visible i, .is-resizing-custom-marker-zone .custom-marker-section-resize i { border-color: var(--cyan); box-shadow: -3px 0 10px rgba(42, 212, 224, .2); }
.custom-marker-shelf { position: relative; flex: 0 0 auto; min-width: 104px; display: flex; align-items: center; gap: 5px; }
.custom-marker-track { flex: 1; min-width: 0; min-height: 25px; display: flex; gap: 5px; overflow: hidden; }
.custom-marker-empty { flex: 1; display: grid; place-items: center; border: 1px dashed rgba(54, 105, 132, .35); color: #496b80; font-size: 8px; cursor: context-menu; }
.custom-marker-tag { position: relative; flex: 0 0 111px; min-width: 0; height: 25px; display: flex; align-items: center; gap: 5px; padding: 0 7px; overflow: hidden; border: 1px solid rgba(44, 91, 117, .72); background: rgba(8, 28, 44, .86); color: #83a6bb; font: 9px ui-monospace, monospace; cursor: grab; animation: custom-marker-arrive .2s ease-out; transition: opacity .14s ease, transform .16s ease, color .14s ease, border-color .14s ease, background .14s ease, box-shadow .14s ease; }
.custom-marker-tag i { flex: 0 0 auto; color: #5cbfd1; font-style: normal; font-weight: 800; }.custom-marker-tag span { overflow: hidden; text-overflow: ellipsis; }.custom-marker-tag:hover, .custom-marker-tag.is-active { color: #c1dce8; border-color: rgba(42, 212, 224, .62); background: rgba(16, 54, 73, .9); }.custom-marker-tag.is-combine { border-style: double; background: rgba(32, 34, 61, .82); }.custom-marker-tag.is-combine i { color: #c399ef; }.custom-marker-tag.is-dragging { opacity: .26; }.custom-marker-tag.is-drop-before { box-shadow: inset 3px 0 var(--cyan); transform: translateX(3px); }.custom-marker-tag.is-drop-after { box-shadow: inset -3px 0 var(--cyan); transform: translateX(-3px); }.custom-marker-tag.is-drop-combine { color: #e4c9ff; border-color: #bc86ed; background: rgba(126, 73, 164, .2); box-shadow: 0 0 0 2px rgba(185, 126, 232, .14); transform: translateY(-2px); }
.custom-marker-overflow { position: relative; flex: 0 0 auto; }.custom-marker-overflow > button { height: 25px; display: flex; align-items: center; gap: 3px; padding: 0 5px; border: 1px solid rgba(44, 91, 117, .72); background: #091a2a; color: #7799ae; cursor: pointer; }.custom-marker-overflow > button:hover, .custom-marker-overflow > button.is-active { color: var(--cyan); border-color: var(--line-bright); }.custom-marker-overflow > button svg { width: 13px; height: 13px; }.custom-marker-overflow > button b { font-size: 8px; }.custom-marker-overflow-menu { position: absolute; z-index: 15; top: 31px; right: 0; width: 260px; max-height: min(360px, 54vh); padding: 6px; overflow-y: auto; border: 1px solid #28627c; background: #071725; box-shadow: 0 16px 44px rgba(0,0,0,.52); }.custom-marker-overflow-menu .custom-marker-tag { width: 100%; height: 34px; margin-bottom: 4px; cursor: pointer; animation: none; }.custom-marker-overflow-menu .custom-marker-tag small { margin-left: auto; color: #55788d; font-size: 8px; }
.custom-marker-drag-ghost { position: fixed; z-index: 50; max-width: 180px; height: 27px; display: flex; align-items: center; padding: 0 10px; overflow: hidden; border: 1px solid var(--cyan); background: rgba(8, 31, 47, .94); color: #c4e3ec; font: 9px ui-monospace, monospace; box-shadow: 0 8px 26px rgba(0,0,0,.46); pointer-events: none; transform: translate(12px, 12px); }.custom-marker-drag-ghost.is-delete { border-color: var(--red); color: #ff94a0; background: rgba(64, 18, 30, .94); }
.custom-marker-context-menu { position: fixed; z-index: 70; width: 224px; padding: 6px; border: 1px solid #326b85; border-radius: 5px; background: #071725; box-shadow: 0 18px 48px rgba(0,0,0,.62); animation: custom-marker-menu-in .12s ease-out; }
.custom-marker-context-menu header { padding: 7px 8px 8px; border-bottom: 1px solid var(--line); }.custom-marker-context-menu header span { display: block; color: #52778d; font: 7px ui-monospace, monospace; letter-spacing: .15em; }.custom-marker-context-menu header strong { display: block; margin-top: 3px; overflow: hidden; color: #c7dbe6; font-size: 10px; text-overflow: ellipsis; }
.custom-marker-context-menu > button { width: 100%; min-height: 42px; display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: center; gap: 7px; padding: 6px 8px; border: 0; border-bottom: 1px solid rgba(31, 67, 89, .55); background: transparent; color: #8eabba; text-align: left; cursor: pointer; }.custom-marker-context-menu > button:last-child { border-bottom: 0; }.custom-marker-context-menu > button:hover:not(:disabled), .custom-marker-context-menu > button:focus-visible { outline: 0; color: var(--cyan); background: rgba(42, 212, 224, .07); box-shadow: inset 2px 0 var(--cyan); }.custom-marker-context-menu > button:disabled { opacity: .35; cursor: default; }.custom-marker-context-menu > button.danger:hover { color: #ff8391; background: rgba(255, 99, 117, .07); box-shadow: inset 2px 0 var(--red); }.custom-marker-context-menu > button i { color: #5fc4d3; font: 700 13px ui-monospace, monospace; text-align: center; }.custom-marker-context-menu > button.danger i { color: #e36d7c; }.custom-marker-context-menu > button span { min-width: 0; }.custom-marker-context-menu > button b, .custom-marker-context-menu > button small { display: block; }.custom-marker-context-menu > button b { font-size: 10px; }.custom-marker-context-menu > button small { margin-top: 2px; color: #55778c; font-size: 8px; }
@keyframes custom-marker-arrive { from { opacity: 0; transform: translateY(-4px) scale(.96); } to { opacity: 1; transform: translateY(0) scale(1); } }
.custom-marker-editor { position: absolute; z-index: 18; top: 34px; right: 0; width: min(520px, 72vw); max-height: min(520px, 66vh); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #34728d; border-radius: 5px; background: #071725; color: #9ab7c8; box-shadow: 0 20px 58px rgba(0,0,0,.62); }.custom-marker-editor > header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--line); background: #0b2132; }.custom-marker-editor > header span { display: block; color: #547b91; font: 8px ui-monospace, monospace; letter-spacing: .14em; }.custom-marker-editor > header strong { display: block; margin-top: 2px; color: #d3e5ef; font-size: 12px; }.custom-marker-editor > header button { width: 26px; height: 26px; display: grid; place-items: center; padding: 0; border: 1px solid transparent; background: transparent; color: #63879b; cursor: pointer; }.custom-marker-editor > header button:hover { color: var(--cyan); border-color: var(--line); }.custom-marker-editor > header svg { width: 14px; }.custom-marker-name { flex: 0 0 auto; display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 8px; padding: 10px 12px; }.custom-marker-name span { color: #6f92a7; font-size: 9px; }.custom-marker-name input { height: 32px; padding: 0 9px; font-size: 11px; }.custom-marker-rules { min-height: 0; padding: 0 12px 8px; overflow-y: auto; }.custom-marker-rule { margin-bottom: 8px; padding: 8px; border: 1px solid rgba(39, 82, 107, .72); background: #081a29; }.custom-marker-rule-heading { display: grid; grid-template-columns: 24px minmax(0, 1fr) 29px; gap: 6px; align-items: center; margin-bottom: 6px; }.custom-marker-rule-heading b { color: #53788d; font-size: 8px; }.custom-marker-rule-heading input { height: 27px; padding: 0 7px; font-size: 9px; }.custom-marker-rule-heading button { height: 27px; padding: 0; border: 1px solid var(--line); background: #0a1e2f; color: #617f92; font: 800 9px ui-monospace, monospace; cursor: pointer; }.custom-marker-rule-heading button.is-active { color: var(--cyan); border-color: var(--cyan); background: rgba(42,212,224,.09); }.custom-marker-rule textarea { width: 100%; min-height: 48px; resize: vertical; padding: 7px 8px; border: 1px solid var(--line); outline: 0; background: #061522; color: #bfd4df; font: 10px/1.5 ui-monospace, monospace; }.custom-marker-rule textarea:focus { border-color: var(--cyan); }.custom-marker-error { margin: 0 12px 8px; color: #ff8997; font-size: 9px; }.custom-marker-editor > footer { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; border-top: 1px solid var(--line); background: #061421; }.custom-marker-editor > footer div { display: flex; gap: 6px; }.custom-marker-editor > footer button { min-height: 29px; padding: 0 11px; border: 1px solid var(--line); background: #091b2b; color: #8eabbd; font-size: 9px; cursor: pointer; }.custom-marker-editor > footer button:hover:not(:disabled) { color: var(--cyan); border-color: var(--line-bright); }.custom-marker-editor > footer button.primary { border-color: var(--cyan); background: #24c4d0; color: #031820; }.custom-marker-editor > footer button.danger { color: #d87b87; }.custom-marker-editor > footer button:disabled { opacity: .35; cursor: default; }
@keyframes custom-marker-menu-in { from { opacity: 0; transform: translateY(-3px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
.custom-marker-editor { position: absolute; z-index: 18; top: 34px; right: 0; width: min(570px, 76vw); max-height: min(590px, 70vh); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #34728d; border-radius: 5px; background: #071725; color: #9ab7c8; box-shadow: 0 20px 58px rgba(0,0,0,.62); }
.custom-marker-editor > header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--line); background: #0b2132; }.custom-marker-editor > header span { display: block; color: #547b91; font: 8px ui-monospace, monospace; letter-spacing: .14em; }.custom-marker-editor > header strong { display: block; margin-top: 2px; color: #d3e5ef; font-size: 12px; }.custom-marker-editor > header button { width: 26px; height: 26px; display: grid; place-items: center; padding: 0; border: 1px solid transparent; background: transparent; color: #63879b; cursor: pointer; }.custom-marker-editor > header button:hover { color: var(--cyan); border-color: var(--line); }.custom-marker-editor > header svg { width: 14px; }
.custom-marker-name { flex: 0 0 auto; display: grid; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 8px; padding: 10px 12px; }.custom-marker-name span { color: #6f92a7; font-size: 9px; }.custom-marker-name input { height: 32px; padding: 0 9px; font-size: 11px; }
.custom-marker-rules { position: relative; min-height: 0; padding: 0 12px 8px; overflow-y: auto; }.custom-marker-rule { position: relative; margin-bottom: 8px; padding: 8px; border: 1px solid rgba(39, 82, 107, .72); background: #081a29; transition: opacity .14s ease, transform .14s ease, border-color .14s ease, box-shadow .14s ease; }.custom-marker-rule.is-dragging { opacity: .28; }.custom-marker-rule.is-drop-before { box-shadow: inset 0 3px var(--cyan); transform: translateY(2px); }.custom-marker-rule.is-drop-after { box-shadow: inset 0 -3px var(--cyan); transform: translateY(-2px); }
.custom-marker-rule-heading { display: grid; grid-template-columns: 25px 20px minmax(0, 1fr) 30px 28px; gap: 6px; align-items: center; margin-bottom: 6px; }.custom-marker-rule-heading b { color: #53788d; font-size: 8px; }.custom-marker-rule-heading input { min-width: 0; height: 27px; padding: 0 7px; font-size: 9px; }.custom-marker-rule-heading button { height: 27px; padding: 0; border: 1px solid var(--line); background: #0a1e2f; color: #617f92; font: 800 9px ui-monospace, monospace; cursor: pointer; }.custom-marker-rule-heading button.is-active { color: var(--cyan); border-color: var(--cyan); background: rgba(42,212,224,.09); }.custom-marker-rule-heading button:disabled { opacity: .28; cursor: default; }
.custom-marker-rule-grip { display: grid; place-items: center; cursor: grab !important; touch-action: none; }.custom-marker-rule-grip i { width: 12px; height: 14px; opacity: .7; background-image: radial-gradient(circle, #668da3 1px, transparent 1.5px); background-size: 6px 5px; }.custom-marker-rule-grip:hover i { opacity: 1; }.custom-marker-rule-remove:hover:not(:disabled) { color: #ff7d8c; border-color: rgba(255, 99, 117, .6); }
.custom-marker-rule textarea { width: 100%; min-height: 48px; resize: vertical; padding: 7px 8px; border: 1px solid var(--line); outline: 0; background: #061522; color: #bfd4df; font: 10px/1.5 ui-monospace, monospace; }.custom-marker-rule textarea:focus { border-color: var(--cyan); }.custom-marker-rule-add { width: 100%; min-height: 31px; border: 1px dashed rgba(66, 143, 174, .52); background: rgba(17, 54, 72, .28); color: #75a8bd; font-size: 9px; cursor: pointer; }.custom-marker-rule-add:hover:not(:disabled) { color: var(--cyan); border-color: var(--cyan); background: rgba(42, 212, 224, .06); }.custom-marker-rule-add:disabled { opacity: .35; cursor: default; }.custom-marker-rule-add i { margin-right: 5px; font-style: normal; }
.custom-marker-rule-drag-ghost { position: fixed; z-index: 72; left: auto; right: 24px; width: 220px; height: 28px; display: flex; align-items: center; padding: 0 9px; overflow: hidden; border: 1px solid var(--cyan); background: rgba(8, 31, 47, .96); color: #c4e3ec; font: 9px ui-monospace, monospace; pointer-events: none; transform: translateY(10px); box-shadow: 0 8px 24px rgba(0,0,0,.46); }
.custom-marker-error { margin: 0 12px 8px; color: #ff8997; font-size: 9px; }.custom-marker-editor > footer { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; border-top: 1px solid var(--line); background: #061421; }.custom-marker-editor > footer div { display: flex; gap: 6px; }.custom-marker-editor > footer button { min-height: 29px; padding: 0 11px; border: 1px solid var(--line); background: #091b2b; color: #8eabbd; font-size: 9px; cursor: pointer; }.custom-marker-editor > footer button:hover:not(:disabled) { color: var(--cyan); border-color: var(--line-bright); }.custom-marker-editor > footer button.primary { border-color: var(--cyan); background: #24c4d0; color: #031820; }.custom-marker-editor > footer button.danger { color: #d87b87; }.custom-marker-editor > footer button:disabled { opacity: .35; cursor: default; }
.log-outline-popover { position: absolute; z-index: 8; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; visibility: hidden; opacity: 0; border: 1px solid #28627c; border-radius: 5px; background: #071725; box-shadow: 0 18px 48px rgba(0, 0, 0, .55), 0 0 0 1px rgba(42, 212, 224, .04); transition: opacity .14s ease, box-shadow .16s ease; }
.log-outline-popover.is-ready { visibility: visible; opacity: 1; }.log-outline-popover.is-adjusting { box-shadow: 0 22px 60px rgba(0, 0, 0, .62), 0 0 0 1px rgba(42, 212, 224, .16); transition: none; }
.log-outline-popover > header { flex: 0 0 auto; min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px 10px 14px; border-bottom: 1px solid var(--line); background: rgba(13, 42, 59, .68); cursor: move; user-select: none; touch-action: none; }