Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<seconds>` 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
Expand Down
29 changes: 22 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<seconds>`): 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 |
|----------|-----------------|
Expand Down Expand Up @@ -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`。

| 接口 | 回答什么问题 |
|------|--------------|
Expand Down
111 changes: 105 additions & 6 deletions ponte/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,19 @@
=================== ==========================================================

用法:``ponte serve --demo``(见 ``ponte.main.serve``)。

时间轴可以被**钉在某一刻**:``?at=<秒>``(相对启动)。锚点是**读**而不是写——时间轴本来
就是"某个时刻的纯函数",所以让那个时刻从请求里来,服务端不必持有任何可变状态;去掉这个
参数就回到实时。于是一个 URL 就能把看板固定在某一刻,截图里自帯"这是哪一刻",而页面上的
按钮只是会改地址栏里那个数字的便利层。
"""

from __future__ import annotations

import math
import os
import time
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

Expand All @@ -38,6 +44,9 @@
#: 事件流保留多少条(与看板的 ``_FEED_LIMIT`` 对齐;多几条无妨,看板会自己截断)。
_FEED_LIMIT = 8

#: ``?at=`` 的上限:一天。更远的锚点没有意义,也顺手挡住离谱输入。
_AT_LIMIT = 86_400.0

#: 这些状态下 SSH 会话还活着——可用率因此把它们算成"在线"。
_ALIVE_STATES = frozenset({CONNECTED, UNKNOWN})

Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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:
"""端口在某一刻的样子。

Expand All @@ -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:
Expand All @@ -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)
14 changes: 9 additions & 5 deletions ponte/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
Loading
Loading