Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# ruff: noqa: E402
|
||||
|
||||
from nonebot import get_driver, require
|
||||
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
|
||||
|
||||
require("nonebot_plugin_apscheduler")
|
||||
require("nonebot_plugin_alconna")
|
||||
require("nonebot_plugin_uninfo")
|
||||
require("nonebot_plugin_localstore")
|
||||
|
||||
from . import __main__ as __main__, misc_statistics as misc_statistics
|
||||
from .bg_provider import bg_preloader
|
||||
from .collectors import (
|
||||
enable_collectors,
|
||||
load_builtin_collectors,
|
||||
registered_collectors,
|
||||
)
|
||||
from .config import ConfigModel, config
|
||||
from .templates import load_builtin_templates, loaded_templates
|
||||
|
||||
# 统一配置标准:把常用、可安全热更的配置暴露给 Web 管理台
|
||||
# (路径/嵌套模型/复杂 list 字段不改,避免格式互转出错)
|
||||
from hexi.config_standard import register_model_config # noqa: E402
|
||||
|
||||
register_model_config(
|
||||
__name__,
|
||||
config,
|
||||
fields=[
|
||||
"ps_template",
|
||||
"ps_command",
|
||||
"ps_only_su",
|
||||
"ps_need_at",
|
||||
"ps_reply_target",
|
||||
"ps_bg_provider",
|
||||
"ps_collect_interval",
|
||||
"ps_proc_len",
|
||||
"ps_test_timeout",
|
||||
"ps_show_current_bot_only",
|
||||
"ps_ob_v11_use_get_status",
|
||||
],
|
||||
types={"ps_bg_provider": "enum"},
|
||||
options={"ps_bg_provider": [
|
||||
{"value": "loli", "label": "loliapi"},
|
||||
{"value": "lolicon", "label": "lolicon"},
|
||||
{"value": "local", "label": "本地背景"},
|
||||
{"value": "none", "label": "无背景"},
|
||||
]},
|
||||
labels={
|
||||
"ps_template": "状态卡片模板",
|
||||
"ps_command": "触发指令(每行一个)",
|
||||
"ps_only_su": "仅超级管理员可用",
|
||||
"ps_need_at": "需要 @ 机器人",
|
||||
"ps_reply_target": "回复原消息",
|
||||
"ps_bg_provider": "背景来源",
|
||||
"ps_collect_interval": "采集间隔(秒)",
|
||||
"ps_proc_len": "进程列表长度",
|
||||
"ps_test_timeout": "测速超时(秒)",
|
||||
"ps_show_current_bot_only": "只显示当前 bot",
|
||||
"ps_ob_v11_use_get_status": "OneBot v11 用 get_status",
|
||||
},
|
||||
descriptions={
|
||||
"ps_template": "内置: default",
|
||||
"ps_command": "每行一个命令,缺省为 运行状态/状态/zt/yxzt/status",
|
||||
"ps_bg_provider": "loli/lolicon 在线随机图,local 本地, none 关闭",
|
||||
"ps_collect_interval": "后台采集数据的时间间隔(秒)",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
# lazy load builtin templates and collectors
|
||||
@driver.on_startup
|
||||
async def _():
|
||||
if config.ps_template not in loaded_templates:
|
||||
load_builtin_templates()
|
||||
current_template = loaded_templates.get(config.ps_template)
|
||||
if current_template is None:
|
||||
raise ValueError(f"Template {config.ps_template} not found")
|
||||
|
||||
if (current_template.collectors is None) or any(
|
||||
(x not in registered_collectors) for x in current_template.collectors
|
||||
):
|
||||
load_builtin_collectors()
|
||||
|
||||
collectors = (
|
||||
set(registered_collectors)
|
||||
if current_template.collectors is None
|
||||
else current_template.collectors
|
||||
)
|
||||
await enable_collectors(*collectors)
|
||||
|
||||
bg_preloader.start_preload()
|
||||
|
||||
|
||||
usage = f"指令:{' / '.join(config.ps_command)}"
|
||||
if config.ps_need_at:
|
||||
usage += "\n注意:使用指令时需要@机器人"
|
||||
if config.ps_only_su:
|
||||
usage += "\n注意:仅SuperUser可以使用此指令"
|
||||
|
||||
__version__ = "2.2.2"
|
||||
__plugin_meta__ = PluginMetadata(
|
||||
name="PicStatus",
|
||||
description="以图片形式显示当前设备的运行状态",
|
||||
usage=usage,
|
||||
type="application",
|
||||
homepage="https://github.com/lgc-NB2Dev/nonebot-plugin-picstatus",
|
||||
config=ConfigModel,
|
||||
supported_adapters=inherit_supported_adapters(
|
||||
"nonebot_plugin_alconna",
|
||||
"nonebot_plugin_uninfo",
|
||||
),
|
||||
extra={"License": "MIT", "Author": "LgCookie"},
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
import asyncio
|
||||
|
||||
from cookit.loguru import warning_suppress
|
||||
from cookit.nonebot.alconna import extract_reply_msg
|
||||
from nonebot import logger, on_command
|
||||
from nonebot.adapters import Bot as BaseBot, Event as BaseEvent, Message as BaseMessage
|
||||
from nonebot.matcher import current_bot, current_event, current_matcher
|
||||
from nonebot.params import CommandArg
|
||||
from nonebot.permission import SUPERUSER
|
||||
from nonebot.rule import Rule, to_me
|
||||
from nonebot.typing import T_State
|
||||
from nonebot_plugin_alconna.uniseg import Image, OriginalUniMsg, UniMessage, image_fetch
|
||||
|
||||
from .bg_provider import BgBytesData, bg_preloader
|
||||
from .collectors import collect_all
|
||||
from .config import config
|
||||
from .misc_statistics import bot_avatar_cache, bot_info_cache, cache_bot_avatar
|
||||
from .templates import render_current_template
|
||||
|
||||
|
||||
def check_empty_arg_rule(arg: BaseMessage = CommandArg()):
|
||||
return not arg.extract_plain_text()
|
||||
|
||||
|
||||
def trigger_rule():
|
||||
rule = Rule(check_empty_arg_rule)
|
||||
if config.ps_need_at:
|
||||
rule &= to_me()
|
||||
return rule
|
||||
|
||||
|
||||
_cmd, *_alias = config.ps_command
|
||||
stat_matcher = on_command(
|
||||
_cmd,
|
||||
aliases=set(_alias),
|
||||
rule=trigger_rule(),
|
||||
permission=SUPERUSER if config.ps_only_su else None,
|
||||
)
|
||||
|
||||
|
||||
async def get_pic_from_msg(msg: UniMessage) -> BgBytesData | None:
|
||||
msg = r if ((r := extract_reply_msg(msg)) and Image in r) else msg
|
||||
if Image not in msg:
|
||||
return None
|
||||
img = msg[Image, 0]
|
||||
data = await image_fetch(
|
||||
current_event.get(),
|
||||
current_bot.get(),
|
||||
current_matcher.get().state,
|
||||
img,
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
return BgBytesData(data=data, mime=img.mimetype or "image")
|
||||
|
||||
|
||||
@stat_matcher.handle()
|
||||
async def _(bot: BaseBot, event: BaseEvent, state: T_State, msg: OriginalUniMsg):
|
||||
if (
|
||||
(bot.self_id not in bot_avatar_cache)
|
||||
and (info := bot_info_cache.get(bot.self_id))
|
||||
and info.avatar
|
||||
):
|
||||
await cache_bot_avatar(info.avatar, bot, event, state)
|
||||
|
||||
async def get_bg():
|
||||
with warning_suppress("Failed to fetch image from user message"):
|
||||
if bg := await get_pic_from_msg(msg):
|
||||
return bg
|
||||
return await bg_preloader.get()
|
||||
|
||||
try:
|
||||
bg, collected = await asyncio.gather(get_bg(), collect_all())
|
||||
ret = await render_current_template(collected=collected, bg=bg)
|
||||
except Exception:
|
||||
logger.exception("获取运行状态图失败")
|
||||
await UniMessage("获取运行状态图片失败,请检查后台输出").send(
|
||||
reply_to=config.ps_reply_target,
|
||||
)
|
||||
else:
|
||||
await UniMessage.image(raw=ret).send(reply_to=config.ps_reply_target)
|
||||
@@ -0,0 +1,411 @@
|
||||
import asyncio as aio
|
||||
import mimetypes
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterable, Callable
|
||||
from math import floor
|
||||
from pathlib import Path
|
||||
from typing import Generic, NamedTuple, ParamSpec, TypeAlias, TypedDict, TypeVar
|
||||
from typing_extensions import override
|
||||
|
||||
from cookit.common import race
|
||||
from cookit.loguru import warning_suppress
|
||||
from httpx import AsyncClient, Response
|
||||
from nonebot import get_driver, logger
|
||||
|
||||
from .config import BG_PRELOAD_CACHE_DIR, DEFAULT_BG_PATH, config
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from asyncio.taskgroups import TaskGroup
|
||||
else:
|
||||
from taskgroup import TaskGroup
|
||||
|
||||
|
||||
class BgBytesData(NamedTuple):
|
||||
data: bytes | None
|
||||
mime: str
|
||||
|
||||
|
||||
class BgFileData(NamedTuple):
|
||||
path: Path | None
|
||||
mime: str
|
||||
|
||||
|
||||
BgData: TypeAlias = BgBytesData | BgFileData
|
||||
|
||||
BGProviderType = Callable[[int], AsyncIterable[BgData]]
|
||||
T = TypeVar("T")
|
||||
TBP = TypeVar("TBP", bound=BGProviderType)
|
||||
P = ParamSpec("P")
|
||||
|
||||
DEFAULT_MIME = "application/octet-stream"
|
||||
|
||||
registered_bg_providers: dict[str, BGProviderType] = {}
|
||||
|
||||
|
||||
def get_bg_files() -> list["Path"]:
|
||||
if not config.ps_bg_local_path.exists():
|
||||
logger.warning("Custom background path does not exist, fallback to default")
|
||||
return [DEFAULT_BG_PATH]
|
||||
if config.ps_bg_local_path.is_file():
|
||||
return [config.ps_bg_local_path]
|
||||
|
||||
files = [x for x in config.ps_bg_local_path.glob("*") if x.is_file()]
|
||||
if not files:
|
||||
logger.warning("Custom background dir has no file in it, fallback to default")
|
||||
return [DEFAULT_BG_PATH]
|
||||
return files
|
||||
|
||||
|
||||
BG_FILES = get_bg_files()
|
||||
|
||||
|
||||
def refresh_bg_files():
|
||||
global BG_FILES
|
||||
BG_FILES = get_bg_files()
|
||||
|
||||
|
||||
def bg_provider(name: str | None = None):
|
||||
def deco(func: TBP) -> TBP:
|
||||
provider_name = name or func.__name__
|
||||
if provider_name in registered_bg_providers:
|
||||
raise ValueError(f"Duplicate bg provider name `{provider_name}`")
|
||||
registered_bg_providers[provider_name] = func
|
||||
return func
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def iter_batch_sizes(size: int, max_size: int):
|
||||
if size <= max_size:
|
||||
yield size
|
||||
else:
|
||||
full_sizes = floor(max_size / size)
|
||||
for _ in range(full_sizes):
|
||||
yield max_size
|
||||
if rest_count := full_sizes * max_size:
|
||||
yield rest_count
|
||||
|
||||
|
||||
def resp_to_bg_data(resp: Response):
|
||||
return BgBytesData(
|
||||
resp.content,
|
||||
(resp.headers.get("Content-Type") or DEFAULT_MIME),
|
||||
)
|
||||
|
||||
|
||||
class CoIterator(ABC, Generic[T]):
|
||||
def __init__(self):
|
||||
self.queue = aio.Queue[T | None]()
|
||||
|
||||
@abstractmethod
|
||||
async def run_tasks(self): ...
|
||||
|
||||
async def run(self):
|
||||
await self.run_tasks()
|
||||
await self.queue.put(None)
|
||||
|
||||
async def __aiter__(self):
|
||||
async with TaskGroup() as t:
|
||||
t.create_task(self.run())
|
||||
while (x := await self.queue.get()) is not None:
|
||||
yield x
|
||||
|
||||
|
||||
@bg_provider("loli")
|
||||
class LoliBGProvider(CoIterator[BgData]):
|
||||
def __init__(self, num: int):
|
||||
super().__init__()
|
||||
self.num = num
|
||||
self.sem = aio.Semaphore(4)
|
||||
|
||||
async def task_piece(self, cli: AsyncClient):
|
||||
async with self.sem:
|
||||
with warning_suppress("Failed to fetch image"):
|
||||
x = resp_to_bg_data(
|
||||
(
|
||||
await cli.get("https://www.loliapi.com/acg/pe/")
|
||||
).raise_for_status(),
|
||||
)
|
||||
await self.queue.put(x)
|
||||
|
||||
@override
|
||||
async def run_tasks(self):
|
||||
async with AsyncClient(
|
||||
follow_redirects=True,
|
||||
proxy=config.proxy,
|
||||
timeout=config.ps_req_timeout,
|
||||
) as cli:
|
||||
await aio.gather(*(self.task_piece(cli) for _ in range(self.num)))
|
||||
|
||||
|
||||
class LoliconRespDataUrls(TypedDict):
|
||||
original: str
|
||||
|
||||
|
||||
class LoliconRespData(TypedDict):
|
||||
urls: LoliconRespDataUrls
|
||||
|
||||
|
||||
class LoliconResp(TypedDict):
|
||||
data: list[LoliconRespData]
|
||||
|
||||
|
||||
@bg_provider("lolicon")
|
||||
class LoliconBGProvider(CoIterator[BgData]):
|
||||
def __init__(self, num: int):
|
||||
super().__init__()
|
||||
self.num = num
|
||||
self.sem = aio.Semaphore(4)
|
||||
self.url_queue = aio.Queue[str | None]()
|
||||
|
||||
async def do_fetch_urls_piece(self, num: int, cli: AsyncClient):
|
||||
with warning_suppress("Failed to fetch urls"):
|
||||
resp = await cli.get(
|
||||
"https://api.lolicon.app/setu/v2",
|
||||
params={
|
||||
"num": num,
|
||||
"r18": config.ps_bg_lolicon_r18_type,
|
||||
"proxy": "false",
|
||||
"excludeAI": "true",
|
||||
},
|
||||
)
|
||||
data: LoliconResp = resp.raise_for_status().json()
|
||||
for x in data["data"]:
|
||||
await self.url_queue.put(x["urls"]["original"])
|
||||
|
||||
async def fetch_urls_task_f(self):
|
||||
async with AsyncClient(
|
||||
follow_redirects=True,
|
||||
proxy=config.proxy,
|
||||
timeout=config.ps_req_timeout,
|
||||
) as cli:
|
||||
for x in iter_batch_sizes(self.num, 20):
|
||||
await self.do_fetch_urls_piece(x, cli)
|
||||
await self.url_queue.put(None)
|
||||
|
||||
async def fetch_image(self, url: str, cli: AsyncClient):
|
||||
async with self.sem:
|
||||
with warning_suppress("Failed to fetch image"):
|
||||
bg = resp_to_bg_data((await cli.get(url)).raise_for_status())
|
||||
await self.queue.put(bg)
|
||||
|
||||
@override
|
||||
async def run_tasks(self):
|
||||
pixiv_client = AsyncClient(
|
||||
follow_redirects=True,
|
||||
proxy=config.proxy,
|
||||
timeout=config.ps_req_timeout,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/119.0.0.0 "
|
||||
"Safari/537.36"
|
||||
),
|
||||
"Referer": "https://www.pixiv.net/",
|
||||
},
|
||||
)
|
||||
async with TaskGroup() as t, pixiv_client:
|
||||
t.create_task(self.fetch_urls_task_f())
|
||||
while (x := await self.url_queue.get()) is not None:
|
||||
t.create_task(self.fetch_image(x, pixiv_client))
|
||||
|
||||
|
||||
@bg_provider()
|
||||
async def local(num: int):
|
||||
files = random.sample(BG_FILES, num)
|
||||
# logger.debug(f"Chosen background `{files}`")
|
||||
for x in files:
|
||||
yield BgFileData(
|
||||
x,
|
||||
mimetypes.guess_type(x)[0] or DEFAULT_MIME,
|
||||
)
|
||||
|
||||
|
||||
def create_none_bg():
|
||||
return BgBytesData(None, DEFAULT_MIME)
|
||||
|
||||
|
||||
@bg_provider()
|
||||
async def none(num: int):
|
||||
for _ in range(num):
|
||||
yield create_none_bg()
|
||||
|
||||
|
||||
async def fetch_bg(num: int) -> AsyncIterable[BgData]:
|
||||
if config.ps_bg_provider not in registered_bg_providers:
|
||||
logger.warning(
|
||||
f"Unknown background provider `{config.ps_bg_provider}`, fallback to local",
|
||||
)
|
||||
async for x in local(num):
|
||||
yield x
|
||||
return
|
||||
|
||||
# at least we should return one image (x)
|
||||
# has_img = False
|
||||
try:
|
||||
provider = registered_bg_providers[config.ps_bg_provider]
|
||||
async for x in provider(num):
|
||||
# has_img = True
|
||||
yield x
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error when getting background, fallback to get one local bg",
|
||||
)
|
||||
async for x in local(1):
|
||||
yield x
|
||||
# else:
|
||||
# if has_img:
|
||||
# return
|
||||
# logger.warning(
|
||||
# "Background provider returned empty iterator, fallback to get one local bg",
|
||||
# )
|
||||
# async for x in local(1):
|
||||
# yield x
|
||||
|
||||
|
||||
def cache_bg(bg: BgBytesData):
|
||||
if not bg.data:
|
||||
return BgFileData(None, bg.mime)
|
||||
BG_PRELOAD_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = BG_PRELOAD_CACHE_DIR / f"{time.time_ns()}.{bg.mime.split('/')[-1]}"
|
||||
path.write_bytes(bg.data)
|
||||
return BgFileData(path, bg.mime)
|
||||
|
||||
|
||||
def read_cached_bg_file(bg: BgFileData) -> BgBytesData | None:
|
||||
if not bg.path:
|
||||
return BgBytesData(None, bg.mime)
|
||||
with warning_suppress("Failed to read cached file"):
|
||||
data = bg.path.read_bytes()
|
||||
if bg.path.is_relative_to(BG_PRELOAD_CACHE_DIR):
|
||||
with warning_suppress("Failed to unlink cached file"):
|
||||
bg.path.unlink()
|
||||
return BgBytesData(data, bg.mime)
|
||||
return None
|
||||
|
||||
|
||||
async def get_one_fallback() -> BgBytesData:
|
||||
with warning_suppress("Failed to get local bg file, fallback to none"):
|
||||
async for x in local(1):
|
||||
if bg := read_cached_bg_file(x):
|
||||
return bg
|
||||
logger.warning("Failed to read local bg file, fallback to none")
|
||||
return create_none_bg()
|
||||
|
||||
|
||||
class BgPreloader:
|
||||
def __init__(self, preload_count: int):
|
||||
# if preload_count < 1:
|
||||
# raise ValueError("preload_count must be greater than or equals 1")
|
||||
self.preload_count = preload_count
|
||||
self.background_queue = aio.Queue[BgData]()
|
||||
self.current_load_task_main: aio.Task | None = None
|
||||
self.consumed_in_loading: bool = False
|
||||
self.image_got_signal = aio.Event()
|
||||
self.fire_tasks: set[aio.Task] = set()
|
||||
|
||||
# we allow fetch_bg return less image than we require
|
||||
async def preload_task(
|
||||
self,
|
||||
count: int,
|
||||
fire: bool = False,
|
||||
fire_done_signal: aio.Event | None = None,
|
||||
):
|
||||
logger.debug(f"Preload task started, will preload {count} images, {fire=}")
|
||||
try:
|
||||
async for x in fetch_bg(count):
|
||||
logger.debug("Got one image")
|
||||
if self.preload_count > 0 or (
|
||||
fire_done_signal and fire_done_signal.is_set()
|
||||
):
|
||||
x = cache_bg(x) if isinstance(x, BgBytesData) else x
|
||||
await self.background_queue.put(x)
|
||||
self.image_got_signal.set()
|
||||
self.image_got_signal.clear()
|
||||
except Exception:
|
||||
logger.exception("Unexpected error occurred in preload task")
|
||||
else:
|
||||
logger.debug("Preload task finished")
|
||||
|
||||
if fire:
|
||||
return
|
||||
if (
|
||||
self.consumed_in_loading
|
||||
or self.background_queue.qsize() < self.preload_count
|
||||
):
|
||||
self.consumed_in_loading = False
|
||||
self.start_preload()
|
||||
else:
|
||||
self.current_load_task_main = None
|
||||
|
||||
def start_preload(self, force: bool = False):
|
||||
count = self.preload_count - self.background_queue.qsize()
|
||||
if count <= 0 and not force:
|
||||
logger.debug(
|
||||
"Current background queue size meets preload count, skip preload",
|
||||
)
|
||||
return
|
||||
task = aio.create_task(self.preload_task(count))
|
||||
self.current_load_task_main = task
|
||||
|
||||
def set_defer_preload(self):
|
||||
if self.current_load_task_main:
|
||||
logger.debug("Main preload task already running, set flag")
|
||||
self.consumed_in_loading = True
|
||||
else:
|
||||
self.start_preload()
|
||||
|
||||
async def _get_on_fire(self) -> BgBytesData:
|
||||
task_done_signal = aio.Event()
|
||||
fire_task = aio.create_task(
|
||||
self.preload_task(1, fire=True, fire_done_signal=task_done_signal),
|
||||
)
|
||||
fire_task.add_done_callback(lambda _: task_done_signal.set())
|
||||
fire_task.add_done_callback(lambda _: self.fire_tasks.discard(fire_task))
|
||||
self.fire_tasks.add(fire_task)
|
||||
try:
|
||||
await race(
|
||||
# self.image_got_signal.wait(), # lazy to handle this racing condition now
|
||||
task_done_signal.wait(),
|
||||
aio.sleep(15),
|
||||
)
|
||||
finally:
|
||||
task_done_signal.set()
|
||||
# fire_task.cancel() # should we cancel here? i'm letting it cache to queue
|
||||
|
||||
if not self.background_queue.empty():
|
||||
bg = await self.background_queue.get()
|
||||
self.set_defer_preload()
|
||||
if (not isinstance(bg, BgFileData)) or (bg := read_cached_bg_file(bg)):
|
||||
return bg
|
||||
|
||||
logger.error("Unable to get an background image, falling back to local")
|
||||
return await get_one_fallback()
|
||||
|
||||
async def get(self) -> BgBytesData:
|
||||
self.set_defer_preload()
|
||||
|
||||
while not self.background_queue.empty():
|
||||
bg = await self.background_queue.get()
|
||||
self.set_defer_preload()
|
||||
if (not isinstance(bg, BgFileData)) or (bg := read_cached_bg_file(bg)):
|
||||
return bg
|
||||
|
||||
# normally all items in queue should be valid
|
||||
# if they not, we should fetch
|
||||
return await self._get_on_fire()
|
||||
|
||||
|
||||
bg_preloader = BgPreloader(config.ps_bg_preload_count)
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
|
||||
@driver.on_shutdown
|
||||
async def _():
|
||||
for t in bg_preloader.fire_tasks:
|
||||
t.cancel()
|
||||
@@ -0,0 +1,226 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, TypeVar
|
||||
from typing_extensions import override
|
||||
|
||||
from nonebot import logger
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
|
||||
from ..config import config
|
||||
|
||||
T = TypeVar("T")
|
||||
TI = TypeVar("TI")
|
||||
TR = TypeVar("TR")
|
||||
TC = TypeVar("TC", bound="Collector")
|
||||
TCF = TypeVar("TCF", bound=Callable[[], Awaitable[Any]])
|
||||
R = TypeVar("R")
|
||||
|
||||
Undefined = type("Undefined", (), {})
|
||||
|
||||
|
||||
class SkipCollectError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Collector(Generic[TI, TR]):
|
||||
@abstractmethod
|
||||
async def _get(self) -> TI: ...
|
||||
|
||||
@abstractmethod
|
||||
async def get(self) -> TR: ...
|
||||
|
||||
|
||||
class BaseNormalCollector(Collector[T, T], Generic[T]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
@override
|
||||
async def get(self) -> T:
|
||||
return await self._get()
|
||||
|
||||
|
||||
class BaseFirstTimeCollector(Collector[T, T], Generic[T]):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._cached: T | Undefined = Undefined()
|
||||
|
||||
@override
|
||||
async def get(self) -> T:
|
||||
if not isinstance(self._cached, Undefined):
|
||||
return self._cached
|
||||
data = await self._get()
|
||||
self._cached = data
|
||||
return data
|
||||
|
||||
|
||||
class BasePeriodicCollector(Collector[T, deque[T]], Generic[T]):
|
||||
def __init__(self, size: int = config.ps_default_collect_cache_size) -> None:
|
||||
super().__init__()
|
||||
self.data = deque(maxlen=size)
|
||||
|
||||
@override
|
||||
async def get(self) -> deque[T]:
|
||||
return self.data
|
||||
|
||||
async def collect(self):
|
||||
try:
|
||||
data = await self._get()
|
||||
except SkipCollectError:
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Error occurred while collecting data")
|
||||
else:
|
||||
self.data.append(data)
|
||||
|
||||
|
||||
registered_collectors: dict[str, type[Collector]] = {}
|
||||
enabled_collectors: dict[str, Collector] = {}
|
||||
|
||||
|
||||
def collector(name: str):
|
||||
def deco(cls: type[TC]) -> type[TC]:
|
||||
if name in registered_collectors:
|
||||
raise ValueError(f"Collector {name} already exists")
|
||||
registered_collectors[name] = cls
|
||||
logger.debug(f"Registered collector {name}")
|
||||
return cls
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def _enable_collector(name: str):
|
||||
if name not in registered_collectors:
|
||||
raise ValueError(f"Collector {name} not found")
|
||||
cls = registered_collectors[name]
|
||||
if issubclass(cls, BasePeriodicCollector) and name in config.ps_collect_cache_size:
|
||||
instance = cls(size=config.ps_collect_cache_size[name])
|
||||
else:
|
||||
instance = cls()
|
||||
enabled_collectors[name] = instance
|
||||
|
||||
|
||||
async def enable_collectors(*names: str):
|
||||
for name in names:
|
||||
_enable_collector(name)
|
||||
await init_first_time_collectors()
|
||||
await setup_periodic_collectors_update_job()
|
||||
|
||||
|
||||
def functional_collector(cls: type[Collector], name: str | None = None):
|
||||
def deco(func: TCF) -> TCF:
|
||||
collector_name = name or func.__name__
|
||||
if not collector_name:
|
||||
raise ValueError("name must be provided")
|
||||
|
||||
class Collector(cls):
|
||||
async def _get(self) -> Any:
|
||||
return await func()
|
||||
|
||||
collector(collector_name)(Collector)
|
||||
return func
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def normal_collector(name: str | None = None):
|
||||
return functional_collector(BaseNormalCollector, name)
|
||||
|
||||
|
||||
def first_time_collector(name: str | None = None):
|
||||
return functional_collector(BaseFirstTimeCollector, name)
|
||||
|
||||
|
||||
def periodic_collector(name: str | None = None):
|
||||
return functional_collector(BasePeriodicCollector, name)
|
||||
|
||||
|
||||
class BaseTimeBasedCounterCollector(Collector[R, Any], Generic[T, R]):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.last_obj: Undefined | T = Undefined()
|
||||
self.last_time: float = 0
|
||||
self.normal_delay: float = 1
|
||||
|
||||
@abstractmethod
|
||||
async def _calc(self, past: T, now: T, time_passed: float) -> R: ...
|
||||
|
||||
@abstractmethod
|
||||
async def _get_obj(self) -> T: ...
|
||||
|
||||
@override
|
||||
async def _get(self) -> R:
|
||||
past = self.last_obj
|
||||
past_time = self.last_time
|
||||
time_now = time.time()
|
||||
time_passed = time_now - past_time
|
||||
|
||||
self.last_time = time_now
|
||||
self.last_obj = await self._get_obj()
|
||||
if isinstance(past, Undefined):
|
||||
raise SkipCollectError
|
||||
return await self._calc(past, self.last_obj, time_passed)
|
||||
|
||||
|
||||
class NormalTimeBasedCounterCollector(
|
||||
BaseTimeBasedCounterCollector[T, R],
|
||||
BaseNormalCollector[R],
|
||||
Generic[T, R],
|
||||
):
|
||||
@override
|
||||
async def get(self) -> R:
|
||||
with suppress(SkipCollectError):
|
||||
await self._get()
|
||||
await asyncio.sleep(self.normal_delay)
|
||||
return await self._get()
|
||||
|
||||
|
||||
class PeriodicTimeBasedCounterCollector(
|
||||
BaseTimeBasedCounterCollector[T, R],
|
||||
BasePeriodicCollector[R],
|
||||
Generic[T, R],
|
||||
): ...
|
||||
|
||||
|
||||
async def collect_all() -> dict[str, Any]:
|
||||
async def get(name: str):
|
||||
return name, await enabled_collectors[name].get()
|
||||
|
||||
res = await asyncio.gather(*(get(name) for name in enabled_collectors))
|
||||
return dict(res)
|
||||
|
||||
|
||||
def load_builtin_collectors():
|
||||
for module in Path(__file__).parent.iterdir():
|
||||
if not module.name.startswith("_"):
|
||||
importlib.import_module(f".{module.stem}", __package__)
|
||||
|
||||
|
||||
async def init_first_time_collectors():
|
||||
await asyncio.gather(
|
||||
*(
|
||||
x.get()
|
||||
for x in enabled_collectors.values()
|
||||
if isinstance(x, BaseFirstTimeCollector)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def setup_periodic_collectors_update_job():
|
||||
collectors = [
|
||||
x for x in enabled_collectors.values() if isinstance(x, BasePeriodicCollector)
|
||||
]
|
||||
if not collectors:
|
||||
return
|
||||
logger.debug("Setting up periodic collectors")
|
||||
|
||||
@scheduler.scheduled_job("interval", seconds=config.ps_collect_interval)
|
||||
async def _do():
|
||||
await asyncio.gather(*(x.collect() for x in collectors))
|
||||
|
||||
await _do()
|
||||
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from nonebot import get_bots, logger
|
||||
from nonebot.matcher import current_bot
|
||||
|
||||
from ..config import config
|
||||
from ..misc_statistics import bot_connect_time, bot_info_cache, recv_num, send_num
|
||||
from ..util import format_timedelta
|
||||
from . import normal_collector
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nonebot.adapters import Bot as BaseBot
|
||||
|
||||
try:
|
||||
from nonebot.adapters.onebot.v11 import Bot as OBV11Bot
|
||||
except ImportError:
|
||||
OBV11Bot = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotStatus:
|
||||
self_id: str
|
||||
adapter: str
|
||||
nick: str
|
||||
bot_connected: str
|
||||
msg_rec: str
|
||||
msg_sent: str
|
||||
|
||||
|
||||
async def get_ob11_msg_num(bot: "BaseBot") -> tuple[int | None, int | None]:
|
||||
if not (config.ps_ob_v11_use_get_status and OBV11Bot and isinstance(bot, OBV11Bot)):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
bot_stat = (await bot.get_status()).get("stat")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error when getting bot status: {e.__class__.__name__}: {e}",
|
||||
)
|
||||
return None, None
|
||||
if not bot_stat:
|
||||
return None, None
|
||||
|
||||
msg_rec = bot_stat.get("message_received") or bot_stat.get(
|
||||
"MessageReceived",
|
||||
)
|
||||
msg_sent = bot_stat.get("message_sent") or bot_stat.get("MessageSent")
|
||||
return msg_rec, msg_sent
|
||||
|
||||
|
||||
async def get_bot_status(bot: "BaseBot", now_time: datetime) -> BotStatus:
|
||||
nick = (
|
||||
((info := bot_info_cache[bot.self_id]).nick or info.name or info.id)
|
||||
if (not config.ps_use_env_nick) and (bot.self_id in bot_info_cache)
|
||||
else next(iter(config.nickname), None)
|
||||
) or "Bot"
|
||||
bot_connected = (
|
||||
format_timedelta(now_time - t)
|
||||
if (t := bot_connect_time.get(bot.self_id))
|
||||
else "未知"
|
||||
)
|
||||
|
||||
msg_rec, msg_sent = await get_ob11_msg_num(bot)
|
||||
if msg_rec is None:
|
||||
msg_rec = recv_num.get(bot.self_id)
|
||||
if msg_sent is None:
|
||||
msg_sent = send_num.get(bot.self_id)
|
||||
msg_rec = "未知" if (msg_rec is None) else str(msg_rec)
|
||||
msg_sent = "未知" if (msg_sent is None) else str(msg_sent)
|
||||
|
||||
return BotStatus(
|
||||
self_id=bot.self_id,
|
||||
adapter=bot.adapter.get_name(),
|
||||
nick=nick,
|
||||
bot_connected=bot_connected,
|
||||
msg_rec=msg_rec,
|
||||
msg_sent=msg_sent,
|
||||
)
|
||||
|
||||
|
||||
@normal_collector()
|
||||
async def bots() -> list[BotStatus]:
|
||||
now_time = datetime.now().astimezone()
|
||||
return (
|
||||
[await get_bot_status(current_bot.get(), now_time)]
|
||||
if config.ps_show_current_bot_only
|
||||
else await asyncio.gather(
|
||||
*(get_bot_status(bot, now_time) for bot in get_bots().values()),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
import psutil
|
||||
from cpuinfo import get_cpu_info
|
||||
from nonebot import logger
|
||||
|
||||
from . import first_time_collector, normal_collector, periodic_collector
|
||||
|
||||
|
||||
@dataclass
|
||||
class CpuFreq:
|
||||
current: float | None
|
||||
min: float | None # noqa: A003
|
||||
max: float | None # noqa: A003
|
||||
|
||||
|
||||
@first_time_collector()
|
||||
async def cpu_brand() -> str:
|
||||
try:
|
||||
brand = (
|
||||
cast("str", get_cpu_info().get("brand_raw", ""))
|
||||
.split("@", maxsplit=1)[0]
|
||||
.strip()
|
||||
)
|
||||
if brand.lower().endswith(("cpu", "processor")):
|
||||
brand = brand.rsplit(maxsplit=1)[0].strip()
|
||||
except Exception:
|
||||
logger.exception("Error when getting CPU brand")
|
||||
return "未知型号"
|
||||
else:
|
||||
return brand
|
||||
|
||||
|
||||
@first_time_collector()
|
||||
async def cpu_count_logical() -> int | None:
|
||||
return psutil.cpu_count()
|
||||
|
||||
|
||||
@first_time_collector()
|
||||
async def cpu_count() -> int | None:
|
||||
return psutil.cpu_count(logical=False)
|
||||
|
||||
|
||||
async def get_cpu_percent() -> float:
|
||||
"""实时采样 0.5s,避免 psutil 首次调用返回 0.0"""
|
||||
return await asyncio.to_thread(psutil.cpu_percent, 0.5)
|
||||
|
||||
|
||||
async def get_cpu_percent_periodic() -> float:
|
||||
"""周期采样:两次调用之间的间隔即为采样区间,直接取瞬时差值即可"""
|
||||
return psutil.cpu_percent()
|
||||
|
||||
|
||||
normal_collector("cpu_percent")(get_cpu_percent)
|
||||
periodic_collector("cpu_percent_periodic")(get_cpu_percent_periodic)
|
||||
|
||||
|
||||
async def get_cpu_freq() -> CpuFreq:
|
||||
cpu_freq = psutil.cpu_freq()
|
||||
return CpuFreq(
|
||||
current=getattr(cpu_freq, "current", None),
|
||||
min=getattr(cpu_freq, "min", None),
|
||||
max=getattr(cpu_freq, "max", None),
|
||||
)
|
||||
|
||||
|
||||
normal_collector("cpu_freq")(get_cpu_freq)
|
||||
periodic_collector("cpu_freq_periodic")(get_cpu_freq)
|
||||
@@ -0,0 +1,127 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
import psutil
|
||||
from psutil._common import sdiskpart, sdiskio
|
||||
|
||||
from ..config import config
|
||||
from ..util import match_list_regexp
|
||||
from . import (
|
||||
BaseTimeBasedCounterCollector,
|
||||
NormalTimeBasedCounterCollector,
|
||||
PeriodicTimeBasedCounterCollector,
|
||||
collector,
|
||||
normal_collector,
|
||||
periodic_collector,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskUsageNormal:
|
||||
name: str
|
||||
percent: float
|
||||
used: int
|
||||
total: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskUsageWithExc:
|
||||
name: str
|
||||
exception: str
|
||||
|
||||
|
||||
DiskUsageType: TypeAlias = DiskUsageNormal | DiskUsageWithExc
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskIO:
|
||||
name: str
|
||||
read: float
|
||||
write: float
|
||||
|
||||
|
||||
@periodic_collector()
|
||||
async def get_disk_usage() -> list[DiskUsageType]:
|
||||
def get_one(disk: sdiskpart) -> DiskUsageType | None:
|
||||
mountpoint = disk.mountpoint
|
||||
|
||||
if match_list_regexp(config.ps_ignore_parts, mountpoint):
|
||||
# logger.info(f"空间读取 分区 {mountpoint} 匹配 {regex.re.pattern},忽略")
|
||||
return None
|
||||
|
||||
try:
|
||||
usage = psutil.disk_usage(mountpoint)
|
||||
except Exception as e:
|
||||
# logger.exception(f"读取 {mountpoint} 占用失败")
|
||||
return (
|
||||
None
|
||||
if config.ps_ignore_bad_parts
|
||||
else DiskUsageWithExc(name=mountpoint, exception=str(e))
|
||||
)
|
||||
|
||||
return DiskUsageNormal(
|
||||
name=mountpoint,
|
||||
percent=usage.percent,
|
||||
used=usage.used,
|
||||
total=usage.total,
|
||||
)
|
||||
|
||||
usage = [x for x in map(get_one, psutil.disk_partitions()) if x]
|
||||
if config.ps_sort_parts:
|
||||
usage.sort(
|
||||
key=lambda x: x.percent if isinstance(x, DiskUsageNormal) else -1,
|
||||
reverse=not config.ps_sort_parts_reverse,
|
||||
)
|
||||
|
||||
return usage
|
||||
|
||||
|
||||
normal_collector("disk_usage")(get_disk_usage)
|
||||
periodic_collector("disk_usage_periodic")(get_disk_usage)
|
||||
|
||||
|
||||
class BaseDiskIOCollector(
|
||||
BaseTimeBasedCounterCollector[dict[str, sdiskio], list[DiskIO]],
|
||||
):
|
||||
async def _calc(
|
||||
self,
|
||||
past: dict[str, sdiskio],
|
||||
now: dict[str, sdiskio],
|
||||
time_passed: float,
|
||||
) -> list[DiskIO]:
|
||||
def calc_one(name: str, past_it: sdiskio, now_it: sdiskio) -> DiskIO | None:
|
||||
if match_list_regexp(config.ps_ignore_disk_ios, name):
|
||||
# logger.info(f"IO统计 磁盘 {name} 匹配 {regex.re.pattern},忽略")
|
||||
return None
|
||||
|
||||
read = (now_it.read_bytes - past_it.read_bytes) / time_passed
|
||||
write = (now_it.write_bytes - past_it.write_bytes) / time_passed
|
||||
|
||||
if read == 0 and write == 0 and config.ps_ignore_no_io_disk:
|
||||
# logger.info(f"IO统计 忽略无IO磁盘 {name}")
|
||||
return None
|
||||
|
||||
return DiskIO(name=name, read=read, write=write)
|
||||
|
||||
res = [calc_one(name, past[name], now[name]) for name in past if name in now]
|
||||
res = [x for x in res if x]
|
||||
if config.ps_sort_disk_ios:
|
||||
res.sort(key=lambda x: x.read + x.write, reverse=True)
|
||||
return res
|
||||
|
||||
async def _get_obj(self) -> dict[str, sdiskio]:
|
||||
return psutil.disk_io_counters(perdisk=True)
|
||||
|
||||
|
||||
@collector("disk_io")
|
||||
class NormalDiskIOCollector(
|
||||
BaseDiskIOCollector,
|
||||
NormalTimeBasedCounterCollector[dict[str, sdiskio], list[DiskIO]],
|
||||
): ...
|
||||
|
||||
|
||||
@collector("disk_io_periodic")
|
||||
class PeriodicDiskIOCollector(
|
||||
BaseDiskIOCollector,
|
||||
PeriodicTimeBasedCounterCollector[dict[str, sdiskio], list[DiskIO]],
|
||||
): ...
|
||||
@@ -0,0 +1,30 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import psutil
|
||||
|
||||
from . import normal_collector, periodic_collector
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryStat:
|
||||
percent: float
|
||||
used: int
|
||||
total: int
|
||||
|
||||
|
||||
async def get_memory_stat() -> MemoryStat:
|
||||
mem = psutil.virtual_memory()
|
||||
return MemoryStat(percent=mem.percent, used=mem.used, total=mem.total)
|
||||
|
||||
|
||||
normal_collector("memory_stat")(get_memory_stat)
|
||||
periodic_collector("memory_stat_periodic")(get_memory_stat)
|
||||
|
||||
|
||||
async def get_swap_stat() -> MemoryStat:
|
||||
swap = psutil.swap_memory()
|
||||
return MemoryStat(percent=swap.percent, used=swap.used, total=swap.total)
|
||||
|
||||
|
||||
normal_collector("swap_stat")(get_swap_stat)
|
||||
periodic_collector("swap_stat_periodic")(get_swap_stat)
|
||||
@@ -0,0 +1,130 @@
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import nonebot
|
||||
import psutil
|
||||
|
||||
from ..misc_statistics import nonebot_run_time
|
||||
from ..util import format_time_delta_ps
|
||||
from . import first_time_collector, normal_collector, periodic_collector
|
||||
|
||||
|
||||
def parse_env(env: str) -> dict[str, str | None]:
|
||||
env_lines = env.strip().splitlines()
|
||||
env_dict: dict[str, str | None] = {}
|
||||
|
||||
for line in env_lines:
|
||||
if "=" not in line:
|
||||
env_dict[line.upper()] = None
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
env_dict[key.upper()] = value.strip("\"'").strip()
|
||||
|
||||
return env_dict
|
||||
|
||||
|
||||
def parse_env_file(env_file: str | Path) -> dict[str, str | None] | None:
|
||||
if not isinstance(env_file, Path):
|
||||
env_file = Path(env_file)
|
||||
if not env_file.exists():
|
||||
return None
|
||||
content = env_file.read_text(encoding="u8")
|
||||
return parse_env(content)
|
||||
|
||||
|
||||
# Thanks to https://github.com/nonedesktop/nonebot-plugin-guestool/blob/main/nonebot_plugin_guestool/info.py
|
||||
def get_linux_name_version() -> tuple[str, str] | None:
|
||||
env = parse_env_file("/etc/os-release")
|
||||
if env and (name := env.get("NAME")) and (version_id := env.get("VERSION_ID")):
|
||||
return name, version_id
|
||||
|
||||
env = parse_env_file("/etc/lsb-release")
|
||||
if (
|
||||
env
|
||||
and (name := env.get("DISTRIB_ID"))
|
||||
and (version_id := env.get("DISTRIB_RELEASE"))
|
||||
):
|
||||
return name, version_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@normal_collector("nonebot_run_time")
|
||||
async def nonebot_run_time_str() -> str:
|
||||
now_time = datetime.now().astimezone()
|
||||
return (
|
||||
format_time_delta_ps(now_time - nonebot_run_time)
|
||||
if nonebot_run_time
|
||||
else "未知"
|
||||
)
|
||||
|
||||
|
||||
@normal_collector()
|
||||
async def system_run_time() -> str:
|
||||
now_time = datetime.now().astimezone()
|
||||
return format_time_delta_ps(
|
||||
now_time - datetime.fromtimestamp(psutil.boot_time()).astimezone(),
|
||||
)
|
||||
|
||||
|
||||
@first_time_collector()
|
||||
async def nonebot_version() -> str:
|
||||
return nonebot.__version__
|
||||
|
||||
|
||||
@first_time_collector()
|
||||
async def ps_version() -> str:
|
||||
from .. import __version__
|
||||
|
||||
return __version__
|
||||
|
||||
|
||||
async def get_time_str() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
normal_collector("time")(get_time_str)
|
||||
periodic_collector("time_periodic")(get_time_str)
|
||||
|
||||
|
||||
@first_time_collector()
|
||||
async def python_version() -> str:
|
||||
return f"{platform.python_implementation()} {platform.python_version()}"
|
||||
|
||||
|
||||
@first_time_collector()
|
||||
async def system_name():
|
||||
system, _, release, version, machine, _ = platform.uname()
|
||||
system, release, version = platform.system_alias(system, release, version)
|
||||
|
||||
if system == "Java":
|
||||
_, _, _, (system, release, machine) = platform.java_ver()
|
||||
|
||||
if system == "Darwin":
|
||||
return f"MacOS {platform.mac_ver()[0]} {machine}"
|
||||
|
||||
if system == "Windows":
|
||||
return f"Windows {release} {platform.win32_edition()} {machine}"
|
||||
|
||||
if system == "Linux":
|
||||
if (pfx := os.getenv("PREFIX")) and "termux" in pfx:
|
||||
system = f"Termux (Android) {release}" # a strange platform
|
||||
|
||||
elif os.getenv("ANDROID_ROOT") == "/system":
|
||||
system = f"Linux (Android) {release}"
|
||||
|
||||
elif ver := get_linux_name_version():
|
||||
name, version_id = ver
|
||||
version = release if version_id.lower() == "rolling" else version_id
|
||||
system = f"{name} {version}"
|
||||
|
||||
else:
|
||||
system = f"未知 Linux {release}"
|
||||
|
||||
return f"{system} {machine}"
|
||||
|
||||
return f"{system} {release}"
|
||||
@@ -0,0 +1,124 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
import psutil
|
||||
from httpx import AsyncClient, ReadTimeout
|
||||
from psutil._common import snetio
|
||||
|
||||
from ..config import TestSiteCfg, config
|
||||
from ..util import match_list_regexp
|
||||
from . import (
|
||||
BaseTimeBasedCounterCollector,
|
||||
NormalTimeBasedCounterCollector,
|
||||
PeriodicTimeBasedCounterCollector,
|
||||
collector,
|
||||
normal_collector,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkIO:
|
||||
name: str
|
||||
sent: float
|
||||
recv: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkConnectionOK:
|
||||
name: str
|
||||
status: int
|
||||
reason: str
|
||||
delay: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkConnectionError:
|
||||
name: str
|
||||
error: str
|
||||
|
||||
|
||||
NetworkConnectionType: TypeAlias = NetworkConnectionOK | NetworkConnectionError
|
||||
|
||||
|
||||
class BaseNetworkIOCollector(
|
||||
BaseTimeBasedCounterCollector[dict[str, snetio], list[NetworkIO]],
|
||||
):
|
||||
async def _calc(
|
||||
self,
|
||||
past: dict[str, snetio],
|
||||
now: dict[str, snetio],
|
||||
time_passed: float,
|
||||
) -> list[NetworkIO]:
|
||||
def calc_one(name: str, past_it: snetio, now_it: snetio) -> NetworkIO | None:
|
||||
if match_list_regexp(config.ps_ignore_nets, name):
|
||||
# logger.info(f"网卡IO统计 {name} 匹配 {regex.re.pattern},忽略")
|
||||
return None
|
||||
|
||||
sent = (now_it.bytes_sent - past_it.bytes_sent) / time_passed
|
||||
recv = (now_it.bytes_recv - past_it.bytes_recv) / time_passed
|
||||
|
||||
if sent == 0 and recv == 0 and config.ps_ignore_0b_net:
|
||||
# logger.info(f"网卡IO统计 忽略无IO网卡 {name}")
|
||||
return None
|
||||
|
||||
return NetworkIO(name=name, sent=sent, recv=recv)
|
||||
|
||||
res = [calc_one(name, past[name], now[name]) for name in past if name in now]
|
||||
res = [x for x in res if x]
|
||||
if config.ps_sort_nets:
|
||||
res.sort(key=lambda x: x.sent + x.recv, reverse=True)
|
||||
return res
|
||||
|
||||
async def _get_obj(self) -> dict[str, snetio]:
|
||||
return psutil.net_io_counters(pernic=True)
|
||||
|
||||
|
||||
@collector("network_io")
|
||||
class NormalDiskIOCollector(
|
||||
BaseNetworkIOCollector,
|
||||
NormalTimeBasedCounterCollector[dict[str, snetio], list[NetworkIO]],
|
||||
): ...
|
||||
|
||||
|
||||
@collector("network_io_periodic")
|
||||
class PeriodicDiskIOCollector(
|
||||
BaseNetworkIOCollector,
|
||||
PeriodicTimeBasedCounterCollector[dict[str, snetio], list[NetworkIO]],
|
||||
): ...
|
||||
|
||||
|
||||
@normal_collector()
|
||||
async def network_connection() -> list[NetworkConnectionType]:
|
||||
def format_conn_error(error: Exception) -> str:
|
||||
if isinstance(error, ReadTimeout):
|
||||
return "超时"
|
||||
return error.__class__.__name__
|
||||
|
||||
async def test_one(site: TestSiteCfg) -> NetworkConnectionType:
|
||||
try:
|
||||
async with AsyncClient(
|
||||
timeout=config.ps_test_timeout,
|
||||
proxy=config.proxy if site.use_proxy else None,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
start = time.time()
|
||||
resp = await client.get(str(site.url))
|
||||
delay = (time.time() - start) * 1000
|
||||
|
||||
except Exception as e:
|
||||
return NetworkConnectionError(name=site.name, error=format_conn_error(e))
|
||||
|
||||
return NetworkConnectionOK(
|
||||
name=site.name,
|
||||
status=resp.status_code,
|
||||
reason=resp.reason_phrase,
|
||||
delay=delay,
|
||||
)
|
||||
|
||||
res = await asyncio.gather(*map(test_one, config.ps_test_sites))
|
||||
if config.ps_sort_sites:
|
||||
res.sort(key=lambda x: x.delay if isinstance(x, NetworkConnectionOK) else -1)
|
||||
|
||||
return res
|
||||
@@ -0,0 +1,61 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
import psutil
|
||||
|
||||
from ..config import config
|
||||
from ..util import match_list_regexp
|
||||
from . import normal_collector, periodic_collector
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessStatus:
|
||||
name: str
|
||||
cpu: float
|
||||
mem: int
|
||||
|
||||
|
||||
async def get_process_status() -> list[ProcessStatus]:
|
||||
if not config.ps_proc_len:
|
||||
return []
|
||||
|
||||
async def parse_one(proc: psutil.Process) -> ProcessStatus | None:
|
||||
name = proc.name()
|
||||
if match_list_regexp(config.ps_ignore_procs, name):
|
||||
# logger.info(f"进程 {name} 匹配 {regex.re.pattern},忽略")
|
||||
return None
|
||||
|
||||
# proc.cpu_percent()
|
||||
# await asyncio.sleep(1)
|
||||
cpu_count = psutil.cpu_count()
|
||||
with proc.oneshot():
|
||||
cpu = proc.cpu_percent()
|
||||
cpu = (
|
||||
(cpu / cpu_count) if config.ps_proc_cpu_max_100p and cpu_count else cpu
|
||||
)
|
||||
mem: int = proc.memory_info().rss
|
||||
|
||||
return ProcessStatus(name=name, cpu=cpu, mem=mem)
|
||||
|
||||
def sorter(x: ProcessStatus):
|
||||
sort_by = config.ps_proc_sort_by
|
||||
if sort_by == "mem":
|
||||
return x.mem
|
||||
# if sort_by == "cpu":
|
||||
return x.cpu
|
||||
|
||||
proc_list = cast(
|
||||
"list[ProcessStatus | None | Exception]",
|
||||
await asyncio.gather(
|
||||
*(parse_one(proc) for proc in psutil.process_iter()),
|
||||
return_exceptions=True,
|
||||
),
|
||||
)
|
||||
proc_list = [x for x in proc_list if x and (not isinstance(x, Exception))]
|
||||
proc_list.sort(key=sorter, reverse=True)
|
||||
return proc_list[: config.ps_proc_len]
|
||||
|
||||
|
||||
normal_collector("process_status")(get_process_status)
|
||||
periodic_collector("process_status_periodic")(get_process_status)
|
||||
@@ -0,0 +1,118 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from cookit.nonebot.localstore import ensure_localstore_path_config
|
||||
from nonebot import get_plugin_config
|
||||
from nonebot.compat import type_validate_python
|
||||
from nonebot_plugin_localstore import get_plugin_cache_dir
|
||||
from pydantic import AnyHttpUrl, BaseModel, Field
|
||||
|
||||
ensure_localstore_path_config()
|
||||
|
||||
CACHE_DIR = get_plugin_cache_dir()
|
||||
|
||||
BG_PRELOAD_CACHE_DIR = CACHE_DIR / "bg_preload"
|
||||
if BG_PRELOAD_CACHE_DIR.exists():
|
||||
shutil.rmtree(BG_PRELOAD_CACHE_DIR)
|
||||
BG_PRELOAD_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
RES_PATH = Path(__file__).parent / "res"
|
||||
ASSETS_PATH = RES_PATH / "assets"
|
||||
TEMPLATE_PATH = RES_PATH / "templates"
|
||||
DEFAULT_BG_PATH = ASSETS_PATH / "default_bg.webp"
|
||||
DEFAULT_AVATAR_PATH = ASSETS_PATH / "default_avatar.webp"
|
||||
|
||||
ProcSortByType = Literal["cpu", "mem"]
|
||||
|
||||
|
||||
class TestSiteCfg(BaseModel):
|
||||
name: str
|
||||
url: AnyHttpUrl
|
||||
use_proxy: bool = False
|
||||
|
||||
|
||||
class ConfigModel(BaseModel):
|
||||
# region builtin
|
||||
superusers: set[str]
|
||||
nickname: set[str]
|
||||
# endregion
|
||||
|
||||
# region global
|
||||
proxy: str | None = None
|
||||
# endregion
|
||||
|
||||
# region behavior
|
||||
ps_template: str = "default"
|
||||
ps_command: list[str] = ["运行状态", "状态", "zt", "yxzt", "status"]
|
||||
ps_only_su: bool = False
|
||||
ps_need_at: bool = False
|
||||
ps_reply_target: bool = True
|
||||
ps_req_timeout: int | None = 10
|
||||
# endregion
|
||||
|
||||
# region style
|
||||
ps_bg_provider: str = "loli"
|
||||
ps_bg_preload_count: int = 2
|
||||
ps_bg_lolicon_r18_type: Literal[0, 1, 2] = 0
|
||||
ps_bg_local_path: Path = DEFAULT_BG_PATH
|
||||
ps_default_avatar: Path = DEFAULT_AVATAR_PATH
|
||||
# endregion
|
||||
|
||||
# region collectors
|
||||
# region base
|
||||
ps_collect_interval: int = 5
|
||||
ps_default_collect_cache_size: int = 1
|
||||
ps_collect_cache_size: dict[str, int] = Field(default_factory=dict)
|
||||
# endregion
|
||||
|
||||
# region header
|
||||
ps_use_env_nick: bool = False
|
||||
ps_show_current_bot_only: bool = False
|
||||
ps_ob_v11_use_get_status: bool = True
|
||||
ps_count_message_sent_event: bool | set[str] = False
|
||||
ps_disconnect_reset_counter: bool = True
|
||||
# endregion
|
||||
|
||||
# region disk
|
||||
# usage
|
||||
ps_ignore_parts: list[str] = []
|
||||
ps_ignore_bad_parts: bool = False
|
||||
ps_sort_parts: bool = True
|
||||
ps_sort_parts_reverse: bool = False
|
||||
# io
|
||||
ps_ignore_disk_ios: list[str] = [r"^(loop|zram)\d*$"]
|
||||
ps_ignore_no_io_disk: bool = False
|
||||
ps_sort_disk_ios: bool = True
|
||||
# endregion
|
||||
|
||||
# region network
|
||||
# io
|
||||
ps_ignore_nets: list[str] = [r"^lo(op)?\d*$|^(Loopback|本地连接)"]
|
||||
ps_ignore_0b_net: bool = False
|
||||
ps_sort_nets: bool = True
|
||||
# connection_test
|
||||
ps_test_sites: list[TestSiteCfg] = [ # v1 compat #59
|
||||
type_validate_python(
|
||||
TestSiteCfg,
|
||||
{"name": "百度", "url": "https://www.baidu.com/"},
|
||||
),
|
||||
type_validate_python(
|
||||
TestSiteCfg,
|
||||
{"name": "Google", "url": "https://www.google.com/", "use_proxy": True},
|
||||
),
|
||||
]
|
||||
ps_sort_sites: bool = True
|
||||
ps_test_timeout: int = 5
|
||||
# endregion
|
||||
|
||||
# region process
|
||||
ps_proc_len: int = 5
|
||||
ps_ignore_procs: list[str] = ["^System Idle Process$"]
|
||||
ps_proc_sort_by: ProcSortByType = "cpu"
|
||||
ps_proc_cpu_max_100p: bool = False
|
||||
# endregion
|
||||
# endregion components
|
||||
|
||||
|
||||
config: ConfigModel = get_plugin_config(ConfigModel)
|
||||
@@ -0,0 +1,153 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from cookit.loguru import log_exception_warning
|
||||
from nonebot import get_driver, logger
|
||||
from nonebot.adapters import Bot as BaseBot, Event as BaseEvent
|
||||
from nonebot.message import event_preprocessor
|
||||
from nonebot.typing import T_State
|
||||
from nonebot_plugin_alconna import image_fetch
|
||||
from nonebot_plugin_alconna.uniseg import Image
|
||||
from nonebot_plugin_uninfo import User, get_interface
|
||||
|
||||
from .config import config
|
||||
|
||||
nonebot_run_time: datetime = datetime.now().astimezone()
|
||||
bot_connect_time: dict[str, datetime] = {}
|
||||
recv_num: dict[str, int] = {}
|
||||
send_num: dict[str, int] = {}
|
||||
|
||||
bot_info_cache: dict[str, User] = {}
|
||||
bot_avatar_cache: dict[str, bytes | None] = {}
|
||||
|
||||
driver = get_driver()
|
||||
|
||||
SEND_APIS: dict[str, list[str] | Callable[[str], bool]] = {
|
||||
# "BilibiliLive": [], # 狗东西发消息不走 call_api
|
||||
"Console": ["send_msg"],
|
||||
"Ding": ["send"],
|
||||
"Discord": ["create_message"],
|
||||
"Feishu": ["im/v1/messages"],
|
||||
"Kaiheila": ["message_create", "directMessage_create"],
|
||||
"Minecraft": ["send_msg"],
|
||||
"mirai2": ["send_friend_message", "send_group_message", "send_temp_message"],
|
||||
"ntchat": lambda x: x.startswith("send_"),
|
||||
"OneBot V11": ["send_private_msg", "send_group_msg", "send_msg"],
|
||||
"OneBot V12": ["send_message"],
|
||||
"QQ": [
|
||||
"post_dms_messages",
|
||||
"post_messages",
|
||||
"post_c2c_messages",
|
||||
"post_c2c_files",
|
||||
"post_group_messages",
|
||||
"post_group_files",
|
||||
],
|
||||
"RedProtocol": ["send_message", "send_fake_forward"],
|
||||
"Satori": ["message_create"],
|
||||
"Telegram": lambda x: x.startswith("send_"),
|
||||
"大别野": ["send_message"],
|
||||
}
|
||||
|
||||
|
||||
def method_is_send_msg(platform: str, name: str) -> bool:
|
||||
return (platform in SEND_APIS) and (
|
||||
(name in it) if isinstance((it := SEND_APIS[platform]), list) else it(name)
|
||||
)
|
||||
|
||||
|
||||
if config.ps_count_message_sent_event:
|
||||
|
||||
@event_preprocessor
|
||||
async def _(bot: BaseBot, event: BaseEvent):
|
||||
if (
|
||||
config.ps_count_message_sent_event
|
||||
and (
|
||||
(config.ps_count_message_sent_event is True)
|
||||
or bot.adapter.get_name() in config.ps_count_message_sent_event
|
||||
)
|
||||
and (
|
||||
(event.get_type() == "message_sent")
|
||||
or (
|
||||
event.get_type() == "message" and event.get_user_id() == bot.self_id
|
||||
)
|
||||
)
|
||||
):
|
||||
# logger.debug(f"Bot {bot.self_id} sent counter +1")
|
||||
send_num[bot.self_id] += 1
|
||||
|
||||
|
||||
if config.ps_count_message_sent_event is not True:
|
||||
|
||||
@BaseBot.on_called_api
|
||||
async def called_api(
|
||||
bot: BaseBot,
|
||||
exc: Exception | None,
|
||||
api: str,
|
||||
_: dict[str, Any],
|
||||
__: Any,
|
||||
):
|
||||
if (
|
||||
(not exc)
|
||||
and (config.ps_count_message_sent_event is not True)
|
||||
and (
|
||||
(config.ps_count_message_sent_event is False)
|
||||
or (bot.adapter.get_name() not in config.ps_count_message_sent_event)
|
||||
)
|
||||
and method_is_send_msg(bot.adapter.get_name(), api)
|
||||
):
|
||||
# logger.debug(f"Bot {bot.self_id} sent counter +1")
|
||||
send_num[bot.self_id] += 1
|
||||
|
||||
|
||||
async def cache_bot_avatar(avatar: str, bot: BaseBot, event: BaseEvent, state: T_State):
|
||||
try:
|
||||
img = await image_fetch(event, bot, state, Image(url=avatar))
|
||||
except Exception as e:
|
||||
log_exception_warning(e, f"Failed to get avatar of bot {bot.self_id}")
|
||||
return None
|
||||
else:
|
||||
if not img:
|
||||
logger.warning(
|
||||
f"Cannot get avatar of bot {bot.self_id}"
|
||||
f" because image_fetch returned None",
|
||||
)
|
||||
bot_avatar_cache[bot.self_id] = img
|
||||
return img
|
||||
|
||||
|
||||
async def cache_bot_info(bot: BaseBot):
|
||||
try:
|
||||
it = get_interface(bot)
|
||||
info = (await it.get_user(bot.self_id)) if it else None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error when getting bot info: {e.__class__.__name__}: {e}")
|
||||
else:
|
||||
if info:
|
||||
bot_info_cache[bot.self_id] = info
|
||||
return info
|
||||
return None
|
||||
|
||||
|
||||
@driver.on_bot_connect
|
||||
async def _(bot: BaseBot):
|
||||
bot_connect_time[bot.self_id] = datetime.now().astimezone()
|
||||
if bot.self_id not in recv_num:
|
||||
recv_num[bot.self_id] = 0
|
||||
if (bot.self_id not in send_num) and (bot.adapter.get_name() in SEND_APIS):
|
||||
send_num[bot.self_id] = 0
|
||||
await cache_bot_info(bot)
|
||||
|
||||
|
||||
@driver.on_bot_disconnect
|
||||
async def _(bot: BaseBot):
|
||||
bot_connect_time.pop(bot.self_id, None)
|
||||
if config.ps_disconnect_reset_counter:
|
||||
recv_num.pop(bot.self_id, None)
|
||||
send_num.pop(bot.self_id, None)
|
||||
|
||||
|
||||
@event_preprocessor
|
||||
async def _(bot: BaseBot, event: BaseEvent):
|
||||
if event.get_type() == "message":
|
||||
recv_num[bot.self_id] += 1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
@@ -0,0 +1,3 @@
|
||||
declare module globalThis {
|
||||
var plugins: (() => Promise<any>)[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
globalThis.plugins = [];
|
||||
@@ -0,0 +1,77 @@
|
||||
/// <reference path="./globalThis.d.ts" />
|
||||
(() => {
|
||||
/**
|
||||
* @template {any[]} A
|
||||
* @template R
|
||||
* @param {(...args: A) => Promise<R>} func
|
||||
* @returns {(...args: A) => Promise<R | undefined>}
|
||||
*/
|
||||
function wrapErr(func) {
|
||||
return async (...args) => {
|
||||
try {
|
||||
return await func(...args);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
async function makeObjUrlFromUrl(url) {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/** @typedef {(elem: HTMLElement, objUrl: string) => Promise<any>} PropSetterType */
|
||||
|
||||
/** @type {Record<string, PropSetterType>} */
|
||||
const propSetterMap = {
|
||||
'data-background-image': async (elem, objUrl) => {
|
||||
elem.style.backgroundImage = `url(${objUrl})`;
|
||||
},
|
||||
'data-src': async (elem, objUrl) => {
|
||||
if (elem instanceof HTMLImageElement) elem.src = objUrl;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elem
|
||||
* @param {string} attr
|
||||
* @param {PropSetterType} setter
|
||||
*/
|
||||
async function lazyLoadOne(elem, attr, setter) {
|
||||
const url = elem.getAttribute(attr);
|
||||
if (!url) return;
|
||||
const objUrl = await makeObjUrlFromUrl(url);
|
||||
await setter(elem, objUrl);
|
||||
elem.removeAttribute(attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} attr
|
||||
*/
|
||||
async function lazyLoad(attr) {
|
||||
/** @type {HTMLElement[]} */
|
||||
// @ts-ignore
|
||||
const elements = [...document.body.querySelectorAll(`[${attr}]`)].filter(
|
||||
(v) => v instanceof HTMLElement
|
||||
);
|
||||
const tasks = elements.map((v) =>
|
||||
wrapErr(lazyLoadOne)(v, attr, propSetterMap[attr])
|
||||
);
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
async function lazyLoadAll() {
|
||||
await Promise.all(Object.keys(propSetterMap).map(lazyLoad));
|
||||
}
|
||||
|
||||
// 使用 globalThis.plugins.push 注册插件
|
||||
// 只有这样才能保证你的插件运行完成之后才会网页截图
|
||||
globalThis.plugins.push(async () => {
|
||||
await lazyLoadAll();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,10 @@
|
||||
(async () => {
|
||||
for (const plugin of globalThis.plugins) {
|
||||
try {
|
||||
await plugin();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
document.body.classList.add('done');
|
||||
})();
|
||||
@@ -0,0 +1,65 @@
|
||||
import importlib
|
||||
from collections.abc import Awaitable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
from typing_extensions import Protocol, Unpack
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
from ..config import config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bg_provider import BgBytesData
|
||||
|
||||
|
||||
class TemplateRendererKwargs(TypedDict):
|
||||
collected: dict[str, Any]
|
||||
bg: "BgBytesData"
|
||||
|
||||
|
||||
class TemplateRenderer(Protocol):
|
||||
__name__: str
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
**kwargs: Unpack[TemplateRendererKwargs],
|
||||
) -> Awaitable[bytes]: ...
|
||||
|
||||
|
||||
@dataclass()
|
||||
class TemplateInfo:
|
||||
renderer: TemplateRenderer
|
||||
collectors: set[str] | None = None
|
||||
|
||||
|
||||
loaded_templates: dict[str, TemplateInfo] = {}
|
||||
|
||||
|
||||
def pic_template(
|
||||
name: str | None = None,
|
||||
collecting: set[str] | None = None,
|
||||
):
|
||||
def deco(func: TemplateRenderer):
|
||||
template_name = name or func.__name__
|
||||
if template_name in loaded_templates:
|
||||
raise ValueError(f"Template {template_name} already exists")
|
||||
loaded_templates[template_name] = TemplateInfo(
|
||||
renderer=func,
|
||||
collectors=collecting,
|
||||
)
|
||||
logger.debug(f"Registered template {template_name}")
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def load_builtin_templates():
|
||||
for module in Path(__file__).parent.iterdir():
|
||||
name = module.name
|
||||
if (not module.is_dir()) or name.startswith("_"):
|
||||
continue
|
||||
assert importlib.import_module(f".{name}", __package__)
|
||||
|
||||
|
||||
async def render_current_template(**kwargs: Unpack[TemplateRendererKwargs]):
|
||||
return await loaded_templates[config.ps_template].renderer(**kwargs)
|
||||
@@ -0,0 +1,140 @@
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import jinja2
|
||||
from cookit import flatten
|
||||
from cookit.pyd import field_validator
|
||||
from nonebot import get_plugin_config, require
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...util import debug
|
||||
from .. import pic_template
|
||||
from ..pw_render import (
|
||||
ROUTE_URL,
|
||||
add_background_router,
|
||||
add_root_router,
|
||||
base_router_group,
|
||||
make_file_router,
|
||||
register_global_filter_to,
|
||||
resolve_file_url,
|
||||
)
|
||||
|
||||
require("nonebot_plugin_htmlrender")
|
||||
|
||||
from nonebot_plugin_htmlrender import get_new_page # noqa: E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...bg_provider import BgBytesData
|
||||
|
||||
RES_PATH = Path(__file__).parent / "res"
|
||||
TEMPLATE_PATH = RES_PATH / "templates"
|
||||
CSS_PATH = RES_PATH / "css"
|
||||
ENVIRONMENT = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(str(TEMPLATE_PATH)),
|
||||
autoescape=jinja2.select_autoescape(["html", "xml"]),
|
||||
enable_async=True,
|
||||
)
|
||||
register_global_filter_to(ENVIRONMENT)
|
||||
|
||||
template_router_group = base_router_group.copy()
|
||||
template_router_group.router(f"{ROUTE_URL}/default/res/**/*", priority=99)(
|
||||
make_file_router(query_name=None, base_path=RES_PATH, prefix_omit="default/res/"),
|
||||
)
|
||||
|
||||
COMPONENT_COLLECTORS = {
|
||||
"header": {"bots", "nonebot_run_time", "system_run_time"},
|
||||
"cpu_mem": {
|
||||
"cpu_percent",
|
||||
"cpu_count",
|
||||
"cpu_count_logical",
|
||||
"cpu_freq",
|
||||
"cpu_brand",
|
||||
"memory_stat",
|
||||
"swap_stat",
|
||||
},
|
||||
"disk": {"disk_usage", "disk_io"},
|
||||
"network": {"network_io", "network_connection"},
|
||||
"process": {"process_status"},
|
||||
"footer": {
|
||||
"nonebot_version",
|
||||
"ps_version",
|
||||
"time",
|
||||
"python_version",
|
||||
"system_name",
|
||||
},
|
||||
}
|
||||
PERIODIC_COLLECTORS_MAP = {
|
||||
"cpu_percent": "cpu_percent_periodic",
|
||||
"cpu_freq": "cpu_freq_periodic",
|
||||
"disk_usage": "disk_usage_periodic",
|
||||
"disk_io": "disk_io_periodic",
|
||||
"memory_stat": "memory_stat_periodic",
|
||||
"swap_stat": "swap_stat_periodic",
|
||||
"time": "time_periodic",
|
||||
"network_io": "network_io_periodic",
|
||||
"process_status": "process_status_periodic",
|
||||
}
|
||||
PERIODIC_COLLECTORS_MAP_REVERSE = {v: k for k, v in PERIODIC_COLLECTORS_MAP.items()}
|
||||
|
||||
|
||||
class TemplateConfig(BaseModel):
|
||||
ps_default_components: list[str] = [
|
||||
"header",
|
||||
"cpu_mem",
|
||||
"disk",
|
||||
"network",
|
||||
"process",
|
||||
"footer",
|
||||
]
|
||||
ps_default_additional_css: list[str] = []
|
||||
ps_default_additional_script: list[str] = []
|
||||
ps_default_pic_format: Literal["jpeg", "png"] = "jpeg"
|
||||
ps_default_use_periodic: bool = True
|
||||
|
||||
@field_validator("ps_default_additional_css")
|
||||
def resolve_css_url(cls, v: list[str]): # noqa: N805
|
||||
return [resolve_file_url(x, {"default/res/css": CSS_PATH}) for x in v]
|
||||
|
||||
@field_validator("ps_default_additional_script")
|
||||
def resolve_script_url(cls, v: list[str]): # noqa: N805
|
||||
return [resolve_file_url(x) for x in v]
|
||||
|
||||
|
||||
template_config = get_plugin_config(TemplateConfig)
|
||||
collecting = set(
|
||||
flatten(COMPONENT_COLLECTORS[k] for k in template_config.ps_default_components),
|
||||
)
|
||||
if template_config.ps_default_use_periodic:
|
||||
collecting = {(PERIODIC_COLLECTORS_MAP.get(x) or x) for x in collecting}
|
||||
|
||||
|
||||
@pic_template(collecting=collecting)
|
||||
async def default(collected: dict[str, Any], bg: "BgBytesData", **_) -> bytes:
|
||||
for k, v in collected.copy().items():
|
||||
if (
|
||||
template_config.ps_default_use_periodic
|
||||
and k in PERIODIC_COLLECTORS_MAP_REVERSE
|
||||
):
|
||||
del collected[k]
|
||||
k = PERIODIC_COLLECTORS_MAP_REVERSE[k]
|
||||
if isinstance(v, deque):
|
||||
collected[k] = v[-1] if v else None
|
||||
|
||||
template = ENVIRONMENT.get_template("index.html.jinja")
|
||||
html = await template.render_async(d=collected, config=template_config)
|
||||
|
||||
if debug.enabled:
|
||||
debug.write(html, "default_{time}.html")
|
||||
|
||||
router_group = template_router_group.copy()
|
||||
add_root_router(router_group, html)
|
||||
add_background_router(router_group, bg)
|
||||
|
||||
async with get_new_page() as page:
|
||||
await router_group.apply(page)
|
||||
await page.goto(f"{ROUTE_URL}/")
|
||||
await page.wait_for_selector("body.done")
|
||||
elem = await page.query_selector(".main-background")
|
||||
assert elem
|
||||
return await elem.screenshot(type="jpeg")
|
||||
@@ -0,0 +1,369 @@
|
||||
/* Base */
|
||||
|
||||
* {
|
||||
--font-family: 'HarmonyOS Sans SC', 'Source Han Sans SC', 'Source Han Sans',
|
||||
sans-serif;
|
||||
--monospace-font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas',
|
||||
monospace;
|
||||
|
||||
--primary-text-color: #3a3a3a;
|
||||
--secondary-text-color: #6a6a6a;
|
||||
|
||||
--default-background: #3a3a3aaa;
|
||||
--background-mask-color: #fafafa66;
|
||||
--card-background-color: #fafafaaa;
|
||||
--default-box-shadow: 2px 2px 6px #6a6a6a66;
|
||||
|
||||
--label-red-text-color: var(--secondary-text-color);
|
||||
--label-orange-text-color: var(--secondary-text-color);
|
||||
--label-yellow-text-color: var(--secondary-text-color);
|
||||
--label-green-text-color: var(--secondary-text-color);
|
||||
--label-cyan-text-color: var(--secondary-text-color);
|
||||
--label-blue-text-color: var(--secondary-text-color);
|
||||
--label-purple-text-color: var(--secondary-text-color);
|
||||
--label-gray-text-color: var(--secondary-text-color);
|
||||
--label-black-text-color: #fafafa;
|
||||
|
||||
--background-color: var(--card-background-color);
|
||||
--label-red-bg-color: #e05661aa;
|
||||
--label-orange-bg-color: #ee9025aa;
|
||||
--label-yellow-bg-color: #eea825aa;
|
||||
--label-green-bg-color: #1da912aa;
|
||||
--label-cyan-bg-color: #56b6c2aa;
|
||||
--label-blue-bg-color: #118dc3aa;
|
||||
--label-purple-bg-color: #9a77cfaa;
|
||||
--label-gray-bg-color: #bebebeaa;
|
||||
--label-black-bg-color: #3a3a3aaa;
|
||||
|
||||
--prog-low-text-color: var(--secondary-text-color);
|
||||
--prog-medium-text-color: var(--secondary-text-color);
|
||||
--prog-high-text-color: var(--secondary-text-color);
|
||||
--prog-low-bg-color: var(--label-green-bg-color);
|
||||
--prog-medium-bg-color: var(--label-orange-bg-color);
|
||||
--prog-high-bg-color: var(--label-red-bg-color);
|
||||
|
||||
--segment-color: #bebebe;
|
||||
}
|
||||
|
||||
.red {
|
||||
--background-color: var(--label-red-bg-color);
|
||||
--secondary-text-color: var(--label-red-text-color);
|
||||
}
|
||||
|
||||
.orange {
|
||||
--background-color: var(--label-orange-bg-color);
|
||||
--secondary-text-color: var(--label-orange-text-color);
|
||||
}
|
||||
|
||||
.yellow {
|
||||
--background-color: var(--label-yellow-bg-color);
|
||||
--secondary-text-color: var(--label-yellow-text-color);
|
||||
}
|
||||
|
||||
.green {
|
||||
--background-color: var(--label-green-bg-color);
|
||||
--secondary-text-color: var(--label-green-text-color);
|
||||
}
|
||||
|
||||
.cyan {
|
||||
--background-color: var(--label-cyan-bg-color);
|
||||
--secondary-text-color: var(--label-cyan-text-color);
|
||||
}
|
||||
|
||||
.blue {
|
||||
--background-color: var(--label-blue-bg-color);
|
||||
--secondary-text-color: var(--label-blue-text-color);
|
||||
}
|
||||
|
||||
.purple {
|
||||
--background-color: var(--label-purple-bg-color);
|
||||
--secondary-text-color: var(--label-purple-text-color);
|
||||
}
|
||||
|
||||
.gray {
|
||||
--background-color: var(--label-gray-bg-color);
|
||||
--secondary-text-color: var(--label-gray-text-color);
|
||||
}
|
||||
|
||||
.black {
|
||||
--background-color: var(--label-black-bg-color);
|
||||
--secondary-text-color: var(--label-black-text-color);
|
||||
}
|
||||
|
||||
.prog-low {
|
||||
--background-color: var(--prog-low-bg-color);
|
||||
--secondary-text-color: var(--prog-low-text-color);
|
||||
}
|
||||
|
||||
.prog-medium {
|
||||
--background-color: var(--prog-medium-bg-color);
|
||||
--secondary-text-color: var(--prog-medium-text-color);
|
||||
}
|
||||
|
||||
.prog-high {
|
||||
--background-color: var(--prog-high-bg-color);
|
||||
--secondary-text-color: var(--prog-high-text-color);
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: var(--monospace-font-family);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
color: var(--primary-text-color);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.main-background {
|
||||
width: 650px;
|
||||
background: var(--default-background);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.main-background-mask {
|
||||
padding: 16px;
|
||||
background-color: var(--background-mask-color);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: var(--card-background-color);
|
||||
box-shadow: var(--default-box-shadow);
|
||||
backdrop-filter: blur(2px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.splitter > *:not(:first-child) {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 2px solid var(--segment-color);
|
||||
}
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Span Label */
|
||||
|
||||
span.label {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--default-box-shadow);
|
||||
background-color: var(--background-color);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.label-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label-container > * {
|
||||
margin-right: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Account */
|
||||
|
||||
.account {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.account .avatar {
|
||||
width: 125px;
|
||||
height: 125px;
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--default-box-shadow);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.account .description {
|
||||
margin-left: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.account .description .nickname {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-word;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Donut Chart */
|
||||
|
||||
.donut-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.donut-chart .chart-wrapper,
|
||||
.donut-chart .chart,
|
||||
.donut-chart .shadow {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.donut-chart .chart-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.donut-chart .chart {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.donut-chart .chart .empty,
|
||||
.donut-chart .chart .slice {
|
||||
fill: transparent;
|
||||
stroke-width: 15px;
|
||||
}
|
||||
|
||||
.donut-chart .chart .empty {
|
||||
stroke: var(--label-gray-bg-color);
|
||||
}
|
||||
|
||||
.donut-chart .chart .slice {
|
||||
stroke: var(--background-color);
|
||||
stroke-dasharray: calc((67.5px * 2) * 3.1415926);
|
||||
stroke-dashoffset: calc(
|
||||
(67.5px * 2) * 3.1415926 / 360 * (360 - (360 * var(--percent)))
|
||||
);
|
||||
}
|
||||
|
||||
.donut-chart .shadow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--default-box-shadow);
|
||||
}
|
||||
|
||||
.donut-chart .label {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.donut-chart .label,
|
||||
.donut-chart .title {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
text-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.donut-chart .desc {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--default-box-shadow);
|
||||
}
|
||||
|
||||
.progress-bar .background {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--label-gray-bg-color);
|
||||
}
|
||||
|
||||
.progress-bar .progress {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.progress-bar .label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* #### Split Line #### */
|
||||
|
||||
/* Card Header */
|
||||
|
||||
.card.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card.header .label-container {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card.header .extra > * {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Donut Chart Line */
|
||||
|
||||
.donut-chart-line {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* List Grid */
|
||||
|
||||
.list-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-grid.disk-usage {
|
||||
grid-template-columns: auto minmax(180px, 100%) auto;
|
||||
}
|
||||
|
||||
.list-grid.disk-io,
|
||||
.list-grid.network-io,
|
||||
.list-grid.network-connection-test,
|
||||
.list-grid.process-usage {
|
||||
grid-template-columns: minmax(0, 100%) auto auto auto auto auto;
|
||||
}
|
||||
|
||||
.list-grid.network-connection-test {
|
||||
grid-template-columns: minmax(0, 100%) auto auto auto;
|
||||
}
|
||||
|
||||
.list-grid.network-connection-test .error {
|
||||
grid-column-end: span 3;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
.footer {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
color: var(--primary-text-color);
|
||||
text-shadow: var(--default-box-shadow);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.card {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.card,
|
||||
span.label,
|
||||
.progress-bar {
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
* {
|
||||
--default-box-shadow: none;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
* {
|
||||
--primary-text-color: #d0d6e2;
|
||||
--secondary-text-color: #abb2bf;
|
||||
|
||||
--default-background: #d0d6e2aa;
|
||||
--background-mask-color: #282c3455;
|
||||
--card-background-color: #282c3488;
|
||||
--default-box-shadow: 2px 2px 6px #abb2bf66;
|
||||
|
||||
--label-default-bg-color: #282c34aa;
|
||||
--label-red-bg-color: #e06c75aa;
|
||||
--label-orange-bg-color: #d19a66aa;
|
||||
--label-yellow-bg-color: #e5c07baa;
|
||||
--label-green-bg-color: #98c379aa;
|
||||
--label-cyan-bg-color: #56b6c2aa;
|
||||
--label-blue-bg-color: #61afefaa;
|
||||
--label-purple-bg-color: #c678ddaa;
|
||||
--label-gray-bg-color: #737c8caa;
|
||||
--label-black-bg-color: #d0d6e2aa;
|
||||
--label-black-text-color: #282c34;
|
||||
|
||||
--segment-color: #5c6370;
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{% from 'macros.html.jinja' import header, cpu_mem, disk, network, process, footer %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="/default/res/css/index.css" />
|
||||
{% for css in config.ps_default_additional_css -%}
|
||||
<link rel="stylesheet" href="{{ css }}" />{% endfor %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main-background" data-background-image="/api/background">
|
||||
<div class="main-background-mask">
|
||||
<div class="main">
|
||||
{% for name in config.ps_default_components %}
|
||||
{% if name == "header" %}
|
||||
{{ header(d) }}
|
||||
{% elif name == "cpu_mem" %}
|
||||
{{ cpu_mem(d) }}
|
||||
{% elif name == "disk" %}
|
||||
{{ disk(d) }}
|
||||
{% elif name == "network" %}
|
||||
{{ network(d) }}
|
||||
{% elif name == "process" %}
|
||||
{{ process(d) }}
|
||||
{% elif name == "footer" %}
|
||||
{{ footer(d) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script src="/js/init-global.js"></script>
|
||||
<script src="/js/lazy-load.js"></script>
|
||||
{% for script in config.ps_default_additional_script -%}
|
||||
<script src="{{ script }}"></script>{% endfor %}
|
||||
<script src="/js/load-plugin.js"></script>
|
||||
|
||||
</html>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
{% macro donut_chart(percent, title, caption) %}
|
||||
<div class="donut-chart">
|
||||
<div class="chart-wrapper">
|
||||
<svg class="chart" style="{% if percent != None %}--percent: {{ percent / 100 }}{% endif %}">
|
||||
<circle class="empty" cx="50%" cy="50%" r="67.5px" />
|
||||
{% if percent != None %}
|
||||
<circle class="slice {{ percent | percent_to_color }}" cx="50%" cy="50%" r="67.5px" />
|
||||
{% endif %}
|
||||
</svg>
|
||||
<div class="shadow"></div>
|
||||
<div class="label">
|
||||
{%- if percent == None %}未部署
|
||||
{%- else %}{{ '{0:.0f}%'.format(percent) }}{% endif -%}
|
||||
</div>
|
||||
</div>
|
||||
<div class="title">{{ title }}</div>
|
||||
<div class="desc">{{ caption | br }}</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro header(d) %}
|
||||
<div class="card header splitter">
|
||||
{% for info in d.bots %}
|
||||
<div class="account">
|
||||
<img class="avatar" data-src="/api/bot_avatar/{{ info.self_id }}" />
|
||||
<div class="description">
|
||||
<div class="nickname">{{ info.nick }}</div>
|
||||
<div class="status label-container">
|
||||
<span class="label purple">{{ info.adapter }}</span>
|
||||
<span class="label green">Bot已连接 {{ info.bot_connected }}</span>
|
||||
<span class="label blue">收 {{ info.msg_rec }}</span>
|
||||
<span class="label orange">发 {{ info.msg_sent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="extra label-container">
|
||||
<span class="label gray">NoneBot运行 {{ d.nonebot_run_time }}</span>
|
||||
<span class="label gray">系统运行 {{ d.system_run_time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro cpu_mem(d) %}
|
||||
{% if d.cpu_count %}{% set count = d.cpu_count %}{% else %}{% set count = '??' %}{% endif %}
|
||||
{% if d.cpu_count_logical %}{% set logical = d.cpu_count_logical %}{% else %}{% set logical = '??' %}{% endif %}
|
||||
{% set freq = d.cpu_freq | format_cpu_freq %}
|
||||
{% set ram_used = d.memory_stat.used | auto_convert_unit %}
|
||||
{% set ram_total = d.memory_stat.total | auto_convert_unit %}
|
||||
{% set swap_used = d.swap_stat.used | auto_convert_unit %}
|
||||
{% set swap_total = d.swap_stat.total | auto_convert_unit %}
|
||||
<div class="card cpu-mem-usage donut-chart-line">
|
||||
{{ donut_chart(d.cpu_percent, "CPU", "{}核 {}线程 {}\n{}".format(count, logical, freq, d.cpu_brand)) }}
|
||||
{{ donut_chart(d.memory_stat.percent, "RAM", "{} / {}").format(ram_used, ram_total) }}
|
||||
{{ donut_chart(d.swap_stat.percent, "SWAP", "{} / {}").format(swap_used, swap_total) }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro disk(d) %}
|
||||
<div class="card disk-info splitter">
|
||||
<div class="list-grid disk-usage">
|
||||
{% for it in d.disk_usage %}
|
||||
<div>{{ it.name }}</div>
|
||||
<div class="progress-bar">
|
||||
<div class="background"></div>
|
||||
{% if it.exception %}
|
||||
<div class="label">{{ it.exception }}</div>
|
||||
{% else %}
|
||||
<div class="progress {{ it.percent | percent_to_color }}" style="width: {{ it.percent }}%"></div>
|
||||
<div class="label">{{ it.used | auto_convert_unit }} / {{ it.total | auto_convert_unit }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="align-right">
|
||||
{%- if it.percent %}{{ '{0:.1f}%'.format(it.percent) }}
|
||||
{%- else %}??.?%{% endif -%}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if d.disk_io -%}
|
||||
<div class="list-grid disk-io">
|
||||
{% for it in d.disk_io %}
|
||||
<div>{{ it.name }}</div>
|
||||
<div>读</div>
|
||||
<div class="align-right">{{ it.read | auto_convert_unit(suffix='/s') }}</div>
|
||||
<div>|</div>
|
||||
<div>写</div>
|
||||
<div class="align-right">{{ it.write | auto_convert_unit(suffix='/s') }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro network(d) %}
|
||||
<div class="card network-info splitter">
|
||||
<div class="list-grid network-io">
|
||||
{% for it in d.network_io or [] %}
|
||||
<div>{{ it.name }}</div>
|
||||
<div>↑</div>
|
||||
<div class="align-right">{{ it.sent | auto_convert_unit(suffix='/s') }}</div>
|
||||
<div>|</div>
|
||||
<div>↓</div>
|
||||
<div class="align-right">{{ it.recv | auto_convert_unit(suffix='/s') }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="list-grid network-connection-test">
|
||||
{% for it in d.network_connection %}
|
||||
<div>{{ it.name }}</div>
|
||||
{% if it.error %}
|
||||
<div class="error">{{ it.error }}</div>
|
||||
{% else %}
|
||||
<div>{{ it.status }} {{ it.reason }}</div>
|
||||
<div>|</div>
|
||||
<div>{{ '{0:.2f}ms'.format(it.delay) }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro process(d) %}
|
||||
<div class="card process-info splitter">
|
||||
<div class="list-grid process-usage">
|
||||
{% for it in d.process_status or [] %}
|
||||
<div>{{ it.name }}</div>
|
||||
<div>CPU</div>
|
||||
<div class="align-right">{{ '{0:.1f}%'.format(it.cpu) }}</div>
|
||||
<div>|</div>
|
||||
<div>MEM</div>
|
||||
<div class="align-right">{{ it.mem | auto_convert_unit }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro footer(d) %}
|
||||
<div class="footer">
|
||||
NoneBot {{ d.nonebot_version }} × PicStatus {{ d.ps_version }} | {{ d.time }}<br />
|
||||
{{ d.python_version }} | {{ d.system_name }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,142 @@
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from cookit import auto_convert_byte
|
||||
from cookit.jinja import all_filters
|
||||
from cookit.jinja.filters import cookit_global_filter
|
||||
from cookit.pw import CKRouterFunc, RouterGroup, make_real_path_router
|
||||
from cookit.pw.loguru import log_router_err
|
||||
from nonebot import logger
|
||||
from yarl import URL
|
||||
|
||||
from ..config import DEFAULT_AVATAR_PATH, config
|
||||
from ..misc_statistics import bot_avatar_cache
|
||||
from ..util import format_cpu_freq
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import jinja2
|
||||
from playwright.async_api import Request, Route
|
||||
|
||||
from ..bg_provider import BgBytesData
|
||||
|
||||
TC = TypeVar("TC", bound=Callable[..., Any])
|
||||
|
||||
ROOT_PATH = Path(__file__).parent.parent
|
||||
RES_PATH = ROOT_PATH / "res"
|
||||
ROUTE_URL = "http://picstatus.nonebot"
|
||||
RES_LOCATION_MAP = {
|
||||
"": RES_PATH,
|
||||
}
|
||||
|
||||
# region pw
|
||||
|
||||
base_router_group = RouterGroup()
|
||||
|
||||
|
||||
def resolve_file_url(
|
||||
path: str,
|
||||
additional_locations: dict[str, Path] | None = None,
|
||||
) -> str:
|
||||
if path.startswith("res:"):
|
||||
path = path[4:].lstrip("/")
|
||||
locations = {**RES_LOCATION_MAP, **(additional_locations or {})}
|
||||
for pfx, loc in locations.items():
|
||||
if (loc / path).exists():
|
||||
return f"/{pfx}/{path}"
|
||||
raise ValueError(f"Cannot resolve builtin resource `{path}`")
|
||||
params = urlencode({"path": path})
|
||||
return f"/api/local_file?{params}"
|
||||
|
||||
|
||||
def make_file_router(
|
||||
query_name: str | None = None,
|
||||
base_path: Path | None = None,
|
||||
prefix_omit: str = "",
|
||||
) -> CKRouterFunc:
|
||||
@log_router_err()
|
||||
@make_real_path_router
|
||||
async def router(request: "Request", **_):
|
||||
url = URL(request.url)
|
||||
query_path = url.query.get(query_name, "") if query_name else url.path[1:]
|
||||
if prefix_omit and query_path.startswith(prefix_omit):
|
||||
query_path = query_path[len(prefix_omit) :]
|
||||
path = Path((base_path / query_path) if base_path else query_path)
|
||||
logger.debug(f"Associated file `{path}`")
|
||||
return path
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@base_router_group.router(f"{ROUTE_URL}/api/bot_avatar/*")
|
||||
@log_router_err()
|
||||
async def _(route: "Route", request: "Request", **_):
|
||||
url = URL(request.url)
|
||||
self_id = url.parts[-1]
|
||||
|
||||
if self_id in bot_avatar_cache:
|
||||
await route.fulfill(body=bot_avatar_cache[self_id])
|
||||
return
|
||||
|
||||
data = (
|
||||
config.ps_default_avatar
|
||||
if config.ps_default_avatar.is_file()
|
||||
else DEFAULT_AVATAR_PATH
|
||||
).read_bytes()
|
||||
await route.fulfill(body=data)
|
||||
|
||||
|
||||
base_router_group.router(f"{ROUTE_URL}/api/local_file*")(
|
||||
make_file_router(query_name="path", base_path=None),
|
||||
)
|
||||
|
||||
base_router_group.router(f"{ROUTE_URL}/**/*", priority=100)(
|
||||
make_file_router(query_name=None, base_path=RES_PATH),
|
||||
)
|
||||
|
||||
|
||||
def add_root_router(router_group: RouterGroup, html: str):
|
||||
@router_group.router(f"{ROUTE_URL}/")
|
||||
@log_router_err()
|
||||
async def _(route: "Route", **_):
|
||||
await route.fulfill(content_type="text/html", body=html)
|
||||
|
||||
|
||||
def add_background_router(router_group: RouterGroup, bg: "BgBytesData"):
|
||||
@router_group.router(f"{ROUTE_URL}/api/background")
|
||||
@log_router_err()
|
||||
async def _(route: "Route", **_):
|
||||
await route.fulfill(content_type=bg.mime, body=bg.data)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region jinja
|
||||
|
||||
|
||||
jinja_filter = type(cookit_global_filter)(all_filters.copy())
|
||||
|
||||
|
||||
def register_global_filter_to(env: "jinja2.Environment"):
|
||||
env.filters.update(jinja_filter.data)
|
||||
|
||||
|
||||
jinja_filter(format_cpu_freq)
|
||||
|
||||
|
||||
@jinja_filter
|
||||
def percent_to_color(percent: float) -> str:
|
||||
if percent < 70:
|
||||
return "prog-low"
|
||||
if percent < 90:
|
||||
return "prog-medium"
|
||||
return "prog-high"
|
||||
|
||||
|
||||
@jinja_filter
|
||||
def auto_convert_unit(value: float, **kw) -> str:
|
||||
return auto_convert_byte(value=value, with_space=False, **kw)
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,39 @@
|
||||
import re
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cookit import DebugFileWriter, auto_convert_unit, format_timedelta
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .collectors.cpu import CpuFreq
|
||||
|
||||
format_time_delta_ps = partial(format_timedelta, day_divider=" ", day_suffix="天")
|
||||
|
||||
|
||||
def match_list_regexp(reg_list: list[str], txt: str) -> re.Match | None:
|
||||
return next((match for r in reg_list if (match := re.search(r, txt))), None)
|
||||
|
||||
|
||||
def format_freq(value: float) -> str:
|
||||
"""将 MHz 频率格式化为 MHz/GHz(十进制 1000 换算)"""
|
||||
return auto_convert_unit(
|
||||
units=("MHz", "GHz", "THz"),
|
||||
multiplier=1000,
|
||||
unit_threshold=1000,
|
||||
value=value,
|
||||
with_space=False,
|
||||
)
|
||||
|
||||
|
||||
def format_cpu_freq(freq: "CpuFreq") -> str:
|
||||
if not freq.current and not freq.max:
|
||||
return "主频未知"
|
||||
current = freq.current or freq.max or 0.0
|
||||
max_freq = freq.max or 0.0
|
||||
if not max_freq or max_freq == current:
|
||||
return format_freq(current)
|
||||
return f"{format_freq(current)} / {format_freq(max_freq)}"
|
||||
|
||||
|
||||
debug = DebugFileWriter(Path(__file__).resolve().parents[2] / "logs", "picstatus")
|
||||
Reference in New Issue
Block a user