Compare commits

...

3 Commits

Author SHA1 Message Date
SmartUp Developer 42d8731ff7 perf: website_sync 批量查询 + custom_pages origin TTL 缓存 2026-05-25 00:52:38 +08:00
SmartUp Developer 4971263a3a perf: 前端按需引入 Element Plus + Vite 拆包 + template 优化 2026-05-25 00:18:08 +08:00
SmartUp Developer 41a439d830 perf: SQLite WAL + 复合索引 + GZip + scheduler jitter + 构建缓存 2026-05-25 00:08:10 +08:00
19 changed files with 595 additions and 28 deletions
+4 -2
View File
@@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1
# syntax=docker/dockerfile:1.4
# ---- Stage 1: Build frontend ----
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
@@ -42,7 +42,9 @@ RUN --mount=type=cache,target=/root/.cache/pip \
-r requirements.txt
# Playwright Chromium:安装在镜像层中,业务代码变更不会触发重下
RUN playwright install chromium
# --mount=type=cache 利用 BuildKit 缓存避免每次构建重下 ~170 MB 浏览器
RUN --mount=type=cache,target=/root/.cache/ms-playwright,sharing=locked \
playwright install chromium
# 源码层:业务代码变更不影响上面所有依赖层
COPY backend/ .
+5
View File
@@ -76,6 +76,11 @@ npm run dev # 代理到 localhost:8000
SQLite 数据库位于 `./data/app.db`,直接复制即可备份:
> **WAL 模式说明**:启用 WAL 后,备份前请先执行 checkpoint 将 WAL 合并入主库:
> ```bash
> sqlite3 ./data/app.db "PRAGMA wal_checkpoint(TRUNCATE);"
> ```
```bash
cp ./data/app.db ./data/app.db.$(date +%Y%m%d)
```
+40 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import create_engine, inspect, text
from sqlalchemy import create_engine, event, inspect, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from app.config import get_settings
@@ -8,6 +8,21 @@ engine = create_engine(
settings.database_url,
connect_args={"check_same_thread": False},
)
# ── SQLite 性能 PRAGMAWAL + 缓存 + 超时) ──
@event.listens_for(engine, "connect", insert=True)
def _set_sqlite_pragma(dbapi_connection, connection_record):
if engine.dialect.name != "sqlite":
return
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA synchronous=NORMAL;")
cursor.execute("PRAGMA busy_timeout=5000;")
cursor.execute("PRAGMA foreign_keys=ON;")
cursor.execute("PRAGMA cache_size=-20000;") # 20 MB page cache
cursor.execute("PRAGMA mmap_size=67108864;") # 64 MB(小内存容器友好)
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -28,11 +43,35 @@ def init_db():
# import models so SQLAlchemy registers them
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key # noqa: F401
Base.metadata.create_all(bind=engine)
_ensure_indexes()
_migrate_custom_pages()
_migrate_upstreams()
_migrate_upstream_generated_keys()
# ── 已有数据库幂等索引迁移 ─────────────────────────────────
_NEW_INDEXES = [
"CREATE INDEX IF NOT EXISTS ix_snapshot_upstream_captured ON upstream_rate_snapshots(upstream_id, captured_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_notif_event_created ON notification_logs(event_type, created_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_notif_status_created ON notification_logs(status, created_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_sync_website_created ON website_sync_logs(website_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_sync_binding_created ON website_sync_logs(binding_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS ix_upstream_enabled ON upstreams(enabled)",
"CREATE INDEX IF NOT EXISTS ix_key_upstream_name ON upstream_generated_keys(upstream_id, key_name)",
]
def _ensure_indexes() -> None:
"""创建 InitDB 时可能未包含的复合索引,幂等执行。"""
with engine.begin() as conn:
for stmt in _NEW_INDEXES:
try:
conn.execute(text(stmt))
except Exception:
logger = __import__("logging").getLogger(__name__)
logger.warning("index creation failed (non-fatal): %s", stmt[:60])
def _migrate_custom_pages():
"""Apply small SQLite-safe migrations for deployments without Alembic."""
inspector = inspect(engine)
+2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
@@ -88,6 +89,7 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1024)
# API routers
app.include_router(auth.router)
+6 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import Integer, String, DateTime, Text, ForeignKey
from sqlalchemy import Index, Integer, String, DateTime, Text, ForeignKey, text
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
@@ -19,3 +19,8 @@ class NotificationLog(Base):
status: Mapped[str] = mapped_column(String(16), nullable=False)
response_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
__table_args__ = (
Index("ix_notif_event_created", "event_type", text("created_at DESC")),
Index("ix_notif_status_created", "status", text("created_at DESC")),
)
+5 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timezone
from sqlalchemy import Integer, Text, DateTime, ForeignKey
from sqlalchemy import Index, Integer, Text, DateTime, ForeignKey, text
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
@@ -11,3 +11,7 @@ class UpstreamRateSnapshot(Base):
upstream_id: Mapped[int] = mapped_column(Integer, ForeignKey("upstreams.id", ondelete="CASCADE"), index=True)
snapshot_json: Mapped[str] = mapped_column(Text, nullable=False)
captured_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
__table_args__ = (
Index("ix_snapshot_upstream_captured", "upstream_id", text("captured_at DESC")),
)
+5 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import Integer, String, Boolean, DateTime, Text, Float
from sqlalchemy import Index, Integer, String, Boolean, DateTime, Text, Float
from sqlalchemy.orm import mapped_column, Mapped
from app.database import Base
@@ -36,3 +36,7 @@ class Upstream(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_upstream_enabled", "enabled"),
)
+2 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -30,4 +30,5 @@ class UpstreamGeneratedKey(Base):
__table_args__ = (
UniqueConstraint("upstream_id", "group_id", "key_name", name="uq_upstream_group_key"),
Index("ix_key_upstream_name", "upstream_id", "key_name"),
)
+6 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -66,3 +66,8 @@ class WebsiteSyncLog(Base):
status: Mapped[str] = mapped_column(String(16), nullable=False)
message: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
__table_args__ = (
Index("ix_sync_website_created", "website_id", text("created_at DESC")),
Index("ix_sync_binding_created", "binding_id", text("created_at DESC")),
)
+39 -3
View File
@@ -321,13 +321,49 @@ def _same_origin(a: str, b: str) -> bool:
return _origin(a).rstrip("/") == _origin(b).rstrip("/")
# ── TTL 缓存:origin → upstream_id30 秒自动过期,无手动 invalidate)──
import time as _time
from functools import wraps as _wraps
def _ttl_cache(ttl_seconds: int = 30, maxsize: int = 64):
def decorator(fn):
cache: dict = {}
@_wraps(fn)
def wrapper(*args):
key = args
now = _time.monotonic()
if key in cache:
value, ts = cache[key]
if now - ts < ttl_seconds:
return value
value = fn(*args)
if len(cache) >= maxsize:
cache.pop(next(iter(cache)))
cache[key] = (value, now)
return value
return wrapper
return decorator
def _find_matching_upstream(db: Session, page: CustomPage) -> Optional[Upstream]:
page_origin = _origin(page.url)
if not page_origin:
return None
for upstream in db.query(Upstream).order_by(Upstream.id).all():
if _origin(upstream.base_url) == page_origin:
return upstream
upstream_id = _origin_to_upstream_id(page_origin)
if upstream_id is None:
return None
return db.query(Upstream).filter(Upstream.id == upstream_id).first()
@_ttl_cache(ttl_seconds=30, maxsize=64)
def _origin_to_upstream_id(origin: str) -> Optional[int]:
"""按 origin 查匹配的上游 ID,结果缓存 30 秒。"""
from app.database import SessionLocal
from app.models.upstream import Upstream
with SessionLocal() as sess:
for upstream in sess.query(Upstream).order_by(Upstream.id).all():
if _origin(upstream.base_url) == origin:
return upstream.id
return None
+2
View File
@@ -284,6 +284,8 @@ def refresh_upstream(upstream_id: int, interval_seconds: int = 0, enabled: bool
replace_existing=True,
coalesce=True,
max_instances=1,
misfire_grace_time=60,
jitter=30,
)
logger.info("scheduler job %s set to %ds interval", job_id, interval_seconds)
+16 -2
View File
@@ -96,13 +96,27 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True)
if not website:
raise WebsiteError("网站不存在")
sources = binding_sources(binding)
# ── 批量预查:收集所有上游 ID,一次查询上游名称 ──
upstream_ids = {int(s.get("upstream_id") or 0) for s in sources if s.get("upstream_id")}
upstreams = {}
if upstream_ids:
rows = db.query(Upstream).filter(Upstream.id.in_(upstream_ids)).all()
upstreams = {u.id: u for u in rows}
# ── 同一轮 sync 内的快照缓存(调用级,函数返回即释放)──
_snap_cache: dict[int, dict[str, Any]] = {}
def _get_snap(upstream_id: int) -> dict[str, Any]:
if upstream_id not in _snap_cache:
_snap_cache[upstream_id] = latest_rate_map(db, upstream_id)
return _snap_cache[upstream_id]
source_rates: list[dict[str, Any]] = []
for source in sources:
upstream_id = int(source.get("upstream_id") or 0)
group_id = str(source.get("group_id") or "")
groups = latest_rate_map(db, upstream_id)
groups = _get_snap(upstream_id)
group = groups.get(group_id) if group_id else None
upstream = db.query(Upstream).filter(Upstream.id == upstream_id).first()
upstream = upstreams.get(upstream_id)
source_rates.append({
"upstream_id": upstream_id,
"upstream_name": source.get("upstream_name") or (upstream.name if upstream else ""),
+5
View File
@@ -19,6 +19,11 @@ services:
- DATABASE_URL=sqlite:////app/data/app.db
- TZ=${TZ:-Asia/Shanghai}
- UNHEALTHY_THRESHOLD=${UNHEALTHY_THRESHOLD:-3}
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 30s
+403
View File
@@ -20,6 +20,8 @@
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
@@ -555,12 +557,55 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
@@ -1173,6 +1218,19 @@
"vue": "^3.5.0"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
@@ -1245,6 +1303,22 @@
"node": ">= 0.4"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1257,6 +1331,13 @@
"node": ">= 0.8"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -1424,12 +1505,32 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -1618,6 +1719,31 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz",
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
@@ -1702,6 +1828,38 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1733,6 +1891,17 @@
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1740,6 +1909,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -1781,6 +1957,18 @@
}
}
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
"exsolve": "^1.0.8",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz",
@@ -1818,6 +2006,37 @@
"node": ">=10"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rollup": {
"version": "4.60.3",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.3.tgz",
@@ -1863,6 +2082,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"dev": true,
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1872,6 +2098,19 @@
"node": ">=0.10.0"
}
},
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz",
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -1903,6 +2142,163 @@
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
"node_modules/unimport": {
"version": "5.7.0",
"resolved": "https://registry.npmmirror.com/unimport/-/unimport-5.7.0.tgz",
"integrity": "sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"escape-string-regexp": "^5.0.0",
"estree-walker": "^3.0.3",
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"mlly": "^1.8.0",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"pkg-types": "^2.3.0",
"scule": "^1.3.0",
"strip-literal": "^3.1.0",
"tinyglobby": "^0.2.15",
"unplugin": "^2.3.11",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/unimport/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz",
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"acorn": "^8.15.0",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/unplugin-auto-import": {
"version": "21.0.0",
"resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz",
"integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"picomatch": "^4.0.3",
"unimport": "^5.6.0",
"unplugin": "^2.3.11",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@nuxt/kit": "^4.0.0",
"@vueuse/core": "*"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"@vueuse/core": {
"optional": true
}
}
},
"node_modules/unplugin-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
"dev": true,
"license": "MIT",
"dependencies": {
"pathe": "^2.0.3",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/unplugin-vue-components": {
"version": "32.1.0",
"resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"local-pkg": "^1.2.0",
"magic-string": "^0.30.21",
"mlly": "^1.8.2",
"obug": "^2.1.1",
"picomatch": "^4.0.4",
"tinyglobby": "^0.2.16",
"unplugin": "^3.0.0",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@nuxt/kit": "^3.2.2 || ^4.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
}
}
},
"node_modules/unplugin-vue-components/node_modules/unplugin": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.0.0.tgz",
"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.3",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",
@@ -2069,6 +2465,13 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
}
}
}
+2
View File
@@ -20,6 +20,8 @@
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
+12 -9
View File
@@ -1,21 +1,24 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElIcons from '@element-plus/icons-vue'
// 程序化 API 组件手动引入(按需引入不自动包含)
import { ElMessage, ElMessageBox, ElNotification, ElLoading } from 'element-plus'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import 'element-plus/es/components/notification/style/css'
import 'element-plus/es/components/loading/style/css'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
// Register all Element Plus icons globally
for (const [name, component] of Object.entries(ElIcons)) {
app.component(name, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
// 确保程序化 API 在 setup 外也可用(已全局引用,不 tree-shake
ElMessage
ElMessageBox
ElNotification
ElLoading
+3 -1
View File
@@ -83,7 +83,7 @@
</el-descriptions>
<div class="detail-section">
<div class="detail-label">Payload</div>
<pre class="code-block">{{ JSON.stringify(detailRow.payload, null, 2) }}</pre>
<pre class="code-block">{{ detailPayloadText }}</pre>
</div>
<div v-if="detailRow.response_text" class="detail-section">
<div class="detail-label">响应</div>
@@ -123,6 +123,8 @@ const eventTagType = (e: string) =>
const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : t + 'Z'
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
const detailPayloadText = computed(() => detailRow.value ? JSON.stringify(detailRow.value.payload, null, 2) : '')
async function loadList() {
tableLoading.value = true
+12 -4
View File
@@ -428,7 +428,7 @@
<div v-if="expandedId === snap.id" class="snap-body">
<el-table
:data="groupRows(snap.snapshot)"
:data="groupRows(snap)"
size="small"
:header-cell-style="{ background: 'rgba(255, 244, 232, 0.02)', color: 'var(--text-soft)' }"
:cell-style="{ background: 'transparent', color: 'var(--text-primary)' }"
@@ -725,9 +725,15 @@ const toUTC = (t: string) => /[Z+\-]\d*$/.test(t.trim()) ? t : `${t}Z`
const fmtTime = (t: string) => dayjs(toUTC(t)).format('MM-DD HH:mm:ss')
const fmtTimeFull = (t: string) => dayjs(toUTC(t)).format('YYYY-MM-DD HH:mm:ss')
function groupRows(snapshot: any) {
if (!snapshot?.groups) return []
return Object.values(snapshot.groups) as any[]
const _groupRowsCache = new Map<number, any[]>()
function groupRows(snap: { id: number; snapshot: any }) {
if (!snap?.snapshot?.groups) return []
const cached = _groupRowsCache.get(snap.id)
if (cached) return cached
const rows = Object.values(snap.snapshot.groups) as any[]
_groupRowsCache.set(snap.id, rows)
return rows
}
function shrinkError(value: string) {
@@ -833,6 +839,7 @@ function openDetail(row: UpstreamData) {
generatedKeys.value = []
snapshotOffset.value = 0
expandedId.value = null
_groupRowsCache.clear()
detailVisible.value = true
}
@@ -854,6 +861,7 @@ async function loadSnapshots() {
try {
const res = await upstreamsApi.listSnapshots(detailUpstream.value.id, snapshotLimit, snapshotOffset.value)
snapshots.value = res.data
_groupRowsCache.clear()
if (res.data.length > 0 && expandedId.value === null) {
expandedId.value = res.data[0].id
}
+26 -1
View File
@@ -1,9 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver({ importStyle: 'css' })] }),
],
resolve: {
alias: { '@': resolve(__dirname, 'src') },
},
@@ -16,5 +23,23 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: false,
target: 'es2020',
cssCodeSplit: true,
chunkSizeWarningLimit: 800,
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes('/node_modules/element-plus/') || id.includes('/node_modules/@element-plus/')) {
return 'vendor-el';
}
if (id.includes('/node_modules/vue/') || id.includes('/node_modules/pinia/') || id.includes('/node_modules/vue-router/')) {
return 'vendor-vue';
}
if (id.includes('/node_modules/axios/')) {
return 'vendor-axios';
}
},
},
},
},
})