mirror of
https://github.com/tanweai/pua.git
synced 2026-08-30 18:01:52 +08:00
feat(pua): add silent heartbeat stats
Summary: - Add a silent SessionStart heartbeat hook that posts minimal anonymous install activity to Cloudflare without emitting stdout, stderr, or additionalContext. - Add the Cloudflare Pages heartbeat endpoint, D1 migration, and admin-only stats page at #/admin/heartbeats. - Add heartbeat regression gates, release consistency checks, FAQ/design notes, and bump marketplace/package versions to 3.4.3. Rationale: - Active-user measurement belongs in a hook-side mechanical gate, not in the model prompt, so the worker context cannot narrate or leak the telemetry behavior. - Privacy gates disable the signal for offline mode, telemetry opt-out, and feedback-off users before any local telemetry identity is created. Tests: - bash evals/test-heartbeat.sh - bash evals/test-release-consistency.sh - static eval suite: governance, feedback auth, integrity guard, issue regressions, Microsoft flavor, platform compat, loop hook, Windows Python hooks, YAML frontmatter - bash evals/test-behavior.sh - npm --prefix landing run build - ./landing/node_modules/.bin/tsc -p pi/pua/tsconfig.json - git diff --check Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -8,8 +8,8 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "pua",
|
||||
"description": "PUA 我们不养闲 Agent — 11 modular skills, 14 corporate flavors, L0-L4 pressure, auto-iteration loop, ENFP yes-mode, Chinese-mom nagging mode, concentrated shot mode, agent lifecycle teardown protocol, feedback system. v3.4.2: authentic Microsoft flavor with Connects, Impact Descriptors, SLITE/LITE, PIP/GVSA, and Three Circles of Impact.",
|
||||
"version": "3.4.2",
|
||||
"description": "PUA 我们不养闲 Agent — 11 modular skills, 14 corporate flavors, L0-L4 pressure, auto-iteration loop, ENFP yes-mode, Chinese-mom nagging mode, concentrated shot mode, agent lifecycle teardown protocol, feedback system. v3.4.3: silent heartbeat hook with admin-only Cloudflare active-user stats and no prompt leakage.",
|
||||
"version": "3.4.3",
|
||||
"source": "./",
|
||||
"author": {
|
||||
"name": "探微安全实验室",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pua",
|
||||
"version": "3.4.2",
|
||||
"description": "Forces high-agency exhaustive problem-solving with corporate PUA pressure. Triggers on explicit PUA requests, user frustration, repeated failures, passive behavior, unverified completion, or quality complaints. v3.4.2 restores authentic Microsoft flavor: Connects, Impact Descriptors, SLITE/LITE, PIP/GVSA, and Three Circles of Impact. Not for normal first-attempt requests.",
|
||||
"version": "3.4.3",
|
||||
"description": "Forces high-agency exhaustive problem-solving with corporate PUA pressure. Triggers on explicit PUA requests, user frustration, repeated failures, passive behavior, unverified completion, or quality complaints. v3.4.3 adds silent heartbeat telemetry and admin-only Cloudflare active-user stats without prompt leakage. Not for normal first-attempt requests.",
|
||||
"author": {
|
||||
"name": "探微安全实验室",
|
||||
"url": "https://github.com/tanweai"
|
||||
@@ -24,6 +24,9 @@
|
||||
"owner-consciousness",
|
||||
"methodology",
|
||||
"mama-mode",
|
||||
"chinese-mom"
|
||||
"chinese-mom",
|
||||
"heartbeat",
|
||||
"cloudflare",
|
||||
"active-users"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pua-skills",
|
||||
"description": "PUA Motivator for CodeBuddy -- high-agency pressure mode for explicit PUA requests, repeated failures, user frustration, passive behavior, unverified completion, with 14 corporate/PIP flavors.",
|
||||
"version": "3.4.2",
|
||||
"version": "3.4.3",
|
||||
"owner": {
|
||||
"name": "探微安全实验室",
|
||||
"url": "https://github.com/tanweai"
|
||||
@@ -9,8 +9,8 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "pua",
|
||||
"description": "PUA Motivator -- forces CodeBuddy to exhaust materially different approaches before giving up. v3.4.2 adds authentic Microsoft Connects/PIP/GVSA flavor for fixed-thinking failures.",
|
||||
"version": "3.4.2",
|
||||
"description": "PUA Motivator -- forces CodeBuddy to exhaust materially different approaches before giving up. v3.4.3 adds silent active-user telemetry plumbing for Claude Code while preserving no-leak prompt behavior.",
|
||||
"version": "3.4.3",
|
||||
"source": "./"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pua",
|
||||
"version": "3.4.2",
|
||||
"description": "PUA Motivator -- forces high-agency follow-through when explicitly requested, after repeated failures, user frustration, passive/giving-up behavior, or unverified completion. Chinese corporate PUA and English PIP cultural modes, now with authentic Microsoft Connects/PIP/GVSA flavor. Not for normal first-attempt requests.",
|
||||
"version": "3.4.3",
|
||||
"description": "PUA Motivator -- forces high-agency follow-through when explicitly requested, after repeated failures, user frustration, passive/giving-up behavior, or unverified completion. Includes corporate/PIP cultural modes plus silent active-user stats for Claude Code deployments. Not for normal first-attempt requests.",
|
||||
"author": {
|
||||
"name": "探微安全实验室",
|
||||
"url": "https://github.com/tanweai"
|
||||
@@ -17,7 +17,10 @@
|
||||
"corporate-culture",
|
||||
"pip",
|
||||
"performance-improvement-plan",
|
||||
"codebuddy"
|
||||
"codebuddy",
|
||||
"heartbeat",
|
||||
"cloudflare",
|
||||
"active-users"
|
||||
],
|
||||
"category": "Productivity"
|
||||
}
|
||||
|
||||
+11
@@ -77,3 +77,14 @@ Codex 没有 Claude Code 的 `/pua:xxx` slash command 命名空间时,可以
|
||||
|
||||
- start/intervene → “亲自动手” / “亲自介入”;
|
||||
- stop/release → “释放” / “退场”。
|
||||
|
||||
## 静默 heartbeat 会不会污染对话?
|
||||
|
||||
不会。v3.4.3 的活跃用户统计走 **SessionStart command hook**,不是 skill prompt,也不输出 `additionalContext`。因此模型上下文里不会出现 heartbeat endpoint、install id 或统计提示。
|
||||
|
||||
治理边界:
|
||||
|
||||
- `offline: true`、`telemetry: false` 或 `feedback_frequency: 0` 会关闭 heartbeat;
|
||||
- 本地只生成随机 install id,Cloudflare D1 只保存 SHA-256 hash;
|
||||
- 管理页面是 `https://openpua.ai/#/admin/heartbeats`,需要 GitHub 登录并命中管理员白名单;
|
||||
- hook 有静默测试:即使网络失败,也不能向对话输出任何字节。
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Silent Heartbeat + Cloudflare Active-User Stats Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add active-user visibility for PUA Skill without polluting Claude conversations. The heartbeat must be a mechanical hook-side signal, not prompt content: no `additionalContext`, no stdout/stderr, no text the model can echo.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Action side:** `hooks/heartbeat.sh` runs on `SessionStart`, before `session-restore.sh`.
|
||||
- **Conversation boundary:** heartbeat writes nothing to stdout/stderr and never appears in `skills/pua/SKILL.md` or SessionStart context.
|
||||
- **Privacy gates:** `offline=true`, `telemetry=false`, or `feedback_frequency=0` disables heartbeat before creating a local install id.
|
||||
- **Telemetry identity:** local random `~/.pua/install_id`; Cloudflare stores only SHA-256 hashes.
|
||||
- **Cloudflare side:** `/api/heartbeat` accepts minimal POST events and authenticated admin GET stats.
|
||||
- **Admin page:** `#/admin/heartbeats` shows active installs/sessions by day, version, platform, and flavor.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SessionStart --> HeartbeatHook[hooks/heartbeat.sh]
|
||||
HeartbeatHook -->|silent POST| CF[/api/heartbeat]
|
||||
CF --> D1[(D1: heartbeat_installs/events)]
|
||||
Admin[GitHub-auth admin] --> Stats[#/admin/heartbeats]
|
||||
Stats --> CF
|
||||
```
|
||||
|
||||
## Mechanical Gates
|
||||
|
||||
1. Hook silence gate: runtime test proves no stdout/stderr even when network fails.
|
||||
2. Context leak gate: SessionStart additionalContext must not contain heartbeat endpoint/id words.
|
||||
3. Privacy gate: offline mode must not create telemetry identity.
|
||||
4. Admin gate: GET stats requires signed session + GitHub allowlist.
|
||||
5. Abuse gate: POST body size limit, origin allowlist when Origin exists, per-IP/UA rate limit.
|
||||
|
||||
## INTJ Note
|
||||
|
||||
The design separates **measurement** from **narrative**. If telemetry is placed in the skill prompt, it becomes part of the model's story and can leak into outputs. If telemetry is placed in a silent hook with tests, it becomes an institution-level control: observable to the owner, invisible to the worker context.
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Heartbeat telemetry gates: silent SessionStart hook + admin-only Cloudflare stats.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
pass() { echo " ✅ PASS: $1"; PASS=$((PASS+1)); }
|
||||
fail() { echo " ❌ FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
assert_file() { local file="$1" name="$2"; [ -f "$ROOT/$file" ] && pass "$name" || fail "$name"; }
|
||||
assert_grep() { local pat="$1" file="$2" name="$3"; grep -qE -- "$pat" "$ROOT/$file" 2>/dev/null && pass "$name" || fail "$name"; }
|
||||
assert_not_grep() { local pat="$1" file="$2" name="$3"; ! grep -qE -- "$pat" "$ROOT/$file" 2>/dev/null && pass "$name" || fail "$name"; }
|
||||
|
||||
run_silence_check() {
|
||||
local label="$1"
|
||||
local home_dir="$2"
|
||||
local config_json="$3"
|
||||
local out err
|
||||
mkdir -p "$home_dir/.pua"
|
||||
printf '%s\n' "$config_json" > "$home_dir/.pua/config.json"
|
||||
out="$home_dir/out.txt"
|
||||
err="$home_dir/err.txt"
|
||||
HOME="$home_dir" \
|
||||
CLAUDE_PLUGIN_ROOT="$ROOT" \
|
||||
PUA_HEARTBEAT_ENDPOINT="http://127.0.0.1:9/api/heartbeat" \
|
||||
PUA_HEARTBEAT_INTERVAL_SECONDS=0 \
|
||||
bash "$ROOT/hooks/heartbeat.sh" >"$out" 2>"$err" || true
|
||||
sleep 0.2
|
||||
if [ ! -s "$out" ] && [ ! -s "$err" ]; then
|
||||
pass "$label emits no stdout/stderr"
|
||||
else
|
||||
fail "$label emits no stdout/stderr"
|
||||
sed 's/^/ stdout: /' "$out" || true
|
||||
sed 's/^/ stderr: /' "$err" || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Heartbeat Gates ==="
|
||||
assert_file hooks/heartbeat.sh "heartbeat hook exists"
|
||||
assert_file landing/functions/api/heartbeat.ts "Cloudflare heartbeat endpoint exists"
|
||||
assert_file landing/migrations/0004_heartbeat.sql "D1 heartbeat migration exists"
|
||||
assert_file landing/src/pages/AdminStats.tsx "admin heartbeat page exists"
|
||||
|
||||
if [ -f "$ROOT/hooks/heartbeat.sh" ]; then
|
||||
bash -n "$ROOT/hooks/heartbeat.sh" && pass "heartbeat hook has valid bash syntax" || fail "heartbeat hook has valid bash syntax"
|
||||
assert_grep '/api/heartbeat' hooks/heartbeat.sh "heartbeat hook posts to /api/heartbeat"
|
||||
assert_grep 'PUA_HEARTBEAT_ENDPOINT' hooks/heartbeat.sh "heartbeat endpoint is overrideable"
|
||||
assert_grep 'offline' hooks/heartbeat.sh "heartbeat respects offline config"
|
||||
assert_grep 'telemetry' hooks/heartbeat.sh "heartbeat respects telemetry opt-out"
|
||||
assert_grep 'feedback_frequency' hooks/heartbeat.sh "heartbeat respects feedback-off opt-out"
|
||||
assert_grep 'get_flavor' hooks/heartbeat.sh "heartbeat normalizes flavor aliases"
|
||||
assert_grep '--max-time' hooks/heartbeat.sh "heartbeat network call has timeout"
|
||||
assert_grep '>/dev/null 2>&1|> *"?/dev/null"? +2>&1|2> *"?/dev/null"?' hooks/heartbeat.sh "heartbeat suppresses curl output"
|
||||
fi
|
||||
|
||||
python3 - "$ROOT" <<'PY' && pass "SessionStart registers silent heartbeat before context restore" || fail "SessionStart registers silent heartbeat before context restore"
|
||||
import json, pathlib, sys
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
data = json.loads((root / 'hooks/hooks.json').read_text())
|
||||
items = data.get('hooks', {}).get('SessionStart', [])
|
||||
if not items:
|
||||
raise SystemExit('no SessionStart hooks')
|
||||
for item in items:
|
||||
commands = [hook.get('command','') for hook in item.get('hooks', [])]
|
||||
if not any('heartbeat.sh' in c for c in commands):
|
||||
raise SystemExit(f"missing heartbeat in matcher {item.get('matcher')}")
|
||||
hb = next(i for i,c in enumerate(commands) if 'heartbeat.sh' in c)
|
||||
restore = next((i for i,c in enumerate(commands) if 'session-restore.sh' in c), None)
|
||||
if restore is not None and hb > restore:
|
||||
raise SystemExit(f"heartbeat should run before session-restore in matcher {item.get('matcher')}")
|
||||
PY
|
||||
|
||||
if [ -f "$ROOT/landing/functions/api/heartbeat.ts" ]; then
|
||||
assert_grep 'import \{ getSession \} from "\./_session"' landing/functions/api/heartbeat.ts "admin GET imports session verifier"
|
||||
assert_grep 'ADMIN_GITHUB_LOGINS' landing/functions/api/heartbeat.ts "admin allowlist is configurable"
|
||||
assert_grep 'heartbeat_installs' landing/functions/api/heartbeat.ts "endpoint writes install aggregate"
|
||||
assert_grep 'heartbeat_events' landing/functions/api/heartbeat.ts "endpoint writes event log"
|
||||
assert_grep 'COUNT\(DISTINCT install_id_hash\)' landing/functions/api/heartbeat.ts "stats count distinct active users"
|
||||
assert_grep 'sha256Hex' landing/functions/api/heartbeat.ts "endpoint hashes install identifiers"
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/landing/src/App.tsx" ]; then
|
||||
assert_grep '#/admin/heartbeats' landing/src/App.tsx "admin hash route is registered"
|
||||
fi
|
||||
if [ -f "$ROOT/landing/src/pages/AdminStats.tsx" ]; then
|
||||
assert_grep '/api/heartbeat' landing/src/pages/AdminStats.tsx "admin page fetches heartbeat stats"
|
||||
assert_grep '/api/auth/github' landing/src/pages/AdminStats.tsx "admin page offers GitHub login"
|
||||
fi
|
||||
|
||||
if [ -f "$ROOT/hooks/heartbeat.sh" ]; then
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
run_silence_check "online heartbeat" "$tmp/online-home" '{"telemetry": true, "flavor": "microsoft"}'
|
||||
if [ -s "$tmp/online-home/.pua/install_id" ]; then
|
||||
pass "heartbeat creates stable local anonymous install id"
|
||||
else
|
||||
fail "heartbeat creates stable local anonymous install id"
|
||||
fi
|
||||
run_silence_check "offline heartbeat" "$tmp/offline-home" '{"offline": true, "telemetry": true}'
|
||||
if [ ! -e "$tmp/offline-home/.pua/install_id" ]; then
|
||||
pass "offline mode does not create telemetry identity"
|
||||
else
|
||||
fail "offline mode does not create telemetry identity"
|
||||
fi
|
||||
|
||||
leak_home="$tmp/leak-home"
|
||||
mkdir -p "$leak_home/.pua"
|
||||
printf '%s\n' '{"always_on": true, "telemetry": true}' > "$leak_home/.pua/config.json"
|
||||
HOME="$leak_home" PUA_CONFIG="$leak_home/.pua/config.json" bash "$ROOT/hooks/session-restore.sh" > "$tmp/session.out" 2>/dev/null || true
|
||||
if grep -Eiq 'heartbeat|install_id|/api/heartbeat' "$tmp/session.out"; then
|
||||
fail "SessionStart context does not leak heartbeat implementation details"
|
||||
else
|
||||
pass "SessionStart context does not leak heartbeat implementation details"
|
||||
fi
|
||||
fi
|
||||
|
||||
assert_not_grep 'heartbeat|install_id|/api/heartbeat' skills/pua/SKILL.md "main skill prompt does not mention heartbeat"
|
||||
|
||||
echo "======================="
|
||||
echo "Passed: $PASS"
|
||||
echo "Failed: $FAIL"
|
||||
echo "Total: $((PASS+FAIL))"
|
||||
echo "======================="
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -128,6 +128,11 @@ required_paths = [
|
||||
'evals/test-platform-compat.sh',
|
||||
'evals/test-feedback-auth.sh',
|
||||
'evals/test-microsoft-flavor.sh',
|
||||
'evals/test-heartbeat.sh',
|
||||
'hooks/heartbeat.sh',
|
||||
'landing/functions/api/heartbeat.ts',
|
||||
'landing/migrations/0004_heartbeat.sql',
|
||||
'landing/src/pages/AdminStats.tsx',
|
||||
'skills/pua/references/methodology-microsoft.md',
|
||||
]
|
||||
for rel in required_paths:
|
||||
@@ -169,6 +174,17 @@ if 'Harness Integrity (anti-cheating governance)' not in session_restore:
|
||||
if 'Multi-Agent Governance Topology' not in session_restore:
|
||||
errors.append('SessionStart protocol missing Multi-Agent Governance Topology injection')
|
||||
|
||||
if not any(any('heartbeat.sh' in hook.get('command', '') for hook in item.get('hooks', [])) for item in hooks_json.get('hooks', {}).get('SessionStart', [])):
|
||||
errors.append('SessionStart missing silent heartbeat.sh registration')
|
||||
heartbeat_hook = (root / 'hooks/heartbeat.sh').read_text(encoding='utf-8') if (root / 'hooks/heartbeat.sh').exists() else ''
|
||||
for term in ['PUA_HEARTBEAT_ENDPOINT', '/api/heartbeat', 'offline', 'telemetry', 'feedback_frequency', '--max-time']:
|
||||
if term not in heartbeat_hook:
|
||||
errors.append(f'heartbeat hook missing required term: {term}')
|
||||
heartbeat_api = (root / 'landing/functions/api/heartbeat.ts').read_text(encoding='utf-8') if (root / 'landing/functions/api/heartbeat.ts').exists() else ''
|
||||
for term in ['ADMIN_GITHUB_LOGINS', 'getSession(request, env.SESSION_SECRET)', 'heartbeat_installs', 'heartbeat_events', 'COUNT(DISTINCT install_id_hash)', 'sha256Hex']:
|
||||
if term not in heartbeat_api:
|
||||
errors.append(f'heartbeat endpoint missing required term: {term}')
|
||||
|
||||
for forbidden in ['Applies to ALL task types', 'All task types', 'code, config, debug, deploy, research']:
|
||||
if forbidden in skill.split('---', 2)[1]:
|
||||
errors.append(f'pua skill description is too broad and may false-trigger: {forbidden}')
|
||||
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/bin/bash
|
||||
# PUA silent heartbeat hook.
|
||||
# Contract: best-effort, no stdout/stderr, no additionalContext, no user-visible prompt text.
|
||||
|
||||
exec >/dev/null 2>&1
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
source "${SCRIPT_DIR}/flavor-helper.sh" || exit 0
|
||||
|
||||
is_true() {
|
||||
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
||||
true|1|yes|on) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_false() {
|
||||
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
||||
false|0|no|off) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
CONFIG="$(pua_config_file)"
|
||||
OFFLINE="False"
|
||||
TELEMETRY="True"
|
||||
FEEDBACK_FREQUENCY=""
|
||||
FLAVOR="alibaba"
|
||||
|
||||
if [ -f "$CONFIG" ]; then
|
||||
OFFLINE="$(pua_json_get "$CONFIG" offline False)"
|
||||
TELEMETRY="$(pua_json_get "$CONFIG" telemetry True)"
|
||||
FEEDBACK_FREQUENCY="$(pua_json_get "$CONFIG" feedback_frequency "")"
|
||||
FLAVOR="$(pua_json_get "$CONFIG" flavor alibaba)"
|
||||
fi
|
||||
|
||||
# Normalize Chinese/alias flavor names through the shared flavor map. This keeps
|
||||
# Cloudflare stats segmented by canonical flavor ids (for example 微软 → microsoft).
|
||||
get_flavor >/dev/null 2>&1 || true
|
||||
FLAVOR="${PUA_FLAVOR:-$FLAVOR}"
|
||||
|
||||
# Privacy gates: offline/off/feedback_frequency=0 means no telemetry identity is created.
|
||||
is_true "$OFFLINE" && exit 0
|
||||
is_false "$TELEMETRY" && exit 0
|
||||
case "$FEEDBACK_FREQUENCY" in
|
||||
0|never|off|false|False) exit 0 ;;
|
||||
esac
|
||||
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
STATE_DIR="${PUA_STATE_DIR:-${HOME:-.}/.pua}"
|
||||
INSTALL_ID_FILE="${PUA_HEARTBEAT_ID_FILE:-${STATE_DIR}/install_id}"
|
||||
LAST_FILE="${PUA_HEARTBEAT_LAST_FILE:-${STATE_DIR}/.heartbeat_last}"
|
||||
mkdir -p "$STATE_DIR" || exit 0
|
||||
|
||||
now="$(date +%s 2>/dev/null || printf '0')"
|
||||
interval="${PUA_HEARTBEAT_INTERVAL_SECONDS:-21600}"
|
||||
case "$interval" in
|
||||
''|*[!0-9]*) interval=21600 ;;
|
||||
esac
|
||||
if [ -f "$LAST_FILE" ] && [ "$interval" -gt 0 ]; then
|
||||
last="$(cat "$LAST_FILE" 2>/dev/null || printf '0')"
|
||||
case "$last" in ''|*[!0-9]*) last=0 ;; esac
|
||||
if [ $((now - last)) -lt "$interval" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
new_install_id() {
|
||||
if command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr '[:upper:]' '[:lower:]'
|
||||
elif [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
cat /proc/sys/kernel/random/uuid
|
||||
else
|
||||
printf 'pua-%s-%s-%s\n' "${now}" "$$" "$(od -An -N8 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ ! -s "$INSTALL_ID_FILE" ]; then
|
||||
umask 077
|
||||
new_install_id > "${INSTALL_ID_FILE}.tmp.$$" 2>/dev/null || exit 0
|
||||
mv "${INSTALL_ID_FILE}.tmp.$$" "$INSTALL_ID_FILE" 2>/dev/null || rm -f "${INSTALL_ID_FILE}.tmp.$$"
|
||||
fi
|
||||
|
||||
INSTALL_ID="$(cat "$INSTALL_ID_FILE" 2>/dev/null | head -n 1 | tr -cd '[:alnum:]_.:-' | cut -c1-128)"
|
||||
[ -n "$INSTALL_ID" ] || exit 0
|
||||
|
||||
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/.." && pwd)}"
|
||||
PLUGIN_JSON="${PLUGIN_ROOT}/plugin.json"
|
||||
VERSION="unknown"
|
||||
PY="$(pua_python_cmd 2>/dev/null || true)"
|
||||
if [ -n "$PY" ] && [ -f "$PLUGIN_JSON" ]; then
|
||||
VERSION="$($PY -c 'import json,sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1], encoding="utf-8")).get("version", "unknown"))
|
||||
except Exception:
|
||||
print("unknown")' "$(pua_to_python_path "$PLUGIN_JSON")" 2>/dev/null || printf 'unknown')"
|
||||
fi
|
||||
|
||||
PLATFORM="${PUA_HEARTBEAT_PLATFORM:-claude-code}"
|
||||
EVENT_NAME="session_start"
|
||||
ENDPOINT="${PUA_HEARTBEAT_ENDPOINT:-https://pua-skill.pages.dev/api/heartbeat}"
|
||||
|
||||
if [ -n "$PY" ]; then
|
||||
PAYLOAD="$($PY -c 'import json,sys
|
||||
install_id,version,platform,event_name,flavor=sys.argv[1:6]
|
||||
print(json.dumps({
|
||||
"install_id": install_id,
|
||||
"plugin_version": version,
|
||||
"platform": platform,
|
||||
"event_name": event_name,
|
||||
"flavor": flavor,
|
||||
}, ensure_ascii=False, separators=(",", ":")))' "$INSTALL_ID" "$VERSION" "$PLATFORM" "$EVENT_NAME" "$FLAVOR" 2>/dev/null || true)"
|
||||
else
|
||||
PAYLOAD="{\"install_id\":\"${INSTALL_ID}\",\"plugin_version\":\"${VERSION}\",\"platform\":\"${PLATFORM}\",\"event_name\":\"${EVENT_NAME}\",\"flavor\":\"alibaba\"}"
|
||||
fi
|
||||
[ -n "$PAYLOAD" ] || exit 0
|
||||
|
||||
printf '%s\n' "$now" > "$LAST_FILE" 2>/dev/null || true
|
||||
|
||||
(
|
||||
curl -fsS --max-time 2 \
|
||||
-X POST "$ENDPOINT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: pua-skill-heartbeat/${VERSION}" \
|
||||
--data-binary "$PAYLOAD" \
|
||||
>/dev/null 2>&1
|
||||
) &
|
||||
|
||||
exit 0
|
||||
@@ -39,6 +39,11 @@
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.sh\"",
|
||||
"timeout": 2
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-restore.sh\"",
|
||||
@@ -49,6 +54,11 @@
|
||||
{
|
||||
"matcher": "startup|resume",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/heartbeat.sh\"",
|
||||
"timeout": 2
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-restore.sh\"",
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { getSession } from "./_session"
|
||||
|
||||
interface Env {
|
||||
DB: D1Database
|
||||
SESSION_SECRET: string
|
||||
ADMIN_GITHUB_LOGINS?: string
|
||||
}
|
||||
|
||||
type HeartbeatBody = {
|
||||
install_id?: string
|
||||
plugin_version?: string
|
||||
platform?: string
|
||||
event_name?: string
|
||||
flavor?: string
|
||||
}
|
||||
|
||||
type CountRow = {
|
||||
total_installs: number
|
||||
active_24h: number
|
||||
active_7d: number
|
||||
active_30d: number
|
||||
sessions_24h: number
|
||||
sessions_7d: number
|
||||
sessions_30d: number
|
||||
}
|
||||
|
||||
const MAX_BODY_BYTES = 4 * 1024
|
||||
const MAX_INSTALL_ID_BYTES = 128
|
||||
const RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
const RATE_LIMIT_MAX_WRITES = 120
|
||||
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
"https://openpua.ai",
|
||||
"https://www.openpua.ai",
|
||||
"https://pua-skill.pages.dev",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
])
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength
|
||||
}
|
||||
|
||||
function corsHeadersFor(request: Request) {
|
||||
const origin = request.headers.get("Origin") || ""
|
||||
const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : "*"
|
||||
return {
|
||||
"Access-Control-Allow-Origin": allowedOrigin,
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
}
|
||||
|
||||
function originAllowed(request: Request): boolean {
|
||||
const origin = request.headers.get("Origin")
|
||||
return !origin || ALLOWED_ORIGINS.has(origin)
|
||||
}
|
||||
|
||||
function json(data: unknown, init: ResponseInit = {}, request?: Request): Response {
|
||||
return Response.json(data, { ...init, headers: { ...(request ? corsHeadersFor(request) : {}), ...(init.headers || {}) } })
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
function cleanText(value: unknown, maxBytes: number, fallback = ""): string {
|
||||
if (value === undefined || value === null) return fallback
|
||||
const text = String(value).trim()
|
||||
if (!text) return fallback
|
||||
if (byteLength(text) > maxBytes) throw new Error(`field too large; max ${maxBytes} bytes`)
|
||||
return text
|
||||
}
|
||||
|
||||
function cleanEnumText(value: unknown, maxBytes: number, fallback: string): string {
|
||||
const text = cleanText(value, maxBytes, fallback)
|
||||
const normalized = text.replace(/[^a-zA-Z0-9_.:/@ -]/g, "").trim().slice(0, maxBytes)
|
||||
return normalized || fallback
|
||||
}
|
||||
|
||||
function validateHeartbeatBody(body: HeartbeatBody) {
|
||||
const installId = cleanText(body.install_id, MAX_INSTALL_ID_BYTES)
|
||||
if (!installId) throw new Error("install_id is required")
|
||||
return {
|
||||
install_id: installId,
|
||||
plugin_version: cleanEnumText(body.plugin_version, 48, "unknown"),
|
||||
platform: cleanEnumText(body.platform, 64, "claude-code"),
|
||||
event_name: cleanEnumText(body.event_name, 64, "session_start") || "session_start",
|
||||
flavor: cleanEnumText(body.flavor, 64, "unknown"),
|
||||
}
|
||||
}
|
||||
|
||||
async function rateLimitKey(request: Request): Promise<string> {
|
||||
const ip = request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "unknown"
|
||||
const ua = request.headers.get("User-Agent") || "unknown"
|
||||
return sha256Hex(`${ip}|${ua}|heartbeat-v1`)
|
||||
}
|
||||
|
||||
async function ensureHeartbeatTables(env: Env): Promise<void> {
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_installs (
|
||||
install_id_hash TEXT PRIMARY KEY,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
session_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_plugin_version TEXT,
|
||||
last_platform TEXT,
|
||||
last_flavor TEXT,
|
||||
last_ip_country TEXT,
|
||||
last_user_agent_hash TEXT
|
||||
)
|
||||
`).run()
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
install_id_hash TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL DEFAULT 'session_start',
|
||||
plugin_version TEXT,
|
||||
platform TEXT,
|
||||
flavor TEXT,
|
||||
ip_country TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`).run()
|
||||
await env.DB.prepare(`
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (key, window_start)
|
||||
)
|
||||
`).run()
|
||||
}
|
||||
|
||||
async function checkRateLimit(request: Request, env: Env): Promise<boolean> {
|
||||
await ensureHeartbeatTables(env)
|
||||
const key = await rateLimitKey(request)
|
||||
const windowStart = Math.floor(Date.now() / (RATE_LIMIT_WINDOW_SECONDS * 1000)) * RATE_LIMIT_WINDOW_SECONDS
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO heartbeat_rate_limits (key, window_start, count, updated_at)
|
||||
VALUES (?, ?, 1, datetime('now'))
|
||||
ON CONFLICT(key, window_start)
|
||||
DO UPDATE SET count = count + 1, updated_at = datetime('now')
|
||||
`).bind(key, windowStart).run()
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT count FROM heartbeat_rate_limits WHERE key = ? AND window_start = ?"
|
||||
).bind(key, windowStart).first<{ count: number }>()
|
||||
await env.DB.prepare(
|
||||
"DELETE FROM heartbeat_rate_limits WHERE window_start < ?"
|
||||
).bind(windowStart - RATE_LIMIT_WINDOW_SECONDS * 60).run()
|
||||
return (row?.count || 0) <= RATE_LIMIT_MAX_WRITES
|
||||
}
|
||||
|
||||
function adminLogins(env: Env): Set<string> {
|
||||
const configured = (env.ADMIN_GITHUB_LOGINS || "tanweai,xsser")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
return new Set(configured)
|
||||
}
|
||||
|
||||
async function handlePost(request: Request, env: Env): Promise<Response> {
|
||||
if (!originAllowed(request)) {
|
||||
return json({ error: "Origin not allowed" }, { status: 403 }, request)
|
||||
}
|
||||
|
||||
const contentLength = Number(request.headers.get("content-length") || 0)
|
||||
if (contentLength > MAX_BODY_BYTES) {
|
||||
return json({ error: `Request body too large (max ${MAX_BODY_BYTES} bytes)` }, { status: 413 }, request)
|
||||
}
|
||||
|
||||
let bodyText = ""
|
||||
try { bodyText = await request.text() } catch {}
|
||||
if (!bodyText) return json({ error: "JSON body required" }, { status: 400 }, request)
|
||||
if (byteLength(bodyText) > MAX_BODY_BYTES) {
|
||||
return json({ error: `Request body too large (max ${MAX_BODY_BYTES} bytes)` }, { status: 413 }, request)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = validateHeartbeatBody(JSON.parse(bodyText) as HeartbeatBody)
|
||||
const allowed = await checkRateLimit(request, env)
|
||||
if (!allowed) return json({ error: "Rate limit exceeded" }, { status: 429 }, request)
|
||||
|
||||
const installIdHash = await sha256Hex(`pua-install:${body.install_id}`)
|
||||
const userAgent = request.headers.get("User-Agent") || "unknown"
|
||||
const userAgentHash = await sha256Hex(`ua:${userAgent}`)
|
||||
const ipCountry = request.headers.get("CF-IPCountry") || "unknown"
|
||||
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO heartbeat_installs (
|
||||
install_id_hash, first_seen, last_seen, session_count,
|
||||
last_plugin_version, last_platform, last_flavor, last_ip_country, last_user_agent_hash
|
||||
) VALUES (?, datetime('now'), datetime('now'), 1, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(install_id_hash)
|
||||
DO UPDATE SET
|
||||
last_seen = datetime('now'),
|
||||
session_count = session_count + 1,
|
||||
last_plugin_version = excluded.last_plugin_version,
|
||||
last_platform = excluded.last_platform,
|
||||
last_flavor = excluded.last_flavor,
|
||||
last_ip_country = excluded.last_ip_country,
|
||||
last_user_agent_hash = excluded.last_user_agent_hash
|
||||
`).bind(
|
||||
installIdHash,
|
||||
body.plugin_version,
|
||||
body.platform,
|
||||
body.flavor,
|
||||
ipCountry,
|
||||
userAgentHash
|
||||
).run()
|
||||
|
||||
await env.DB.prepare(`
|
||||
INSERT INTO heartbeat_events (install_id_hash, event_name, plugin_version, platform, flavor, ip_country, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
`).bind(
|
||||
installIdHash,
|
||||
body.event_name,
|
||||
body.plugin_version,
|
||||
body.platform,
|
||||
body.flavor,
|
||||
ipCountry
|
||||
).run()
|
||||
|
||||
return json({ ok: true }, {}, request)
|
||||
} catch (e) {
|
||||
const message = String((e as Error).message || e)
|
||||
const status = /too large/.test(message) ? 413 : (/required|Unexpected token|JSON/.test(message) ? 400 : 500)
|
||||
return json({ error: status === 500 ? "Failed to save heartbeat" : message }, { status }, request)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGet(request: Request, env: Env): Promise<Response> {
|
||||
if (!env.SESSION_SECRET) {
|
||||
return json({ error: "Admin session is not configured" }, { status: 500 }, request)
|
||||
}
|
||||
const session = await getSession(request, env.SESSION_SECRET)
|
||||
if (!session) return json({ error: "Login required" }, { status: 401 }, request)
|
||||
if (!adminLogins(env).has(session.login.toLowerCase())) {
|
||||
return json({ error: "Admin access required" }, { status: 403 }, request)
|
||||
}
|
||||
|
||||
await ensureHeartbeatTables(env)
|
||||
const totals = await env.DB.prepare(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM heartbeat_installs) AS total_installs,
|
||||
(SELECT COUNT(*) FROM heartbeat_installs WHERE last_seen >= datetime('now', '-1 day')) AS active_24h,
|
||||
(SELECT COUNT(*) FROM heartbeat_installs WHERE last_seen >= datetime('now', '-7 days')) AS active_7d,
|
||||
(SELECT COUNT(*) FROM heartbeat_installs WHERE last_seen >= datetime('now', '-30 days')) AS active_30d,
|
||||
(SELECT COUNT(*) FROM heartbeat_events WHERE created_at >= datetime('now', '-1 day')) AS sessions_24h,
|
||||
(SELECT COUNT(*) FROM heartbeat_events WHERE created_at >= datetime('now', '-7 days')) AS sessions_7d,
|
||||
(SELECT COUNT(*) FROM heartbeat_events WHERE created_at >= datetime('now', '-30 days')) AS sessions_30d
|
||||
`).first<CountRow>()
|
||||
|
||||
const daily = await env.DB.prepare(`
|
||||
SELECT date(created_at) AS day, COUNT(DISTINCT install_id_hash) AS active_users, COUNT(*) AS sessions
|
||||
FROM heartbeat_events
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
GROUP BY day
|
||||
ORDER BY day DESC
|
||||
`).all()
|
||||
|
||||
const byVersion = await env.DB.prepare(`
|
||||
SELECT COALESCE(plugin_version, 'unknown') AS plugin_version,
|
||||
COUNT(DISTINCT install_id_hash) AS active_users,
|
||||
COUNT(*) AS sessions
|
||||
FROM heartbeat_events
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
GROUP BY plugin_version
|
||||
ORDER BY active_users DESC, sessions DESC
|
||||
LIMIT 20
|
||||
`).all()
|
||||
|
||||
const byPlatform = await env.DB.prepare(`
|
||||
SELECT COALESCE(platform, 'unknown') AS platform,
|
||||
COUNT(DISTINCT install_id_hash) AS active_users,
|
||||
COUNT(*) AS sessions
|
||||
FROM heartbeat_events
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
GROUP BY platform
|
||||
ORDER BY active_users DESC, sessions DESC
|
||||
LIMIT 20
|
||||
`).all()
|
||||
|
||||
const byFlavor = await env.DB.prepare(`
|
||||
SELECT COALESCE(flavor, 'unknown') AS flavor,
|
||||
COUNT(DISTINCT install_id_hash) AS active_users,
|
||||
COUNT(*) AS sessions
|
||||
FROM heartbeat_events
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
GROUP BY flavor
|
||||
ORDER BY active_users DESC, sessions DESC
|
||||
LIMIT 20
|
||||
`).all()
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
generated_at: new Date().toISOString(),
|
||||
viewer: { login: session.login, avatar: session.avatar },
|
||||
totals: totals || {
|
||||
total_installs: 0,
|
||||
active_24h: 0,
|
||||
active_7d: 0,
|
||||
active_30d: 0,
|
||||
sessions_24h: 0,
|
||||
sessions_7d: 0,
|
||||
sessions_30d: 0,
|
||||
},
|
||||
daily: daily.results,
|
||||
by_version: byVersion.results,
|
||||
by_platform: byPlatform.results,
|
||||
by_flavor: byFlavor.results,
|
||||
}, {}, request)
|
||||
}
|
||||
|
||||
export const onRequest: PagesFunction<Env> = async ({ request, env }) => {
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: corsHeadersFor(request) })
|
||||
}
|
||||
if (request.method === "POST") return handlePost(request, env)
|
||||
if (request.method === "GET") return handleGet(request, env)
|
||||
return json({ error: "Method not allowed" }, { status: 405 }, request)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Silent heartbeat telemetry: anonymous install aggregates + admin stats.
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_installs (
|
||||
install_id_hash TEXT PRIMARY KEY,
|
||||
first_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
session_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_plugin_version TEXT,
|
||||
last_platform TEXT,
|
||||
last_flavor TEXT,
|
||||
last_ip_country TEXT,
|
||||
last_user_agent_hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
install_id_hash TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL DEFAULT 'session_start',
|
||||
plugin_version TEXT,
|
||||
platform TEXT,
|
||||
flavor TEXT,
|
||||
ip_country TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS heartbeat_rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
window_start INTEGER NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (key, window_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_installs_last_seen ON heartbeat_installs(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_events_created_at ON heartbeat_events(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_events_version ON heartbeat_events(plugin_version, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_events_platform ON heartbeat_events(platform, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_events_flavor ON heartbeat_events(flavor, created_at);
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
NumberTicker,
|
||||
} from "./components/motion-primitives"
|
||||
import Contribute from "./pages/Contribute"
|
||||
import AdminStats from "./pages/AdminStats"
|
||||
import {
|
||||
type Lang,
|
||||
LANG_LABELS,
|
||||
@@ -323,6 +324,9 @@ export default function App() {
|
||||
if (hash === "#/contribute") {
|
||||
return <Contribute lang={lang} />
|
||||
}
|
||||
if (hash === "#/admin/heartbeats") {
|
||||
return <AdminStats lang={lang} />
|
||||
}
|
||||
const L = (o: Record<Lang, string>) => o[lang]
|
||||
const activeBenchmark = BENCHMARKS.find((benchmark) => benchmark.name === activeTab) ?? BENCHMARKS[0]
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import type { Lang } from "../i18n"
|
||||
|
||||
type Totals = {
|
||||
total_installs: number
|
||||
active_24h: number
|
||||
active_7d: number
|
||||
active_30d: number
|
||||
sessions_24h: number
|
||||
sessions_7d: number
|
||||
sessions_30d: number
|
||||
}
|
||||
|
||||
type DailyRow = { day: string; active_users: number; sessions: number }
|
||||
type BreakdownRow = { active_users: number; sessions: number; [key: string]: string | number }
|
||||
|
||||
type StatsData = {
|
||||
ok: true
|
||||
generated_at: string
|
||||
viewer: { login: string; avatar: string }
|
||||
totals: Totals
|
||||
daily: DailyRow[]
|
||||
by_version: BreakdownRow[]
|
||||
by_platform: BreakdownRow[]
|
||||
by_flavor: BreakdownRow[]
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; data: StatsData }
|
||||
| { status: "unauthorized" }
|
||||
| { status: "forbidden" }
|
||||
| { status: "error"; message: string }
|
||||
|
||||
interface Props {
|
||||
lang: Lang
|
||||
}
|
||||
|
||||
function cardStyle(): React.CSSProperties {
|
||||
return {
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "1rem",
|
||||
background: "var(--bg)",
|
||||
padding: "1.25rem",
|
||||
boxShadow: "0 12px 40px rgba(15, 23, 42, 0.06)",
|
||||
}
|
||||
}
|
||||
|
||||
function StatCard({ label, value, hint }: { label: string; value: number; hint?: string }) {
|
||||
return (
|
||||
<div style={cardStyle()}>
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "0.78rem", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em" }}>{label}</div>
|
||||
<div style={{ marginTop: "0.45rem", fontSize: "2rem", fontWeight: 800, letterSpacing: "-0.04em" }}>{value.toLocaleString()}</div>
|
||||
{hint && <div style={{ marginTop: "0.35rem", color: "var(--text-muted)", fontSize: "0.82rem" }}>{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreakdownTable({ title, rows, labelKey }: { title: string; rows: BreakdownRow[]; labelKey: string }) {
|
||||
return (
|
||||
<section style={cardStyle()}>
|
||||
<h2 style={{ margin: "0 0 1rem", fontSize: "1rem", letterSpacing: "-0.02em" }}>{title}</h2>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.86rem" }}>
|
||||
<thead>
|
||||
<tr style={{ color: "var(--text-muted)", textAlign: "left" }}>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)" }}>Segment</th>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>Active users</th>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>Sessions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={String(row[labelKey])}>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", fontWeight: 600 }}>{String(row[labelKey] || "unknown")}</td>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>{Number(row.active_users || 0).toLocaleString()}</td>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>{Number(row.sessions || 0).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr><td colSpan={3} style={{ padding: "1rem 0", color: "var(--text-muted)" }}>No data yet</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminStats({ lang }: Props) {
|
||||
const L = (zh: string, en: string) => (lang === "zh" ? zh : en)
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch("/api/heartbeat", { credentials: "include" })
|
||||
.then(async (response) => {
|
||||
if (response.status === 401) return { kind: "unauthorized" as const }
|
||||
if (response.status === 403) return { kind: "forbidden" as const }
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
return { kind: "ready" as const, data: await response.json() as StatsData }
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.kind === "ready") setState({ status: "ready", data: result.data })
|
||||
if (result.kind === "unauthorized") setState({ status: "unauthorized" })
|
||||
if (result.kind === "forbidden") setState({ status: "forbidden" })
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!cancelled) setState({ status: "error", message: error.message })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const header = (
|
||||
<div style={{ background: "var(--bg)", borderBottom: "1px solid var(--border)", padding: "1.5rem" }}>
|
||||
<div className="container" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "1rem" }}>
|
||||
<a href="/" style={{ textDecoration: "none", color: "inherit", display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<span style={{ fontWeight: 800, fontSize: "1.35rem", letterSpacing: "-0.04em" }}>pua</span>
|
||||
<span style={{ color: "var(--text-muted)" }}>/</span>
|
||||
<span style={{ fontSize: "0.95rem", fontWeight: 600 }}>{L("活跃统计", "Heartbeat Stats")}</span>
|
||||
</a>
|
||||
{state.status === "ready" && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.65rem", color: "var(--text-muted)", fontSize: "0.85rem" }}>
|
||||
<img src={state.data.viewer.avatar} alt={state.data.viewer.login} style={{ width: "1.8rem", height: "1.8rem", borderRadius: "999px" }} />
|
||||
<span>{state.data.viewer.login}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <div style={{ minHeight: "100vh", background: "var(--bg-alt)" }}>{header}<div className="container" style={{ padding: "3rem 1.5rem", color: "var(--text-muted)" }}>Loading...</div></div>
|
||||
}
|
||||
|
||||
if (state.status === "unauthorized" || state.status === "forbidden" || state.status === "error") {
|
||||
const message = state.status === "unauthorized"
|
||||
? L("需要先用 GitHub 登录。", "GitHub login is required.")
|
||||
: state.status === "forbidden"
|
||||
? L("当前 GitHub 账号不在管理员白名单。", "This GitHub account is not on the admin allowlist.")
|
||||
: state.message
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--bg-alt)" }}>
|
||||
{header}
|
||||
<main className="container" style={{ padding: "3rem 1.5rem" }}>
|
||||
<section style={{ ...cardStyle(), maxWidth: "34rem", margin: "0 auto", textAlign: "center" }}>
|
||||
<h1 style={{ margin: "0 0 0.75rem", fontSize: "1.5rem", letterSpacing: "-0.03em" }}>{L("管理员统计", "Admin Stats")}</h1>
|
||||
<p style={{ color: "var(--text-secondary)", lineHeight: 1.7 }}>{message}</p>
|
||||
{state.status === "unauthorized" && <a href="/api/auth/github" className="btn-primary" style={{ marginTop: "1rem", display: "inline-flex" }}>{L("使用 GitHub 登录", "Login with GitHub")}</a>}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { totals } = state.data
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--bg-alt)" }}>
|
||||
{header}
|
||||
<main className="container" style={{ padding: "3rem 1.5rem" }}>
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: "2.2rem", letterSpacing: "-0.05em" }}>{L("PUA Skill 活跃用户", "PUA Skill Active Users")}</h1>
|
||||
<p style={{ color: "var(--text-muted)", lineHeight: 1.7, maxWidth: "48rem" }}>
|
||||
{L("只展示匿名聚合数据:安装标识只以 SHA-256 哈希保存,页面需要管理员 GitHub 登录。", "Anonymous aggregate data only: install identifiers are stored as SHA-256 hashes and this page requires admin GitHub login.")}
|
||||
</p>
|
||||
<p style={{ color: "var(--text-muted)", fontSize: "0.82rem" }}>Generated at {state.data.generated_at}</p>
|
||||
</div>
|
||||
|
||||
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))", gap: "1rem", marginBottom: "1.25rem" }}>
|
||||
<StatCard label="Total installs" value={totals.total_installs} />
|
||||
<StatCard label="Active 24h" value={totals.active_24h} hint={`${totals.sessions_24h.toLocaleString()} sessions`} />
|
||||
<StatCard label="Active 7d" value={totals.active_7d} hint={`${totals.sessions_7d.toLocaleString()} sessions`} />
|
||||
<StatCard label="Active 30d" value={totals.active_30d} hint={`${totals.sessions_30d.toLocaleString()} sessions`} />
|
||||
</section>
|
||||
|
||||
<section style={{ ...cardStyle(), marginBottom: "1.25rem" }}>
|
||||
<h2 style={{ margin: "0 0 1rem", fontSize: "1rem", letterSpacing: "-0.02em" }}>{L("最近 30 天", "Last 30 days")}</h2>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.86rem" }}>
|
||||
<thead>
|
||||
<tr style={{ color: "var(--text-muted)", textAlign: "left" }}>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)" }}>Day</th>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>Active users</th>
|
||||
<th style={{ padding: "0.5rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>Sessions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.data.daily.map((row) => (
|
||||
<tr key={row.day}>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", fontWeight: 600 }}>{row.day}</td>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>{row.active_users.toLocaleString()}</td>
|
||||
<td style={{ padding: "0.6rem 0", borderBottom: "1px solid var(--border)", textAlign: "right" }}>{row.sessions.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{state.data.daily.length === 0 && (
|
||||
<tr><td colSpan={3} style={{ padding: "1rem 0", color: "var(--text-muted)" }}>No data yet</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(18rem, 1fr))", gap: "1rem" }}>
|
||||
<BreakdownTable title="By version" rows={state.data.by_version} labelKey="plugin_version" />
|
||||
<BreakdownTable title="By platform" rows={state.data.by_platform} labelKey="platform" />
|
||||
<BreakdownTable title="By flavor" rows={state.data.by_flavor} labelKey="flavor" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tanweai/pi-pua",
|
||||
"version": "3.4.2",
|
||||
"version": "3.4.3",
|
||||
"description": "PUA high-agency governance extension and skill pack for the Pi coding agent.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
+6
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pua",
|
||||
"version": "3.4.2",
|
||||
"description": "Forces high-agency exhaustive problem-solving with corporate PUA pressure escalation when the AI is passive, gives up, skips verification, produces low-quality work, or the user explicitly asks for PUA/try-harder mode. v3.4.2 restores the authentic Microsoft flavor with Connects, Impact Descriptors, SLITE/LITE, PIP/GVSA, and Three Circles of Impact for fixed-thinking failures. Does not target normal first-attempt requests.",
|
||||
"version": "3.4.3",
|
||||
"description": "Forces high-agency exhaustive problem-solving with corporate PUA pressure escalation when the AI is passive, gives up, skips verification, produces low-quality work, or the user explicitly asks for PUA/try-harder mode. v3.4.3 adds a silent heartbeat hook with admin-only Cloudflare active-user stats while keeping heartbeat details out of conversation prompts. Does not target normal first-attempt requests.",
|
||||
"author": {
|
||||
"name": "探微安全实验室",
|
||||
"url": "https://github.com/tanweai"
|
||||
@@ -33,6 +33,9 @@
|
||||
"trae",
|
||||
"codex-aliases",
|
||||
"diagnosis-first",
|
||||
"rate-limit"
|
||||
"rate-limit",
|
||||
"heartbeat",
|
||||
"cloudflare",
|
||||
"active-users"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user