ErisPulse-ConnHub 0.1.0__tar.gz
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.
- erispulse_connhub-0.1.0/ConnHub/Config.py +76 -0
- erispulse_connhub-0.1.0/ConnHub/Core.py +272 -0
- erispulse_connhub-0.1.0/ConnHub/I18n.py +189 -0
- erispulse_connhub-0.1.0/ConnHub/Panel.py +85 -0
- erispulse_connhub-0.1.0/ConnHub/Service.py +153 -0
- erispulse_connhub-0.1.0/ConnHub/Visualizer.py +200 -0
- erispulse_connhub-0.1.0/ConnHub/__init__.py +3 -0
- erispulse_connhub-0.1.0/ErisPulse_ConnHub.egg-info/PKG-INFO +146 -0
- erispulse_connhub-0.1.0/ErisPulse_ConnHub.egg-info/SOURCES.txt +19 -0
- erispulse_connhub-0.1.0/ErisPulse_ConnHub.egg-info/dependency_links.txt +1 -0
- erispulse_connhub-0.1.0/ErisPulse_ConnHub.egg-info/entry_points.txt +2 -0
- erispulse_connhub-0.1.0/ErisPulse_ConnHub.egg-info/requires.txt +5 -0
- erispulse_connhub-0.1.0/ErisPulse_ConnHub.egg-info/top_level.txt +1 -0
- erispulse_connhub-0.1.0/LICENSE +21 -0
- erispulse_connhub-0.1.0/PKG-INFO +146 -0
- erispulse_connhub-0.1.0/README.md +131 -0
- erispulse_connhub-0.1.0/pyproject.toml +30 -0
- erispulse_connhub-0.1.0/setup.cfg +4 -0
- erispulse_connhub-0.1.0/tests/test_commands.py +102 -0
- erispulse_connhub-0.1.0/tests/test_service.py +131 -0
- erispulse_connhub-0.1.0/tests/test_visual.py +69 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from ErisPulse.Core.Bases import BaseConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ConnHubConfig(BaseConfig):
|
|
8
|
+
|
|
9
|
+
enabled: bool = field(
|
|
10
|
+
default=True,
|
|
11
|
+
metadata={
|
|
12
|
+
"description": {"i18n": "ConnHub.enabled", "default": "是否启用模块"},
|
|
13
|
+
"ui": {"widget": "switch", "group": "basic", "order": 1},
|
|
14
|
+
},
|
|
15
|
+
)
|
|
16
|
+
api_enabled: bool = field(
|
|
17
|
+
default=True,
|
|
18
|
+
metadata={
|
|
19
|
+
"description": {"i18n": "ConnHub.api_enabled", "default": "启用 HTTP API 与 SSE 事件流"},
|
|
20
|
+
"ui": {"widget": "switch", "group": "api", "order": 2},
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
panel_enabled: bool = field(
|
|
24
|
+
default=True,
|
|
25
|
+
metadata={
|
|
26
|
+
"description": {"i18n": "ConnHub.panel_enabled", "default": "启用内置连接面板网页"},
|
|
27
|
+
"ui": {"widget": "switch", "group": "api", "order": 3},
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
allow_force_close: bool = field(
|
|
31
|
+
default=False,
|
|
32
|
+
metadata={
|
|
33
|
+
"description": {"i18n": "ConnHub.allow_force_close", "default": "允许关闭其他模块归属的连接(跳过归属校验)"},
|
|
34
|
+
"ui": {"widget": "switch", "group": "api", "order": 4},
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
snapshot_interval_secs: int = field(
|
|
38
|
+
default=15,
|
|
39
|
+
metadata={
|
|
40
|
+
"description": {"i18n": "ConnHub.snapshot_interval_secs", "default": "向面板推送全量快照的间隔秒数(0 关闭)"},
|
|
41
|
+
"ui": {"widget": "number", "group": "api", "order": 5},
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
broadcast_timeout: float = field(
|
|
45
|
+
default=10.0,
|
|
46
|
+
metadata={
|
|
47
|
+
"description": {"i18n": "ConnHub.broadcast_timeout", "default": "广播时单条连接的发送超时秒数"},
|
|
48
|
+
"ui": {"widget": "number", "group": "api", "order": 6},
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
top_max: int = field(
|
|
52
|
+
default=20,
|
|
53
|
+
metadata={
|
|
54
|
+
"description": {"i18n": "ConnHub.top_max", "default": "连接列表单次展示的最大条数"},
|
|
55
|
+
"ui": {"widget": "number", "group": "basic", "order": 7},
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
render_card: bool = field(
|
|
59
|
+
default=True,
|
|
60
|
+
metadata={
|
|
61
|
+
"description": {"i18n": "ConnHub.render_card", "default": "命令输出用 Takumi 渲染为卡片(不可用时降级文本)"},
|
|
62
|
+
"ui": {"widget": "switch", "group": "render", "order": 8},
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
theme: str = field(
|
|
66
|
+
default="auto",
|
|
67
|
+
metadata={
|
|
68
|
+
"description": {"i18n": "ConnHub.theme", "default": "卡片配色 (auto=按时间自动切深浅色)"},
|
|
69
|
+
"ui": {
|
|
70
|
+
"widget": "select",
|
|
71
|
+
"options": ["auto", "light", "dark"],
|
|
72
|
+
"group": "render",
|
|
73
|
+
"order": 9,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from ErisPulse import connections, i18n, respond, router, sdk
|
|
4
|
+
from ErisPulse.Core.Bases import BaseModule, ModuleMeta
|
|
5
|
+
from ErisPulse.Core.Event import command
|
|
6
|
+
from ErisPulse.loaders import ModuleLoadStrategy
|
|
7
|
+
|
|
8
|
+
from .Config import ConnHubConfig
|
|
9
|
+
from .I18n import ConnHubI18n
|
|
10
|
+
from .Panel import PANEL_HTML
|
|
11
|
+
from .Service import VALID_KINDS, ConnHubService
|
|
12
|
+
from .Visualizer import CardVisualizer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Main(BaseModule):
|
|
16
|
+
|
|
17
|
+
ConfigClass = ConnHubConfig
|
|
18
|
+
I18nClass = ConnHubI18n
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.sdk = sdk
|
|
22
|
+
self.logger = sdk.logger.get_child("ConnHub")
|
|
23
|
+
self._service = ConnHubService(connections, self.logger)
|
|
24
|
+
self._viz = CardVisualizer(sdk, self.logger)
|
|
25
|
+
self._snapshot_task = None
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def get_load_strategy() -> ModuleLoadStrategy:
|
|
29
|
+
return ModuleLoadStrategy(lazy_load=False, priority=30)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def get_meta() -> ModuleMeta:
|
|
33
|
+
return ModuleMeta(
|
|
34
|
+
name="ConnHub",
|
|
35
|
+
description="连接中心:查看/广播/关闭框架登记的 WS/SSE 连接,提供 HTTP API 与实时面板",
|
|
36
|
+
version="0.1.0",
|
|
37
|
+
author="ErisPulse-devs",
|
|
38
|
+
group="运维",
|
|
39
|
+
tags=["连接池", "广播", "SSE", "面板"],
|
|
40
|
+
min_sdk_version="2.10.0",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def on_load(self, event: dict) -> bool:
|
|
44
|
+
if not self.cfg.enabled:
|
|
45
|
+
self.logger.info("连接中心未启用(enabled=false)")
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
self._register_lifecycle()
|
|
49
|
+
self._register_commands()
|
|
50
|
+
if self.cfg.api_enabled:
|
|
51
|
+
self._register_routes()
|
|
52
|
+
self._snapshot_task = self.spawn(self._snapshot_loop())
|
|
53
|
+
self.logger.info(i18n.t("ConnHub.module_loaded", event=event))
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
async def on_unload(self, event: dict) -> bool:
|
|
57
|
+
if self._snapshot_task is not None:
|
|
58
|
+
self._snapshot_task.cancel()
|
|
59
|
+
await asyncio.gather(self._snapshot_task, return_exceptions=True)
|
|
60
|
+
self._snapshot_task = None
|
|
61
|
+
self.logger.info(i18n.t("ConnHub.module_unloaded", event=event))
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
def _register_lifecycle(self):
|
|
65
|
+
@sdk.lifecycle.on("connection.registered")
|
|
66
|
+
async def _on_registered(data):
|
|
67
|
+
self._service.on_connection_event("registered")
|
|
68
|
+
await self._push_event("registered", data)
|
|
69
|
+
|
|
70
|
+
@sdk.lifecycle.on("connection.unregistered")
|
|
71
|
+
async def _on_unregistered(data):
|
|
72
|
+
self._service.on_connection_event("unregistered")
|
|
73
|
+
await self._push_event("unregistered", data)
|
|
74
|
+
|
|
75
|
+
@sdk.lifecycle.on("connection.group.joined")
|
|
76
|
+
async def _on_group_joined(data):
|
|
77
|
+
await self._push_event("group.joined", data)
|
|
78
|
+
|
|
79
|
+
@sdk.lifecycle.on("connection.group.left")
|
|
80
|
+
async def _on_group_left(data):
|
|
81
|
+
await self._push_event("group.left", data)
|
|
82
|
+
|
|
83
|
+
async def _push_event(self, action: str, data: dict):
|
|
84
|
+
payload = ConnHubService.event_payload(action, data)
|
|
85
|
+
await self._broadcast_to_watchers(payload)
|
|
86
|
+
|
|
87
|
+
async def _broadcast_to_watchers(self, payload):
|
|
88
|
+
try:
|
|
89
|
+
await connections.broadcast(
|
|
90
|
+
payload,
|
|
91
|
+
namespace="ConnHub",
|
|
92
|
+
kind="sse",
|
|
93
|
+
timeout=self.cfg.broadcast_timeout,
|
|
94
|
+
)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
self.logger.warning(f"推送事件到面板失败: {e}")
|
|
97
|
+
|
|
98
|
+
async def _snapshot_loop(self):
|
|
99
|
+
while True:
|
|
100
|
+
try:
|
|
101
|
+
interval = int(self.cfg.snapshot_interval_secs)
|
|
102
|
+
if interval <= 0:
|
|
103
|
+
await asyncio.sleep(5)
|
|
104
|
+
continue
|
|
105
|
+
await asyncio.sleep(interval)
|
|
106
|
+
await self._broadcast_to_watchers(self._service.snapshot_payload())
|
|
107
|
+
except asyncio.CancelledError:
|
|
108
|
+
raise
|
|
109
|
+
except Exception as e:
|
|
110
|
+
self.logger.warning(f"快照推送异常: {e}")
|
|
111
|
+
await asyncio.sleep(5)
|
|
112
|
+
|
|
113
|
+
def _register_commands(self):
|
|
114
|
+
@command("conn", master=True, help="连接中心管理")
|
|
115
|
+
async def conn_handler(event):
|
|
116
|
+
await event.reply(
|
|
117
|
+
"用法: /conn list [kind] | /conn stats | /conn group add|remove <id> <组名> | "
|
|
118
|
+
"/conn broadcast <组名|*> <消息> | /conn close <id>"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@command("conn list", master=True, help="列出登记中的连接", args="[kind:str]")
|
|
122
|
+
async def conn_list_handler(event, kind: str = ""):
|
|
123
|
+
if kind and kind not in VALID_KINDS:
|
|
124
|
+
await event.reply(i18n.t("ConnHub.invalid_kind", kind=kind))
|
|
125
|
+
return
|
|
126
|
+
await self._reply_card(
|
|
127
|
+
event,
|
|
128
|
+
lambda: self._service.list_card_html(kind or None, self.cfg.top_max, self._viz),
|
|
129
|
+
self._service.format_list(kind or None, self.cfg.top_max),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@command("conn stats", master=True, help="查看连接池统计")
|
|
133
|
+
async def conn_stats_handler(event):
|
|
134
|
+
await self._reply_card(
|
|
135
|
+
event,
|
|
136
|
+
lambda: self._service.stats_card_html(self._viz),
|
|
137
|
+
self._service.format_stats(),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@command("conn group add", master=True, help="把连接加入分组", args="<cid:str> <group:str>")
|
|
141
|
+
async def conn_group_add_handler(event, cid: str, group: str):
|
|
142
|
+
try:
|
|
143
|
+
self._service.assign(cid, group)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
await event.reply(self._conn_error_text(e, cid))
|
|
146
|
+
return
|
|
147
|
+
await event.reply(i18n.t("ConnHub.group_assigned", id=cid, group=group))
|
|
148
|
+
|
|
149
|
+
@command("conn group remove", master=True, help="把连接移出分组", args="<cid:str> <group:str>")
|
|
150
|
+
async def conn_group_remove_handler(event, cid: str, group: str):
|
|
151
|
+
try:
|
|
152
|
+
self._service.dismiss(cid, group)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
await event.reply(self._conn_error_text(e, cid))
|
|
155
|
+
return
|
|
156
|
+
await event.reply(i18n.t("ConnHub.group_dismissed", id=cid, group=group))
|
|
157
|
+
|
|
158
|
+
@command("conn broadcast", master=True, help="向分组广播消息", args="<group:str> <text:rest>")
|
|
159
|
+
async def conn_broadcast_handler(event, group: str, text: str):
|
|
160
|
+
result = await self._service.broadcast(group, {"type": "message", "from": "conn", "text": text},
|
|
161
|
+
self.cfg.broadcast_timeout)
|
|
162
|
+
await event.reply(i18n.t(
|
|
163
|
+
"ConnHub.broadcast_done",
|
|
164
|
+
total=result.total,
|
|
165
|
+
sent=len(result.sent),
|
|
166
|
+
failed=len(result.failed),
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
@command("conn close", master=True, help="关闭连接", args="<cid:str>")
|
|
170
|
+
async def conn_close_handler(event, cid: str):
|
|
171
|
+
try:
|
|
172
|
+
await self._service.close(cid, force=self.cfg.allow_force_close)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
await event.reply(self._conn_error_text(e, cid))
|
|
175
|
+
return
|
|
176
|
+
await event.reply(i18n.t("ConnHub.close_done", id=cid))
|
|
177
|
+
|
|
178
|
+
async def _reply_card(self, event, html_fn, fallback_text: str):
|
|
179
|
+
if self.cfg.render_card:
|
|
180
|
+
png = await self._viz.render(html_fn(), theme=self.cfg.theme)
|
|
181
|
+
if png is not None:
|
|
182
|
+
try:
|
|
183
|
+
await event.reply(png, method="Image")
|
|
184
|
+
return
|
|
185
|
+
except Exception as e:
|
|
186
|
+
self.logger.warning(f"图片回复失败,降级文本: {e}")
|
|
187
|
+
await event.reply(fallback_text)
|
|
188
|
+
|
|
189
|
+
def _conn_error_text(self, error: Exception, cid: str) -> str:
|
|
190
|
+
from ErisPulse import ConnectionNotFoundError, ConnectionPermissionError
|
|
191
|
+
|
|
192
|
+
if isinstance(error, ConnectionNotFoundError):
|
|
193
|
+
return i18n.t("ConnHub.close_not_found", id=cid)
|
|
194
|
+
if isinstance(error, ConnectionPermissionError):
|
|
195
|
+
owner = getattr(error, "owner", "") or ""
|
|
196
|
+
return i18n.t("ConnHub.close_forbidden", id=cid, owner=owner)
|
|
197
|
+
return f"{type(error).__name__}: {error}"
|
|
198
|
+
|
|
199
|
+
def _register_routes(self):
|
|
200
|
+
@router.get("/api/connections")
|
|
201
|
+
async def api_connections():
|
|
202
|
+
return respond(self._service.list_dicts())
|
|
203
|
+
|
|
204
|
+
@router.get("/api/stats")
|
|
205
|
+
async def api_stats():
|
|
206
|
+
return respond(self._service.stats())
|
|
207
|
+
|
|
208
|
+
@router.post("/api/broadcast")
|
|
209
|
+
async def api_broadcast(request):
|
|
210
|
+
try:
|
|
211
|
+
body = await request.json()
|
|
212
|
+
except Exception:
|
|
213
|
+
return respond({"error": "invalid_json"}, status_code=400)
|
|
214
|
+
data = body.get("data") if isinstance(body, dict) else None
|
|
215
|
+
if data is None:
|
|
216
|
+
return respond({"error": "data_required"}, status_code=400)
|
|
217
|
+
group = body.get("group") or None
|
|
218
|
+
try:
|
|
219
|
+
timeout = float(body.get("timeout", self.cfg.broadcast_timeout))
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
timeout = self.cfg.broadcast_timeout
|
|
222
|
+
result = await self._service.broadcast(group, data, timeout)
|
|
223
|
+
return respond({
|
|
224
|
+
"total": result.total,
|
|
225
|
+
"sent": result.sent,
|
|
226
|
+
"failed": {cid: str(exc) for cid, exc in result.failed.items()},
|
|
227
|
+
"excluded": result.excluded,
|
|
228
|
+
"ok": result.ok,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
@router.post("/api/close")
|
|
232
|
+
async def api_close(request):
|
|
233
|
+
try:
|
|
234
|
+
body = await request.json()
|
|
235
|
+
except Exception:
|
|
236
|
+
return respond({"error": "invalid_json"}, status_code=400)
|
|
237
|
+
cid = body.get("id") if isinstance(body, dict) else None
|
|
238
|
+
if not cid:
|
|
239
|
+
return respond({"error": "id_required"}, status_code=400)
|
|
240
|
+
force = bool(body.get("force")) and self.cfg.allow_force_close
|
|
241
|
+
try:
|
|
242
|
+
await self._service.close(cid, force=force)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
from ErisPulse import ConnectionNotFoundError, ConnectionPermissionError
|
|
245
|
+
|
|
246
|
+
if isinstance(e, ConnectionNotFoundError):
|
|
247
|
+
return respond({"error": "not_found", "id": cid}, status_code=404)
|
|
248
|
+
if isinstance(e, ConnectionPermissionError):
|
|
249
|
+
return respond({"error": "permission_denied", "id": cid}, status_code=403)
|
|
250
|
+
return respond({"error": str(e)}, status_code=500)
|
|
251
|
+
return respond({"closed": cid})
|
|
252
|
+
|
|
253
|
+
@router.sse("/events")
|
|
254
|
+
async def event_stream(sse):
|
|
255
|
+
sse.join_group("connhub:watchers")
|
|
256
|
+
try:
|
|
257
|
+
await sse.send(self._service.snapshot_payload(), event="snapshot")
|
|
258
|
+
except Exception:
|
|
259
|
+
return
|
|
260
|
+
while True:
|
|
261
|
+
await asyncio.sleep(60)
|
|
262
|
+
|
|
263
|
+
if self.cfg.panel_enabled:
|
|
264
|
+
@router.get("/panel")
|
|
265
|
+
async def panel():
|
|
266
|
+
return PANEL_HTML, 200, {"Content-Type": "text/html; charset=utf-8"}
|
|
267
|
+
|
|
268
|
+
router.register_home_entry(
|
|
269
|
+
name={"i18n": "ConnHub.panel_title", "default": "ConnHub 连接面板"},
|
|
270
|
+
url="/connhub/panel",
|
|
271
|
+
icon_svg='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 12h4M13.5 10.8 16.5 7.6M13.5 13.2l3 3.2"/></svg>',
|
|
272
|
+
)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from ErisPulse.Core.Bases import BaseI18n, I18nKey
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ConnHubI18n(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
|
+
api_enabled: I18nKey = I18nKey(
|
|
15
|
+
default="Enable HTTP API and SSE event stream",
|
|
16
|
+
zh_CN="启用 HTTP API 与 SSE 事件流",
|
|
17
|
+
zh_TW="啟用 HTTP API 與 SSE 事件流",
|
|
18
|
+
en="Enable HTTP API and SSE event stream",
|
|
19
|
+
ja="HTTP API と SSE イベントストリームを有効化",
|
|
20
|
+
ru="Включить HTTP API и поток событий SSE",
|
|
21
|
+
)
|
|
22
|
+
panel_enabled: I18nKey = I18nKey(
|
|
23
|
+
default="Enable built-in web panel",
|
|
24
|
+
zh_CN="启用内置连接面板网页",
|
|
25
|
+
zh_TW="啟用內建連線面板網頁",
|
|
26
|
+
en="Enable built-in web panel",
|
|
27
|
+
ja="内蔵ウェブパネルを有効化",
|
|
28
|
+
ru="Включить встроенную веб-панель",
|
|
29
|
+
)
|
|
30
|
+
allow_force_close: I18nKey = I18nKey(
|
|
31
|
+
default="Allow closing connections owned by other modules (skip ownership check)",
|
|
32
|
+
zh_CN="允许关闭其他模块归属的连接(跳过归属校验)",
|
|
33
|
+
zh_TW="允許關閉其他模組歸屬的連線(跳過歸屬校驗)",
|
|
34
|
+
en="Allow closing connections owned by other modules (skip ownership check)",
|
|
35
|
+
ja="他モジュール所有の接続を閉じることを許可(所有権チェックを省略)",
|
|
36
|
+
ru="Разрешить закрытие соединений других модулей (без проверки владельца)",
|
|
37
|
+
)
|
|
38
|
+
snapshot_interval_secs: I18nKey = I18nKey(
|
|
39
|
+
default="Snapshot push interval in seconds (0 = off)",
|
|
40
|
+
zh_CN="向面板推送全量快照的间隔秒数(0 关闭)",
|
|
41
|
+
zh_TW="向面板推送全量快照的間隔秒數(0 關閉)",
|
|
42
|
+
en="Snapshot push interval in seconds (0 = off)",
|
|
43
|
+
ja="スナップショット推送間隔(秒)(0 で無効)",
|
|
44
|
+
ru="Интервал отправки снапшотов в секундах (0 = выкл.)",
|
|
45
|
+
)
|
|
46
|
+
broadcast_timeout: I18nKey = I18nKey(
|
|
47
|
+
default="Per-connection send timeout for broadcast (seconds)",
|
|
48
|
+
zh_CN="广播时单条连接的发送超时秒数",
|
|
49
|
+
zh_TW="廣播時單條連線的傳送超時秒數",
|
|
50
|
+
en="Per-connection send timeout for broadcast (seconds)",
|
|
51
|
+
ja="ブロードキャストの接続ごとの送信タイムアウト(秒)",
|
|
52
|
+
ru="Таймаут отправки на одно соединение при рассылке (сек.)",
|
|
53
|
+
)
|
|
54
|
+
top_max: I18nKey = I18nKey(
|
|
55
|
+
default="Max connections shown in one list",
|
|
56
|
+
zh_CN="连接列表单次展示的最大条数",
|
|
57
|
+
zh_TW="連線列表單次展示的最大條數",
|
|
58
|
+
en="Max connections shown in one list",
|
|
59
|
+
ja="リスト1回の最大表示件数",
|
|
60
|
+
ru="Максимум соединений в одном списке",
|
|
61
|
+
)
|
|
62
|
+
render_card: I18nKey = I18nKey(
|
|
63
|
+
default="Render command output as image cards via Takumi (fallback to text)",
|
|
64
|
+
zh_CN="命令输出用 Takumi 渲染为卡片(不可用时降级文本)",
|
|
65
|
+
zh_TW="命令輸出用 Takumi 渲染為卡片(不可用時降級文本)",
|
|
66
|
+
en="Render command output as image cards via Takumi (fallback to text)",
|
|
67
|
+
ja="コマンド出力を Takumi でカード画像化(不可時はテキスト)",
|
|
68
|
+
ru="Рендер вывода команд в карточки через Takumi (иначе текст)",
|
|
69
|
+
)
|
|
70
|
+
theme: I18nKey = I18nKey(
|
|
71
|
+
default="Card color theme (auto = light/dark by time)",
|
|
72
|
+
zh_CN="卡片配色 (auto=按时间自动切深浅色)",
|
|
73
|
+
zh_TW="卡片配色 (auto=按時間自動切深淺色)",
|
|
74
|
+
en="Card color theme (auto = light/dark by time)",
|
|
75
|
+
ja="カード配色(auto=時間で自動切替)",
|
|
76
|
+
ru="Тема карточек (auto = светлая/тёмная по времени)",
|
|
77
|
+
)
|
|
78
|
+
module_loaded: I18nKey = I18nKey(
|
|
79
|
+
default="ConnHub loaded: {event}",
|
|
80
|
+
zh_CN="连接中心已加载: {event}",
|
|
81
|
+
zh_TW="連線中心已載入: {event}",
|
|
82
|
+
en="ConnHub loaded: {event}",
|
|
83
|
+
ja="ConnHub を読み込みました: {event}",
|
|
84
|
+
ru="ConnHub загружен: {event}",
|
|
85
|
+
)
|
|
86
|
+
module_unloaded: I18nKey = I18nKey(
|
|
87
|
+
default="ConnHub unloaded: {event}",
|
|
88
|
+
zh_CN="连接中心已卸载: {event}",
|
|
89
|
+
zh_TW="連線中心已卸載: {event}",
|
|
90
|
+
en="ConnHub unloaded: {event}",
|
|
91
|
+
ja="ConnHub を解除しました: {event}",
|
|
92
|
+
ru="ConnHub выгружен: {event}",
|
|
93
|
+
)
|
|
94
|
+
no_connections: I18nKey = I18nKey(
|
|
95
|
+
default="No connections registered",
|
|
96
|
+
zh_CN="当前没有登记中的连接",
|
|
97
|
+
zh_TW="當前沒有登記中的連線",
|
|
98
|
+
en="No connections registered",
|
|
99
|
+
ja="登録中の接続はありません",
|
|
100
|
+
ru="Нет зарегистрированных соединений",
|
|
101
|
+
)
|
|
102
|
+
list_header: I18nKey = I18nKey(
|
|
103
|
+
default="{count} connection(s):",
|
|
104
|
+
zh_CN="共 {count} 条连接:",
|
|
105
|
+
zh_TW="共 {count} 條連線:",
|
|
106
|
+
en="{count} connection(s):",
|
|
107
|
+
ja="{count} 件の接続:",
|
|
108
|
+
ru="Соединений: {count}",
|
|
109
|
+
)
|
|
110
|
+
list_truncated: I18nKey = I18nKey(
|
|
111
|
+
default="...and {count} more",
|
|
112
|
+
zh_CN="…其余 {count} 条已省略",
|
|
113
|
+
zh_TW="…其餘 {count} 條已省略",
|
|
114
|
+
en="...and {count} more",
|
|
115
|
+
ja="…他 {count} 件を省略",
|
|
116
|
+
ru="…ещё {count}",
|
|
117
|
+
)
|
|
118
|
+
stats_header: I18nKey = I18nKey(
|
|
119
|
+
default="Connection stats (total {total}):",
|
|
120
|
+
zh_CN="连接统计(共 {total} 条):",
|
|
121
|
+
zh_TW="連線統計(共 {total} 條):",
|
|
122
|
+
en="Connection stats (total {total}):",
|
|
123
|
+
ja="接続統計(合計 {total}):",
|
|
124
|
+
ru="Статистика соединений (всего {total}):",
|
|
125
|
+
)
|
|
126
|
+
broadcast_done: I18nKey = I18nKey(
|
|
127
|
+
default="Broadcast finished: target {total}, sent {sent}, failed {failed}",
|
|
128
|
+
zh_CN="广播完成:目标 {total} 条,成功 {sent},失败 {failed}",
|
|
129
|
+
zh_TW="廣播完成:目標 {total} 條,成功 {sent},失敗 {failed}",
|
|
130
|
+
en="Broadcast finished: target {total}, sent {sent}, failed {failed}",
|
|
131
|
+
ja="ブロードキャスト完了:対象 {total}、成功 {sent}、失敗 {failed}",
|
|
132
|
+
ru="Рассылка завершена: цель {total}, отправлено {sent}, ошибок {failed}",
|
|
133
|
+
)
|
|
134
|
+
close_done: I18nKey = I18nKey(
|
|
135
|
+
default="Connection closed: {id}",
|
|
136
|
+
zh_CN="连接已关闭: {id}",
|
|
137
|
+
zh_TW="連線已關閉: {id}",
|
|
138
|
+
en="Connection closed: {id}",
|
|
139
|
+
ja="接続を閉じました: {id}",
|
|
140
|
+
ru="Соединение закрыто: {id}",
|
|
141
|
+
)
|
|
142
|
+
close_not_found: I18nKey = I18nKey(
|
|
143
|
+
default="Connection not found: {id}",
|
|
144
|
+
zh_CN="连接不存在或已断开: {id}",
|
|
145
|
+
zh_TW="連線不存在或已斷開: {id}",
|
|
146
|
+
en="Connection not found: {id}",
|
|
147
|
+
ja="接続が見つかりません: {id}",
|
|
148
|
+
ru="Соединение не найдено: {id}",
|
|
149
|
+
)
|
|
150
|
+
close_forbidden: I18nKey = I18nKey(
|
|
151
|
+
default="Permission denied: connection {id} is owned by {owner}",
|
|
152
|
+
zh_CN="权限不足:连接 {id} 归属于 {owner},可在配置中开启 allow_force_close",
|
|
153
|
+
zh_TW="權限不足:連線 {id} 歸屬於 {owner},可在配置中開啟 allow_force_close",
|
|
154
|
+
en="Permission denied: connection {id} is owned by {owner}",
|
|
155
|
+
ja="権限がありません: 接続 {id} は {owner} の所有です",
|
|
156
|
+
ru="Доступ запрещён: соединение {id} принадлежит {owner}",
|
|
157
|
+
)
|
|
158
|
+
group_assigned: I18nKey = I18nKey(
|
|
159
|
+
default="Connection {id} joined group {group}",
|
|
160
|
+
zh_CN="连接 {id} 已加入分组 {group}",
|
|
161
|
+
zh_TW="連線 {id} 已加入分組 {group}",
|
|
162
|
+
en="Connection {id} joined group {group}",
|
|
163
|
+
ja="接続 {id} をグループ {group} に追加しました",
|
|
164
|
+
ru="Соединение {id} добавлено в группу {group}",
|
|
165
|
+
)
|
|
166
|
+
group_dismissed: I18nKey = I18nKey(
|
|
167
|
+
default="Connection {id} left group {group}",
|
|
168
|
+
zh_CN="连接 {id} 已移出分组 {group}",
|
|
169
|
+
zh_TW="連線 {id} 已移出分組 {group}",
|
|
170
|
+
en="Connection {id} left group {group}",
|
|
171
|
+
ja="接続 {id} をグループ {group} から外しました",
|
|
172
|
+
ru="Соединение {id} удалено из группы {group}",
|
|
173
|
+
)
|
|
174
|
+
panel_title: I18nKey = I18nKey(
|
|
175
|
+
default="ConnHub connections panel",
|
|
176
|
+
zh_CN="ConnHub 连接面板",
|
|
177
|
+
zh_TW="ConnHub 連線面板",
|
|
178
|
+
en="ConnHub connections panel",
|
|
179
|
+
ja="ConnHub 接続パネル",
|
|
180
|
+
ru="Панель соединений ConnHub",
|
|
181
|
+
)
|
|
182
|
+
invalid_kind: I18nKey = I18nKey(
|
|
183
|
+
default="Unknown connection kind: {kind} (server/client/sse)",
|
|
184
|
+
zh_CN="未知的连接种类: {kind}(可选 server/client/sse)",
|
|
185
|
+
zh_TW="未知的連線種類: {kind}(可選 server/client/sse)",
|
|
186
|
+
en="Unknown connection kind: {kind} (server/client/sse)",
|
|
187
|
+
ja="不明な接続種別: {kind}(server/client/sse)",
|
|
188
|
+
ru="Неизвестный тип соединения: {kind} (server/client/sse)",
|
|
189
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
PANEL_HTML = """<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ConnHub</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin: 0; font-family: system-ui, "Segoe UI", sans-serif; background: #14161a; color: #d7dae0; }
|
|
11
|
+
header { padding: 16px 24px; border-bottom: 1px solid #262a31; display: flex; align-items: baseline; gap: 12px; }
|
|
12
|
+
h1 { font-size: 18px; margin: 0; }
|
|
13
|
+
#live { font-size: 12px; color: #6f7783; }
|
|
14
|
+
main { padding: 20px 24px; max-width: 1080px; margin: 0 auto; }
|
|
15
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
|
16
|
+
.card { background: #1b1f26; border: 1px solid #262a31; border-radius: 8px; padding: 12px 14px; }
|
|
17
|
+
.card .label { font-size: 12px; color: #8b93a1; }
|
|
18
|
+
.card .value { font-size: 22px; margin-top: 4px; }
|
|
19
|
+
h2 { font-size: 14px; color: #8b93a1; margin: 20px 0 8px; font-weight: 600; }
|
|
20
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
21
|
+
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid #262a31; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 320px; }
|
|
22
|
+
th { color: #8b93a1; font-weight: 600; }
|
|
23
|
+
#events { font-size: 12px; font-family: ui-monospace, Consolas, monospace; background: #1b1f26; border: 1px solid #262a31; border-radius: 8px; padding: 10px 12px; height: 180px; overflow-y: auto; }
|
|
24
|
+
#events div { padding: 2px 0; border-bottom: 1px dashed #22262d; }
|
|
25
|
+
.empty { color: #6f7783; font-size: 13px; }
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<header><h1>ConnHub 连接面板</h1><span id="live">connecting...</span></header>
|
|
30
|
+
<main>
|
|
31
|
+
<div class="cards" id="cards"></div>
|
|
32
|
+
<h2>连接列表</h2>
|
|
33
|
+
<table>
|
|
34
|
+
<thead><tr><th>ID</th><th>种类</th><th>命名空间</th><th>归属</th><th>分组</th></tr></thead>
|
|
35
|
+
<tbody id="rows"><tr><td colspan="5" class="empty">加载中...</td></tr></tbody>
|
|
36
|
+
</table>
|
|
37
|
+
<h2>事件流</h2>
|
|
38
|
+
<div id="events"></div>
|
|
39
|
+
</main>
|
|
40
|
+
<script>
|
|
41
|
+
const fmt = (v) => v === undefined || v === null || v === "" ? "-" : v;
|
|
42
|
+
function renderStats(stats) {
|
|
43
|
+
const cards = [["总计", stats.total], ["种类", Object.entries(stats.by_kind || {}).map(([k, v]) => k + ": " + v).join(" ") || "-"],
|
|
44
|
+
["累计登记", stats.registered_total], ["累计注销", stats.unregistered_total]];
|
|
45
|
+
document.getElementById("cards").innerHTML = cards.map(([l, v]) =>
|
|
46
|
+
'<div class="card"><div class="label">' + l + '</div><div class="value">' + fmt(v) + "</div></div>").join("");
|
|
47
|
+
}
|
|
48
|
+
function renderRows(conns) {
|
|
49
|
+
const rows = document.getElementById("rows");
|
|
50
|
+
if (!conns.length) { rows.innerHTML = '<tr><td colspan="5" class="empty">暂无连接</td></tr>'; return; }
|
|
51
|
+
rows.innerHTML = conns.map(c =>
|
|
52
|
+
"<tr><td>" + c.id + "</td><td>" + c.kind + "</td><td>" + fmt(c.namespace) + "</td><td>" + fmt(c.owner) +
|
|
53
|
+
"</td><td>" + (c.groups.length ? c.groups.join(", ") : "-") + "</td></tr>").join("");
|
|
54
|
+
}
|
|
55
|
+
async function refresh() {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch("api/stats", {headers: {"Accept": "application/json"}});
|
|
58
|
+
if (res.ok) renderStats(await res.json());
|
|
59
|
+
const res2 = await fetch("api/connections", {headers: {"Accept": "application/json"}});
|
|
60
|
+
if (res2.ok) renderRows(await res2.json());
|
|
61
|
+
} catch (e) { document.getElementById("live").textContent = "fetch failed"; }
|
|
62
|
+
}
|
|
63
|
+
function logEvent(text) {
|
|
64
|
+
const box = document.getElementById("events");
|
|
65
|
+
const div = document.createElement("div");
|
|
66
|
+
const now = new Date().toLocaleTimeString();
|
|
67
|
+
div.textContent = "[" + now + "] " + text;
|
|
68
|
+
box.prepend(div);
|
|
69
|
+
while (box.childElementCount > 100) box.lastChild.remove();
|
|
70
|
+
}
|
|
71
|
+
refresh();
|
|
72
|
+
setInterval(refresh, 5000);
|
|
73
|
+
const es = new EventSource("events");
|
|
74
|
+
es.onopen = () => { document.getElementById("live").textContent = "live"; };
|
|
75
|
+
es.onerror = () => { document.getElementById("live").textContent = "reconnecting..."; };
|
|
76
|
+
es.onmessage = (ev) => {
|
|
77
|
+
let data;
|
|
78
|
+
try { data = JSON.parse(ev.data); } catch (e) { return; }
|
|
79
|
+
if (data.type === "snapshot") { renderStats(data.stats); renderRows(data.connections); return; }
|
|
80
|
+
if (data.type === "connection") { logEvent(ev.data); refresh(); }
|
|
81
|
+
};
|
|
82
|
+
</script>
|
|
83
|
+
</body>
|
|
84
|
+
</html>
|
|
85
|
+
"""
|