ErisPulse-ShadowOps 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.
ShadowOps/Config.py ADDED
@@ -0,0 +1,48 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from ErisPulse.Core.Bases import BaseConfig
4
+
5
+
6
+ @dataclass
7
+ class ShadowOpsConfig(BaseConfig):
8
+
9
+ enabled: bool = field(
10
+ default=True,
11
+ metadata={
12
+ "description": {"i18n": "ShadowOps.enabled", "default": "是否启用模块"},
13
+ "ui": {"widget": "switch", "group": "basic", "order": 1},
14
+ },
15
+ )
16
+ diff_max_rows: int = field(
17
+ default=10,
18
+ metadata={
19
+ "description": {"i18n": "ShadowOps.diff_max_rows", "default": "对比报告单次展示的最大条目数"},
20
+ "ui": {"widget": "number", "group": "report", "order": 2},
21
+ },
22
+ )
23
+ audit_deep: bool = field(
24
+ default=False,
25
+ metadata={
26
+ "description": {"i18n": "ShadowOps.audit_deep", "default": "审计时附带 gc 实例普查(有全局暂停开销)"},
27
+ "ui": {"widget": "switch", "group": "report", "order": 3},
28
+ },
29
+ )
30
+ render_card: bool = field(
31
+ default=True,
32
+ metadata={
33
+ "description": {"i18n": "ShadowOps.render_card", "default": "报告用 Takumi 渲染为卡片(不可用时降级文本)"},
34
+ "ui": {"widget": "switch", "group": "render", "order": 4},
35
+ },
36
+ )
37
+ theme: str = field(
38
+ default="auto",
39
+ metadata={
40
+ "description": {"i18n": "ShadowOps.theme", "default": "卡片配色 (auto=按时间自动切深浅色)"},
41
+ "ui": {
42
+ "widget": "select",
43
+ "options": ["auto", "light", "dark"],
44
+ "group": "render",
45
+ "order": 5,
46
+ },
47
+ },
48
+ )
ShadowOps/Core.py ADDED
@@ -0,0 +1,148 @@
1
+ from ErisPulse import i18n, sdk
2
+ from ErisPulse.Core.Bases import BaseModule, ModuleMeta
3
+ from ErisPulse.Core.Event import command
4
+ from ErisPulse.loaders import ModuleLoadStrategy
5
+
6
+ from .Config import ShadowOpsConfig
7
+ from .I18n import ShadowOpsI18n
8
+ from .Service import ShadowOpsService
9
+ from .Visualizer import CardVisualizer
10
+
11
+
12
+ class Main(BaseModule):
13
+
14
+ ConfigClass = ShadowOpsConfig
15
+ I18nClass = ShadowOpsI18n
16
+
17
+ def __init__(self):
18
+ self.sdk = sdk
19
+ self.logger = sdk.logger.get_child("ShadowOps")
20
+ self._service = ShadowOpsService(sdk, self.logger)
21
+ self._viz = CardVisualizer(sdk, self.logger)
22
+
23
+ @staticmethod
24
+ def get_load_strategy() -> ModuleLoadStrategy:
25
+ return ModuleLoadStrategy(lazy_load=False, priority=20)
26
+
27
+ @staticmethod
28
+ def get_meta() -> ModuleMeta:
29
+ return ModuleMeta(
30
+ name="ShadowOps",
31
+ description="影子灰度管理模块:启动/对比/转正/放弃影子模块,附归属权审计",
32
+ version="0.1.0",
33
+ author="ErisPulse-devs",
34
+ group="运维",
35
+ tags=["影子模块", "灰度", "热更新", "审计"],
36
+ min_sdk_version="2.9.0",
37
+ )
38
+
39
+ async def on_load(self, event: dict) -> bool:
40
+ if not self.cfg.enabled:
41
+ self.logger.info("影子灰度管理未启用(enabled=false)")
42
+ return True
43
+
44
+ self._register_commands()
45
+ self.logger.info(i18n.t("ShadowOps.module_loaded", event=event))
46
+ return True
47
+
48
+ async def on_unload(self, event: dict) -> bool:
49
+ self.logger.info(i18n.t("ShadowOps.module_unloaded", event=event))
50
+ return True
51
+
52
+ async def _reply_card(self, event, html, fallback_text: str):
53
+ if self.cfg.render_card:
54
+ png = await self._viz.render(html, theme=self.cfg.theme)
55
+ if png is not None:
56
+ try:
57
+ await event.reply(png, method="Image")
58
+ return
59
+ except Exception as e:
60
+ self.logger.warning(f"图片回复失败,降级文本: {e}")
61
+ await event.reply(fallback_text)
62
+
63
+ def _shadow_error_text(self, error: Exception, name: str) -> str:
64
+ from ErisPulse.Core.Bases.errors import ShadowError
65
+
66
+ if isinstance(error, ShadowError):
67
+ return f"{type(error).__name__}: {error}"
68
+ return f"{type(error).__name__}: {error}"
69
+
70
+ def _register_commands(self):
71
+ @command("shadow", master=True, help="影子灰度管理")
72
+ async def shadow_handler(event):
73
+ await event.reply(
74
+ "用法: /shadow start <模块名> <v2路径> | /shadow list | /shadow diff <模块名> | "
75
+ "/shadow promote <模块名> confirm | /shadow dismiss <模块名> | /shadow audit <模块名>"
76
+ )
77
+
78
+ @command("shadow start", master=True, help="启动影子(新版代码以独立 owner 并存试运行)",
79
+ args="<name:str> <source:rest>")
80
+ async def shadow_start_handler(event, name: str, source: str):
81
+ source = source.strip()
82
+ if not source:
83
+ await event.reply("缺少 v2 路径")
84
+ return
85
+ try:
86
+ owner = await self._service.start(name, source)
87
+ except Exception as e:
88
+ await event.reply(self._shadow_error_text(e, name))
89
+ return
90
+ await event.reply(i18n.t("ShadowOps.started", owner=owner, name=name))
91
+
92
+ @command("shadow list", master=True, help="列出运行中的影子")
93
+ async def shadow_list_handler(event):
94
+ await self._reply_card(
95
+ event,
96
+ self._service.list_card_html(self._viz),
97
+ self._service.format_list_text(),
98
+ )
99
+
100
+ @command("shadow diff", master=True, help="对比 v1 实际发送 × v2 意向发送", args="<name:str>")
101
+ async def shadow_diff_handler(event, name: str):
102
+ report = self._service.diff(name)
103
+ await self._reply_card(
104
+ event,
105
+ self._service.diff_card_html(name, report, self.cfg.diff_max_rows, self._viz),
106
+ self._service.format_diff_text(name, report, self.cfg.diff_max_rows),
107
+ )
108
+
109
+ @command("shadow promote", master=True, help="转正影子(需 yes 确认)",
110
+ args="<name:str> [confirm:literal=no|yes]")
111
+ async def shadow_promote_handler(event, name: str, confirm: str = "no"):
112
+ if confirm != "yes":
113
+ await event.reply(i18n.t("ShadowOps.promote_need_confirm", name=name))
114
+ return
115
+ try:
116
+ ok = await self._service.promote(name)
117
+ except Exception as e:
118
+ await event.reply(self._shadow_error_text(e, name))
119
+ return
120
+ if ok:
121
+ await event.reply(i18n.t("ShadowOps.promoted", name=name))
122
+ else:
123
+ await event.reply(f"promote returned False: {name}")
124
+
125
+ @command("shadow dismiss", master=True, help="放弃影子", args="<name:str>")
126
+ async def shadow_dismiss_handler(event, name: str):
127
+ try:
128
+ ok = await self._service.dismiss(name)
129
+ except Exception as e:
130
+ await event.reply(self._shadow_error_text(e, name))
131
+ return
132
+ if ok:
133
+ await event.reply(i18n.t("ShadowOps.dismissed", name=name))
134
+ else:
135
+ await event.reply(f"dismiss returned False: {name}")
136
+
137
+ @command("shadow audit", master=True, help="归属权审计", args="<name:str>")
138
+ async def shadow_audit_handler(event, name: str):
139
+ try:
140
+ report = self._service.audit(name, deep=self.cfg.audit_deep)
141
+ except Exception as e:
142
+ await event.reply(self._shadow_error_text(e, name))
143
+ return
144
+ await self._reply_card(
145
+ event,
146
+ self._service.audit_card_html(name, report, self._viz),
147
+ self._service.format_audit_text(name, report),
148
+ )
ShadowOps/I18n.py ADDED
@@ -0,0 +1,125 @@
1
+ from ErisPulse.Core.Bases import BaseI18n, I18nKey
2
+
3
+
4
+ class ShadowOpsI18n(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
+ diff_max_rows: I18nKey = I18nKey(
15
+ default="Max entries shown in one diff report",
16
+ zh_CN="对比报告单次展示的最大条目数",
17
+ zh_TW="對比報告單次展示的最大條目數",
18
+ en="Max entries shown in one diff report",
19
+ ja="差分レポート1回の最大表示件数",
20
+ ru="Максимум записей в одном отчёте сравнения",
21
+ )
22
+ audit_deep: I18nKey = I18nKey(
23
+ default="Include gc instance census in audit (has global pause cost)",
24
+ zh_CN="审计时附带 gc 实例普查(有全局暂停开销)",
25
+ zh_TW="審計時附帶 gc 實例普查(有全域暫停開銷)",
26
+ en="Include gc instance census in audit (has global pause cost)",
27
+ ja="監査に gc インスタンス調査を含める(グローバル一時停止あり)",
28
+ ru="Включать обзор gc-экземпляров в аудит (глобальные паузы)",
29
+ )
30
+ render_card: I18nKey = I18nKey(
31
+ default="Render reports as image cards via Takumi (fallback to text)",
32
+ zh_CN="报告用 Takumi 渲染为卡片(不可用时降级文本)",
33
+ zh_TW="報告用 Takumi 渲染為卡片(不可用時降級文本)",
34
+ en="Render reports as image cards via Takumi (fallback to text)",
35
+ ja="レポートを Takumi でカード画像化(不可時はテキスト)",
36
+ ru="Рендер отчётов в карточки через Takumi (иначе текст)",
37
+ )
38
+ theme: I18nKey = I18nKey(
39
+ default="Card color theme (auto = light/dark by time)",
40
+ zh_CN="卡片配色 (auto=按时间自动切深浅色)",
41
+ zh_TW="卡片配色 (auto=按時間自動切深淺色)",
42
+ en="Card color theme (auto = light/dark by time)",
43
+ ja="カード配色(auto=時間で自動切替)",
44
+ ru="Тема карточек (auto = светлая/тёмная по времени)",
45
+ )
46
+ module_loaded: I18nKey = I18nKey(
47
+ default="ShadowOps loaded: {event}",
48
+ zh_CN="影子灰度管理已加载: {event}",
49
+ zh_TW="影子灰度管理已載入: {event}",
50
+ en="ShadowOps loaded: {event}",
51
+ ja="ShadowOps を読み込みました: {event}",
52
+ ru="ShadowOps загружен: {event}",
53
+ )
54
+ module_unloaded: I18nKey = I18nKey(
55
+ default="ShadowOps unloaded: {event}",
56
+ zh_CN="影子灰度管理已卸载: {event}",
57
+ zh_TW="影子灰度管理已卸載: {event}",
58
+ en="ShadowOps unloaded: {event}",
59
+ ja="ShadowOps を解除しました: {event}",
60
+ ru="ShadowOps выгружен: {event}",
61
+ )
62
+ started: I18nKey = I18nKey(
63
+ default="Shadow started: {owner} (shadowing {name})",
64
+ zh_CN="影子已启动: {owner}(对照模块 {name})",
65
+ zh_TW="影子已啟動: {owner}(對照模組 {name})",
66
+ en="Shadow started: {owner} (shadowing {name})",
67
+ ja="シャドーを開始しました: {owner}(対象 {name})",
68
+ ru="Тень запущена: {owner} (для {name})",
69
+ )
70
+ promoted: I18nKey = I18nKey(
71
+ default="Shadow promoted: {name}",
72
+ zh_CN="影子已转正: {name}",
73
+ zh_TW="影子已轉正: {name}",
74
+ en="Shadow promoted: {name}",
75
+ ja="シャドーを本番化しました: {name}",
76
+ ru="Тень повышена: {name}",
77
+ )
78
+ dismissed: I18nKey = I18nKey(
79
+ default="Shadow dismissed: {name}",
80
+ zh_CN="影子已放弃: {name}",
81
+ zh_TW="影子已放棄: {name}",
82
+ en="Shadow dismissed: {name}",
83
+ ja="シャドーを破棄しました: {name}",
84
+ ru="Тень отклонена: {name}",
85
+ )
86
+ list_empty: I18nKey = I18nKey(
87
+ default="No active shadows",
88
+ zh_CN="当前没有运行中的影子",
89
+ zh_TW="當前沒有運行中的影子",
90
+ en="No active shadows",
91
+ ja="実行中のシャドーはありません",
92
+ ru="Нет активных теней",
93
+ )
94
+ list_header: I18nKey = I18nKey(
95
+ default="Active shadows ({count}):",
96
+ zh_CN="运行中的影子({count} 个):",
97
+ zh_TW="運行中的影子({count} 個):",
98
+ en="Active shadows ({count}):",
99
+ ja="実行中のシャドー({count} 件):",
100
+ ru="Активные тени ({count}):",
101
+ )
102
+ diff_no_shadow: I18nKey = I18nKey(
103
+ default="Module {name} has no active shadow",
104
+ zh_CN="模块 {name} 没有运行中的影子",
105
+ zh_TW="模組 {name} 沒有運行中的影子",
106
+ en="Module {name} has no active shadow",
107
+ ja="モジュール {name} に実行中のシャドーはありません",
108
+ ru="У модуля {name} нет активной тени",
109
+ )
110
+ diff_empty: I18nKey = I18nKey(
111
+ default="Shadow has no outbound entries yet",
112
+ zh_CN="影子还没有意向出站记录",
113
+ zh_TW="影子還沒有意向出站記錄",
114
+ en="Shadow has no outbound entries yet",
115
+ ja="シャドーの出站記録はまだありません",
116
+ ru="У тени пока нет записей исходящих",
117
+ )
118
+ promote_need_confirm: I18nKey = I18nKey(
119
+ default="Promote will unload {name} and switch to the shadow version; retry with: /shadow promote {name} yes",
120
+ zh_CN="转正会卸载 {name} 并切换到影子版本;确认请输入: /shadow promote {name} yes",
121
+ zh_TW="轉正會卸載 {name} 並切換到影子版本;確認請輸入: /shadow promote {name} yes",
122
+ en="Promote will unload {name} and switch to the shadow version; retry with: /shadow promote {name} yes",
123
+ ja="本番化は {name} をアンロードしてシャドー版に切替えます。確認するには: /shadow promote {name} yes",
124
+ ru="Повышение выгрузит {name} и переключит на версию-тень; подтвердите: /shadow promote {name} yes",
125
+ )
ShadowOps/Service.py ADDED
@@ -0,0 +1,166 @@
1
+ from typing import Any
2
+
3
+ from ErisPulse import i18n
4
+
5
+
6
+ def _shadow_manager():
7
+ from ErisPulse.Core.shadow import shadow_manager
8
+
9
+ return shadow_manager
10
+
11
+
12
+ class ShadowOpsService:
13
+
14
+ def __init__(self, sdk, logger):
15
+ self.sdk = sdk
16
+ self.logger = logger
17
+
18
+ def list_shadows(self) -> list[dict[str, str]]:
19
+ items: list[dict[str, str]] = []
20
+ try:
21
+ shadows = getattr(_shadow_manager(), "_shadows", {}) or {}
22
+ except Exception:
23
+ shadows = {}
24
+ for real_name, info in sorted(shadows.items()):
25
+ items.append({
26
+ "real_name": real_name,
27
+ "shadow_owner": info.get("shadow_owner", ""),
28
+ })
29
+ return items
30
+
31
+ def format_list_text(self) -> str:
32
+ items = self.list_shadows()
33
+ if not items:
34
+ return i18n.t("ShadowOps.list_empty")
35
+ lines = [i18n.t("ShadowOps.list_header", count=len(items))]
36
+ for item in items:
37
+ lines.append(f"{item['real_name']} -> {item['shadow_owner']}")
38
+ return "\n".join(lines)
39
+
40
+ def list_card_html(self, viz) -> str:
41
+ import datetime
42
+
43
+ items = self.list_shadows()
44
+ if items:
45
+ rows = "".join(
46
+ viz.count_row(item["real_name"], "", f"shadow owner: {item['shadow_owner']}")
47
+ for item in items
48
+ )
49
+ body = rows
50
+ subtitle = f"共 {len(items)} 个影子在运行"
51
+ else:
52
+ body = "<div class='empty'>当前没有运行中的影子</div>"
53
+ subtitle = "共 0 个影子"
54
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
55
+ return (
56
+ viz.head("影子模块", subtitle)
57
+ + f"<div class='body'>{body}</div>"
58
+ + viz.foot(f"ShadowOps · {stamp}", "shadow_start / diff / promote / dismiss")
59
+ )
60
+
61
+ def diff(self, name: str) -> dict[str, Any]:
62
+ return self.sdk.module.shadow_diff(name)
63
+
64
+ @staticmethod
65
+ def _entry_line(entry: dict) -> str:
66
+ kind = entry.get("kind", "?")
67
+ platform = entry.get("platform", "")
68
+ method = entry.get("method", "")
69
+ target = entry.get("target", "")
70
+ preview = str(entry.get("preview", "") or "")
71
+ return f"[{kind}] {platform}.{method} -> {target} {preview}".rstrip()
72
+
73
+ def format_diff_text(self, name: str, report: dict, max_rows: int) -> str:
74
+ if not report.get("shadow_owner"):
75
+ return i18n.t("ShadowOps.diff_no_shadow", name=name)
76
+ aligned = report.get("aligned") or []
77
+ if not aligned:
78
+ return i18n.t("ShadowOps.diff_empty")
79
+ lines = [f"影子 {report['shadow_owner']} · 在账 {report.get('count', len(aligned))} 条"]
80
+ for item in aligned[:max_rows]:
81
+ shadow = item.get("shadow") or {}
82
+ actual = item.get("actual") or []
83
+ lines.append(self._entry_line(shadow))
84
+ lines.append(f" 实际发送: {len(actual)} 条 (trace={shadow.get('trace_id', '-')[:8]})")
85
+ hidden = len(aligned) - max_rows
86
+ if hidden > 0:
87
+ lines.append(f"…其余 {hidden} 条已省略")
88
+ return "\n".join(lines)
89
+
90
+ def diff_card_html(self, name: str, report: dict, max_rows: int, viz) -> str:
91
+ import datetime
92
+
93
+ if not report.get("shadow_owner"):
94
+ return (
95
+ viz.head("行为对比", f"模块 {name}")
96
+ + "<div class='body'><div class='empty'>没有运行中的影子</div></div>"
97
+ + viz.foot("ShadowOps", "shadow_start")
98
+ )
99
+ aligned = report.get("aligned") or []
100
+ if not aligned:
101
+ body = "<div class='empty'>影子还没有意向出站记录</div>"
102
+ else:
103
+ matched = sum(1 for item in aligned if item.get("actual"))
104
+ rows = []
105
+ for item in aligned[:max(1, max_rows)]:
106
+ shadow = item.get("shadow") or {}
107
+ actual = item.get("actual") or []
108
+ line = self._entry_line(shadow)
109
+ chips = viz.chips([
110
+ f"trace {str(shadow.get('trace_id', ''))[:12]}",
111
+ f"实际发送 {len(actual)} 条",
112
+ ])
113
+ rows.append(viz.count_row(
114
+ shadow.get("kind", "?"), "✓" if actual else "✗", line, chips,
115
+ ))
116
+ body = "".join(rows)
117
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
118
+ return (
119
+ viz.head("行为对比", f"模块 {name} · 影子 {report['shadow_owner']} · 在账 {report.get('count', 0)} 条")
120
+ + f"<div class='body'>{body}</div>"
121
+ + viz.foot(f"ShadowOps · {stamp}", f"展示上限 {max(1, max_rows)}")
122
+ )
123
+
124
+ async def start(self, name: str, source: str) -> str:
125
+ return await self.sdk.module.shadow_start(name, source=source)
126
+
127
+ async def promote(self, name: str) -> bool:
128
+ return await self.sdk.module.promote_shadow(name)
129
+
130
+ async def dismiss(self, name: str) -> bool:
131
+ return await self.sdk.module.dismiss_shadow(name)
132
+
133
+ def audit(self, name: str, deep: bool = False) -> dict[str, Any]:
134
+ return self.sdk.module.audit(name, deep=deep)
135
+
136
+ @staticmethod
137
+ def format_audit_text(name: str, report: dict) -> str:
138
+ lines = [f"归属审计: {name}"]
139
+ counts = report.get("counts") or {}
140
+ rendered = " ".join(f"{k}={v}" for k, v in sorted(counts.items()))
141
+ lines.append(f"counts: {rendered or '-'}")
142
+ orphans = report.get("orphans")
143
+ if orphans:
144
+ lines.append(f"orphans: {orphans}")
145
+ recyclable = report.get("instance_recyclable")
146
+ if recyclable is not None:
147
+ lines.append(f"instance_recyclable: {recyclable}")
148
+ return "\n".join(lines)
149
+
150
+ def audit_card_html(self, name: str, report: dict, viz) -> str:
151
+ import datetime
152
+
153
+ counts = sorted((report.get("counts") or {}).items())
154
+ body = viz.kv_section("归属资源计数", counts or [("-", "0")])
155
+ orphans = report.get("orphans")
156
+ if orphans:
157
+ body += viz.kv_section("孤儿 owner", [(str(o), "") for o in orphans])
158
+ recyclable = report.get("instance_recyclable")
159
+ if recyclable is not None:
160
+ body += viz.kv_section("实例可回收", [("instance_recyclable", str(recyclable))])
161
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
162
+ return (
163
+ viz.head("归属权审计", f"模块 {name}")
164
+ + f"<div class='body'>{body}</div>"
165
+ + viz.foot(f"ShadowOps · {stamp}", "module.audit")
166
+ )
@@ -0,0 +1,160 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ PALETTES = {
5
+ "light": {
6
+ "page": "#f4f6f9", "card": "#ffffff", "ink": "#2b3440", "sub": "#7d8a99",
7
+ "accent": "#8a6db5", "accent2": "#5aa27b", "warn": "#c0804a",
8
+ "accent_soft": "#ece5f6", "border": "#dfe6ee", "chipbg": "#f0f4f9", "codebg": "#eef2f7",
9
+ },
10
+ "dark": {
11
+ "page": "#171b21", "card": "#20262e", "ink": "#e6ebf1", "sub": "#93a1b0",
12
+ "accent": "#ab8fd6", "accent2": "#7fc29a", "warn": "#d3a569",
13
+ "accent_soft": "rgba(171,143,214,0.14)", "border": "#2c343e",
14
+ "chipbg": "rgba(171,143,214,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.warn .value { color: __WARN__; }
36
+ .section { margin-top: 18px; }
37
+ .section-title { font-size: 12.5px; color: __SUB__; font-weight: 600; letter-spacing: 1px; margin-bottom: 8px; text-transform: uppercase; }
38
+ .kv-grid { display: flex; flex-wrap: wrap; gap: 8px; }
39
+ .kv { background: __CHIPBG__; border: 1px solid __BORDER__; border-radius: 8px; padding: 6px 12px; font-size: 13px; }
40
+ .kv b { color: __ACCENT__; font-weight: 600; }
41
+ .row { padding: 10px 12px; border-radius: 10px; margin-bottom: 6px; background: __CHIPBG__; border: 1px solid __BORDER__; }
42
+ .row:last-child { margin-bottom: 0; }
43
+ .row-main { display: flex; align-items: center; gap: 10px; }
44
+ .row-name { font-family: __FONTMONO__; font-size: 13px; background: __CODEBG__; border-radius: 6px; padding: 3px 8px; }
45
+ .row-dim { font-size: 12px; color: __SUB__; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
46
+ .row-count { font-size: 15px; font-weight: 700; color: __ACCENT__; font-family: __FONTMONO__; }
47
+ .row-count.ok { color: __ACCENT2__; }
48
+ .row-count.miss { color: __WARN__; }
49
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
50
+ .chip { font-size: 11.5px; background: __ACCENT_SOFT__; color: __ACCENT__; border-radius: 9999px; padding: 2px 10px; }
51
+ .empty { text-align: center; color: __SUB__; font-size: 13px; padding: 18px 0; }
52
+ .foot { padding: 12px 24px; border-top: 1px solid __BORDER__; font-size: 11.5px; color: __SUB__; display: flex; justify-content: space-between; }
53
+ """
54
+
55
+
56
+ def _esc(text) -> str:
57
+ import html
58
+
59
+ return html.escape(str(text))
60
+
61
+
62
+ class CardVisualizer:
63
+
64
+ CARD_WIDTH = 780
65
+
66
+ def __init__(self, sdk, logger):
67
+ self.sdk = sdk
68
+ self.logger = logger
69
+ self._takumi_inst = None
70
+
71
+ @property
72
+ def takumi(self):
73
+ if self._takumi_inst is None:
74
+ inst = None
75
+ try:
76
+ inst = self.sdk.module.get("Takumi")
77
+ except Exception:
78
+ inst = None
79
+ if inst is None:
80
+ inst = getattr(self.sdk, "Takumi", None)
81
+ self._takumi_inst = inst
82
+ return self._takumi_inst
83
+
84
+ def resolve_palette(self, theme: str) -> dict:
85
+ import datetime
86
+
87
+ if theme in PALETTES:
88
+ mode = theme
89
+ else:
90
+ hour = datetime.datetime.now().hour
91
+ mode = "dark" if (hour >= 19 or hour < 7) else "light"
92
+ return dict(PALETTES[mode])
93
+
94
+ def _css(self, palette: dict) -> str:
95
+ css = _CSS_TPL.replace("__FONTS__", _FONTS).replace("__FONTMONO__", _FONTS_MONO)
96
+ for key, value in palette.items():
97
+ css = css.replace(f"__{key.upper()}__", value)
98
+ return css
99
+
100
+ async def render(self, body_html: str, theme: str = "auto") -> Optional[bytes]:
101
+ takumi = self.takumi
102
+ if takumi is None or not hasattr(takumi, "render_html"):
103
+ self.logger.debug("Takumi 不可用,跳过渲染")
104
+ return None
105
+ try:
106
+ result = takumi.render_html(
107
+ f"<div class='page'>{body_html}</div>",
108
+ stylesheets=[self._css(self.resolve_palette(theme))],
109
+ width=self.CARD_WIDTH,
110
+ height=None,
111
+ lang="zh-CN",
112
+ )
113
+ if asyncio.iscoroutine(result):
114
+ result = await result
115
+ return result if isinstance(result, (bytes, bytearray)) else None
116
+ except Exception as e:
117
+ self.logger.warning(f"渲染卡片失败: {e}")
118
+ return None
119
+
120
+ def head(self, title: str, subtitle: str) -> str:
121
+ return (
122
+ f"<div class='head'><div><div class='title'>{_esc(title)}</div>"
123
+ f"<div class='subtitle'>{_esc(subtitle)}</div></div></div>"
124
+ )
125
+
126
+ def foot(self, left: str, right: str) -> str:
127
+ return f"<div class='foot'><span>{_esc(left)}</span><span>{_esc(right)}</span></div>"
128
+
129
+ @staticmethod
130
+ def nums(items) -> str:
131
+ cells = "".join(
132
+ f"<div class='num {cls}'><div class='label'>{_esc(label)}</div>"
133
+ f"<div class='value'>{_esc(value)}</div></div>"
134
+ for label, value, cls in items
135
+ )
136
+ return f"<div class='nums'>{cells}</div>"
137
+
138
+ @staticmethod
139
+ def kv_section(title: str, pairs) -> str:
140
+ chips = "".join(f"<div class='kv'>{_esc(k)} <b>{_esc(v)}</b></div>" for k, v in pairs)
141
+ return (
142
+ f"<div class='section'><div class='section-title'>{_esc(title)}</div>"
143
+ f"<div class='kv-grid'>{chips}</div></div>"
144
+ )
145
+
146
+ @staticmethod
147
+ def count_row(name: str, right: str, dim: str = "", chips: str = "", right_cls: str = "") -> str:
148
+ return (
149
+ f"<div class='row'><div class='row-main'>"
150
+ f"<span class='row-name'>{_esc(name)}</span>"
151
+ f"<span class='row-dim'>{_esc(dim)}</span>"
152
+ f"<span class='row-count {right_cls}'>{_esc(right)}</span></div>{chips}</div>"
153
+ )
154
+
155
+ @staticmethod
156
+ def chips(items, cls: str = "") -> str:
157
+ if not items:
158
+ return ""
159
+ rendered = "".join(f"<span class='chip {cls}'>{_esc(t)}</span>" for t in items)
160
+ return f"<div class='chips'>{rendered}</div>"
ShadowOps/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .Config import ShadowOpsConfig
2
+ from .I18n import ShadowOpsI18n
3
+ from .Core import Main
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: ErisPulse-ShadowOps
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 ShadowOps" />
19
+
20
+ # ErisPulse ShadowOps
21
+
22
+ **影子灰度管理模块 —— 在聊天中启动、对比、转正、放弃影子模块。**
23
+
24
+ 基于 ErisPulse 2.9 影子模块运行时 API 的运营工具,附归属权审计,报告经 Takumi 渲染为卡片。
25
+
26
+ <p>
27
+ <a href="https://pypi.org/project/ErisPulse-ShadowOps/"><img src="https://img.shields.io/pypi/v/ErisPulse-ShadowOps?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
28
+ <a href="https://pypi.org/project/ErisPulse-ShadowOps/"><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-ShadowOps"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-ShadowOps?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
31
+ <a href="https://pepy.tech/project/ErisPulse-ShadowOps"><img src="https://img.shields.io/pepy/dt/ErisPulse-ShadowOps?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
+ ShadowOps 是基于 [ErisPulse](https://github.com/ErisPulse/ErisPulse/) 架构开发的影子灰度运营模块。影子 = 同一模块的新版本,以独立 owner 与线上旧版并存试运行:收到真实事件副本、出站被拦截记账而非真正发出。本模块把这些运行时操作暴露为聊天命令:
46
+
47
+ - `shadow start`:把 v2 代码目录装载为影子
48
+ - `shadow diff`:对比 v1 实际发送 × v2 意向发送(按 trace_id 对齐)
49
+ - `shadow promote`:确认后一键转正(失败自动回滚)
50
+ - `shadow dismiss`:放弃影子
51
+ - `shadow audit`:归属权审计(资源计数 / 孤儿 owner)
52
+
53
+ 要求 ErisPulse >= 2.9.0。
54
+
55
+ ---
56
+
57
+ ## 效果展示
58
+
59
+ | `/shadow list` | `/shadow diff` | `/shadow audit` |
60
+ |:---:|:---:|:---:|
61
+ | ![list](.github/assets/demo/list-light.png) | ![diff](.github/assets/demo/diff-light.png) | ![audit](.github/assets/demo/audit-light.png) |
62
+
63
+ ---
64
+
65
+
66
+ ---
67
+
68
+ ## 安装
69
+
70
+ ```bash
71
+ epsdk install ErisPulse-ShadowOps
72
+ ```
73
+
74
+ ---
75
+
76
+ ## 命令
77
+
78
+ | 命令 | 说明 |
79
+ |------|------|
80
+ | `/shadow` | 显示用法 |
81
+ | `/shadow start <模块名> <v2路径>` | 启动影子(v2 代码目录或单 .py 文件,建议放在插件目录之外) |
82
+ | `/shadow list` | 列出运行中的影子 |
83
+ | `/shadow diff <模块名>` | 行为对比报告 |
84
+ | `/shadow promote <模块名> yes` | 转正(必须带 `yes` 确认) |
85
+ | `/shadow dismiss <模块名>` | 放弃影子 |
86
+ | `/shadow audit <模块名>` | 归属权审计 |
87
+
88
+ 全部命令需要框架主人权限。
89
+
90
+ ---
91
+
92
+ ## 使用示例
93
+
94
+ ```bash
95
+ # 1. 把新版代码放到服务器任意目录(如 downloads/roll_v2/)
96
+
97
+ # 2. 启动灰度
98
+ /shadow start roll downloads/roll_v2
99
+
100
+ # 3. 试运行一段时间后对比行为
101
+ /shadow diff roll
102
+
103
+ # 4. 确认无害后转正(失败自动回滚旧版本)
104
+ /shadow promote roll yes
105
+
106
+ # 或放弃
107
+ /shadow dismiss roll
108
+ ```
109
+
110
+ ---
111
+
112
+ ## 配置说明
113
+
114
+ ```toml
115
+ [ShadowOps]
116
+ enabled = true # 是否启用模块
117
+ diff_max_rows = 10 # 对比报告单次展示条目数
118
+ audit_deep = false # 审计附带 gc 实例普查(有全局暂停开销)
119
+ render_card = true # 报告用 Takumi 渲染为卡片
120
+ theme = "auto" # 卡片配色 (auto/light/dark)
121
+ ```
122
+
123
+ | 配置项 | 类型 | 默认值 | 说明 |
124
+ |--------|------|--------|------|
125
+ | `enabled` | bool | `true` | 是否启用模块 |
126
+ | `diff_max_rows` | int | `10` | 对比报告条目上限 |
127
+ | `audit_deep` | bool | `false` | gc 实例普查 |
128
+ | `render_card` | bool | `true` | 卡片渲染 |
129
+ | `theme` | str | `auto` | 卡片配色 |
130
+
131
+ ---
132
+
133
+ ## 注意事项
134
+
135
+ 1. 影子源为本地路径;同一 PyPI 包无法在同解释器内新旧并存
136
+ 2. promote 是运行时切换,重启后需持久化安装新版本(`pip install -U`)
137
+ 3. 走框架的发送/API/KV 全部拦得住;绕过框架的副作用不可拦截(audit 可见资源归属)
138
+
139
+ ---
140
+
141
+ ## 参考链接
142
+
143
+ - [ErisPulse 主库](https://github.com/ErisPulse/ErisPulse/)
144
+ - [影子模块文档](https://github.com/ErisPulse/ErisPulse/tree/main/docs/zh-CN/advanced/shadow.md)
@@ -0,0 +1,12 @@
1
+ ShadowOps/Config.py,sha256=hCAmuBaTZuv3untrH2BLRKjVMJp-eKeE_Fh24ePUnSU,1644
2
+ ShadowOps/Core.py,sha256=ytAh0qqe8j_sBI2xVt-vC19T4apKhydIC2_FWhhotAk,6124
3
+ ShadowOps/I18n.py,sha256=vjqdXbl734Oyps_QDCmcviPmR55dWheAhgVlpCNZ3bI,6338
4
+ ShadowOps/Service.py,sha256=cPnFCXB5E1-c7kkN3FVD7_WHiKn10xIShXu03UEI_Hk,6630
5
+ ShadowOps/Visualizer.py,sha256=AwYPKfMjnEgEpNDGlQYpxoLvHEyNpsWTMxSkbg6xqHw,6908
6
+ ShadowOps/__init__.py,sha256=xaiGuD9Rbj-TxCkPUmjV5own_F28Z-g_f76XOWkM6WY,91
7
+ erispulse_shadowops-0.1.0.dist-info/licenses/LICENSE,sha256=OO7qHOo6QdvZahvf8NTfkgcVGuh8fqFcxKTvxmbmz_k,1065
8
+ erispulse_shadowops-0.1.0.dist-info/METADATA,sha256=vkI6BGHdogXhtHMhPTeZPl_AlorkPxkcx6vmErjxIZ8,5020
9
+ erispulse_shadowops-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ erispulse_shadowops-0.1.0.dist-info/entry_points.txt,sha256=p_6Eg-446l31m3PqNO7bk0JnHrcVzDNxBWNH4BLHNyk,46
11
+ erispulse_shadowops-0.1.0.dist-info/top_level.txt,sha256=HT3nYaeNe5BoCNxhDlZMOZBUn-MvF9i_meHQdr8wN6c,10
12
+ erispulse_shadowops-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [erispulse.module]
2
+ ShadowOps = ShadowOps:Main
@@ -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
+ ShadowOps