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