ErisPulse-GovReport 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- GovReport/Config.py +62 -0
- GovReport/Core.py +209 -0
- GovReport/I18n.py +173 -0
- GovReport/Models.py +30 -0
- GovReport/Stats.py +331 -0
- GovReport/Visualizer.py +190 -0
- GovReport/__init__.py +4 -0
- erispulse_govreport-0.1.0.dist-info/METADATA +160 -0
- erispulse_govreport-0.1.0.dist-info/RECORD +13 -0
- erispulse_govreport-0.1.0.dist-info/WHEEL +5 -0
- erispulse_govreport-0.1.0.dist-info/entry_points.txt +2 -0
- erispulse_govreport-0.1.0.dist-info/licenses/LICENSE +21 -0
- erispulse_govreport-0.1.0.dist-info/top_level.txt +1 -0
GovReport/Config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from ErisPulse.Core.Bases import BaseConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class GovReportConfig(BaseConfig):
|
|
8
|
+
|
|
9
|
+
enabled: bool = field(
|
|
10
|
+
default=True,
|
|
11
|
+
metadata={
|
|
12
|
+
"description": {"i18n": "GovReport.enabled", "default": "是否启用模块"},
|
|
13
|
+
"ui": {"widget": "switch", "group": "basic", "order": 1},
|
|
14
|
+
},
|
|
15
|
+
)
|
|
16
|
+
collect_command_calls: bool = field(
|
|
17
|
+
default=True,
|
|
18
|
+
metadata={
|
|
19
|
+
"description": {"i18n": "GovReport.collect_command_calls", "default": "采集命令执行记录(command.executed)"},
|
|
20
|
+
"ui": {"widget": "switch", "group": "collect", "order": 2},
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
collect_blocked: bool = field(
|
|
24
|
+
default=True,
|
|
25
|
+
metadata={
|
|
26
|
+
"description": {"i18n": "GovReport.collect_blocked", "default": "采集事件拦截记录(中间件否决 / 作用域拦截)"},
|
|
27
|
+
"ui": {"widget": "switch", "group": "collect", "order": 3},
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
retention_days: int = field(
|
|
31
|
+
default=30,
|
|
32
|
+
metadata={
|
|
33
|
+
"description": {"i18n": "GovReport.retention_days", "default": "记录保留天数(0 永久保留,启动与每日清理)"},
|
|
34
|
+
"ui": {"widget": "number", "group": "collect", "order": 4},
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
top_n: int = field(
|
|
38
|
+
default=10,
|
|
39
|
+
metadata={
|
|
40
|
+
"description": {"i18n": "GovReport.top_n", "default": "统计报表单次展示的最大条目数"},
|
|
41
|
+
"ui": {"widget": "number", "group": "report", "order": 5},
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
render_card: bool = field(
|
|
45
|
+
default=True,
|
|
46
|
+
metadata={
|
|
47
|
+
"description": {"i18n": "GovReport.render_card", "default": "报表用 Takumi 渲染为卡片(不可用时降级文本)"},
|
|
48
|
+
"ui": {"widget": "switch", "group": "render", "order": 6},
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
theme: str = field(
|
|
52
|
+
default="auto",
|
|
53
|
+
metadata={
|
|
54
|
+
"description": {"i18n": "GovReport.theme", "default": "卡片配色 (auto=按时间自动切深浅色)"},
|
|
55
|
+
"ui": {
|
|
56
|
+
"widget": "select",
|
|
57
|
+
"options": ["auto", "light", "dark"],
|
|
58
|
+
"group": "render",
|
|
59
|
+
"order": 7,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
)
|
GovReport/Core.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from ErisPulse import i18n, sdk
|
|
5
|
+
from ErisPulse.Core import Depends
|
|
6
|
+
from ErisPulse.Core.Bases import BaseModule, ModuleMeta
|
|
7
|
+
from ErisPulse.Core.Event import command
|
|
8
|
+
from ErisPulse.loaders import ModuleLoadStrategy
|
|
9
|
+
|
|
10
|
+
from .Config import GovReportConfig
|
|
11
|
+
from .I18n import GovReportI18n
|
|
12
|
+
from .Models import BlockEvent, CommandCall
|
|
13
|
+
from .Stats import GovStatsService, parse_window
|
|
14
|
+
from .Visualizer import CardVisualizer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_stats_service(event):
|
|
18
|
+
from ErisPulse import module as module_mgr
|
|
19
|
+
|
|
20
|
+
inst = module_mgr.get("GovReport")
|
|
21
|
+
if inst is None:
|
|
22
|
+
raise RuntimeError("GovReport not loaded")
|
|
23
|
+
return inst._stats
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Main(BaseModule):
|
|
27
|
+
|
|
28
|
+
ConfigClass = GovReportConfig
|
|
29
|
+
I18nClass = GovReportI18n
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.sdk = sdk
|
|
33
|
+
self.logger = sdk.logger.get_child("GovReport")
|
|
34
|
+
self._stats = GovStatsService(self.logger)
|
|
35
|
+
self._viz = CardVisualizer(sdk, self.logger)
|
|
36
|
+
self._daily_task = None
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def get_load_strategy() -> ModuleLoadStrategy:
|
|
40
|
+
return ModuleLoadStrategy(lazy_load=False, priority=20)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def get_meta() -> ModuleMeta:
|
|
44
|
+
return ModuleMeta(
|
|
45
|
+
name="GovReport",
|
|
46
|
+
description="治理观测模块:采集命令执行与事件拦截记录,输出统计报表与配额消耗",
|
|
47
|
+
version="0.1.0",
|
|
48
|
+
author="ErisPulse-devs",
|
|
49
|
+
group="运维",
|
|
50
|
+
tags=["治理", "统计", "ORM", "观测"],
|
|
51
|
+
min_sdk_version="2.9.0",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def on_load(self, event: dict) -> bool:
|
|
55
|
+
if not self.cfg.enabled:
|
|
56
|
+
self.logger.info("治理观测未启用(enabled=false)")
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
await CommandCall.create_table()
|
|
60
|
+
await BlockEvent.create_table()
|
|
61
|
+
await self._stats.purge_expired(self.cfg.retention_days)
|
|
62
|
+
|
|
63
|
+
self._register_collectors()
|
|
64
|
+
self._register_commands()
|
|
65
|
+
self._daily_task = self.spawn(self._daily_purge_loop())
|
|
66
|
+
self.logger.info(i18n.t("GovReport.module_loaded", event=event))
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
async def on_unload(self, event: dict) -> bool:
|
|
70
|
+
if self._daily_task is not None:
|
|
71
|
+
self._daily_task.cancel()
|
|
72
|
+
await asyncio.gather(self._daily_task, return_exceptions=True)
|
|
73
|
+
self._daily_task = None
|
|
74
|
+
self.logger.info(i18n.t("GovReport.module_unloaded", event=event))
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
async def _daily_purge_loop(self):
|
|
78
|
+
while True:
|
|
79
|
+
await asyncio.sleep(86400)
|
|
80
|
+
try:
|
|
81
|
+
await self._stats.purge_expired(self.cfg.retention_days)
|
|
82
|
+
except Exception as e:
|
|
83
|
+
self.logger.warning(f"每日清理失败: {e}")
|
|
84
|
+
|
|
85
|
+
def _register_collectors(self):
|
|
86
|
+
@sdk.lifecycle.on("command.executed")
|
|
87
|
+
async def _on_command_executed(data):
|
|
88
|
+
if not self.cfg.collect_command_calls:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
await CommandCall.create(
|
|
92
|
+
ts=time.time(),
|
|
93
|
+
command=str(data.get("command", ""))[:128],
|
|
94
|
+
platform=str(data.get("platform", ""))[:64],
|
|
95
|
+
user_id=str(data.get("user_id", ""))[:128],
|
|
96
|
+
success=bool(data.get("success")),
|
|
97
|
+
error=str(data.get("error", "") or "")[:255],
|
|
98
|
+
)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
self.logger.warning(f"记录命令执行失败: {e}")
|
|
101
|
+
|
|
102
|
+
@sdk.lifecycle.on("adapter.event.blocked")
|
|
103
|
+
async def _on_event_blocked(data):
|
|
104
|
+
if not self.cfg.collect_blocked:
|
|
105
|
+
return
|
|
106
|
+
raw = data.get("event") or {}
|
|
107
|
+
try:
|
|
108
|
+
await BlockEvent.create(
|
|
109
|
+
ts=time.time(),
|
|
110
|
+
source="middleware",
|
|
111
|
+
middleware=str(data.get("middleware", ""))[:128],
|
|
112
|
+
platform=str(data.get("platform", ""))[:64],
|
|
113
|
+
detail_type=str(data.get("detail_type", ""))[:64],
|
|
114
|
+
user_id=str(raw.get("user_id", "") or "")[:128],
|
|
115
|
+
)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
self.logger.warning(f"记录拦截事件失败: {e}")
|
|
118
|
+
|
|
119
|
+
@sdk.lifecycle.on("scope.blocked")
|
|
120
|
+
async def _on_scope_blocked(data):
|
|
121
|
+
if not self.cfg.collect_blocked:
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
await BlockEvent.create(
|
|
125
|
+
ts=time.time(),
|
|
126
|
+
source="scope",
|
|
127
|
+
dimension=str(data.get("dimension", ""))[:16],
|
|
128
|
+
module=str(data.get("module", "") or "")[:128],
|
|
129
|
+
platform=str(data.get("platform", ""))[:64],
|
|
130
|
+
user_id=str(data.get("user_id", "") or "")[:128],
|
|
131
|
+
session_id=str(data.get("session_id", "") or "")[:128],
|
|
132
|
+
)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
self.logger.warning(f"记录作用域拦截失败: {e}")
|
|
135
|
+
|
|
136
|
+
async def _reply_card(self, event, html, fallback_text: str):
|
|
137
|
+
if self.cfg.render_card:
|
|
138
|
+
png = await self._viz.render(html, theme=self.cfg.theme)
|
|
139
|
+
if png is not None:
|
|
140
|
+
try:
|
|
141
|
+
await event.reply(png, method="Image")
|
|
142
|
+
return
|
|
143
|
+
except Exception as e:
|
|
144
|
+
self.logger.warning(f"图片回复失败,降级文本: {e}")
|
|
145
|
+
await event.reply(fallback_text)
|
|
146
|
+
|
|
147
|
+
def _register_commands(self):
|
|
148
|
+
@command("gov", master=True, help="治理观测")
|
|
149
|
+
async def gov_handler(event):
|
|
150
|
+
await event.reply(
|
|
151
|
+
"用法: /gov stats [窗口] | /gov user <用户ID> [窗口] | /gov blocked [窗口] | "
|
|
152
|
+
"/gov usage | /gov list [命令] | /gov purge <天数>"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@command("gov stats", master=True, help="命令执行统计", args="[window:str=24h]")
|
|
156
|
+
async def gov_stats_handler(event, window: str = "24h", stats=Depends(get_stats_service)):
|
|
157
|
+
secs = parse_window(window)
|
|
158
|
+
if secs is None:
|
|
159
|
+
await event.reply(i18n.t("GovReport.invalid_window", window=window))
|
|
160
|
+
return
|
|
161
|
+
await self._reply_card(
|
|
162
|
+
event,
|
|
163
|
+
await stats.command_stats_card(window, self.cfg.top_n, self._viz),
|
|
164
|
+
await stats.command_stats(secs, window, self.cfg.top_n),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@command("gov user", master=True, help="单用户统计", args="<user_id:str> [window:str=24h]")
|
|
168
|
+
async def gov_user_handler(event, user_id: str, window: str = "24h"):
|
|
169
|
+
secs = parse_window(window)
|
|
170
|
+
if secs is None:
|
|
171
|
+
await event.reply(i18n.t("GovReport.invalid_window", window=window))
|
|
172
|
+
return
|
|
173
|
+
await event.reply(await self._stats.user_stats(user_id, secs, window, self.cfg.top_n))
|
|
174
|
+
|
|
175
|
+
@command("gov blocked", master=True, help="拦截统计", args="[window:str=24h]")
|
|
176
|
+
async def gov_blocked_handler(event, window: str = "24h"):
|
|
177
|
+
secs = parse_window(window)
|
|
178
|
+
if secs is None:
|
|
179
|
+
await event.reply(i18n.t("GovReport.invalid_window", window=window))
|
|
180
|
+
return
|
|
181
|
+
await self._reply_card(
|
|
182
|
+
event,
|
|
183
|
+
await self._stats.blocked_stats_card(window, self.cfg.top_n, self._viz),
|
|
184
|
+
await self._stats.blocked_stats(secs, window, self.cfg.top_n),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@command("gov usage", master=True, help="配额消耗报表")
|
|
188
|
+
async def gov_usage_handler(event):
|
|
189
|
+
await self._reply_card(
|
|
190
|
+
event,
|
|
191
|
+
await self._stats.usage_report_card(self._viz),
|
|
192
|
+
await self._stats.usage_report(),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@command("gov list", master=True, help="治理声明清单", args="[cmd:str]")
|
|
196
|
+
async def gov_list_handler(event, cmd: str = ""):
|
|
197
|
+
await self._reply_card(
|
|
198
|
+
event,
|
|
199
|
+
self._stats.governance_list_card(cmd or None, self._viz),
|
|
200
|
+
self._stats.governance_list(cmd or None),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@command("gov purge", master=True, help="清理旧记录", args="<days:int>")
|
|
204
|
+
async def gov_purge_handler(event, days: int):
|
|
205
|
+
if days <= 0:
|
|
206
|
+
await event.reply(i18n.t("GovReport.invalid_window", window=str(days)))
|
|
207
|
+
return
|
|
208
|
+
calls, blocks = await self._stats.purge(days)
|
|
209
|
+
await event.reply(i18n.t("GovReport.purge_done", calls=calls, blocks=blocks, days=days))
|
GovReport/I18n.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from ErisPulse.Core.Bases import BaseI18n, I18nKey
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GovReportI18n(BaseI18n):
|
|
5
|
+
|
|
6
|
+
enabled: I18nKey = I18nKey(
|
|
7
|
+
default="Enable module",
|
|
8
|
+
zh_CN="是否启用模块",
|
|
9
|
+
zh_TW="是否啟用模組",
|
|
10
|
+
en="Enable module",
|
|
11
|
+
ja="モジュールを有効化",
|
|
12
|
+
ru="Включить модуль",
|
|
13
|
+
)
|
|
14
|
+
collect_command_calls: I18nKey = I18nKey(
|
|
15
|
+
default="Collect command execution records",
|
|
16
|
+
zh_CN="采集命令执行记录(command.executed)",
|
|
17
|
+
zh_TW="採集命令執行記錄(command.executed)",
|
|
18
|
+
en="Collect command execution records",
|
|
19
|
+
ja="コマンド実行記録を収集",
|
|
20
|
+
ru="Собирать записи выполнения команд",
|
|
21
|
+
)
|
|
22
|
+
collect_blocked: I18nKey = I18nKey(
|
|
23
|
+
default="Collect blocked event records (middleware veto / scope)",
|
|
24
|
+
zh_CN="采集事件拦截记录(中间件否决 / 作用域拦截)",
|
|
25
|
+
zh_TW="採集事件攔截記錄(中介軟體否決 / 作用域攔截)",
|
|
26
|
+
en="Collect blocked event records (middleware veto / scope)",
|
|
27
|
+
ja="ブロック記録を収集(ミドルウェア拒否 / スコープ)",
|
|
28
|
+
ru="Собирать записи блокировки событий",
|
|
29
|
+
)
|
|
30
|
+
retention_days: I18nKey = I18nKey(
|
|
31
|
+
default="Retention days (0 = keep forever)",
|
|
32
|
+
zh_CN="记录保留天数(0 永久保留,启动与每日清理)",
|
|
33
|
+
zh_TW="記錄保留天數(0 永久保留,啟動與每日清理)",
|
|
34
|
+
en="Retention days (0 = keep forever)",
|
|
35
|
+
ja="保持日数(0 で無期限保持)",
|
|
36
|
+
ru="Срок хранения в днях (0 = бессрочно)",
|
|
37
|
+
)
|
|
38
|
+
top_n: I18nKey = I18nKey(
|
|
39
|
+
default="Max entries shown in one report",
|
|
40
|
+
zh_CN="统计报表单次展示的最大条目数",
|
|
41
|
+
zh_TW="統計報表單次展示的最大條目數",
|
|
42
|
+
en="Max entries shown in one report",
|
|
43
|
+
ja="レポート1回の最大表示件数",
|
|
44
|
+
ru="Максимум записей в одном отчёте",
|
|
45
|
+
)
|
|
46
|
+
render_card: I18nKey = I18nKey(
|
|
47
|
+
default="Render reports as image cards via Takumi (fallback to text)",
|
|
48
|
+
zh_CN="报表用 Takumi 渲染为卡片(不可用时降级文本)",
|
|
49
|
+
zh_TW="報表用 Takumi 渲染為卡片(不可用時降級文本)",
|
|
50
|
+
en="Render reports as image cards via Takumi (fallback to text)",
|
|
51
|
+
ja="レポートを Takumi でカード画像化(不可時はテキスト)",
|
|
52
|
+
ru="Рендер отчётов в карточки через Takumi (иначе текст)",
|
|
53
|
+
)
|
|
54
|
+
theme: I18nKey = I18nKey(
|
|
55
|
+
default="Card color theme (auto = light/dark by time)",
|
|
56
|
+
zh_CN="卡片配色 (auto=按时间自动切深浅色)",
|
|
57
|
+
zh_TW="卡片配色 (auto=按時間自動切深淺色)",
|
|
58
|
+
en="Card color theme (auto = light/dark by time)",
|
|
59
|
+
ja="カード配色(auto=時間で自動切替)",
|
|
60
|
+
ru="Тема карточек (auto = светлая/тёмная по времени)",
|
|
61
|
+
)
|
|
62
|
+
module_loaded: I18nKey = I18nKey(
|
|
63
|
+
default="GovReport loaded: {event}",
|
|
64
|
+
zh_CN="治理观测已加载: {event}",
|
|
65
|
+
zh_TW="治理觀測已載入: {event}",
|
|
66
|
+
en="GovReport loaded: {event}",
|
|
67
|
+
ja="GovReport を読み込みました: {event}",
|
|
68
|
+
ru="GovReport загружен: {event}",
|
|
69
|
+
)
|
|
70
|
+
module_unloaded: I18nKey = I18nKey(
|
|
71
|
+
default="GovReport unloaded: {event}",
|
|
72
|
+
zh_CN="治理观测已卸载: {event}",
|
|
73
|
+
zh_TW="治理觀測已卸載: {event}",
|
|
74
|
+
en="GovReport unloaded: {event}",
|
|
75
|
+
ja="GovReport を解除しました: {event}",
|
|
76
|
+
ru="GovReport выгружен: {event}",
|
|
77
|
+
)
|
|
78
|
+
stats_header: I18nKey = I18nKey(
|
|
79
|
+
default="Command stats in last {window}: total {total}, success {ok}, failed {failed}, deprecated uses {dep}",
|
|
80
|
+
zh_CN="近 {window} 命令统计:共 {total} 次,成功 {ok},失败 {failed},废弃命令使用 {dep} 次",
|
|
81
|
+
zh_TW="近 {window} 命令統計:共 {total} 次,成功 {ok},失敗 {failed},廢棄命令使用 {dep} 次",
|
|
82
|
+
en="Command stats in last {window}: total {total}, success {ok}, failed {failed}, deprecated uses {dep}",
|
|
83
|
+
ja="直近 {window} のコマンド統計:合計 {total}、成功 {ok}、失敗 {failed}、非推奨使用 {dep}",
|
|
84
|
+
ru="Статистика команд за {window}: всего {total}, успешно {ok}, ошибок {failed}, устаревших {dep}",
|
|
85
|
+
)
|
|
86
|
+
stats_empty: I18nKey = I18nKey(
|
|
87
|
+
default="No command records in window {window}",
|
|
88
|
+
zh_CN="窗口 {window} 内没有命令记录",
|
|
89
|
+
zh_TW="視窗 {window} 內沒有命令記錄",
|
|
90
|
+
en="No command records in window {window}",
|
|
91
|
+
ja="期間 {window} にコマンド記録がありません",
|
|
92
|
+
ru="За период {window} записей команд нет",
|
|
93
|
+
)
|
|
94
|
+
user_header: I18nKey = I18nKey(
|
|
95
|
+
default="User {user} in last {window}: total {total}, success {ok}, failed {failed}",
|
|
96
|
+
zh_CN="用户 {user} 近 {window}:共 {total} 次,成功 {ok},失败 {failed}",
|
|
97
|
+
zh_TW="用戶 {user} 近 {window}:共 {total} 次,成功 {ok},失敗 {failed}",
|
|
98
|
+
en="User {user} in last {window}: total {total}, success {ok}, failed {failed}",
|
|
99
|
+
ja="ユーザー {user} 直近 {window}:合計 {total}、成功 {ok}、失敗 {failed}",
|
|
100
|
+
ru="Пользователь {user} за {window}: всего {total}, успешно {ok}, ошибок {failed}",
|
|
101
|
+
)
|
|
102
|
+
user_empty: I18nKey = I18nKey(
|
|
103
|
+
default="User {user} has no records in window {window}",
|
|
104
|
+
zh_CN="用户 {user} 在窗口 {window} 内没有记录",
|
|
105
|
+
zh_TW="用戶 {user} 在視窗 {window} 內沒有記錄",
|
|
106
|
+
en="User {user} has no records in window {window}",
|
|
107
|
+
ja="ユーザー {user} は期間 {window} に記録がありません",
|
|
108
|
+
ru="У пользователя {user} нет записей за {window}",
|
|
109
|
+
)
|
|
110
|
+
blocked_header: I18nKey = I18nKey(
|
|
111
|
+
default="Blocked events in last {window}: total {total}",
|
|
112
|
+
zh_CN="近 {window} 拦截统计:共 {total} 次",
|
|
113
|
+
zh_TW="近 {window} 攔截統計:共 {total} 次",
|
|
114
|
+
en="Blocked events in last {window}: total {total}",
|
|
115
|
+
ja="直近 {window} のブロック統計:合計 {total}",
|
|
116
|
+
ru="Блокировки за {window}: всего {total}",
|
|
117
|
+
)
|
|
118
|
+
blocked_empty: I18nKey = I18nKey(
|
|
119
|
+
default="No blocked events in window {window}",
|
|
120
|
+
zh_CN="窗口 {window} 内没有拦截记录",
|
|
121
|
+
zh_TW="視窗 {window} 內沒有攔截記錄",
|
|
122
|
+
en="No blocked events in window {window}",
|
|
123
|
+
ja="期間 {window} にブロック記録がありません",
|
|
124
|
+
ru="За период {window} блокировок нет",
|
|
125
|
+
)
|
|
126
|
+
usage_header: I18nKey = I18nKey(
|
|
127
|
+
default="Usage quota consumption ({count} active key(s)):",
|
|
128
|
+
zh_CN="配额消耗(活跃键 {count} 个):",
|
|
129
|
+
zh_TW="配額消耗(活躍鍵 {count} 個):",
|
|
130
|
+
en="Usage quota consumption ({count} active key(s)):",
|
|
131
|
+
ja="クォータ消費(アクティブキー {count} 件):",
|
|
132
|
+
ru="Потребление квот (активных ключей: {count}):",
|
|
133
|
+
)
|
|
134
|
+
usage_empty: I18nKey = I18nKey(
|
|
135
|
+
default="No usage quota records",
|
|
136
|
+
zh_CN="没有配额消耗记录",
|
|
137
|
+
zh_TW="沒有配額消耗記錄",
|
|
138
|
+
en="No usage quota records",
|
|
139
|
+
ja="クォータ消費記録がありません",
|
|
140
|
+
ru="Нет записей потребления квот",
|
|
141
|
+
)
|
|
142
|
+
gov_list_empty: I18nKey = I18nKey(
|
|
143
|
+
default="No command has governance declarations",
|
|
144
|
+
zh_CN="没有任何命令声明治理规则",
|
|
145
|
+
zh_TW="沒有任何命令聲明治理規則",
|
|
146
|
+
en="No command has governance declarations",
|
|
147
|
+
ja="治理宣言を持つコマンドはありません",
|
|
148
|
+
ru="Нет команд с правилами управления",
|
|
149
|
+
)
|
|
150
|
+
gov_list_header: I18nKey = I18nKey(
|
|
151
|
+
default="Governance declarations ({count} command(s)):",
|
|
152
|
+
zh_CN="治理声明清单({count} 个命令):",
|
|
153
|
+
zh_TW="治理聲明清單({count} 個命令):",
|
|
154
|
+
en="Governance declarations ({count} command(s)):",
|
|
155
|
+
ja="治理宣言一覧({count} 件):",
|
|
156
|
+
ru="Объявления управления ({count} команд):",
|
|
157
|
+
)
|
|
158
|
+
purge_done: I18nKey = I18nKey(
|
|
159
|
+
default="Purged {calls} call record(s) and {blocks} block record(s) older than {days} day(s)",
|
|
160
|
+
zh_CN="已清理 {days} 天前的记录:命令 {calls} 条,拦截 {blocks} 条",
|
|
161
|
+
zh_TW="已清理 {days} 天前的記錄:命令 {calls} 條,攔截 {blocks} 條",
|
|
162
|
+
en="Purged {calls} call record(s) and {blocks} block record(s) older than {days} day(s)",
|
|
163
|
+
ja="{days} 日より前の記録を削除:コマンド {calls} 件、ブロック {blocks} 件",
|
|
164
|
+
ru="Удалено записей старше {days} дн.: команд {calls}, блокировок {blocks}",
|
|
165
|
+
)
|
|
166
|
+
invalid_window: I18nKey = I18nKey(
|
|
167
|
+
default="Invalid window: {window} (examples: 30m / 24h / 7d)",
|
|
168
|
+
zh_CN="无效的时间窗口: {window}(示例: 30m / 24h / 7d)",
|
|
169
|
+
zh_TW="無效的時間視窗: {window}(示例: 30m / 24h / 7d)",
|
|
170
|
+
en="Invalid window: {window} (examples: 30m / 24h / 7d)",
|
|
171
|
+
ja="無効な期間指定: {window}(例: 30m / 24h / 7d)",
|
|
172
|
+
ru="Неверный период: {window} (примеры: 30m / 24h / 7d)",
|
|
173
|
+
)
|
GovReport/Models.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from ErisPulse.Core.Bases import Field, Model
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CommandCall(Model):
|
|
5
|
+
|
|
6
|
+
__tablename__ = "gov_command_calls"
|
|
7
|
+
|
|
8
|
+
id: int = Field(primary_key=True, autoincrement=True)
|
|
9
|
+
ts: float = Field(index=True)
|
|
10
|
+
command: str = Field(max_length=128, index=True)
|
|
11
|
+
platform: str = Field(max_length=64, default="")
|
|
12
|
+
user_id: str = Field(max_length=128, default="", index=True)
|
|
13
|
+
success: bool = Field(default=True)
|
|
14
|
+
error: str = Field(max_length=255, default="")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BlockEvent(Model):
|
|
18
|
+
|
|
19
|
+
__tablename__ = "gov_block_events"
|
|
20
|
+
|
|
21
|
+
id: int = Field(primary_key=True, autoincrement=True)
|
|
22
|
+
ts: float = Field(index=True)
|
|
23
|
+
source: str = Field(max_length=16, choices=["middleware", "scope"], index=True)
|
|
24
|
+
middleware: str = Field(max_length=128, default="")
|
|
25
|
+
dimension: str = Field(max_length=16, default="")
|
|
26
|
+
module: str = Field(max_length=128, default="")
|
|
27
|
+
platform: str = Field(max_length=64, default="")
|
|
28
|
+
detail_type: str = Field(max_length=64, default="")
|
|
29
|
+
user_id: str = Field(max_length=128, default="")
|
|
30
|
+
session_id: str = Field(max_length=128, default="")
|
GovReport/Stats.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from ErisPulse import i18n
|
|
5
|
+
|
|
6
|
+
from .Models import BlockEvent, CommandCall
|
|
7
|
+
|
|
8
|
+
USAGE_KEY_PREFIX = "erispulse.usage"
|
|
9
|
+
SEP = "\x00"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_window(window: str) -> Optional[float]:
|
|
13
|
+
from ErisPulse.Core.Event import command_args
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
seconds = command_args.parse_duration(window)
|
|
17
|
+
except Exception:
|
|
18
|
+
return None
|
|
19
|
+
if seconds is None or seconds <= 0:
|
|
20
|
+
return None
|
|
21
|
+
return float(seconds)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fmt_window(window: str) -> str:
|
|
25
|
+
return window
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GovStatsService:
|
|
29
|
+
|
|
30
|
+
def __init__(self, logger):
|
|
31
|
+
self._logger = logger
|
|
32
|
+
|
|
33
|
+
async def _command_window(self, window_secs: float) -> list:
|
|
34
|
+
cutoff = time.time() - window_secs
|
|
35
|
+
return await CommandCall.where(CommandCall.ts >= cutoff).all()
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _top(rows: list, key: str, top_n: int) -> list:
|
|
39
|
+
counter: dict[str, int] = {}
|
|
40
|
+
for r in rows:
|
|
41
|
+
k = getattr(r, key) or "-"
|
|
42
|
+
counter[k] = counter.get(k, 0) + 1
|
|
43
|
+
return sorted(counter.items(), key=lambda kv: -kv[1])[:top_n]
|
|
44
|
+
|
|
45
|
+
async def _usage_entries(self) -> dict:
|
|
46
|
+
from ErisPulse import sdk
|
|
47
|
+
|
|
48
|
+
storage = sdk.storage
|
|
49
|
+
try:
|
|
50
|
+
root = await storage.aget("erispulse")
|
|
51
|
+
except Exception as e:
|
|
52
|
+
self._logger.warning(f"读取配额根键失败: {e}")
|
|
53
|
+
return {}
|
|
54
|
+
if not isinstance(root, dict):
|
|
55
|
+
return {}
|
|
56
|
+
return {
|
|
57
|
+
k: v for k, v in root.items()
|
|
58
|
+
if isinstance(k, str) and k.startswith("usage" + SEP)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async def command_stats_card(self, window_text: str, top_n: int, viz) -> str:
|
|
62
|
+
import datetime
|
|
63
|
+
|
|
64
|
+
rows = await self._command_window(parse_window(window_text) or 0)
|
|
65
|
+
total = len(rows)
|
|
66
|
+
if not total:
|
|
67
|
+
body = "<div class='empty'>窗口内没有命令记录</div>"
|
|
68
|
+
else:
|
|
69
|
+
ok = sum(1 for r in rows if r.success)
|
|
70
|
+
dep = sum(1 for r in rows if r.error == "deprecated")
|
|
71
|
+
body = viz.nums([
|
|
72
|
+
("总调用", total, ""),
|
|
73
|
+
("成功", ok, "ok"),
|
|
74
|
+
("失败", total - ok, "bad"),
|
|
75
|
+
("废弃使用", dep, "warn"),
|
|
76
|
+
])
|
|
77
|
+
body += viz.bar_section("命令 Top", self._top(rows, "command", top_n))
|
|
78
|
+
body += viz.bar_section("用户 Top", self._top(rows, "user_id", top_n), color="accent2")
|
|
79
|
+
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
80
|
+
return (
|
|
81
|
+
viz.head("命令执行统计", f"窗口 {window_text} · 共 {total} 次调用")
|
|
82
|
+
+ f"<div class='body'>{body}</div>"
|
|
83
|
+
+ viz.foot(f"GovReport · {stamp}", f"Top {top_n}")
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def blocked_stats_card(self, window_text: str, top_n: int, viz) -> str:
|
|
87
|
+
import datetime
|
|
88
|
+
|
|
89
|
+
cutoff = time.time() - (parse_window(window_text) or 0)
|
|
90
|
+
rows = await BlockEvent.where(BlockEvent.ts >= cutoff).all()
|
|
91
|
+
total = len(rows)
|
|
92
|
+
if not total:
|
|
93
|
+
body = "<div class='empty'>窗口内没有拦截记录</div>"
|
|
94
|
+
else:
|
|
95
|
+
mw = sum(1 for r in rows if r.source == "middleware")
|
|
96
|
+
body = viz.nums([
|
|
97
|
+
("总拦截", total, ""),
|
|
98
|
+
("中间件否决", mw, "warn"),
|
|
99
|
+
("作用域拦截", total - mw, ""),
|
|
100
|
+
])
|
|
101
|
+
by_name: dict[str, int] = {}
|
|
102
|
+
for r in rows:
|
|
103
|
+
if r.source == "middleware":
|
|
104
|
+
name = r.middleware or "-"
|
|
105
|
+
else:
|
|
106
|
+
name = f"{r.dimension or '-'}:{r.module or '-'}"
|
|
107
|
+
by_name[name] = by_name.get(name, 0) + 1
|
|
108
|
+
pairs = sorted(by_name.items(), key=lambda kv: -kv[1])[:top_n]
|
|
109
|
+
body += viz.bar_section("拦截来源", pairs, color="err")
|
|
110
|
+
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
111
|
+
return (
|
|
112
|
+
viz.head("事件拦截统计", f"窗口 {window_text} · 共 {total} 次")
|
|
113
|
+
+ f"<div class='body'>{body}</div>"
|
|
114
|
+
+ viz.foot(f"GovReport · {stamp}", "middleware / scope")
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def usage_report_card(self, viz) -> str:
|
|
118
|
+
import datetime
|
|
119
|
+
|
|
120
|
+
entries = await self._usage_entries()
|
|
121
|
+
if not entries:
|
|
122
|
+
body = "<div class='empty'>没有配额消耗记录</div>"
|
|
123
|
+
else:
|
|
124
|
+
limits = self._usage_limits()
|
|
125
|
+
rows = []
|
|
126
|
+
for key in sorted(entries, key=lambda k: -entries[k]):
|
|
127
|
+
parts = key[len("usage" + SEP):].split(SEP)
|
|
128
|
+
main_name = parts[0]
|
|
129
|
+
scope = parts[1] if len(parts) > 1 else "?"
|
|
130
|
+
period = parts[2] if len(parts) > 2 else "?"
|
|
131
|
+
limit, unit = limits.get(main_name, (None, ""))
|
|
132
|
+
right = f"{entries[key]}/{limit}" if limit is not None else str(entries[key])
|
|
133
|
+
chips = viz.chips([f"period {period}", f"scope {scope}"])
|
|
134
|
+
rows.append(viz.count_row(main_name, right, unit or "", chips))
|
|
135
|
+
body = "".join(rows)
|
|
136
|
+
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
137
|
+
return (
|
|
138
|
+
viz.head("配额消耗", f"活跃键 {len(entries)} 个")
|
|
139
|
+
+ f"<div class='body'>{body}</div>"
|
|
140
|
+
+ viz.foot(f"GovReport · {stamp}", "usage_limit")
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def governance_list_card(self, name_filter, viz) -> str:
|
|
144
|
+
import datetime
|
|
145
|
+
|
|
146
|
+
from ErisPulse.Core.Event import command
|
|
147
|
+
|
|
148
|
+
registry = command.get_commands()
|
|
149
|
+
rows = []
|
|
150
|
+
for cmd_name, info in sorted(registry.items()):
|
|
151
|
+
if cmd_name != info.get("main_name"):
|
|
152
|
+
continue
|
|
153
|
+
if name_filter and name_filter not in cmd_name:
|
|
154
|
+
continue
|
|
155
|
+
chips = []
|
|
156
|
+
cooldown = info.get("cooldown_seconds")
|
|
157
|
+
if cooldown:
|
|
158
|
+
chips.append(f"cooldown {cooldown:g}s · {info.get('cooldown_key') or 'user'}")
|
|
159
|
+
rate = info.get("rate_limit_spec")
|
|
160
|
+
if rate:
|
|
161
|
+
chips.append(f"rate {rate[0]}/{rate[1]:g}s · {info.get('rate_limit_key') or 'user'}")
|
|
162
|
+
usage = info.get("usage_spec")
|
|
163
|
+
if usage:
|
|
164
|
+
chips.append(f"usage {usage[0]}/{usage[1]} · {info.get('usage_limit_key') or 'user'}")
|
|
165
|
+
deprecated = info.get("deprecated")
|
|
166
|
+
if deprecated:
|
|
167
|
+
chips.append(f"deprecated{'(reject)' if info.get('deprecated_reject') else ''}: {deprecated}")
|
|
168
|
+
if chips:
|
|
169
|
+
rows.append(viz.count_row(
|
|
170
|
+
cmd_name, "", "",
|
|
171
|
+
viz.chips(chips, cls="warn" if deprecated else ""),
|
|
172
|
+
))
|
|
173
|
+
body = "".join(rows) if rows else "<div class='empty'>没有任何命令声明治理规则</div>"
|
|
174
|
+
stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
175
|
+
return (
|
|
176
|
+
viz.head("治理声明清单", f"{len(rows)} 个命令声明了治理规则")
|
|
177
|
+
+ f"<div class='body'>{body}</div>"
|
|
178
|
+
+ viz.foot(f"GovReport · {stamp}", "cooldown / rate / usage / deprecated")
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def command_stats(self, window_secs: float, window_text: str, top_n: int) -> str:
|
|
182
|
+
cutoff = time.time() - window_secs
|
|
183
|
+
rows = await CommandCall.where(CommandCall.ts >= cutoff).all()
|
|
184
|
+
if not rows:
|
|
185
|
+
return i18n.t("GovReport.stats_empty", window=_fmt_window(window_text))
|
|
186
|
+
total = len(rows)
|
|
187
|
+
ok = sum(1 for r in rows if r.success)
|
|
188
|
+
dep = sum(1 for r in rows if r.error == "deprecated")
|
|
189
|
+
lines = [i18n.t(
|
|
190
|
+
"GovReport.stats_header",
|
|
191
|
+
window=_fmt_window(window_text), total=total, ok=ok, failed=total - ok, dep=dep,
|
|
192
|
+
)]
|
|
193
|
+
by_cmd: dict[str, int] = {}
|
|
194
|
+
for r in rows:
|
|
195
|
+
by_cmd[r.command] = by_cmd.get(r.command, 0) + 1
|
|
196
|
+
for cmd, count in sorted(by_cmd.items(), key=lambda kv: -kv[1])[:top_n]:
|
|
197
|
+
lines.append(f"{cmd}: {count}")
|
|
198
|
+
return "\n".join(lines)
|
|
199
|
+
|
|
200
|
+
async def user_stats(self, user_id: str, window_secs: float, window_text: str, top_n: int) -> str:
|
|
201
|
+
cutoff = time.time() - window_secs
|
|
202
|
+
rows = await CommandCall.where(
|
|
203
|
+
(CommandCall.ts >= cutoff) & (CommandCall.user_id == user_id)
|
|
204
|
+
).all()
|
|
205
|
+
if not rows:
|
|
206
|
+
return i18n.t("GovReport.user_empty", user=user_id, window=_fmt_window(window_text))
|
|
207
|
+
total = len(rows)
|
|
208
|
+
ok = sum(1 for r in rows if r.success)
|
|
209
|
+
lines = [i18n.t(
|
|
210
|
+
"GovReport.user_header",
|
|
211
|
+
user=user_id, window=_fmt_window(window_text), total=total, ok=ok, failed=total - ok,
|
|
212
|
+
)]
|
|
213
|
+
by_cmd: dict[str, int] = {}
|
|
214
|
+
for r in rows:
|
|
215
|
+
by_cmd[r.command] = by_cmd.get(r.command, 0) + 1
|
|
216
|
+
for cmd, count in sorted(by_cmd.items(), key=lambda kv: -kv[1])[:top_n]:
|
|
217
|
+
lines.append(f"{cmd}: {count}")
|
|
218
|
+
return "\n".join(lines)
|
|
219
|
+
|
|
220
|
+
async def blocked_stats(self, window_secs: float, window_text: str, top_n: int) -> str:
|
|
221
|
+
cutoff = time.time() - window_secs
|
|
222
|
+
rows = await BlockEvent.where(BlockEvent.ts >= cutoff).all()
|
|
223
|
+
if not rows:
|
|
224
|
+
return i18n.t("GovReport.blocked_empty", window=_fmt_window(window_text))
|
|
225
|
+
lines = [i18n.t("GovReport.blocked_header", window=_fmt_window(window_text), total=len(rows))]
|
|
226
|
+
by_source: dict[str, int] = {}
|
|
227
|
+
by_name: dict[str, int] = {}
|
|
228
|
+
for r in rows:
|
|
229
|
+
by_source[r.source] = by_source.get(r.source, 0) + 1
|
|
230
|
+
if r.source == "middleware":
|
|
231
|
+
name = f"middleware:{r.middleware or '-'}"
|
|
232
|
+
else:
|
|
233
|
+
name = f"scope:{r.dimension or '-'}:{r.module or '-'}"
|
|
234
|
+
by_name[name] = by_name.get(name, 0) + 1
|
|
235
|
+
rendered = " ".join(f"{k}={v}" for k, v in sorted(by_source.items()))
|
|
236
|
+
lines.append(f"by_source: {rendered}")
|
|
237
|
+
for name, count in sorted(by_name.items(), key=lambda kv: -kv[1])[:top_n]:
|
|
238
|
+
lines.append(f"{name}: {count}")
|
|
239
|
+
return "\n".join(lines)
|
|
240
|
+
|
|
241
|
+
async def usage_report(self) -> str:
|
|
242
|
+
from ErisPulse import sdk
|
|
243
|
+
|
|
244
|
+
storage = sdk.storage
|
|
245
|
+
root_key = USAGE_KEY_PREFIX.split(".")[0]
|
|
246
|
+
nested_prefix = USAGE_KEY_PREFIX.split(".", 1)[1] + SEP
|
|
247
|
+
try:
|
|
248
|
+
root = await storage.aget(root_key)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
self._logger.warning(f"读取配额根键失败: {e}")
|
|
251
|
+
root = None
|
|
252
|
+
entries: dict[str, Any] = {}
|
|
253
|
+
if isinstance(root, dict):
|
|
254
|
+
entries = {
|
|
255
|
+
k: v for k, v in root.items()
|
|
256
|
+
if isinstance(k, str) and k.startswith(nested_prefix)
|
|
257
|
+
}
|
|
258
|
+
if not entries:
|
|
259
|
+
return i18n.t("GovReport.usage_empty")
|
|
260
|
+
|
|
261
|
+
limits = self._usage_limits()
|
|
262
|
+
lines = [i18n.t("GovReport.usage_header", count=len(entries))]
|
|
263
|
+
for key in sorted(entries):
|
|
264
|
+
parts = key[len(nested_prefix):].split(SEP)
|
|
265
|
+
main_name = parts[0] if parts else "?"
|
|
266
|
+
scope = parts[1] if len(parts) > 1 else "?"
|
|
267
|
+
period = parts[2] if len(parts) > 2 else "?"
|
|
268
|
+
used = entries[key]
|
|
269
|
+
limit, unit = limits.get(main_name, (None, ""))
|
|
270
|
+
limit_text = f"/{limit}({unit})" if limit is not None else ""
|
|
271
|
+
lines.append(f"{main_name}: {used}{limit_text} period={period} scope={scope}")
|
|
272
|
+
return "\n".join(lines)
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _usage_limits() -> dict[str, tuple[Any, str]]:
|
|
276
|
+
from ErisPulse.Core.Event import command
|
|
277
|
+
|
|
278
|
+
limits: dict[str, tuple[Any, str]] = {}
|
|
279
|
+
try:
|
|
280
|
+
registry = command.get_commands()
|
|
281
|
+
except Exception:
|
|
282
|
+
return limits
|
|
283
|
+
for info in registry.values():
|
|
284
|
+
spec = info.get("usage_spec")
|
|
285
|
+
main_name = info.get("main_name")
|
|
286
|
+
if spec and main_name:
|
|
287
|
+
limits[main_name] = (spec[0], spec[1])
|
|
288
|
+
return limits
|
|
289
|
+
|
|
290
|
+
def governance_list(self, name_filter: Optional[str] = None) -> str:
|
|
291
|
+
from ErisPulse.Core.Event import command
|
|
292
|
+
|
|
293
|
+
registry = command.get_commands()
|
|
294
|
+
lines = []
|
|
295
|
+
for cmd_name, info in sorted(registry.items()):
|
|
296
|
+
if cmd_name != info.get("main_name"):
|
|
297
|
+
continue
|
|
298
|
+
if name_filter and name_filter not in cmd_name:
|
|
299
|
+
continue
|
|
300
|
+
parts = []
|
|
301
|
+
cooldown = info.get("cooldown_seconds")
|
|
302
|
+
if cooldown:
|
|
303
|
+
parts.append(f"cooldown={cooldown:g}s({info.get('cooldown_key') or 'user'})")
|
|
304
|
+
rate = info.get("rate_limit_spec")
|
|
305
|
+
if rate:
|
|
306
|
+
parts.append(f"rate={rate[0]}/{rate[1]:g}s({info.get('rate_limit_key') or 'user'})")
|
|
307
|
+
usage = info.get("usage_spec")
|
|
308
|
+
if usage:
|
|
309
|
+
parts.append(f"usage={usage[0]}/{usage[1]}({info.get('usage_limit_key') or 'user'})")
|
|
310
|
+
deprecated = info.get("deprecated")
|
|
311
|
+
if deprecated:
|
|
312
|
+
reject = "reject" if info.get("deprecated_reject") else "notice"
|
|
313
|
+
parts.append(f"deprecated[{reject}]={deprecated}")
|
|
314
|
+
if parts:
|
|
315
|
+
lines.append(f"{cmd_name}: {' '.join(parts)}")
|
|
316
|
+
if not lines:
|
|
317
|
+
return i18n.t("GovReport.gov_list_empty")
|
|
318
|
+
return "\n".join([i18n.t("GovReport.gov_list_header", count=len(lines))] + lines)
|
|
319
|
+
|
|
320
|
+
async def purge(self, days: int) -> tuple[int, int]:
|
|
321
|
+
cutoff = time.time() - days * 86400
|
|
322
|
+
calls = await CommandCall.delete_all(CommandCall.ts < cutoff)
|
|
323
|
+
blocks = await BlockEvent.delete_all(BlockEvent.ts < cutoff)
|
|
324
|
+
return calls, blocks
|
|
325
|
+
|
|
326
|
+
async def purge_expired(self, retention_days: int) -> None:
|
|
327
|
+
if retention_days <= 0:
|
|
328
|
+
return
|
|
329
|
+
calls, blocks = await self.purge(retention_days)
|
|
330
|
+
if calls or blocks:
|
|
331
|
+
self._logger.info(f"清理过期记录: 命令 {calls} 条, 拦截 {blocks} 条")
|
GovReport/Visualizer.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
PALETTES = {
|
|
5
|
+
"light": {
|
|
6
|
+
"page": "#f4f6f9", "card": "#ffffff", "ink": "#2b3440", "sub": "#7d8a99",
|
|
7
|
+
"accent": "#4a7fb5", "accent2": "#5aa27b", "warn": "#c0804a", "err": "#bf6a6a",
|
|
8
|
+
"accent_soft": "#e3edf7", "border": "#dfe6ee", "chipbg": "#f0f4f9", "codebg": "#eef2f7",
|
|
9
|
+
},
|
|
10
|
+
"dark": {
|
|
11
|
+
"page": "#171b21", "card": "#20262e", "ink": "#e6ebf1", "sub": "#93a1b0",
|
|
12
|
+
"accent": "#7aa9d9", "accent2": "#7fc29a", "warn": "#d3a569", "err": "#d98a8a",
|
|
13
|
+
"accent_soft": "rgba(122,169,217,0.14)", "border": "#2c343e",
|
|
14
|
+
"chipbg": "rgba(122,169,217,0.08)", "codebg": "#262d36",
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_FONTS = '"Noto Sans SC", "Roboto", "PingFang SC", "Microsoft YaHei", sans-serif'
|
|
19
|
+
_FONTS_MONO = '"JetBrains Mono", "Cascadia Code", Consolas, ui-monospace, monospace'
|
|
20
|
+
|
|
21
|
+
_CSS_TPL = """
|
|
22
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
23
|
+
body { font-family: __FONTS__; color: __INK__; -webkit-font-smoothing: antialiased; }
|
|
24
|
+
.page { background: __PAGE__; padding: 24px; }
|
|
25
|
+
.card { background: __CARD__; border: 1px solid __BORDER__; border-radius: 16px; overflow: hidden; }
|
|
26
|
+
.head { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px; border-bottom: 1px solid __BORDER__; }
|
|
27
|
+
.title { font-size: 19px; font-weight: 700; letter-spacing: 0.5px; }
|
|
28
|
+
.subtitle { font-size: 12.5px; color: __SUB__; margin-top: 3px; }
|
|
29
|
+
.body { padding: 18px 24px 22px; }
|
|
30
|
+
.nums { display: flex; gap: 12px; }
|
|
31
|
+
.num { flex: 1; background: __CODEBG__; border-radius: 12px; padding: 14px 16px; text-align: center; }
|
|
32
|
+
.num .label { font-size: 12px; color: __SUB__; }
|
|
33
|
+
.num .value { font-size: 26px; font-weight: 700; color: __ACCENT__; margin-top: 4px; }
|
|
34
|
+
.num.ok .value { color: __ACCENT2__; }
|
|
35
|
+
.num.bad .value { color: __ERR__; }
|
|
36
|
+
.num.warn .value { color: __WARN__; }
|
|
37
|
+
.section { margin-top: 18px; }
|
|
38
|
+
.section-title { font-size: 12.5px; color: __SUB__; font-weight: 600; letter-spacing: 1px; margin-bottom: 8px; text-transform: uppercase; }
|
|
39
|
+
.bars { display: flex; flex-direction: column; gap: 8px; }
|
|
40
|
+
.bar-row { display: flex; align-items: center; gap: 10px; }
|
|
41
|
+
.bar-label { flex: 0 0 170px; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: __FONTMONO__; }
|
|
42
|
+
.bar-track { flex: 1; height: 16px; background: __CODEBG__; border-radius: 9999px; overflow: hidden; }
|
|
43
|
+
.bar-fill { height: 100%; border-radius: 9999px; background: __ACCENT__; }
|
|
44
|
+
.bar-value { flex: 0 0 52px; text-align: right; font-size: 12.5px; color: __SUB__; font-family: __FONTMONO__; }
|
|
45
|
+
.kv-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
46
|
+
.kv { background: __CHIPBG__; border: 1px solid __BORDER__; border-radius: 8px; padding: 6px 12px; font-size: 13px; }
|
|
47
|
+
.kv b { color: __ACCENT__; font-weight: 600; }
|
|
48
|
+
.row { padding: 10px 12px; border-radius: 10px; margin-bottom: 6px; background: __CHIPBG__; border: 1px solid __BORDER__; }
|
|
49
|
+
.row:last-child { margin-bottom: 0; }
|
|
50
|
+
.row-main { display: flex; align-items: center; gap: 10px; }
|
|
51
|
+
.row-name { font-family: __FONTMONO__; font-size: 13px; background: __CODEBG__; border-radius: 6px; padding: 3px 8px; }
|
|
52
|
+
.row-dim { font-size: 12px; color: __SUB__; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
53
|
+
.row-count { font-size: 15px; font-weight: 700; color: __ACCENT__; font-family: __FONTMONO__; }
|
|
54
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
|
55
|
+
.chip { font-size: 11.5px; background: __ACCENT_SOFT__; color: __ACCENT__; border-radius: 9999px; padding: 2px 10px; }
|
|
56
|
+
.chip.warn { color: __WARN__; }
|
|
57
|
+
.chip.err { color: __ERR__; }
|
|
58
|
+
.empty { text-align: center; color: __SUB__; font-size: 13px; padding: 18px 0; }
|
|
59
|
+
.foot { padding: 12px 24px; border-top: 1px solid __BORDER__; font-size: 11.5px; color: __SUB__; display: flex; justify-content: space-between; }
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _esc(text) -> str:
|
|
64
|
+
import html
|
|
65
|
+
|
|
66
|
+
return html.escape(str(text))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CardVisualizer:
|
|
70
|
+
|
|
71
|
+
CARD_WIDTH = 780
|
|
72
|
+
|
|
73
|
+
def __init__(self, sdk, logger):
|
|
74
|
+
self.sdk = sdk
|
|
75
|
+
self.logger = logger
|
|
76
|
+
self._takumi_inst = None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def takumi(self):
|
|
80
|
+
if self._takumi_inst is None:
|
|
81
|
+
inst = None
|
|
82
|
+
try:
|
|
83
|
+
inst = self.sdk.module.get("Takumi")
|
|
84
|
+
except Exception:
|
|
85
|
+
inst = None
|
|
86
|
+
if inst is None:
|
|
87
|
+
inst = getattr(self.sdk, "Takumi", None)
|
|
88
|
+
self._takumi_inst = inst
|
|
89
|
+
return self._takumi_inst
|
|
90
|
+
|
|
91
|
+
def resolve_palette(self, theme: str) -> dict:
|
|
92
|
+
import datetime
|
|
93
|
+
|
|
94
|
+
if theme in PALETTES:
|
|
95
|
+
mode = theme
|
|
96
|
+
else:
|
|
97
|
+
hour = datetime.datetime.now().hour
|
|
98
|
+
mode = "dark" if (hour >= 19 or hour < 7) else "light"
|
|
99
|
+
return dict(PALETTES[mode])
|
|
100
|
+
|
|
101
|
+
def _css(self, palette: dict) -> str:
|
|
102
|
+
css = _CSS_TPL.replace("__FONTS__", _FONTS).replace("__FONTMONO__", _FONTS_MONO)
|
|
103
|
+
for key, value in palette.items():
|
|
104
|
+
css = css.replace(f"__{key.upper()}__", value)
|
|
105
|
+
return css
|
|
106
|
+
|
|
107
|
+
async def render(self, body_html: str, theme: str = "auto") -> Optional[bytes]:
|
|
108
|
+
takumi = self.takumi
|
|
109
|
+
if takumi is None or not hasattr(takumi, "render_html"):
|
|
110
|
+
self.logger.debug("Takumi 不可用,跳过渲染")
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
result = takumi.render_html(
|
|
114
|
+
f"<div class='page'>{body_html}</div>",
|
|
115
|
+
stylesheets=[self._css(self.resolve_palette(theme))],
|
|
116
|
+
width=self.CARD_WIDTH,
|
|
117
|
+
height=None,
|
|
118
|
+
lang="zh-CN",
|
|
119
|
+
)
|
|
120
|
+
if asyncio.iscoroutine(result):
|
|
121
|
+
result = await result
|
|
122
|
+
return result if isinstance(result, (bytes, bytearray)) else None
|
|
123
|
+
except Exception as e:
|
|
124
|
+
self.logger.warning(f"渲染卡片失败: {e}")
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def head(self, title: str, subtitle: str) -> str:
|
|
128
|
+
return (
|
|
129
|
+
f"<div class='head'><div><div class='title'>{_esc(title)}</div>"
|
|
130
|
+
f"<div class='subtitle'>{_esc(subtitle)}</div></div></div>"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def foot(self, left: str, right: str) -> str:
|
|
134
|
+
return f"<div class='foot'><span>{_esc(left)}</span><span>{_esc(right)}</span></div>"
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def nums(items) -> str:
|
|
138
|
+
cells = "".join(
|
|
139
|
+
f"<div class='num {cls}'><div class='label'>{_esc(label)}</div>"
|
|
140
|
+
f"<div class='value'>{_esc(value)}</div></div>"
|
|
141
|
+
for label, value, cls in items
|
|
142
|
+
)
|
|
143
|
+
return f"<div class='nums'>{cells}</div>"
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def bars(pairs, color: str = "accent") -> str:
|
|
147
|
+
if not pairs:
|
|
148
|
+
return "<div class='empty'>无数据</div>"
|
|
149
|
+
top = max(v for _, v in pairs) or 1
|
|
150
|
+
fill = f"__ACCENT__" if color == "accent" else f"__{color.upper()}__"
|
|
151
|
+
rows = "".join(
|
|
152
|
+
f"<div class='bar-row'><span class='bar-label'>{_esc(k)}</span>"
|
|
153
|
+
f"<div class='bar-track'><div class='bar-fill' style='width:{max(3, round(v / top * 100))}%;background:{fill}'></div></div>"
|
|
154
|
+
f"<span class='bar-value'>{_esc(v)}</span></div>"
|
|
155
|
+
for k, v in pairs
|
|
156
|
+
)
|
|
157
|
+
return f"<div class='bars'>{rows}</div>"
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def kv_section(title: str, pairs) -> str:
|
|
161
|
+
chips = "".join(f"<div class='kv'>{_esc(k)} <b>{_esc(v)}</b></div>" for k, v in pairs)
|
|
162
|
+
return (
|
|
163
|
+
f"<div class='section'><div class='section-title'>{_esc(title)}</div>"
|
|
164
|
+
f"<div class='kv-grid'>{chips}</div></div>"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def bar_section(title: str, pairs, color: str = "accent") -> str:
|
|
169
|
+
return (
|
|
170
|
+
f"<div class='section'><div class='section-title'>{_esc(title)}</div>"
|
|
171
|
+
f"{CardVisualizer.bars(pairs, color)}</div>"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def count_row(name: str, right: str, dim: str = "", chips: str = "") -> str:
|
|
176
|
+
return (
|
|
177
|
+
f"<div class='row'><div class='row-main'>"
|
|
178
|
+
f"<span class='row-name'>{_esc(name)}</span>"
|
|
179
|
+
f"<span class='row-dim'>{_esc(dim)}</span>"
|
|
180
|
+
f"<span class='row-count'>{_esc(right)}</span></div>{chips}</div>"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def chips(items, cls: str = "") -> str:
|
|
185
|
+
if not items:
|
|
186
|
+
return ""
|
|
187
|
+
rendered = "".join(
|
|
188
|
+
f"<span class='chip {cls}'>{_esc(t)}</span>" for t in items
|
|
189
|
+
)
|
|
190
|
+
return f"<div class='chips'>{rendered}</div>"
|
GovReport/__init__.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ErisPulse-GovReport
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 治理观测模块:采集命令执行与事件拦截记录,输出统计报表与配额消耗
|
|
5
|
+
Author: wsu2059q
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
|
|
13
|
+
Requires-Dist: ErisPulse-Testing; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
<div align="center">
|
|
17
|
+
|
|
18
|
+
<img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse GovReport" />
|
|
19
|
+
|
|
20
|
+
# ErisPulse GovReport
|
|
21
|
+
|
|
22
|
+
**治理观测模块 —— 命令执行、事件拦截与配额消耗的统计报表。**
|
|
23
|
+
|
|
24
|
+
基于 ErisPulse 2.9 命令治理与生命周期事件的观测模块,使用内置 ORM 持久化记录,报表经 Takumi 渲染为卡片。
|
|
25
|
+
|
|
26
|
+
<p>
|
|
27
|
+
<a href="https://pypi.org/project/ErisPulse-GovReport/"><img src="https://img.shields.io/pypi/v/ErisPulse-GovReport?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
|
|
28
|
+
<a href="https://pypi.org/project/ErisPulse-GovReport/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
|
|
29
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
|
|
30
|
+
<a href="https://github.com/ErisPulse/ErisPulse-GovReport"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-GovReport?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
|
|
31
|
+
<a href="https://pepy.tech/project/ErisPulse-GovReport"><img src="https://img.shields.io/pepy/dt/ErisPulse-GovReport?style=for-the-badge&color=blue" alt="Downloads"></a>
|
|
32
|
+
<a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
<p>
|
|
36
|
+
<a href="./README.md">简体中文</a> | <a href="./README.en.md">English</a>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 简介
|
|
44
|
+
|
|
45
|
+
GovReport 是基于 [ErisPulse](https://github.com/ErisPulse/ErisPulse/) 架构开发的治理观测模块:
|
|
46
|
+
|
|
47
|
+
- 采集 `command.executed` 命令执行记录(成功/失败/废弃)
|
|
48
|
+
- 采集 `adapter.event.blocked`(中间件否决)与 `scope.blocked`(作用域拦截)记录
|
|
49
|
+
- 读取 `usage_limit` 配额计数(`erispulse.usage` 存储键),对照声明上限展示消耗
|
|
50
|
+
- 枚举全部命令的治理声明(cooldown / rate_limit / usage_limit / deprecated)
|
|
51
|
+
- 记录用内置 ORM(`Model` / `Field`)持久化,按保留天数自动清理
|
|
52
|
+
- 报表经 Takumi 渲染为图片卡片,不可用时降级文本
|
|
53
|
+
|
|
54
|
+
要求 ErisPulse >= 2.9.0。
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 效果展示
|
|
59
|
+
|
|
60
|
+
| `/gov stats` | `/gov blocked` |
|
|
61
|
+
|:---:|:---:|
|
|
62
|
+
|  |  |
|
|
63
|
+
|
|
64
|
+
| `/gov usage` | `/gov list` |
|
|
65
|
+
|:---:|:---:|
|
|
66
|
+
|  |  |
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 安装
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
epsdk install ErisPulse-GovReport
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 命令
|
|
82
|
+
|
|
83
|
+
| 命令 | 说明 |
|
|
84
|
+
|------|------|
|
|
85
|
+
| `/gov` | 显示用法 |
|
|
86
|
+
| `/gov stats [窗口]` | 命令执行统计(调用 Top / 成功率 / 废弃使用),窗口如 `24h`、`7d`,默认 `24h` |
|
|
87
|
+
| `/gov user <用户ID> [窗口]` | 单用户调用统计 |
|
|
88
|
+
| `/gov blocked [窗口]` | 拦截统计(中间件否决 / 作用域拦截) |
|
|
89
|
+
| `/gov usage` | 配额消耗报表(used/limit + 周期 + 作用域) |
|
|
90
|
+
| `/gov list [命令]` | 治理声明清单(可按命令名过滤) |
|
|
91
|
+
| `/gov purge <天数>` | 清理指定天数之前的记录 |
|
|
92
|
+
|
|
93
|
+
全部命令需要框架主人权限。
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 使用示例
|
|
98
|
+
|
|
99
|
+
```toml
|
|
100
|
+
# 给命令声明治理规则(本模块负责观测这些规则的运行效果)
|
|
101
|
+
[ErisPulse.event.command]
|
|
102
|
+
prefix = "/"
|
|
103
|
+
|
|
104
|
+
# Python 侧
|
|
105
|
+
# @command("translate", usage_limit="100/day", usage_limit_key="user")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from ErisPulse.Core.Event import command
|
|
110
|
+
|
|
111
|
+
@command("translate", cooldown="30s", rate_limit="5/minute",
|
|
112
|
+
usage_limit="100/day", usage_limit_key="user", help="翻译")
|
|
113
|
+
async def translate_handler(event):
|
|
114
|
+
await event.reply("结果")
|
|
115
|
+
|
|
116
|
+
@command("oldcmd", deprecated="请用 /translate", deprecated_reject=True)
|
|
117
|
+
async def old_handler(event):
|
|
118
|
+
...
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 配置说明
|
|
124
|
+
|
|
125
|
+
```toml
|
|
126
|
+
[GovReport]
|
|
127
|
+
enabled = true # 是否启用模块
|
|
128
|
+
collect_command_calls = true # 采集命令执行记录
|
|
129
|
+
collect_blocked = true # 采集拦截记录
|
|
130
|
+
retention_days = 30 # 记录保留天数(0 永久保留)
|
|
131
|
+
top_n = 10 # 报表单次展示条目数
|
|
132
|
+
render_card = true # 报表用 Takumi 渲染为卡片
|
|
133
|
+
theme = "auto" # 卡片配色 (auto/light/dark)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
137
|
+
|--------|------|--------|------|
|
|
138
|
+
| `enabled` | bool | `true` | 是否启用模块 |
|
|
139
|
+
| `collect_command_calls` | bool | `true` | 采集 `command.executed` |
|
|
140
|
+
| `collect_blocked` | bool | `true` | 采集中间件否决与作用域拦截 |
|
|
141
|
+
| `retention_days` | int | `30` | 保留天数(0 永久,启动与每日清理) |
|
|
142
|
+
| `top_n` | int | `10` | 报表单次展示条目数 |
|
|
143
|
+
| `render_card` | bool | `true` | 报表渲染为图片卡片 |
|
|
144
|
+
| `theme` | str | `auto` | 卡片配色 |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 注意事项
|
|
149
|
+
|
|
150
|
+
1. 配额消耗读取自治理系统的存储键(`erispulse.usage`),重启不丢
|
|
151
|
+
2. 冷却/限流命中默认静默,不在采集范围内(可用 ErisPulse-Testing 的 DispatchTrace 排查单条命令)
|
|
152
|
+
3. 记录写入框架存储后端(SQLite / MySQL / PostgreSQL 由存储配置决定)
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 参考链接
|
|
157
|
+
|
|
158
|
+
- [ErisPulse 主库](https://github.com/ErisPulse/ErisPulse/)
|
|
159
|
+
- [命令治理文档](https://github.com/ErisPulse/ErisPulse/tree/main/docs/zh-CN/getting-started/event-handling.md)
|
|
160
|
+
- [ORM 文档](https://github.com/ErisPulse/ErisPulse/tree/main/docs/zh-CN/developer-guide/orm.md)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
GovReport/Config.py,sha256=5iKlKXcOgSyhZ2URLYxLASNaw4aKkU0Kd82IsFCAJiY,2250
|
|
2
|
+
GovReport/Core.py,sha256=VXayibRRZXKRHU108hr6ceSPQo-YtHILk1p1t3dSLw8,8517
|
|
3
|
+
GovReport/I18n.py,sha256=dy4BiCerl5m7ZHnQdAlo_qvlfVVdkIGObXm7nA108eQ,9667
|
|
4
|
+
GovReport/Models.py,sha256=tJI-_xhMjzSDKJxmreia3i2UhGEMCzkw2ciCSa_9LsM,1094
|
|
5
|
+
GovReport/Stats.py,sha256=aFIYPd78oc58Q8eeHggc8oCPSOXbAs9zwoEp4ozemGE,13828
|
|
6
|
+
GovReport/Visualizer.py,sha256=dTHCgkB-jLd7vjzzaA_GTsbx3gMscFvr4ObfLp_EJxk,8420
|
|
7
|
+
GovReport/__init__.py,sha256=SffgNmAK1lNORgDCsR-JDNcKMZRwf1Vpm1jU9PGS0Cg,135
|
|
8
|
+
erispulse_govreport-0.1.0.dist-info/licenses/LICENSE,sha256=OO7qHOo6QdvZahvf8NTfkgcVGuh8fqFcxKTvxmbmz_k,1065
|
|
9
|
+
erispulse_govreport-0.1.0.dist-info/METADATA,sha256=38j8h2yw3z2ZZztvnQdm_TyGjniIMcNI_-InX7jEmvE,5911
|
|
10
|
+
erispulse_govreport-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
erispulse_govreport-0.1.0.dist-info/entry_points.txt,sha256=-sFfjdLvyPa2Yc5689_Sg31sgFd-nx-jSy6tTTfQob8,46
|
|
12
|
+
erispulse_govreport-0.1.0.dist-info/top_level.txt,sha256=ZgBDF5xOVYAHdI38LKA153-RJ98OOBl29MFIiKXQVV4,10
|
|
13
|
+
erispulse_govreport-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wsu2059q
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
GovReport
|