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,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
|
||||
Reference in New Issue
Block a user