5 Commits
Author SHA1 Message Date
windyboy 2c5d5e3d45 VPS-90: clarify CSG prediction estimates 2026-09-22 08:42:21 +08:00
windyboy 3745c7573c VPS-90: record CSG dashboard v4 rollout 2026-09-22 08:33:06 +08:00
windyboy 812a1eb77d docs(hass): Quick 面板 card-mod 等高 + 饼图图例右侧 + WS 客户端脚本
- 安装 card-mod (HACS),用 CSS `ha-card { height: Npx }` 强制等高
- 饼图+人体感应 520px,温湿度双图 520px
- 饼图图例从底部移到右侧 (legend position: right),高度 510→520
- 关键认知:apexcharts chart.height 含图例,timescale-plotly height 是纯绘图区
- 提交 scripts/ha-ws-client.py (纯 stdlib WS 客户端,文档已引用)
2026-09-21 19:57:35 +08:00
windyboy e08e5d50de docs(us2): gitea-health runbook + 收口 Gitea 文档漂移 (Plane VPS-94)
- runbooks/gitea-health.md 新建: 只读健康检查(容器/版本pin/web/SSH/备份/内存闸门)+ AGENTS 与索引登记
- AGENTS.md: Gitea upstream 段(digest 升级流程/app.ini 600 含密钥)、SSH 示例改 git@、runbook 表加行
- inventory/hosts.md + hosts/us2: repo.windy.me SSH 用户改 git@; us2 角色行 Gitea 化
- hosts/us2: 仓库数修正 16→17(windy/13 含 vps、go-caatsm + cdia/4); Verified 补 2026-09-19 独立审核结果
- 本仓库 remote 已改写为 ssh://git@repo.windy.me:2222/windy/vps.git(VPS-94 Phase 3 漏项)
2026-09-19 14:22:05 +08:00
windyboy ff1a92110c docs(us2): Soft Serve → Gitea 迁移事实与参考镜像 (Plane VPS-94)
- hosts/us2: Gitea 1.27.3-rootless 部署实况 (repo.windy.me SSH:2222/Web), 16 仓迁移核对, 备份/回滚; soft-serve 停用保留作回滚
- compose/gitea: 参考镜像 (rootless compose + 备份 sidecar + 一次性迁移脚本留档)
- AGENTS/inventory/compose README: 服务表与索引同步
2026-09-18 17:24:35 +08:00
15 changed files with 674 additions and 33 deletions
+5 -2
View File
@@ -23,7 +23,7 @@ This repo is the **agent ops handbook + fact source** for maintaining personal V
> so plain `ssh` aborts with `Bad owner or permissions on ...`. Always use > so plain `ssh` aborts with `Bad owner or permissions on ...`. Always use
> `ssh -F /dev/null` from the agent shell and pass options explicitly > `ssh -F /dev/null` from the agent shell and pass options explicitly
> (`~/.ssh/config` is skipped; e.g. `ssh -F /dev/null -p 2222 > (`~/.ssh/config` is skipped; e.g. `ssh -F /dev/null -p 2222
> -i ~/.ssh/id_ed25519 windy@repo.windy.me`). `sudo` never works in the > -i ~/.ssh/id_ed25519 git@repo.windy.me`). `sudo` never works in the
> sandbox (`NoNewPrivs`, no capabilities, `/` read-only). The host itself is > sandbox (`NoNewPrivs`, no capabilities, `/` read-only). The host itself is
> healthy — to inspect or act on the real host from the sandbox use > healthy — to inspect or act on the real host from the sandbox use
> `/mnt/c/WINDOWS/system32/wsl.exe -u root -- <cmd>` (real root: keep > `/mnt/c/WINDOWS/system32/wsl.exe -u root -- <cmd>` (real root: keep
@@ -63,7 +63,7 @@ not maintain a second copy of the machine table here.
| SMTP `mx2.windy.me:587` (STARTTLS) or `:465` | mx2 | client submission; full email + mailbox password — [runbook](runbooks/mailcow-smtp-client.md) | | SMTP `mx2.windy.me:587` (STARTTLS) or `:465` | mx2 | client submission; full email + mailbox password — [runbook](runbooks/mailcow-smtp-client.md) |
| IMAP `mx2.windy.me:993` | mx2 | same mailbox credentials | | IMAP `mx2.windy.me:993` | mx2 | same mailbox credentials |
| https://auth.wsvc.info | us2 (`/opt/vaultwarden`) | Vaultwarden (Postgres, **operational**) — client Server URL | | https://auth.wsvc.info | us2 (`/opt/vaultwarden`) | Vaultwarden (Postgres, **operational**) — client Server URL |
| `repo.windy.me:2222` | us2 (`/opt/soft-serve`) | Soft Serve git — v0.12.2 pinned, backup sidecar; details in [hosts/us2.wsvc.info.md](hosts/us2.wsvc.info.md) | | `repo.windy.me` (git SSH `:2222` / web HTTPS) | us2 (`/opt/gitea`) | Gitea — 1.27.3-rootless pinned, backup sidecar; details in [hosts/us2.wsvc.info.md](hosts/us2.wsvc.info.md) |
| DNS `ns1.wsvc.info:53` | hk2 (`/opt/pdns`, Auth **5.0.6**) | PowerDNS auth — zones `windy.me`, `wsvc.info`, `chans.xyz` | | DNS `ns1.wsvc.info:53` | hk2 (`/opt/pdns`, Auth **5.0.6**) | PowerDNS auth — zones `windy.me`, `wsvc.info`, `chans.xyz` |
| https://pdns.wsvc.info | hk2 (`poweradmin`) | Poweradmin UI | | https://pdns.wsvc.info | hk2 (`poweradmin`) | Poweradmin UI |
| https://pgweb.wsvc.info | hk2 (`pgweb`) | PowerDNS Postgres browser | | https://pgweb.wsvc.info | hk2 (`pgweb`) | PowerDNS Postgres browser |
@@ -85,6 +85,8 @@ not maintain a second copy of the machine table here.
**RustDesk:** Self-hosted RustDesk server on `hk2.chans.xyz` (`/opt/rustdesk`, containers `hbbs`/`hbbr`, image pinned `1.1.14`). The `hbbs -r` relay hostname must resolve to the host's public IP `154.36.174.161` — use `hk2.chans.xyz` (never `hk2.wsvc.info`, which has no DNS record). Health: [rustdesk-health](runbooks/rustdesk-health.md). **RustDesk:** Self-hosted RustDesk server on `hk2.chans.xyz` (`/opt/rustdesk`, containers `hbbs`/`hbbr`, image pinned `1.1.14`). The `hbbs -r` relay hostname must resolve to the host's public IP `154.36.174.161` — use `hk2.chans.xyz` (never `hk2.wsvc.info`, which has no DNS record). Health: [rustdesk-health](runbooks/rustdesk-health.md).
**Gitea (on us2):** Self-hosted Gitea at `repo.windy.me` (web HTTPS + git SSH `:2222`; clone `ssh://git@repo.windy.me:2222/windy/<repo>.git` — user must be `git@`, path needs the owner segment). Replaced Soft Serve 2026-09-18 (Plane VPS-94; `/opt/soft-serve` kept stopped as rollback). Before changing `/opt/gitea`, read [hosts/us2.wsvc.info.md](hosts/us2.wsvc.info.md). Image digest-pinned (`1.27.3-rootless`); upgrade = new digest in `compose.yml` + `docker compose up -d` + re-run health. `config/app.ini` is mode 600 and holds SECRET_KEY/INTERNAL_TOKEN — never commit; the backup sidecar includes it because restores need it. Health: [gitea-health](runbooks/gitea-health.md).
## Runbooks & scripts ## Runbooks & scripts
| Task | Path | | Task | Path |
@@ -100,6 +102,7 @@ not maintain a second copy of the machine table here.
| Matrix health | [runbooks/matrix-health.md](runbooks/matrix-health.md) | | Matrix health | [runbooks/matrix-health.md](runbooks/matrix-health.md) |
| Plane health | [runbooks/plane-health.md](runbooks/plane-health.md) | | Plane health | [runbooks/plane-health.md](runbooks/plane-health.md) |
| RustDesk health (hk2) | [runbooks/rustdesk-health.md](runbooks/rustdesk-health.md) | | RustDesk health (hk2) | [runbooks/rustdesk-health.md](runbooks/rustdesk-health.md) |
| Gitea health (us2) | [runbooks/gitea-health.md](runbooks/gitea-health.md) |
| AdGuard Home health | [runbooks/adguard-home-health.md](runbooks/adguard-home-health.md) | | AdGuard Home health | [runbooks/adguard-home-health.md](runbooks/adguard-home-health.md) |
| Host disk cleanup | [runbooks/host-disk-cleanup.md](runbooks/host-disk-cleanup.md) | | Host disk cleanup | [runbooks/host-disk-cleanup.md](runbooks/host-disk-cleanup.md) |
| Home Assistant maintenance | [runbooks/home-assistant-maintenance.md](runbooks/home-assistant-maintenance.md) + [scripts/ha-maintenance.sh](runbooks/scripts/ha-maintenance.sh) | | Home Assistant maintenance | [runbooks/home-assistant-maintenance.md](runbooks/home-assistant-maintenance.md) + [scripts/ha-maintenance.sh](runbooks/scripts/ha-maintenance.sh) |
+2 -1
View File
@@ -11,7 +11,8 @@ files: every secret is a `${VAR}` reference resolved from the **server-local
| `vaultwarden` | us2 (`/opt/vaultwarden`) | `compose/vaultwarden/compose.yml` | static file + `compose-deploy.yml` | | `vaultwarden` | us2 (`/opt/vaultwarden`) | `compose/vaultwarden/compose.yml` | static file + `compose-deploy.yml` |
| `pdns` | hk2 (`/opt/pdns`) | `compose/pdns/compose.yml` | static file + `compose-deploy.yml` | | `pdns` | hk2 (`/opt/pdns`) | `compose/pdns/compose.yml` | static file + `compose-deploy.yml` |
| `pgdb` | pgdb (`/opt/database`, 无 ansible) | `compose/pgdb/compose.yml` | static file(手动部署:scp → `docker compose config -q``up -d`;服务器文件名 `docker-compose.yml` | | `pgdb` | pgdb (`/opt/database`, 无 ansible) | `compose/pgdb/compose.yml` | static file(手动部署:scp → `docker compose config -q``up -d`;服务器文件名 `docker-compose.yml` |
| `soft-serve` | us2 (`/opt/soft-serve`) | `compose/soft-serve/compose.yml` (+ `Dockerfile.backup`, `scripts/`) | static file(参考镜像, 未接入 compose-deploy; 服务器文件为准 | | `soft-serve` | us2 (`/opt/soft-serve`, 已退役停用) | `compose/soft-serve/compose.yml` (+ `Dockerfile.backup`, `scripts/`) | static file(参考镜像; 2026-09-18 被 gitea 替换 VPS-94, 数据保留作回滚 |
| `gitea` | us2 (`/opt/gitea`) | `compose/gitea/compose.yml` (+ `Dockerfile.backup`, `scripts/`) | static file(参考镜像, 未接入 compose-deploy; 服务器文件为准; 2026-09-18 替换 soft-serve, VPS-94 |
| `adguardhome` | dns.windy.lan (`/opt/adguardhome`) | — (待从 LAN 提取) | static file (pending) | | `adguardhome` | dns.windy.lan (`/opt/adguardhome`) | — (待从 LAN 提取) | static file (pending) |
| `unifi` | ubnt (`/home/windy/unifi-9`) | — (待从 LAN 提取) | static file (pending) | | `unifi` | ubnt (`/home/windy/unifi-9`) | — (待从 LAN 提取) | static file (pending) |
| `wireguard` | us4 (`/opt/wireguard`) | `ansible/templates/wireguard-compose.yml.j2` | role-rendered (inventory vars) | | `wireguard` | us4 (`/opt/wireguard`) | `ansible/templates/wireguard-compose.yml.j2` | role-rendered (inventory vars) |
+4
View File
@@ -0,0 +1,4 @@
# compose/gitea — 秘密一律走服务器本地 .env, 不入库
# 迁移期一次性: Gitea 管理员生成的 token (mirror-migrate.sh 读取, 用后撤销)
GITEA_MIGRATE_USER=
GITEA_MIGRATE_TOKEN=
+3
View File
@@ -0,0 +1,3 @@
FROM alpine:3.20
RUN apk add --no-cache sqlite rsync tzdata
WORKDIR /scripts
+59
View File
@@ -0,0 +1,59 @@
# Gitea on us2 — reference compose (Plane VPS-94, 迁移完成 2026-09-18)
# 参考镜像, 服务器 /opt/gitea 文件为准 (同 soft-serve 约定, 未接入 compose-deploy)
# rootless 镜像: uid 1000 原生非 root; 数据 /var/lib/gitea (宿主 ./data), 配置 /etc/gitea (宿主 ./config)
# SSH: 容器内监听 2322 (非特权, SSH_LISTEN_PORT), 对外 repo.windy.me:2222 经 Traefik TCP entrypoint `ssh`
services:
gitea:
image: gitea/gitea@sha256:1c17ecaead42eb3b5391553d8708103a4beb0e86edf5b9ebc1eb269c318845f2 # 1.27.3-rootless
container_name: gitea
restart: unless-stopped
user: "1000:1000"
environment:
TZ: Asia/Shanghai
volumes:
- ./data:/var/lib/gitea
- ./config:/etc/gitea
- ./secrets:/secrets:ro # 复用的 soft-serve host key (SSH_SERVER_HOST_KEYS)
networks:
- traefik
labels:
- traefik.enable=true
# Web UI: repo.windy.me (2026-09-18 操作者决定复用现有域名, 免 DNS 变更)
- traefik.http.routers.gitea-web.rule=Host(`repo.windy.me`)
- traefik.http.routers.gitea-web.entrypoints=websecure
- traefik.http.routers.gitea-web.tls.certresolver=letsencrypt
- traefik.http.services.gitea-web.loadbalancer.server.port=3000
# SSH: 接管 :2222 (entrypoint 已存在, router 动态生效, 无需重启 Traefik)
- traefik.tcp.routers.gitea-ssh.entrypoints=ssh
- traefik.tcp.routers.gitea-ssh.rule=HostSNI(`*`)
- traefik.tcp.routers.gitea-ssh.tls=false
- traefik.tcp.services.gitea-ssh.loadbalancer.server.port=2322
gitea-backup:
build:
context: .
dockerfile: Dockerfile.backup
container_name: gitea-backup
restart: unless-stopped
volumes:
- ./data:/data:ro
- ./config:/config:ro
- ./backups:/backup
- ./scripts:/scripts
environment:
TZ: Asia/Shanghai
BACKUP_UID: 1000
BACKUP_GID: 1000
entrypoint: >
/bin/sh -ec "
umask 077 &&
touch /backup/backup.log &&
crontab /scripts/crontab.txt &&
echo '[INFO] gitea backup cron installed' &&
crond -f -l 8
"
networks:
traefik:
external: true
name: vw-net
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
umask 077
D() { date "+%Y-%m-%d %H:%M:%S"; }
TS=$(date +%Y%m%d_%H%M%S)
OUT="/backup/gitea_${TS}"
mkdir -p "$OUT"
echo "[$(D)] Starting gitea backup -> $OUT"
# rootless 布局: app.ini=/etc/gitea(宿主 ./config), db+repos=/var/lib/gitea/data(宿主 ./data/data)
# app.ini 含 SECRET_KEY/INTERNAL_TOKEN — 恢复 2FA/session/mirror 凭据必需
tar czf "$OUT/app.ini.tar.gz" -C /config app.ini
sqlite3 /data/data/gitea.db ".backup '$OUT/gitea.db'"
rsync -a /data/data/git/repositories/ "$OUT/repos/"
tar czf "$OUT/repos.tar.gz" -C "$OUT" repos
rm -rf "$OUT/repos"
chmod 600 "$OUT"/*
if [ -n "${BACKUP_UID:-}" ] && [ -n "${BACKUP_GID:-}" ]; then
chown -R "$BACKUP_UID:$BACKUP_GID" "$OUT" /backup/backup.log
fi
echo "[$(D)] Backup OK: $(du -sh "$OUT" | cut -f1)"
+4
View File
@@ -0,0 +1,4 @@
# Run gitea backup daily at 02:00
0 2 * * * /bin/sh /scripts/backup.sh >> /backup/backup.log 2>&1
# Prune backups older than 14 days daily at 03:00
0 3 * * * /bin/sh /scripts/prune.sh >> /backup/backup.log 2>&1
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# 一次性迁移辅助 (Plane VPS-94 Phase 2): 在 gitea 容器内执行。
# 已于 2026-09-18 执行完成 (16 仓), 留档备查; 复用时按 VPS-94 流程重生成一次性 token。
# 用法:
# GITEA_MIGRATE_USER=<user> GITEA_MIGRATE_TOKEN=<token> \
# docker exec -e GITEA_MIGRATE_USER -e GITEA_MIGRATE_TOKEN gitea \
# /scripts/mirror-migrate.sh [public_repo ...]
# 每仓: API 建仓 (默认 private, 参数中列出的为 public) -> push --mirror。
# default_branch 按源仓 symbolic-ref HEAD 设置, 避免非 main 源仓在 Gitea 显示为空。
# 结束后按 VPS-94 Phase 3 逐仓核对 git ls-remote ref 全集。
set -eu
MUSER="${GITEA_MIGRATE_USER:?need GITEA_MIGRATE_USER}"
TOKEN="${GITEA_MIGRATE_TOKEN:?need GITEA_MIGRATE_TOKEN}"
SRC="/migration-src"
API="http://localhost:3000/api/v1"
PUBLIC_REPOS=" $* "
migrate_one() {
dir="$1"
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[SKIP] $dir (not a git repo)"; return 0; }
name=$(basename "$dir"); name=${name%.git}
def_branch=$(git -C "$dir" symbolic-ref --short HEAD)
case "$PUBLIC_REPOS" in *" $name "*) private=false ;; *) private=true ;; esac
echo "[MIGRATE] $name (default=$def_branch private=$private)"
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/user/repos" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"$name\",\"private\":$private,\"default_branch\":\"$def_branch\",\"auto_init\":false}")
case "$code" in
201) : ;;
409) echo " [WARN] $name 已存在, 直接补推" ;;
*) echo " [FAIL] create HTTP $code"; return 1 ;;
esac
git -C "$dir" push --mirror "http://$MUSER:$TOKEN@localhost:3000/$MUSER/$name.git"
echo " [OK] $name pushed"
}
for dir in "$SRC"/*.git "$SRC"/cdia; do
[ -d "$dir" ] || continue
migrate_one "$dir"
done
echo "[DONE] 全部处理完毕; 迁移后记得撤销一次性 token"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
D() { date "+%Y-%m-%d %H:%M:%S"; }
ls -dt /backup/gitea_* 2>/dev/null | tail -n +15 | xargs -r rm -rf
echo "[$(D)] Pruned. Kept $(ls -d /backup/gitea_* 2>/dev/null | wc -l) backups (max 14)"
+216 -14
View File
@@ -305,14 +305,101 @@ supervisor 代理 WS 被拒(loop prevention),用
0.589 元/kWh、15 个 csg_* 数值齐全、回归值不变(14.09/198.65/331.22/ 0.589 元/kWh、15 个 csg_* 数值齐全、回归值不变(14.09/198.65/331.22/
304.99/181.89)、automations on、`ha core check` OK、日志无 template 错误。 304.99/181.89)、automations on、`ha core check` OK、日志无 template 错误。
**CSG 长期归档(W1N-243, 2026-08-29:** scribe 库新增 `csg_history` **CSG 面板 Sections 重构 2026-09-22VPS-90 v4:**
表(逐日 usage/cost/ladder/balance + 逐月累计;2026-07-01 起回填,永久), `dashboard-unknown/power-monitor` 从单列 Masonry 改为原生 Sections(最多 2 列、
由 TimescaleDB 每日任务 **1008** `csg_daily_snapshot()`22:30 关闭 dense placement),并新增子视图 `power-details`。主页按「本月概览 → 阶梯
Asia/Shanghai**TS job 非 pg_cron**upsert 维护。日费用在原生 电价 → 最新数据/季节累计 → 近 30 日趋势」组织:概览只保留本月用电/阶梯电费
`latest_day_cost` 缺失时回退 = 昨日用电 × 当前档费率(模板 与明确标为「预计」的用电/电费;余额因口径未独立验证不展示;欠费仅在
`csg_current_ladder_tariff` 0.639);月费用回退模板 `sensor.0800041935246530_arrears > 0` 时显示。详情页承载上月、近 30 日、月日均、
`csg_this_month_ladder_cost`。**语义**day 行 usage/cost 为该日值, 环比及本年/去年账单统计;移除无决策价值的 `csg_prediction_progress` 展示。
ladder/balance 为 22:30 快照值。详见 [hosts/pgdb.md](../hosts/pgdb.md)。 趋势图使用 `custom:apexcharts-card` 读取 `sensor.csg_history.history_data` 最近 30 条
(该属性来源是上月 + 本月逐日明细),画日柱和 30 日均线,不接 Timescale reader
两条 series 均显式覆盖 `unit: kWh`,避免继承 `sensor.csg_history` 的记录数单位
「条」。用户首轮目视反馈后,「最新数据与本月进度」改为跨两栏:最新日用电与
本月日均各占桌面半栏,当前季节 gauge 全宽,窄屏由 Sections 自动回落堆叠。
外部实施审核收口时补齐预测不确定性标识:两张 Tile 分别命名为
「预计本月用电(估算)」/「预计本月电费(估算)」,且「本月概览」副标题明确
写明「预测按当前速率推算,月内会调整」;仅改展示文案,不改计算或实体。
季节 gauge 由 UI Template Helper 自动切换:配置入口
`01M337A71HTAEBF8AB769J02WF`domain `template`title
`CSG Summer Tariff`source `user`state `loaded`),实体
`binary_sensor.csg_summer_tariff`,状态模板
`{{ 5 <= now().month <= 10 }}``on` 显示夏季 `max 650 / 260 / 600``off`
显示冬季 `max 450 / 200 / 400`;状态既非 on 也非 off 时显示警告。现有 4-25 /
10-25 Matrix 提醒保留,不改 automation;该 helper 当前只被此 dashboard 引用。
写入前 WS 快照:
`/homeassistant/.lovelace-backups/dashboard-unknown-power-monitor-20260922-0815-pre-sections-v4.json`
4953 bytesSHA-256
`f5e3e05ab29a4c13b870547b0a189e95b793d85e82c85c61190dedf66811a874`)。
首轮 v4 到布局/单位修正前的第二快照:
`/homeassistant/.lovelace-backups/dashboard-unknown-power-monitor-20260922-0825-pre-layout-unit-fix.json`
SHA-256 `8b56e1303572405d7c850305ffd435cc5c7ca583330160fdd3f8b213473e1fcd`)。
外部审核文案修正前的第三快照:
`/homeassistant/.lovelace-backups/dashboard-unknown-power-monitor-20260922-0838-pre-estimate-copy-fix.json`
(文件 SHA-256 `b1c1e0c6946818ce7fb44d5d731e3e78aebe9c8d51ed465ce8505a0f1855c05c`)。
验证:`lovelace/config/save` 后规范化 JSON 哈希与 payload 一致;22 个引用实体全部
存在且无 unknown/unavailable;夏季 helper=`on`;图表 30 条为 2026-08-22 至
2026-09-20,重算均值 9.15 kWh 与 `sensor.csg_30d_avg` 一致;最终文案修正后的
规范化配置哈希为
`c16d11c01353f90761da5837b06cc7ad1ea7b400f249c99cf291c03f4980a28d`。浏览器没有 HA
登录态,390/768/桌面三宽度视觉验收仍待人工登录后完成。回滚时先把上述 JSON
经 WS `lovelace/config/save` 写回;再扫描 helper 引用,确认无消费者后才可删除
配置入口,勿直改 `.storage/`
**CSG 长期归档(W1N-243, 2026-08-29;任务改号 2026-09-21:** scribe 库新增
`csg_history` 表(逐日 usage/cost/ladder/balance + 逐月累计;2026-07-01 起回填,
永久),由 TimescaleDB 每日任务 **1010**(原 10082026-09-21 重建)
`csg_daily_snapshot()`**14:30 UTC = 22:30 Asia/Shanghai****TS job 非 pg_cron**
upsert 维护;计费用 `csg_ladder_cost(kwh, month)`。日行 usage 取集成
`yesterday_kwh`,日费用在原生 `latest_day_cost` 缺失时回退 = 昨日用电 ×
当前档费率(模板 `csg_current_ladder_tariff`);月行由阶梯助手重算。**语义**:
day 行 usage/cost 为该日值,ladder/balance 为快照值。详见
[hosts/pgdb.md](../hosts/pgdb.md) § Known issues 2026-09-21(含原 1008 从未成功
的根因与回填)。
**CSG 预测值复核(2026-09-21,只读):**
`sensor.csg_*` 全部 14 个派生值用独立 Python 实现按同一输入重算,逐个与 HA
live state 完全一致(`csg_30d_avg` 9.07 / `csg_predicted_usage` 268.45 /
`csg_predicted_cost_ladder` 158.54 / `csg_this_month_ladder_cost` 99.35 /
`csg_mom_change` -21.6 等)。同时把 9 月逐日口径对回 scribe:集成
`this_month_by_day`09-01..09-1919 天,和 168.68)与 `states_raw`
`yesterday_kwh` 观测值逐日一致(唯一差异 09-06 = 9.29 是 scribe 漏采、集成有值),
确认输入数据无缺口。**因此「预测本月电费」不是算错,而是估计器的窗口问题**:
`csg_predicted_usage = 本月累计 + avg × 剩余天数`,旧版 `avg = csg_30d_avg`
`history_data[-30:]` 的**普通 30 日均值、跨月重叠**)→ 月初被上月(更热)拖高
09-02 实测 317.93 kWh / 190.16 元),随本月凉快日置换回落到 268.45 / 158.54
一个月内摆动 **-17%**。**另注**:集成原生
`latest_day_cost` / `this_month_total_cost` / `last_month_total_cost`
2026-08-29 起**恒为 unknown**`states_raw` 无一条有值),故面板上所有「元」
均为模板阶梯估算值,从未与账单口径对账。
**`csg_sensors.yaml` 预测速率 v22026-09-21,已实施):** 只改
`csg_predicted_usage` 的速率项,`csg_30d_avg` 语义不动(面板「近30日平均」照旧)。
新公式:`avg = mtd × w + recent7 × (1 w)`,其中 `mtd = 本月累计 / 已过天数`
`recent7` = `history_data` 末 7 条均值、`w = min(已过天数, 14) / 14`;两者都取不到
才退回 `csg_30d_avg`。**动机**:本月至今速率按天数爬升可信度,避免月初被上月污染;
近 7 日速率兜底,因为月初唯一的新数据就是上月末。Block C 触发器加
`sensor.csg_history`。**19 天回测**(基准 = 第 19 天本月至今速率外推,逐日重放
集成 `this_month_by_day` + `last_month_by_day`):
| 速率方案 | 预测电费摆动 | 最大偏差 | 平均绝对偏差 |
|---|---|---|---|
| 旧:30 日均值 | 21.1% | 35.99 元 | 14.04 元 |
| v2K=7 | 14.6% | 16.81 元 | 5.65 元 |
| **v2K=14(采用)** | **13.9%** | **15.33 元** | **4.94 元** |
| v2K=14 但兜底用 30 日均值 | 23.3% | 33.12 元 | 6.28 元 |
(末行证实病根就是那个跨月的 30 日均值兜底。)备份
`/homeassistant/.csg-backups/csg_sensors.yaml.bak-20260921-pre-predictor`
(改前)与 `…-pre-k14`K=7 中间态)。**验证**`ha core check` OKCore 重启两次
trigger 模板块不吃 `template.reload`W1N-114 先例);15 个 `csg_*` 全部数值、
无 template 错误;当天 `n=19 ≥ 14` 故两版同值,面板读回
`csg_predicted_usage` 266.34 kWh / `csg_predicted_cost_ladder` 157.19 元 /
`csg_prediction_progress` 63.3%;新值已进 scribe 归档
`states_raw` 08:17:04Z 158.54 → 08:17:10Z 157.19)。**注意**`w` 的爬升窗口
(14 天)是回测选出来的单月参数,属弱证据,换季/换季后值得复看。
> **Seasonal gauge switch (W1N-240 已知事项):** 每年 **11-01** 把 > **Seasonal gauge switch (W1N-240 已知事项):** 每年 **11-01** 把
> `power-monitor` 视图「本月累计」gauge 切到冬季 `max:450` / > `power-monitor` 视图「本月累计」gauge 切到冬季 `max:450` /
@@ -563,13 +650,128 @@ manual copies): reader `remmob/timescale_database_reader` **v1.1.0** (main
各 timescale 卡可选档上调——大功率电器/常驻负载 `…,24h``+3d,7d` 各 timescale 卡可选档上调——大功率电器/常驻负载 `…,24h``+3d,7d`
环境 `6h,12h,24h,48h``+7d,14d,30d`;人体感应 `…,24h``+3d,7d` 环境 `6h,12h,24h,48h``+7d,14d,30d`;人体感应 `…,24h``+3d,7d`
用电量(按插座) `energy_time_ranges` `today,week,month,custom``+3mo` 用电量(按插座) `energy_time_ranges` `today,week,month,custom``+3mo`
**默认档未改**6h / 6h / today / 24h / 12h)。卡片 JS 只接受 **默认档未改**6h / 6h / today / 24h / 12h)。备份
`<n>m|<n>h|<n>d``parseDurationToMs` 正则 `/^(\d+)(m|h|d)$/`
仅 m/h/d,无 w)与命名档 `today|week|month|3mo|6mo|year|years|custom`
`energy_mode` 卡必须用后者。**数据下界注意**:scribe `sensor_minute`
目前最早只到 **2026-08-29**,所以 >15d 的档(14d 边缘、30d 明显)前半段
会是空白,等归档继续累积才好看。备份
`.lovelace-backups/dashboard-quick-20260913-190912-pre-timerange.json` `.lovelace-backups/dashboard-quick-20260913-190912-pre-timerange.json`
⚠️ **本条的 `d` 档当天其实是坏的**——见下方 2026-09-21 修复;`energy_mode`
命名档(today/week/month/3mo)当时就是好的。卡片命名档只能是
`today|week|month|3mo|6mo|year|years|custom`。**数据下界注意**scribe
`sensor_minute` 最早只到 **2026-08-29**,所以 >15d 的档(14d 边缘、30d 明显)
前半段仍是空白,等归档继续累积才好看。
- **Quick 面板改造 + timescale 卡两个真 bug 修复 (2026-09-21)**:起因是用户
觉得「面板标题 + 统计方式」不对劲。只读审查查出三个问题并全部修掉:
**① 卡 JS`<n>d` 档实际渲染成 n 小时(VPS-92 的扩容其实没生效)**。
非 energy 档走 `const hours = parseInt(this._selectedRange) || 24;`
`parseInt("7d") === 7`。全卡唯一能解析 `d``parseDurationToMs()`
(正则 `/^(\d+)(m|h|d)$/`**只被 `resolveEnergyWindow()` 调用一次**
即只服务 energy 卡。后果:`3d``24h` 还短(3h),`14d`→14h、`30d`→30h。
**修复**:改用 `Math.max(1, parseDurationToMs(...)/3600000)`;顺带把采样密度
目标 80 → 160 点(7d 档从 2.1h/桶降到 1.05h/桶)。备份
`.lovelace-backups/timescale-plotly-card.js.bak-20260921-pre-dayfix`
实测(本地 node 跑卡里的 `parseDurationToMs`):`3d→72h / 7d→168h /
14d→336h / 30d→720h`,各档均 ~160 点;`node --check` JS 语法 OK。
**资源 URL 已加 `?v=20260921` 强制浏览器重取**(原来无 hacstag,缓存问题会
让修复「看不见」)。
**② 功率卡画的是瞬时采样而非统计量**。reader 对 `table=sensor_minute`
(时间列名 `minute`)默认 `downsample_method = "last"`
`custom_components/timescale_database_reader/__init__.py` L385-389),
即每桶取「末一分钟的瞬时值」。实测空调 1 小时桶:03:00 真实均值 241.6 W /
峰值 1128 W,图上只画 15719:00 真实均值 97.6 / 峰值 676,图上画 **13**
(看起来像停机)。因 `sensor_minute` 已是均匀 1 分钟 LOCF 序列,`avg()`
才是正确时间均值。**修复**:两张功率卡加 `downsample_method: "avg"`
**用电量卡不用改**——累计计数器配 `last()` 就是桶末读数,卡片再 diff
本来就是对的;人体感应卡显式 `last` 对分类信号也对。
**③ 标题与分组名不副实**。按 7d 实测(峰值/均值)重分组并改名:
| 位置 | 改前 | 改后 |
|---|---|---|
| 分区 | 大功率电器 | **峰值负载**(厨房电器 1839 / 空调 1128 / 电水壶 957 / 咖啡机 882 / 电脑 363 W |
| 分区 | 常驻负载 | **基础负载**(主网络 75 / 冰箱 137 / 大房 290 / 水设备 171 / 无线AP 976* W |
| 分区 | 用电 | **今日用电** |
| 分区 | 灯 + 开关(分开在最顶和最底) | **灯与开关**(合并置顶,可操作项集中) |
| 分区(新) | — | **此刻耗电** |
| 徽标 | 总功率 | **插座合计**(只是 9 个插座之和,不含灯/换气) |
| 实体 | 迷你 / mini数显 / 数显 | **卫生间 / 客厅 / 卧室**(HA area 本来就有,之前用的是设备型号名) |
| 实体 | 电池(移动) | **厨房(电池)**(该设备 area 也是厨房) |
删除 3 张与分区标题重复的卡 title(用电量(按插座) / 室内外温湿度 / 活动状态)。
\* 无线AP 7 天里只有 2 分钟 >150 W 却记到 976 W —— 单点毛刺,会把 y 轴顶飞,
待查。
**④ 新展示**:新增「此刻耗电」区 = `custom:apexcharts-card` **donut**11 个
插座当前功率(`graph_span: 10min` + `update_interval: 1min`),回答「现在什么
在吃电」;环境区拆成**室内外温差**(新模板传感器 `sensor.quick_wen_cha` =
3 个室内温度均值 − 室外,画零线上下 fill)+ **湿度**4 条),4 个温度进徽标。
传感器加在 `templates/quick_sensors.yaml`(W1N-238 约定的落点,顶层列表,
`quick_` 前缀 + unique_id),非 trigger 型模板卡,**`template.reload` 即可生效,
无需重启 Core**(实测 `POST /core/api/services/template/reload` → 200
`sensor.quick_wen_cha` = 3.9 °C)。备份
`templates/quick_sensors.yaml.bak-20260921-pre-wencha`
最终分区顺序(按「可变性」):灯与开关 → 此刻耗电 → 今日用电 →
峰值负载(2) → 基础负载(2) → 环境 → 人体感应。
**⑤ Quick 布局重构 (2026-09-21 晚)**:用户反馈饼图(apexcharts donut)撑满
整行(span 4)且不满、马桶换气开关 tile `columns: "full"` 占满整行太宽、整体
布局需要重新思考。改动:
**布局重排**(阅读流:控制 → 此刻 → 趋势 → 今日 → 环境):
| 行 | 左 (span 2) | 右 (span 2) | 主题 |
|---|---|---|---|
| 1 | 灯与开关 (span 4) | | 可操作项置顶 |
| 2 | 实时功率 (donut) | 人体感应 | 「此刻状态」 |
| 3 | 峰值负载 | 基础负载 | 「功率趋势」(原配对保留) |
| 4 | 今日用电 (span 4) | | 11 条堆叠柱需要宽度 |
| 5 | 温湿度 (span 4) | | 温差+湿度两图并排 |
**标题改名**:此刻耗电→**实时功率**(与峰值负载对仗)、环境→**温湿度**
(更具体)。删饼图卡内冗余标题 "各插座当前功率 (W)"。
**开关收窄**:马桶换气电源 tile 从独立 `columns: "full"`48 列全宽)→ 移入
灯的 2 列嵌套网格第 5 格,与灯卡等宽(半行)。
**饼图高度**`apex_config.chart.height` 280→35011 项底部图例占 ~100px
280 时饼图本体太扁)。
**⑥ card-mod 等高 (2026-09-21 晚)**:不同卡类型(apexcharts vs timescale-plotly
`height` 渲染比例不同(apexcharts 含图例/内边距,timescale 是纯绘图区),
直接对齐数字无法视觉等高。安装 **card-mod**HACS → card-mod + card-mod-studio
后用 CSS 强制 `ha-card` 高度:
```yaml
card_mod:
style: "ha-card { height: 520px !important; }"
```
**最终高度**:饼图+人体感应 = **520px**,温湿度双图 = **520px**。其余图表
(峰值/基础负载 280、今日用电 320)保持各自原生高度,不强制统一——不同图表
类型适合不同高度,强行统一反而让简单图表显得太空。
**关键认知**apexcharts `chart.height` 包含图例区域,timescale-plotly `height`
是纯绘图区。同一数字渲染出来 apexcharts 视觉上更矮。card-mod 直接控制
`ha-card` 容器高度,绕过内部渲染差异。
备份:`.lovelace-backups/dashboard-quick-20260921-pre-titles.json`(同日早期
备份,覆盖标题+布局全部改动的回滚)。
**验证**`ha core check` OKWS 读回与写入 payload **逐字节相同**
新配置引用的 **40 个实体全部存在**且无 unknown/unavailable
卡 JS `node --check` 通过、时长解析实测正确。
**改法(可复用)**:写 Lovelace 配置必须走 WS
`lovelace/config/save`**勿直改 `.storage/`**)。本机 python/HA 容器内经
supervisor 代理连 WS 会被 loop-prevention 拒;可行路径是
`docker run --rm -i --network host -e SUPERVISOR_TOKEN --entrypoint python3
r.hassbus.com/home-assistant/aarch64-hassio-supervisor:<ver> -c "..."`
跑本仓库的 [`scripts/ha-ws-client.py`](../scripts/ha-ws-client.py)(纯 stdlib
不依赖 websockets/aiohttp,也不依赖 `-v` 挂载——`-v /tmp/...` 会落到 Docker 宿主
而不是 SSH add-on 的 `/tmp`)。读配置:`{"action":"get","url_path":"dashboard-quick"}`
写:`{"action":"save",...,"config":{...}}`。备份
`.lovelace-backups/dashboard-quick-20260921-pre-titles.json`。
**回滚**:还原 `dashboard-quick-20260921-pre-titles.json`WS save+
`timescale-plotly-card.js.bak-20260921-pre-dayfix` + 删 `quick_wen_cha`
(还原 `quick_sensors.yaml.bak-20260921-pre-wencha` + `template.reload`)。
改了卡 JS 就要把资源 URL 的 `?v=` 再 bump 一次。
### 地图仪表盘:CARTO keyed tiles via `custom:map-card` (verified 2026-08-30, W1N-261) ### 地图仪表盘:CARTO keyed tiles via `custom:map-card` (verified 2026-08-30, W1N-261)
+16 -13
View File
@@ -2,10 +2,10 @@
| Item | Value | | Item | Value |
|------|--------| |------|--------|
| Role | Multi-service VPS (Vaultwarden, Traefik, Soft Serve, …) | | Role | Multi-service VPS (Vaultwarden, Traefik, Gitea, …) |
| SSH | `ssh -4 windy@us2.wsvc.info` (prefer IPv4 from WSL) | | SSH | `ssh -4 windy@us2.wsvc.info` (prefer IPv4 from WSL) |
| IPv4 | `193.9.44.165` | | IPv4 | `193.9.44.165` |
| Also DNS | `auth.wsvc.info` → this host; `repo.windy.me` → this host (Soft Serve) | | Also DNS | `auth.wsvc.info` → this host; `repo.windy.me` → this host (Gitea) |
| Public HTTPS | Traefik on `:80` / `:443` (`/opt/traefik`) | | Public HTTPS | Traefik on `:80` / `:443` (`/opt/traefik`) |
## Vaultwarden (Bitwarden-compatible) ## Vaultwarden (Bitwarden-compatible)
@@ -80,26 +80,29 @@ ansible-playbook playbooks/compose-reconcile.yml --limit vaultwarden \
| Container | Status | Image / notes | | Container | Status | Image / notes |
|-----------|--------|---------------| |-----------|--------|---------------|
| `soft-serve` | Up | `charmcli/soft-serve:v0.12.2` (`repo.windy.me:2222`) | | `gitea` | Up | `gitea/gitea@sha256:1c17ecaead42e…` (1.27.3-rootless) — SSH `repo.windy.me:2222`, web `https://repo.windy.me` |
| `soft-serve-backup` | Up | alpine + sqlite3 sidecar (daily backup 02:00 / prune 03:00, crond) | | `gitea-backup` | Up | alpine + sqlite3/rsync sidecar (daily backup 02:00 / prune 03:00, crond) |
Soft Serve details (verified/updated 2026-08-30; 核查 [W1N-244](https://linear.app/w1ndy/issue/W1N-244), 修复 [W1N-245](https://linear.app/w1ndy/issue/W1N-245)/[W1N-246](https://linear.app/w1ndy/issue/W1N-246)/[W1N-247](https://linear.app/w1ndy/issue/W1N-247)): ### Gitea (replaced Soft Serve 2026-09-18; [Plane VPS-94](https://plane.chans.xyz))
- `/opt/soft-serve/compose.yml` (+ `Dockerfile.backup`, `scripts/`, `backups/`); data `/opt/soft-serve/data``/var/lib/soft-serve` (sqlite `soft-serve.db`, 12 repos + `windyboy`); env `.env` (`SOFT_SERVE_INITIAL_ADMIN_KEYS` = admin pubkey, first-boot only). 仓库镜像: `compose/soft-serve/`(参考, 服务器文件为准)
- No host ports published: Traefik TCP entrypoint `ssh` (`:2222``soft-serve:23231`, `HostSNI(*)`, `tls=false`) on `vw-net`. Container listens 23231 SSH / 23232 HTTP (git smart-http, no web UI) / 9418 git / 23233 stats (localhost only) - `/opt/gitea/compose.yml` (+ `Dockerfile.backup`, `scripts/`, `config/app.ini`, `data/`, `secrets/`, `backups/`); 镜像: `compose/gitea/`(参考, 服务器文件为准)
- **镜像已固定** `charmcli/soft-serve:v0.12.2`(digest `sha256:554cc770…`, Docker Hub 稳定源; GHCR 为 dev/nightly 源且无 v0.12.x tag); `/soft-serve` 由 named volume `soft-serve_soft-serve-app` 承载, 旧匿名卷已清理(2026-08-30) - **rootless 镜像** uid 1000:1000; SQLite `/opt/gitea/data/data/gitea.db`; repos `/opt/gitea/data/data/git/repositories/`; app.ini `/opt/gitea/config/app.ini`(600, 含 SECRET_KEY)
- **非 root 运行**(2026-08-30, [W1N-248](https://linear.app/w1ndy/issue/W1N-248)): compose `user: "1000:1000"`(uid=windy, 镜像无内置用户); data 全量 `chown 1000:1000`, 容器内 `id` = uid 1000, 功能验证通过 - SSH: 内置 server 容器内 `:2322`(`SSH_LISTEN_PORT` 非特权), Traefik TCP entrypoint `ssh`(`:2222``gitea:2322`, `HostSNI(*)`, `tls=false`) on `vw-net`; clone URL `ssh://git@repo.windy.me:2222/windy/<repo>.git`(owner 段 `windy`)
- **`ssh.public_url` 已修复**(2026-08-30): `config.yaml``ssh://git@repo.windy.me:2222`; 失效 env `SOFT_SERVE_SSH_PUBLIC_URL` 已删(v0.12 不读取); `http`/`git` public_url 保持 `localhost`(未对外暴露) - **host key 复用 soft-serve**(`SSH_SERVER_HOST_KEYS=/secrets/soft_serve_host_ed25519`, ed25519, 指纹 `SHA256:PdxZRe74…`): 客户端 known_hosts 零变更; 仅公钥认证(密码认证未启用)
- **备份**: sidecar `soft-serve-backup` 每日 02:00 → `backups/soft-serve_<TS>/{repos-config.tar.gz, soft-serve.db}`(db 用 `sqlite3 .backup` 在线快照), 03:00 prune 保留 14 份; 产物 chown windy:windy 600(含 `ssh/` host keys)。**恢复**: `docker compose stop soft-serve` → 解包 `repos-config.tar.gz` + 放回 `soft-serve.db``data/``docker compose up -d`。异地副本(hk2/WSL 每日拉取)= follow-up(见 W1N-247) - Web: `https://repo.windy.me`(Traefik websecure + letsencrypt); `DISABLE_REGISTRATION=true`, Actions 关闭; 管理员 `windy`(凭据仅存服务器 `/opt/gitea/.admin-credentials`, 勿入库/入 Plane)
- 仓库: 17 个(`windy/` 13 — 含 vps、自 `windyboy` 并入的 go-caatsm — + `cdia/` 4; 2026-09-19 复核), 2026-09-18 自 soft-serve `push --mirror` 迁移, 逐仓 `ls-remote` ref 全集 + HEAD symref 两端一致; 可见性仅 `dotfiles-personal` private, 其余 public(与 soft-serve 现状一致)
- 备份: sidecar 每日 02:00 → `backups/gitea_<TS>/{app.ini.tar.gz, gitea.db, repos.tar.gz}`(app.ini 含恢复必需 SECRET_KEY), 03:00 prune 保留 14 份; 已验证手动备份产物 109.9M
- 回滚: `/opt/soft-serve` 未删(compose stop + sidecar 停, 数据与旧备份冻结保留), 回滚 = Traefik `:2222` 指回 `soft-serve:23231` + 客户端 remote 回改旧无 owner 段路径; 观察 24 周后清理(历史: W1N-244~248)
| `traefik` | Up | `traefik:v3.6.2` (`/opt/traefik`, public `:80`/`:443`) | | `traefik` | Up | `traefik:v3.6.2` (`/opt/traefik`, public `:80`/`:443`) |
| `nghttpx-proxy` + `squid-backend` | Up | HTTP forward-proxy stack (`/opt/nghttpx`), network `nghttpx_internal-net`; details TBD | | `nghttpx-proxy` + `squid-backend` | Up | HTTP forward-proxy stack (`/opt/nghttpx`), network `nghttpx_internal-net`; details TBD |
**Disk cleanup 2026-09-18** ([Plane vps VPS-93](https://plane.chans.xyz)): root 71% → **23%** (~33G freed) keeping soft-serve / vaultwarden / traefik (nghttpx kept running per operator choice). Removed: unused Docker images + orphan volumes (incl. `zitadel_data` 801M), dormant `/opt` dirs (dendrite + its disabled `dendrite.service` unit, mastodon, dailysync, keycloak, media-repo, authelia, conduit, npm, manager, fusion, zitadel, rustdesk), rootless podman storage (6.4G stale goauthentik), home dev caches, apt cache, journal 3.8G→162M (+`SystemMaxUse=200M` drop-in, active next boot), truncated container logs (nghttpx 550M / traefik / squid). Follow-up: nghttpx-proxy logs grow ~25M/day (INFO per-connection); root-cause log-level/rotation fix still open (needs container restart approval). **Disk cleanup 2026-09-18** ([Plane vps VPS-93](https://plane.chans.xyz)): root 71% → **23%** (~33G freed) keeping soft-serve / vaultwarden / traefik (nghttpx kept running per operator choice). Removed: unused Docker images + orphan volumes (incl. `zitadel_data` 801M), dormant `/opt` dirs (dendrite + its disabled `dendrite.service` unit, mastodon, dailysync, keycloak, media-repo, authelia, conduit, npm, manager, fusion, zitadel, rustdesk), rootless podman storage (6.4G stale goauthentik), home dev caches, apt cache, journal 3.8G→162M (+`SystemMaxUse=200M` drop-in, active next boot), truncated container logs (nghttpx 550M / traefik / squid). Follow-up: nghttpx-proxy logs grow ~25M/day (INFO per-connection); root-cause log-level/rotation fix still open (needs container restart approval).
Remaining running services on this host: `soft-serve`, `vaultwarden` stack, `traefik`, `nghttpx-proxy` + `squid-backend` (undocumented forward proxy, `/opt/nghttpx`). `/home/windy/authelia` (76M) left in place — outside approved cleanup scope. Remaining running services on this host: `gitea`, `vaultwarden` stack, `traefik`, `nghttpx-proxy` + `squid-backend` (undocumented forward proxy, `/opt/nghttpx`). `/opt/soft-serve` kept stopped as rollback (24 weeks, data intact). `/home/windy/authelia` (76M) left in place — outside approved cleanup scope.
## Verified ## Verified
Last checked: **2026-09-18** — operational; disk cleanup done (see note above, Plane vps VPS-93). Prior full probe: 2026-08-29. Last checked: **2026-09-19** — operational. Gitea independently audited (read-only): `1.27.3` = latest 1.27.x patch; HTTPS 200 (LE cert → 2026-12-17); SSH `git@…:2222` key auth OK; backup 02:00 OK (110M); memory available 584 MB; logs clean (record: Plane VPS-94 comment 2026-09-19). Prior: 2026-09-18 migration + disk cleanup (VPS-93); 2026-08-29 full Vaultwarden probe.
- `vaultwarden` + `vw-db` healthy; `DATABASE_URL``pg:5432/vaultwarden` - `vaultwarden` + `vw-db` healthy; `DATABASE_URL``pg:5432/vaultwarden`
- `https://auth.wsvc.info/` **200**, `/admin` **200**, `/api/config` OK (`disableUserRegistration: true`) - `https://auth.wsvc.info/` **200**, `/admin` **200**, `/api/config` OK (`disableUserRegistration: true`)
+2 -2
View File
@@ -15,9 +15,9 @@ the software deployed there, see [the LAN overview](../docs/lan-overview.md).
| Host | Role | SSH | IPv4 | Ansible | Status | Facts | | Host | Role | SSH | IPv4 | Ansible | Status | Facts |
|------|------|-----|------|---------|--------|-------| |------|------|-----|------|---------|--------|-------|
| mx2.windy.me | mailcow (primary MX prio 20) | `ssh -4 windy@mx2.windy.me` | 194.163.160.244 | ✓ (mx2) | active | [hosts/mx2.windy.me.md](../hosts/mx2.windy.me.md) | | mx2.windy.me | mailcow (primary MX prio 20) | `ssh -4 windy@mx2.windy.me` | 194.163.160.244 | ✓ (mx2) | active | [hosts/mx2.windy.me.md](../hosts/mx2.windy.me.md) |
| us2.wsvc.info | Vaultwarden/Postgres (+ Traefik, Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | 193.9.44.165 | ✓ (us2) | active | [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md) | | us2.wsvc.info | Vaultwarden/Postgres (+ Traefik, Gitea, …) | `ssh -4 windy@us2.wsvc.info` | 193.9.44.165 | ✓ (us2) | active | [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md) |
| mx.windy.me | mail (secondary MX prio 30) | TBD | see AAAA/A | — (stub) | stub | — | | mx.windy.me | mail (secondary MX prio 30) | TBD | see AAAA/A | — (stub) | stub | — |
| repo.windy.me | Soft Serve git (on us2) | `ssh -p 2222 windy@repo.windy.me` | 193.9.44.165 | — (service on us2) | stub | see us2 | | repo.windy.me | Gitea git (on us2) | `ssh -p 2222 git@repo.windy.me` | 193.9.44.165 | — (service on us2) | stub | see us2 |
| auth.wsvc.info | Vaultwarden public hostname | — (HTTPS) | → us2 | — (alias) | active | see us2 | | auth.wsvc.info | Vaultwarden public hostname | — (HTTPS) | → us2 | — (alias) | active | see us2 |
| us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | — (stub) | stub | Auth 5.0.5; see hk2 | | us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | — (stub) | stub | Auth 5.0.5; see hk2 |
| us4.wsvc.info | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | ✓ (us4) | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) | | us4.wsvc.info | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | ✓ (us4) | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) |
+1
View File
@@ -15,6 +15,7 @@ runbooks start from [`_template.md`](_template.md).
| Vaultwarden SQLite→PG migrate | [vaultwarden-sqlite-to-postgres.md](vaultwarden-sqlite-to-postgres.md) | change (destructive) | | Vaultwarden SQLite→PG migrate | [vaultwarden-sqlite-to-postgres.md](vaultwarden-sqlite-to-postgres.md) | change (destructive) |
| PowerDNS health check | [pdns-health.md](pdns-health.md) | read-only | | PowerDNS health check | [pdns-health.md](pdns-health.md) | read-only |
| RustDesk health check | [rustdesk-health.md](rustdesk-health.md) | read-only | | RustDesk health check | [rustdesk-health.md](rustdesk-health.md) | read-only |
| Gitea health check | [gitea-health.md](gitea-health.md) | read-only |
| Matrix health check | [matrix-health.md](matrix-health.md) | read-only | | Matrix health check | [matrix-health.md](matrix-health.md) | read-only |
| Plane health check | [plane-health.md](plane-health.md) | read-only | | Plane health check | [plane-health.md](plane-health.md) | read-only |
| pgdb health check | [pgdb-health.md](pgdb-health.md) | read-only | | pgdb health check | [pgdb-health.md](pgdb-health.md) | read-only |
+123
View File
@@ -0,0 +1,123 @@
# Gitea health (us2)
## Purpose
Read-only health check of the `/opt/gitea` stack: web UI, git SSH, backup
sidecar, and resource headroom.
## Scope
- Applicable: [us2.wsvc.info](../hosts/us2.wsvc.info.md), `/opt/gitea`.
- Read-only: does not change Gitea configuration or service state.
- Not applicable: Traefik-wide issues (see hosts/us2 notes); Soft Serve
rollback (see the rollback section of hosts/us2.wsvc.info.md).
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-09-19
- Related systems: us2.wsvc.info, Traefik (`vw-net`), repo.windy.me DNS
Facts: [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md). Reference
compose mirror: [compose/gitea/](../compose/gitea/) (server files win).
## Focused SSH diagnostics
From the agent shell use `ssh -F /dev/null -4 windy@us2.wsvc.info`.
### Containers
```bash
ssh -F /dev/null -4 windy@us2.wsvc.info 'cd /opt/gitea && docker compose ps -a'
```
Expect `gitea` and `gitea-backup` both `Up`. `soft-serve*` containers are
intentionally `Exited` (rollback retention, keep as-is).
### Version / image pin
```bash
ssh -F /dev/null -4 windy@us2.wsvc.info \
'docker exec gitea /usr/local/bin/gitea --version; grep "^ *image:" /opt/gitea/compose.yml'
```
Expect `gitea version 1.27.3` and the digest pin
`gitea/gitea@sha256:1c17ecaead42…` (full digest in compose.yml). If upstream
has a newer 1.27.x patch with security fixes, plan an upgrade — do not
auto-pull `latest`.
### Web UI (from laptop / WSL)
```bash
curl -4sI https://repo.windy.me/ | head -3 # expect HTTP/2 200
echo | openssl s_client -connect repo.windy.me:443 -servername repo.windy.me 2>/dev/null \
| openssl x509 -noout -dates # expect LE cert not expired
```
### Git SSH (the critical check)
```bash
ssh -F /dev/null -o BatchMode=yes -p 2222 git@repo.windy.me 2>&1 | head -2
```
Expect `Hi there, windy! You've successfully authenticated …` and no shell.
Login user **must be `git@`** (`windy@` fails: publickey denied). Clone URLs
need the owner segment: `ssh://git@repo.windy.me:2222/windy/<repo>.git`.
The host key is the reused Soft Serve ed25519 key (fingerprint prefix
`SHA256:PdxZRe74…`) — a changed fingerprint means someone rotated it.
### Registration / hardening (server-side, definitive)
```bash
ssh -F /dev/null -4 windy@us2.wsvc.info \
'grep -E "DISABLE_REGISTRATION|INSTALL_LOCK" /opt/gitea/config/app.ini; stat -c "%a %U" /opt/gitea/config/app.ini'
```
Expect `DISABLE_REGISTRATION = true`, `INSTALL_LOCK = true`, file mode `600`.
### Backup freshness
```bash
ssh -F /dev/null -4 windy@us2.wsvc.info 'tail -4 /opt/gitea/backups/backup.log; ls -lt /opt/gitea/backups/ | head -4'
```
Expect a `Backup OK` line from today's 02:00 run (products include
`app.ini.tar.gz`, `gitea.db`, `repos.tar.gz`) and ≤ 14 retained sets. A
backup set older than ~25h with no log error means cron silently died —
investigate before proceeding.
### Logs and memory gate
```bash
ssh -F /dev/null -4 windy@us2.wsvc.info \
'docker logs gitea --since 24h 2>&1 | grep -iE "error|panic" | tail -5; free -m | head -2'
```
Expect no recurring errors (single SSH auth-failure warnings from the
Traefik proxy IP are background noise) and `available` ≥ 150 MB
(VPS-94 memory gate; gitea idles ~400 MB RSS).
## Safety
- Read-only: never mutate configuration or service state during this check.
- If live state conflicts with an expected value (wrong digest, HTTPS
non-200, stale backup, registration enabled, `available` < 150 MB),
`STOP` and report per repo rules.
## After config changes
`/opt/gitea` has no Ansible coverage yet. Reviewed changes
(compose edit, digest bump) are applied manually and verified:
```bash
ssh -F /dev/null -4 windy@us2.wsvc.info 'cd /opt/gitea && docker compose up -d && docker compose ps'
```
Then re-run the diagnostics above. Keep [compose/gitea/](../compose/gitea/)
in sync (reference copy, server files win).
## References
- [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md) — deployment facts, rollback path
- Plane `vps` project VPS-94 — migration plan, acceptance checks, audit record
- Upstream: https://docs.gitea.com/ (self-hosted admin/config docs)
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Stdlib-only Home Assistant WebSocket client (via the Supervisor core proxy).
Why this exists
---------------
Some HA operations have **no REST route** and must go through the WebSocket API
-- writing Lovelace config (`lovelace/config/save`) is the main one, and the
`.storage/` files must not be hand-edited (HA may overwrite them, and edits skip
validation). The obvious paths are dead ends on this host: the HA host and the
core container have no usable WS client (the Supervisor proxy rejects core's own
loop), and the Supervisor image has neither `websockets` nor `aiohttp`.
HA's WS protocol is plain text frames, so ~90 lines of stdlib is enough.
Run it from the HA host (see hosts/hass.windy.lan.md "改法(可复用)"):
B64=$(base64 < scripts/ha-ws-client.py | tr -d '\n')
echo '{"action":"get","url_path":"dashboard-quick"}' | \
docker run --rm -i --network host -e SUPERVISOR_TOKEN --entrypoint python3 \
r.hassbus.com/home-assistant/aarch64-hassio-supervisor:<ver> \
-c "import base64,sys;exec(base64.b64decode('$B64').decode())" - -
Do NOT use `-v /tmp/...`: that path is resolved by the Docker daemon on the
HAOS host, not inside the SSH add-on, so the mount comes up empty.
Payload / output
----------------
{"action":"get", "url_path":"dashboard-quick"}
{"action":"save", "url_path":"dashboard-quick", "config": {...}}
{"action":"raw", "command": {"type":"lovelace/resources/list"}}
python3 ha-ws-client.py <payload.json|-> <out.json|->
"-" reads the payload from stdin / prints the result to stdout, wrapped in
<<<DSH_RESULT>>> / <<<DSH_END>>> markers so it survives the add-on login banner.
"""
import base64
import json
import os
import random
import socket
import struct
import sys
HOST = os.environ.get("WS_HOST", "172.30.32.2")
PORT = int(os.environ.get("WS_PORT", "80"))
PATH = os.environ.get("WS_PATH", "/core/websocket")
class WS:
def __init__(self, host, port, path, extra_headers=None):
self.s = socket.create_connection((host, port), timeout=30)
key = base64.b64encode(bytes(random.getrandbits(8) for _ in range(16))).decode()
req = (
f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
)
for k, v in (extra_headers or {}).items():
req += f"{k}: {v}\r\n"
self.s.sendall((req + "\r\n").encode())
buf = b""
while b"\r\n\r\n" not in buf:
d = self.s.recv(4096)
if not d:
raise RuntimeError("closed during handshake")
buf += d
head, _, rest = buf.partition(b"\r\n\r\n")
status = head.split(b"\r\n")[0]
if b"101" not in status:
raise RuntimeError("handshake failed: " + status.decode(errors="replace"))
self.buf = rest
def _exact(self, n):
while len(self.buf) < n:
d = self.s.recv(65536)
if not d:
raise RuntimeError("socket closed")
self.buf += d
out, self.buf = self.buf[:n], self.buf[n:]
return out
def send(self, text):
data = text.encode()
mask = bytes(random.getrandbits(8) for _ in range(4))
n = len(data)
hdr = bytearray([0x81])
if n < 126:
hdr.append(0x80 | n)
elif n < 65536:
hdr.append(0x80 | 126)
hdr += struct.pack(">H", n)
else:
hdr.append(0x80 | 127)
hdr += struct.pack(">Q", n)
hdr += mask
self.s.sendall(bytes(hdr) + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def recv(self):
while True:
b0, b1 = self._exact(2)
op = b0 & 0x0F
ln = b1 & 0x7F
if ln == 126:
ln = struct.unpack(">H", self._exact(2))[0]
elif ln == 127:
ln = struct.unpack(">Q", self._exact(8))[0]
payload = self._exact(ln) if ln else b""
if op == 0x8:
raise RuntimeError("server closed")
if op in (0x9, 0xA):
continue
return payload.decode()
def main():
token = os.environ["SUPERVISOR_TOKEN"]
src = sys.argv[1]
payload = json.load(sys.stdin if src == "-" else open(src, encoding="utf-8"))
action = payload.pop("action")
url_path = payload.pop("url_path", "dashboard-quick")
out_path = sys.argv[2]
ws = WS(HOST, PORT, PATH, {"Authorization": f"Bearer {token}"})
while True:
hello = json.loads(ws.recv())
if hello.get("type") == "auth_required":
break
if hello.get("type") == "auth_ok":
break
ws.send(json.dumps({"type": "auth", "access_token": token}))
while True:
r = json.loads(ws.recv())
if r.get("type") == "auth_ok":
break
if r.get("type") == "auth_invalid":
print("AUTH_INVALID:", json.dumps(r, ensure_ascii=False)[:300])
return 1
cmd = {
"id": 1,
"type": "lovelace/config/save" if action == "save" else "lovelace/config",
"url_path": url_path,
}
if action == "save":
cmd["config"] = payload["config"]
elif action == "raw":
cmd = dict(payload["command"])
cmd["id"] = 1
ws.send(json.dumps(cmd))
while True:
r = json.loads(ws.recv())
if r.get("id") != 1:
continue
if not r.get("success"):
print("ERROR:", json.dumps(r, ensure_ascii=False)[:600])
return 1
result = r.get("result")
blob = json.dumps(result if result is not None else {"ok": True},
ensure_ascii=False, indent=1)
if out_path == "-":
print("<<<DSH_RESULT>>>")
print(blob)
print("<<<DSH_END>>>")
else:
with open(out_path, "w", encoding="utf-8") as f:
f.write(blob)
print("OK ->", out_path)
return 0
if __name__ == "__main__":
sys.exit(main())