From 0c15487597ae41a119b2535115ca66cc481efb3e Mon Sep 17 00:00:00 2001 From: modusensus Date: Mon, 21 Sep 2026 14:09:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(serve):=20=E6=BC=94=E7=A4=BA=E6=97=B6?= =?UTF-8?q?=E9=92=9F=E5=8F=AF=E5=86=BB=E7=BB=93=E3=80=81=E5=8F=AF=E6=AD=A5?= =?UTF-8?q?=E8=BF=9B=E3=80=81=E5=8F=AF=E8=B7=B3=E5=88=B0=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --demo 的时间轴以前只能自己走:要看到故障态得干等 140 秒的健康段,而"看一眼前端长什么 样"的人多半已经在等第一轮了。现在页脚有一行"演示时钟"(说明现在看的是哪一刻)加五个 按钮:冻结 / −10s / +10s / 下一处变化 / 回到现在。 三个决定值得记下来。 1) 锚点是**读**,不是控制端点。时间轴本来就是"某个时刻的纯函数",所以让那个时刻从请求里 来:?at=<秒>。服务端因此仍然不持有任何可变状态——没有要抢的锁、没有非幂等路由、没有"谁在 什么时候读到半个状态",而且一个链接就是某一刻(截图把地址栏一并截下来就能自证)。为此 provider 契约改为接收这一次请求的查询串(ponte.serve.Provider),实时那条明确忽略它:真实 状态只有"现在",?at=abc、负数、无穷、超大值一律回退到实时(上限一天)。 2) "下一处变化"跳的是**看得见**的变化,不是下一个阶段边界。web 的"断线 → 重连退避"两段 判定与两列端口完全一样,落在那里的按钮看起来就是坏的。所以判据跟着看板走 (_visible_state),跳过看不出来的边界。测试不是镜像那句判据,而是拿渲染给看板的 payload 重算一遍:到目标之前的每半秒都必须与现在一模一样,到目标那一刻必须不同。 3) 冻结冻的是**数据**,不是刷新。页面照旧每 5 秒"已更新",只是时刻不再推进——所以冻结的 看板是显然冻住了,而不是和挂掉的标签页长得一样。冻结状态下步进保持冻结:逐个状态看过去 正是这个功能的用法。页脚写明三种状态:实时 / 已固定 / 已冻结。 顺带:payload 的演示标记从 "demo": true 长成一个对象(at / anchored / next_at / next_profile),仍是 payload 里唯一多出来的键,契约测试因此没变松;时刻与"下一处变化"必须 与看板同时到达客户端,否则按钮会基于过期的时刻跳。页脚的 status.json / metrics / healthz 链接带上锚点,免得看板钉在某一刻而 JSON 回答"现在"。 验证:449 测试、ruff、两个平台的 mypy、smoke 全过。四处反向对照都确认新测试抓得住:next 退化成"下一个边界"、handler 不传查询串、锚点不校验、实时路径认锚点。浏览器里逐个按钮点 过:冻结 7 秒后读数与 URL 都不动而"已更新"照常走、步进 +10s、两次"下一处变化"分别落在 db 的"端口没监听 → 断线"与 web 的断线上、回到现在把 ?at= 从地址栏去掉。 --------- Co-authored-by: Codebuff --- CHANGELOG.md | 27 ++++ README.md | 29 +++- ponte/demo.py | 111 +++++++++++++- ponte/main.py | 14 +- ponte/serve.py | 262 ++++++++++++++++++++++++++++++-- tests/test_demo.py | 220 ++++++++++++++++++++++++++- tests/test_integration_serve.py | 2 +- tests/test_main.py | 2 +- tests/test_serve.py | 40 +++-- 9 files changed, 664 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b4484..9352413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 not be mistakable for somebody's real infrastructure. Sample hosts are `example.com`. The bind rules are unchanged: a non-loopback `--host` still demands a token. +- **The demo timeline can be pinned, stepped and frozen from the page.** Waiting + out a 140-second healthy stretch to reach the interesting state is a poor way to + look at a dashboard, and a demo that only moves on its own clock is one you + watch rather than use. `ponte serve --demo` now marks the moment it is showing + in the footer and offers `冻结` / `−10s` / `+10s` / `下一处变化` / `回到现在` + beside it. `下一处变化` jumps to the **next change that is visible on the + board**, not to the next phase boundary: a tunnel that moves from + "disconnected" to "retrying" renders identically (same verdict, same ports), so + a button that lands where nothing changed reads as broken — those boundaries are + skipped, which is asserted by re-deriving the board's visible state from the + rendered payload at every half-second up to the target. Freezing stops the + reading advancing *while the page keeps refreshing*, so a frozen board is + visibly frozen instead of being indistinguishable from a dead tab (the `已更新` + ticker keeps moving). Stepping while frozen deliberately stays frozen — walking + a fault state by state is the point. All of it is a **read**: the moment rides a + `?at=` query and the server still holds no mutable state, which is why + a live `ponte serve` ignores the parameter entirely (`?at=abc`, negatives, + infinities and absurd values fall back to live, clamped at a day), why the four + endpoints stay idempotent, why two viewers of one URL see the same board, and + why a copied link reproduces the exact moment it was copied from. The footer's + `status.json` / `metrics` / `healthz` links carry the anchor with them, so a + pinned page does not hand out a JSON of *now*. To keep it a read the status + provider now receives the request's query (`ponte.serve.Provider`); the live + provider ignores it. The demo marker grew from `"demo": true` into + `"demo": {"at": …, "anchored": …, "next_at": …, "next_profile": …}` — still + the one extra key in `/status.json`, but the clock now travels with the data the + page renders, so the buttons cannot act on a stale moment. - **The jump chain is part of the status, next to the destination.** `ProfileStatus.jump` carries the `ssh -J` value, so `ponte status --json` and the dashboard can tell "cannot reach the server" apart from "cannot reach the diff --git a/README.md b/README.md index 0b7548f..02923ae 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,19 @@ ponte serve --open # ...and open it in your browser **No tunnels yet?** `ponte serve --demo` runs the same server on built-in sample data: a small timeline that connects, drops, backs off and reconnects on its own, so the page shows every state it has — healthy, broken, and *unknown* (a probe -that could not answer). It reads no config and never touches a daemon, and the -payload (`"demo": true`), the page header and the CLI output all say the data is -a demo: a dashboard screenshot names your servers, so it must not be mistakable -for somebody's real infrastructure. Sample hosts use `example.com`. +that could not answer). A `演示时钟` line in the footer says which moment you are +looking at, and `冻结` / `−10s` / `+10s` / `下一处变化` / `回到现在` next to it +drive the timeline: `下一处变化` jumps to the next change that is *visible on the +board*, so a fault state is one click away instead of 140 seconds of healthy +staring (two adjacent phases that look identical are skipped — a button landing +where nothing changed reads as broken). Those buttons only move a number in the +URL (`?at=`): the server holds no state, a live `ponte serve` ignores the +parameter, and a copied link shows that exact moment to whoever opens it. It reads +no config and never touches a daemon, and the payload +(`"demo": {"at": …, "anchored": …, "next_at": …}`), the page header and the CLI +output all say the data is a demo: a dashboard screenshot names your servers, so +it must not be mistakable for somebody's real infrastructure. Sample hosts use +`example.com`. | Endpoint | What it answers | |----------|-----------------| @@ -470,9 +479,15 @@ ponte serve --open # 顺手在浏览器里打开 **还没有隧道?** `ponte serve --demo` 用内置的示例数据把同一个服务跑起来:一份自己会 连接、断线、退避、重连的小时间轴,于是页面上该有的状态都会出现——健康、异常,以及 -**未知**(探针没能得出结论)。它不读配置、也不碰守护进程;payload(`"demo": true`)、 -页面头部与命令行输出三处都写明这是演示数据:看板截图里写着服务器地址与端口,它不该被 -误读成某个人的真实基础设施。示例主机名一律用 `example.com`。 +**未知**(探针没能得出结论)。页脚有一行 `演示时钟` 告诉你现在看的是哪一刻,旁边的 +`冻结` / `−10s` / `+10s` / `下一处变化` / `回到现在` 可以推着它走:`下一处变化` 跳到的是 +**看板上看得见**的下一次变化,于是你不用干等那 140 秒的健康段就能看到故障态(相邻但外观 +相同的两段会被跳过——落在一处什么都没变的地方,按钮看起来就是坏的)。这些按钮只是改 +地址栏里的一个数字(`?at=<秒>`):服务端不持有任何状态、实时的 `ponte serve` 直接忽略 +它、复制出去的链接谁打开都是同一刻。它不读配置、也不碰守护进程;payload +(`"demo": {"at": …, "anchored": …, "next_at": …}`)、页面头部与命令行输出三处都写明 +这是演示数据:看板截图里写着服务器地址与端口,它不该被误读成某个人的真实基础设施。 +示例主机名一律用 `example.com`。 | 接口 | 回答什么问题 | |------|--------------| diff --git a/ponte/demo.py b/ponte/demo.py index b9b70cb..59502d5 100644 --- a/ponte/demo.py +++ b/ponte/demo.py @@ -19,6 +19,11 @@ =================== ========================================================== 用法:``ponte serve --demo``(见 ``ponte.main.serve``)。 + +时间轴可以被**钉在某一刻**:``?at=<秒>``(相对启动)。锚点是**读**而不是写——时间轴本来 +就是"某个时刻的纯函数",所以让那个时刻从请求里来,服务端不必持有任何可变状态;去掉这个 +参数就回到实时。于是一个 URL 就能把看板固定在某一刻,截图里自帯"这是哪一刻",而页面上的 +按钮只是会改地址栏里那个数字的便利层。 """ from __future__ import annotations @@ -26,6 +31,7 @@ import math import os import time +from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -38,6 +44,9 @@ #: 事件流保留多少条(与看板的 ``_FEED_LIMIT`` 对齐;多几条无妨,看板会自己截断)。 _FEED_LIMIT = 8 +#: ``?at=`` 的上限:一天。更远的锚点没有意义,也顺手挡住离谱输入。 +_AT_LIMIT = 86_400.0 + #: 这些状态下 SSH 会话还活着——可用率因此把它们算成"在线"。 _ALIVE_STATES = frozenset({CONNECTED, UNKNOWN}) @@ -141,6 +150,24 @@ def _is_up(state: str) -> bool: return state in _ALIVE_STATES +def _parse_at(query: Mapping[str, list[str]] | None) -> float | None: + """读 ``?at=`` 锚点:一个非负的有限秒数,否则 ``None``(跟随墙上时钟)。 + + 坏值一律回退到实时而不报错:这是只读的演示接口,打错一个参数不该让页面挂掉(尤其因为 + 地址栏里那个参数是按钮写上去的,刷新、手改、转发都可能弄出奇怪的值)。 + """ + values = (query or {}).get("at") + if not values: + return None + try: + at = float(values[0].strip()) + except (AttributeError, TypeError, ValueError): + return None + if not math.isfinite(at) or at < 0: + return None + return min(at, _AT_LIMIT) + + def _phase_starts(profile: DemoProfile) -> list[float]: """每一段在循环内的起点(秒)。""" starts: list[float] = [] @@ -317,6 +344,57 @@ def _profile_payload(profile: DemoProfile, *, started: float, now: float) -> dic } +def _visible_state(profile: DemoProfile, index: int) -> tuple[Any, ...]: + """看板在一段里渲染出来的"状态":判定、结论是否确定、进程在不在、两列端口。 + + 这是"**有没有变化**"的判据——它跟着看板走,而不是跟着阶段走(见 :func:`_next_change`)。 + """ + phase = profile.phases[index] + return ( + phase.state == CONNECTED and not phase.reason, + phase.state != UNKNOWN, + _is_up(phase.state), + tuple(_port_state(phase, remote=True) for _ in profile.remote_ports), + tuple(_port_state(phase, remote=False) for _ in profile.local_ports), + ) + + +def _next_change(profile: DemoProfile, elapsed: float) -> tuple[float, int] | None: + """``elapsed`` 之后,这条隧道下一次可见的变化:(虚拟秒, 循环内第几段)。 + + 判据是"看板上看得见的东西变了没有",而不是"进入下一段"。理由具体:像 ``web`` 的 + "断线 → 重连退避"那两段,判定、两列端口完全一样(页面上的字也几乎一样),中间那个边界 + 跳过去等于按钮失灵。所以拿 :func:`_visible_state` 比,跳过那些看不出来的边界。 + + 周期是有限的,所以变化一定在"当前这一轮剩下的边界 + 下一轮"里,不需要无界搜索。 + """ + cycle = profile.cycle_seconds + starts = _phase_starts(profile) + current = _visible_state(profile, _locate(profile, elapsed % cycle)) + base = int(elapsed // cycle) + for cycle_index in (base, base + 1): + for index, start in enumerate(starts): + when = cycle_index * cycle + start + if when <= elapsed: + continue + if _visible_state(profile, index) != current: + return when, index + return None # pragma: no cover - 三个演示 profile 每一轮都会变 + + +def _next_change_at(elapsed: float) -> tuple[float, str, str] | None: + """全部 profile 里最近的一次可见变化:(虚拟秒, 哪条隧道, 变成什么状态)。""" + best: tuple[float, str, str] | None = None + for profile in _PROFILES: + found = _next_change(profile, elapsed) + if found is None: # pragma: no cover - 见 _next_change + continue + when, index = found + if best is None or when < best[0]: + best = (when, profile.name, profile.phases[index].state) + return best + + def _port_state(phase: Phase, *, remote: bool) -> bool: """端口在某一刻的样子。 @@ -343,8 +421,14 @@ def __init__(self, *, started_at: float | None = None) -> None: #: 演示的"守护进程"就是提供看板的这个进程,pid 因此是真的。 self.pid = os.getpid() - def payload(self, *, now: float | None = None) -> dict[str, Any]: - """某一刻的完整 payload(``now`` 可注入,便于测试钉住时刻)。""" + def payload( + self, *, now: float | None = None, anchored: bool = False + ) -> dict[str, Any]: + """某一刻的完整 payload(``now`` 可注入,便于测试钉住时刻)。 + + ``anchored`` 只描述"这一刻是怎么来的"(``?at=`` 钉住的,还是墙上时钟),供页面把 + 时钟标的诚实("已固定" / "实时");它不影响任何一个数据字段。 + """ moment = time.time() if now is None else now profiles: dict[str, dict[str, Any]] = {} for profile in _PROFILES: @@ -355,15 +439,30 @@ def payload(self, *, now: float | None = None) -> dict[str, Any]: section["local_ports"] = {} profiles[profile.name] = section healthy = all(section["healthy"] for section in profiles.values()) + elapsed = max(0.0, moment - self.started_at) + upcoming = _next_change_at(elapsed) return { "running": True, "pid": self.pid, "started_at": self.started_at, - "uptime_seconds": max(0.0, moment - self.started_at), + "uptime_seconds": elapsed, "healthy": healthy, - "demo": True, + # 一个对象而不是 ``true``:标记与"当前停在哪一刻、下一次变化在哪"是同一件事—— + # 都是演示特有的,而且都必须与看板同时到达客户端(否则按钮会基于过期的时刻跳)。 + # 它仍然是 payload 里唯一多出来的键,契约测试因此没变松。 + "demo": { + "at": round(elapsed, 1), + "anchored": anchored, + "next_at": round(upcoming[0], 1) if upcoming else None, + "next_profile": upcoming[1] if upcoming else None, + "next_state": upcoming[2] if upcoming else None, + }, "profiles": profiles, } - def __call__(self) -> dict[str, Any]: - return self.payload() + def __call__(self, query: Mapping[str, list[str]] | None = None) -> dict[str, Any]: + """``ponte serve`` 每次请求都调这个;``?at=`` 把这一刻钉在时间轴的任意位置。""" + at = _parse_at(query) + if at is None: + return self.payload(anchored=False) + return self.payload(now=self.started_at + at, anchored=True) diff --git a/ponte/main.py b/ponte/main.py index 533b67c..0151a54 100644 --- a/ponte/main.py +++ b/ponte/main.py @@ -17,7 +17,7 @@ import sys import time import webbrowser -from collections.abc import Callable +from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING, NoReturn @@ -44,7 +44,7 @@ from ponte.daemon import _format_duration from ponte.demo import DemoStatus from ponte.doctor import FAIL, OK, SKIP, WARN, counts, run_checks -from ponte.serve import create_server, serve_url +from ponte.serve import Provider, create_server, serve_url if TYPE_CHECKING: # pragma: no cover - import cycle guard, runtime import is lazy from ponte.daemon import TunnelDaemon @@ -743,7 +743,7 @@ def serve( raise except Exception as exc: _fail(str(exc)) - provider: Callable[[], dict] = DemoStatus() + provider: Provider = DemoStatus() else: try: daemon = _daemon() @@ -758,8 +758,12 @@ def serve( except Exception as exc: _fail(str(exc)) - def live_status() -> dict: - """每次请求都重新取一次真实状态(接口本身从不缓存)。""" + def live_status(_query: Mapping[str, list[str]]) -> dict: + """每次请求都重新取一次真实状态(接口本身从不缓存)。 + + ``?at=`` 是演示模式的事(演示时间轴是"某个时刻的纯函数",所以能被钉在某一刻)。 + 真实状态只有"现在",所以这里明确忽略查询串——不是忘了处理。 + """ return _status_payload(daemon.status()) provider = live_status diff --git a/ponte/serve.py b/ponte/serve.py index 4ecc494..93b6572 100644 --- a/ponte/serve.py +++ b/ponte/serve.py @@ -29,7 +29,10 @@ * **Read-only, and always fresh.** Every request re-reads the daemon status, so a page can never show a cached "healthy" for a tunnel that has since died. Nothing here can start, stop or reconfigure anything — the worst a leaked - token buys is a read of your port numbers. + token buys is a read of your port numbers. Even the demo clock obeys this: it + is pinned with a ``?at=`` query (see :mod:`ponte.demo`) rather than by poking + the server into a mode, so there is no state to race with, no non-idempotent + endpoint to guard, and two viewers of the same URL see the same board. The one thing this module deliberately does *not* do is invent numbers: the dashboard, the JSON, the health probe and the metrics are all rendered from the @@ -57,6 +60,7 @@ __all__ = [ "PonteHTTPServer", + "Provider", "create_server", "dashboard_html", "health_response", @@ -66,6 +70,16 @@ logger = logging.getLogger(__name__) +#: A status provider: one request's query string in, the payload to render out. +#: +#: The query is part of the contract because ``ponte serve --demo`` pins its +#: timeline with ``?at=`` (:mod:`ponte.demo`). Handing the provider the request is +#: what keeps that a *read* — the alternative, a control endpoint that mutates a +#: clock, would put the first piece of mutable state, and the first +#: non-idempotent route, into a server whose whole posture is "nothing here can +#: change anything". A live daemon simply ignores the argument. +Provider = Callable[[Mapping[str, list[str]]], dict[str, Any]] + #: How many recent retry-loop events a dashboard card shows. _FEED_LIMIT = 8 @@ -753,6 +767,16 @@ def add( cursor: pointer; } button.pause:hover { color: var(--text); border-color: var(--accent); } +/* The demo clock (`ponte serve --demo`). The reading is plain text so it works + without scripting; the buttons are hidden until the script that drives them + is running, because a dead control is worse than no control. */ +.democtl { display: inline-flex; align-items: center; gap: 6px 8px; flex-wrap: wrap; } +.democtl b { color: var(--dim); font-weight: 600; font-variant-numeric: tabular-nums; } +/* Frozen is a *state*, so it is coloured instead of only worded: a screenshot + of a paused clock should say so without anyone reading the label. */ +.democtl.frozen b { color: var(--unk); } +#demobuttons { display: inline-flex; gap: 6px; } +#demobuttons[hidden] { display: none; } .muted { color: var(--faint); } /* Narrow screens: stack the row instead of scrolling sideways. */ @media (max-width: 760px) { @@ -783,11 +807,11 @@ def add(
$board
每 $refresh 秒自动刷新(无脚本时整页刷新) - $demo_note + $demo_controls$demo_note 数据来自 ponte status 的同一份状态,点任意一行看明细 · - status.json · - metrics · - healthz + status.json · + metrics · + healthz