mirror of
https://github.com/AlawnCN/opslog.git
synced 2026-09-24 06:01:12 +00:00
feat: expand custom markers and web configuration
This commit is contained in:
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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
2
src-tauri/Cargo.lock
generated
@@ -2305,7 +2305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opslog"
|
||||
version = "3.0.13"
|
||||
version = "3.0.14"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 不合法/);
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
51
web/src/components/CustomLogMarkerContextMenu.tsx
Normal file
51
web/src/components/CustomLogMarkerContextMenu.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
99
web/src/components/CustomLogMarkerRuleEditor.tsx
Normal file
99
web/src/components/CustomLogMarkerRuleEditor.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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>;
|
||||
});
|
||||
|
||||
|
||||
78
web/src/components/CustomMarkerSectionResizeHandle.tsx
Normal file
78
web/src/components/CustomMarkerSectionResizeHandle.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
29
web/src/custom-marker-layout.ts
Normal file
29
web/src/custom-marker-layout.ts
Normal 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.
|
||||
}
|
||||
};
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user