docs: README 跟上授权延长与通道组,截图和动图改为一条命令重新生成
README - 申请延长写清规则:授权有效期间也可申请,有效的从原到期时间顺延、已结束的从批准时起算, 被撤销的不能延长;批准延长时通道会开到授权结束(与最长开启时长的关系) - 修正通道组「一起开关」与「可以单独开关」的矛盾:闲置按整组计算,每个地址仍可单独开关 - 时长选项用界面上的「通道开启期间」;快速开始按实际流程写(保存后自动弹出邀请设置) - 新增同事端申请延长的三张截图;流程图补上 30 分钟、自定义与 2 小时内可申请延长 截图与动图 - 授权管理、编辑通道、邀请文案、授权成功页、网页面板(含通道组)更新为当前界面 - 离屏渲染时控件按激活状态绘制,开关不再显示成灰色 - 新增 make readme-images:App 离屏渲染、动图合成、授权页与网页面板的无头 Chrome 截图一次完成, 写入 docs/images;授权页 HTML 由服务端测试用模拟状态生成,网页面板用本机模拟 API 界面 - 计时文字第二个单位补零(27分05秒),卡片上的徽标不再随秒数左右跳动;App 与网页面板一致 - 待审批与延长申请的时间格式统一为不带秒;App 预览的分享文案补上延长提示
This commit is contained in:
1 parent
6f2a1472b6
commit
3272e66247
26 files changed
+392
-37
No files matched your search
@@ -205,6 +205,8 @@ func makeDemoVM(tick: Int = 0) -> PortRelayViewModel {
|
||||
final class KeyWindow: NSWindow {
|
||||
override var isKeyWindow: Bool { true }
|
||||
override var isMainWindow: Bool { true }
|
||||
/// 渲染进程不会被激活,AppKit 控件(开关、分段控件)按这个私有属性决定是否画成灰色
|
||||
@objc(hasKeyAppearance) func alwaysHasKeyAppearance() -> Bool { true }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -256,12 +258,11 @@ MainActor.assumeIsolated {
|
||||
let demo = makeDemoVM()
|
||||
render("readme-main-light", CGSize(width: 980, height: 1180), ContentView(vm: demo))
|
||||
render("readme-main-dark", CGSize(width: 980, height: 1180), dark: true, ContentView(vm: demo))
|
||||
render("readme-invite", CGSize(width: 560, height: 250), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
|
||||
render("readme-invite", CGSize(width: 600, height: 270), InviteSheet(vm: demo, tunnel: demo.tunnels[0], autoGenerate: true, isPresented: yes))
|
||||
render("readme-clients", CGSize(width: 760, height: 560), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 0, isPresented: yes))
|
||||
render("readme-access", CGSize(width: 760, height: 490), ClientDetailsSheet(vm: demo, tunnel: demo.tunnels[0], initialTab: 2, isPresented: yes))
|
||||
render("readme-edit", CGSize(width: 520, height: 760), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
||||
render("readme-edit", CGSize(width: 520, height: 570), TunnelEditSheet(vm: demo, tunnel: demo.tunnels[0], isPresented: yes))
|
||||
let g = demo.groups[0]
|
||||
render("group-card", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||
render("readme-group", CGSize(width: 980, height: 330), GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||
render("group-card-dark", CGSize(width: 980, height: 330), dark: true, GroupCardView(group: g, members: demo.members(of: g), visible: demo.members(of: g), vm: demo,
|
||||
onEdit: {}, onAddMember: {}, onInvite: {}, onViewClients: { _ in }, onEditMember: { _ in }, onInviteMember: { _ in }, onMemberClients: { _ in }).padding(20))
|
||||
@@ -284,11 +285,11 @@ MainActor.assumeIsolated {
|
||||
// 授权延长:申请延长、最近结束、已授权设备的「延长」
|
||||
let rvm = makeDemoVM()
|
||||
rvm.tunnels[0].renewals = decode([RenewalItem].self, """
|
||||
[{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.35","name":"李四","invite":"ab1","ended_at":"\(t(-1200))","renew_before":"\(t(6000))",
|
||||
"request":{"id":"rn1","tunnel_id":"\(rvm.tunnels[0].id)","name":"李四","ip":"192.168.1.35","invite":"ab1","at":"\(t(-60))","status":"pending","renewal":true}},
|
||||
[{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.47","name":"赵六","invite":"ab1","ended_at":"\(t(-1200))","renew_before":"\(t(6000))",
|
||||
"request":{"id":"rn1","tunnel_id":"\(rvm.tunnels[0].id)","name":"赵六","ip":"192.168.1.47","invite":"ab1","at":"\(t(-60))","status":"pending","renewal":true}},
|
||||
{"tunnel_id":"\(rvm.tunnels[0].id)","ip":"192.168.1.36","name":"王五","invite":"ab2","ended_at":"\(t(-3000))","renew_before":"\(t(4200))"}]
|
||||
""")
|
||||
render("renew-access", CGSize(width: 760, height: 620), ClientDetailsSheet(vm: rvm, tunnel: rvm.tunnels[0], initialTab: 2, isPresented: yes))
|
||||
render("readme-access", CGSize(width: 760, height: 800), ClientDetailsSheet(vm: rvm, tunnel: rvm.tunnels[0], initialTab: 2, isPresented: yes))
|
||||
render("renew-card", CGSize(width: 980, height: 220), TunnelCardView(tunnel: rvm.tunnels[0], vm: rvm, onEdit: {}, onViewClients: {}, onViewLeases: {}, onInvite: {}).padding(20))
|
||||
render("renew-invite-form", CGSize(width: 600, height: 400), InviteSheet(vm: rvm, tunnel: rvm.tunnels[0], autoGenerate: false, isPresented: yes))
|
||||
render("renew-custom", CGSize(width: 300, height: 90), CustomMinutesForm(actionTitle: "延长") { _ in })
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# 重新生成 README 用到的全部截图和动图(模拟数据),写入 docs/images。
|
||||
# 用法: tools/ui-snapshots/readme.sh(即 make readme-images)
|
||||
# 需要 Xcode 命令行工具和 Google Chrome。
|
||||
set -e
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$DIR/../.." && pwd)"
|
||||
IMG="$ROOT/docs/images"
|
||||
|
||||
# App 界面:readme-xxx.png → app-xxx.png
|
||||
"$DIR/render.sh" readme
|
||||
for f in "$DIR"/out/readme-*.png; do
|
||||
name="$(basename "$f")"
|
||||
cp "$f" "$IMG/app-${name#readme-}"
|
||||
done
|
||||
|
||||
# 通道卡片动图:40 帧,每帧相当于 5 秒
|
||||
FRAMES=40 "$DIR/render.sh" gif
|
||||
(cd "$ROOT" && go run ./tools/ui-snapshots/mkgif -o "$IMG/live-traffic.gif" "$DIR"/out/gif-*.png)
|
||||
|
||||
# 授权页与网页面板
|
||||
"$DIR/web.sh"
|
||||
cp "$DIR"/out/web/*.png "$IMG/"
|
||||
echo "✓ 已更新 docs/images"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# 用无头 Chrome 截取 README 里的授权页和网页面板(模拟数据)。
|
||||
# 用法: tools/ui-snapshots/web.sh 输出: tools/ui-snapshots/out/web/*.png
|
||||
# 需要 Google Chrome,路径可用 CHROME 环境变量指定。
|
||||
set -e
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$(cd "$DIR/../.." && pwd)"
|
||||
BUILD="$DIR/.build/web"
|
||||
OUT="$DIR/out/web"
|
||||
CHROME="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
|
||||
[ -x "$CHROME" ] || { echo "找不到 Chrome:$CHROME(可用 CHROME=... 指定)" >&2; exit 1; }
|
||||
rm -rf "$BUILD" "$OUT"
|
||||
mkdir -p "$BUILD" "$OUT"
|
||||
|
||||
# shot URL 宽 高 输出:按 2 倍像素截图,使用临时的浏览器配置
|
||||
shot() {
|
||||
"$CHROME" --headless=new --disable-gpu --hide-scrollbars --no-first-run --no-default-browser-check \
|
||||
--blink-settings=preferredColorScheme=1 --force-device-scale-factor=2 --window-size="$2,$3" --timeout=3000 \
|
||||
--user-data-dir="$BUILD/chrome" --screenshot="$4" "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# 授权页:服务端用模拟状态生成 HTML。无头 Chrome 的窗口最窄约 500pt,
|
||||
# 所以把页面放进居中的 390pt 宽 iframe(手机宽度),截图后从中间裁出 iframe 部分
|
||||
(cd "$ROOT" && PORTRELAY_README_PAGES="$BUILD" go test ./server -run '^TestReadmePages$' -count=1 >/dev/null)
|
||||
for page in "$BUILD"/auth-*.html; do
|
||||
name="$(basename "$page" .html)"
|
||||
printf '<!DOCTYPE html><body style="margin:0;background:#F5F5F7"><iframe src="%s" width="390" height="610" style="border:0;display:block;margin:0 auto"></iframe>' \
|
||||
"$(basename "$page")" >"$BUILD/frame-$name.html"
|
||||
shot "file://$BUILD/frame-$name.html" 520 610 "$OUT/$name.png"
|
||||
sips -c 1220 780 "$OUT/$name.png" >/dev/null
|
||||
echo "captured $name"
|
||||
done
|
||||
|
||||
# 网页面板:本机模拟 Agent API,只监听回环地址
|
||||
(cd "$ROOT" && go build -o "$BUILD/webpanel" ./tools/ui-snapshots/webpanel)
|
||||
"$BUILD/webpanel" "$ROOT/cmd/portrelay-agent/web/index.html" >"$BUILD/webpanel.url" &
|
||||
pid=$!
|
||||
trap 'kill $pid 2>/dev/null' EXIT
|
||||
for _ in $(seq 50); do [ -s "$BUILD/webpanel.url" ] && break; sleep 0.1; done
|
||||
shot "$(head -1 "$BUILD/webpanel.url")" 1280 1124 "$OUT/web-panel.png"
|
||||
echo "captured web-panel"
|
||||
@@ -0,0 +1,178 @@
|
||||
// webpanel 用模拟数据提供 Agent 网页面板,用于 README 截图(tools/ui-snapshots/web.sh 调用)。
|
||||
//
|
||||
// go run ./tools/ui-snapshots/webpanel cmd/portrelay-agent/web/index.html
|
||||
//
|
||||
// 只监听 127.0.0.1 的随机端口,启动后在标准输出打印面板地址。
|
||||
// 数据与 tools/ui-snapshots/main.swift 的 makeDemoVM 保持一致,App 截图和网页截图展示的是同一组通道。
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var base = time.Now()
|
||||
|
||||
func at(sec float64) string {
|
||||
return base.Add(time.Duration(sec * float64(time.Second))).Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// demoRate 与 main.swift 的同名函数相同:几段查询突发叠加平稳传输
|
||||
func demoRate(k int, seed, level, burst float64) int64 {
|
||||
x := float64(k)
|
||||
v := level * (0.55 + 0.25*math.Sin(x/9+seed) + 0.2*math.Sin(x/3.7+seed*2))
|
||||
for c := -60; c <= 600; c += 21 {
|
||||
h := 0.35 + 0.65*math.Abs(math.Sin(float64(c)*0.731+seed))
|
||||
v += burst * h * math.Exp(-math.Pow((x-float64(c)-7*math.Sin(float64(c)+seed))/3.2, 2))
|
||||
}
|
||||
return int64(math.Max(0, v))
|
||||
}
|
||||
|
||||
type series struct {
|
||||
End int64 `json:"end"`
|
||||
Step int `json:"step"`
|
||||
Up []int64 `json:"up"`
|
||||
Down []int64 `json:"down"`
|
||||
}
|
||||
|
||||
func demoSeries(seed, level, burst, upScale float64, points int) series {
|
||||
s := series{End: base.Unix(), Step: 5, Up: make([]int64, 120), Down: make([]int64, 120)}
|
||||
for i := 0; i < points; i++ {
|
||||
s.Down[i] = demoRate(i, seed, level, burst)
|
||||
s.Up[i] = demoRate(i, seed+1.7, level*upScale, burst*upScale)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sum(a, b series) series {
|
||||
s := series{End: a.End, Step: a.Step}
|
||||
for i := range a.Up {
|
||||
s.Up = append(s.Up, a.Up[i]+b.Up[i])
|
||||
s.Down = append(s.Down, a.Down[i]+b.Down[i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func obj(format string, args ...any) map[string]any {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(fmt.Sprintf(format, args...)), &m); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func member(id, host string, port, conns int, firstAgo float64, connectedSec int, bytesIn, bytesOut int64) map[string]any {
|
||||
first := ""
|
||||
if firstAgo != 0 {
|
||||
first = fmt.Sprintf(`"first_connected_at":%q,`, at(firstAgo))
|
||||
}
|
||||
return obj(`{"id":%q,"name":"订单服务 (%s)","target":"%s:22","listen_port":%d,"whitelist":[],"enabled":true,"group_id":"g1","group_name":"订单服务",
|
||||
"owner":"运维组","started_at":%q,"effective_idle_close_minutes":30,"idle_close_minutes":30,%s"connected_sec":%d,
|
||||
"activity":{"in_use":true,"connected_sec":%d},
|
||||
"metrics":{"tunnel_id":%q,"status":"online","active_conns":%d,"total_conns":%d,"bytes_in":%d,"bytes_out":%d,"blocked_attempts":0}}`,
|
||||
id, host, host, port, at(-1800), first, connectedSec, connectedSec, id, conns, conns*5, bytesIn, bytesOut)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
log.Fatal("usage: webpanel path/to/index.html")
|
||||
}
|
||||
page, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
tunnels := []map[string]any{
|
||||
obj(`{"id":"t1","name":"MySQL 数据库","target":"10.0.1.16:3306","listen_port":33660,"whitelist":[],"enabled":true,
|
||||
"owner":"数据组","duration_minutes":240,"started_at":%q,"expires_at":%q,"historical_bytes_in":1288490188,"historical_bytes_out":53687091200,
|
||||
"idle_close_minutes":30,"effective_idle_close_minutes":30,"first_connected_at":%q,"connected_sec":1520,
|
||||
"activity":{"in_use":true,"connected_sec":1525},
|
||||
"metrics":{"tunnel_id":"t1","status":"online","active_conns":3,"total_conns":1287,"bytes_in":245039104,"bytes_out":8703180,"blocked_attempts":12,
|
||||
"dynamic_leases":[{"ip":"192.168.1.21","expires_at":%q,"name":"张三","invite":"a1","duration":"2h","started_at":%q},
|
||||
{"ip":"192.168.1.35","expires_at":%q,"name":"王小明","invite":"a2","duration":"8h","awaiting_connect":true}],
|
||||
"pending":[{"id":"r1","tunnel_id":"t1","name":"李四","ip":"192.168.1.88","invite":"a3","at":%q,"status":"pending"}]}}`,
|
||||
at(-3725), at(10675), at(-3300), at(7000), at(-200), at(80000), at(-60)),
|
||||
member("g1a", "10.0.3.11", 33670, 1, -1500, 1320, 3203180, 18039104),
|
||||
member("g1b", "10.0.3.12", 33671, 2, -1400, 1100, 2203180, 12039104),
|
||||
member("g1c", "10.0.3.13", 33672, 0, 0, 0, 0, 0),
|
||||
obj(`{"id":"t2","name":"Grafana 监控面板","target":"10.0.2.30:3000","listen_port":33661,"whitelist":["192.168.1.0/24"],"enabled":true,
|
||||
"owner":"运维组","duration_minutes":0,"started_at":%q,"effective_idle_close_minutes":0,"idle_close_minutes":0,"first_connected_at":%q,"connected_sec":36120,
|
||||
"activity":{"in_use":true,"connected_sec":36120},
|
||||
"metrics":{"tunnel_id":"t2","status":"online","active_conns":2,"total_conns":342,"bytes_in":52039104,"bytes_out":2703180,"blocked_attempts":0}}`,
|
||||
at(-50000), at(-49000)),
|
||||
obj(`{"id":"t3","name":"SSH 跳板机(测试环境)","target":"10.0.8.3:22","listen_port":33662,"whitelist":[],"enabled":true,
|
||||
"owner":"运维组","duration_minutes":0,"started_at":%q,"effective_idle_close_minutes":30,"idle_close_minutes":30,"first_connected_at":%q,"connected_sec":640,
|
||||
"activity":{"in_use":false,"idle_since":%q,"auto_close_at":%q,"connected_sec":640},
|
||||
"metrics":{"tunnel_id":"t3","status":"online","active_conns":0,"total_conns":4,"bytes_in":1983000,"bytes_out":204000,"blocked_attempts":0}}`,
|
||||
at(-2100), at(-2000), at(-720), at(1080)),
|
||||
obj(`{"id":"t4","name":"Redis 缓存","target":"10.0.1.20:6379","listen_port":33663,"whitelist":[],"enabled":false,"last_duration_sec":1905,"duration_minutes":0,
|
||||
"effective_idle_close_minutes":30,"last_connected_sec":742,"close_reason":"闲置 30 分钟自动关闭","historical_bytes_in":3145728,"historical_bytes_out":524288,
|
||||
"metrics":{"tunnel_id":"t4","status":"disabled"}}`),
|
||||
}
|
||||
rates := map[string]series{
|
||||
"t1": demoSeries(0.4, 26000, 150000, 0.08, 120),
|
||||
"t2": demoSeries(2.1, 9000, 30000, 0.15, 120),
|
||||
"t3": demoSeries(6, 1500, 9000, 0.2, 30), // 前一段时间有流量,之后闲置
|
||||
"g1a": demoSeries(3.3, 4000, 26000, 0.3, 120),
|
||||
"g1b": demoSeries(4.6, 4000, 26000, 0.3, 120),
|
||||
"g1c": demoSeries(0, 0, 0, 0, 0),
|
||||
}
|
||||
for _, t := range tunnels {
|
||||
if s, ok := rates[t["id"].(string)]; ok {
|
||||
t["rate_history"] = s
|
||||
m := t["metrics"].(map[string]any)
|
||||
m["rate_in"], m["rate_out"] = s.Up[len(s.Up)-1], s.Down[len(s.Down)-1]
|
||||
}
|
||||
}
|
||||
groups := []map[string]any{obj(`{"id":"g1","name":"订单服务","owner":"运维组","created_at":%q,"tunnel_ids":["g1a","g1b","g1c"],
|
||||
"metrics":{"group_id":"g1",
|
||||
"dynamic_leases":[{"ip":"192.168.1.42","expires_at":%q,"name":"小王","invite":"gx1","duration":"8h","started_at":%q}],
|
||||
"pending":[{"id":"gr1","tunnel_id":"","group_id":"g1","name":"小李","ip":"192.168.1.57","invite":"gx2","at":%q,"status":"pending"}]}}`,
|
||||
at(-1800), at(27000), at(-1500), at(-40))}
|
||||
groups[0]["rate_history"] = sum(rates["g1a"], rates["g1b"])
|
||||
|
||||
status := map[string]any{
|
||||
"connected": true, "server_addr": "192.168.1.10:33000", "public_host": "192.168.1.10",
|
||||
"invite_base": "http://192.168.1.10:33000/i/", "total_rate_history": sum(rates["t1"], rates["t2"]),
|
||||
"port_range": "33001 - 34000", "timestamp": base,
|
||||
}
|
||||
stream := map[string]any{"metrics": map[string]any{"active_conns": []any{}, "security_events": []any{}}, "tunnels": tunnels, "groups": groups}
|
||||
for k, v := range status {
|
||||
stream[k] = v
|
||||
}
|
||||
|
||||
serveJSON := func(v any) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(page)
|
||||
})
|
||||
mux.HandleFunc("/api/status", serveJSON(status))
|
||||
mux.HandleFunc("/api/tunnels", serveJSON(tunnels))
|
||||
mux.HandleFunc("/api/groups", serveJSON(groups))
|
||||
mux.HandleFunc("/api/stream", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
b, _ := json.Marshal(stream)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("http://%s/\n", ln.Addr())
|
||||
log.Fatal(http.Serve(ln, mux))
|
||||
}
|
||||
Reference in new issue
Block a user