pi-antigravity-rotator 1.9.2 → 1.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/dashboard.ts +187 -0
- package/src/notification-poller.ts +123 -0
- package/src/proxy.ts +2 -0
- package/src/rotator.ts +2 -0
- package/src/telemetry.ts +1 -1
- package/src/types.ts +12 -0
- package/tools/telemetry-receiver/receiver.js +528 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.3] - 2026-04-29
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Admin Broadcast Notifications**: The dashboard now supports operator-controlled broadcast notifications. A new `notification-poller` checks the telemetry server every 30 minutes for active announcements, allowing operators to push critical alerts (like required re-enrollments or bug notices) to all connected clients.
|
|
7
|
+
- **Admin Notification UI**: The telemetry receiver now includes a full dashboard at `/notifications` to create, edit, delete, and preview broadcast messages with version-targeting capabilities.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- **Telemetry Heartbeat**: Reduced the telemetry heartbeat interval from 6 hours to 1 hour for more timely metrics reporting.
|
|
11
|
+
|
|
3
12
|
## [1.9.2] - 2026-04-29
|
|
4
13
|
|
|
5
14
|
### Fixed
|
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Multi-account rotation proxy for Google Antigravity. Distributes API usage acros
|
|
|
19
19
|
- **Advanced Telemetry & Statistics** -- Track exactly how much USD you save compared to a paid API plan, predict quota depletion with the Forecast grid, view Latency tracking (p50/p95), and explore 60-day historical usage heatmaps
|
|
20
20
|
- **Web dashboard** -- Real-time view of model routing table, per-account quota bars with per-model timers, and flagged account alerts
|
|
21
21
|
- **Auto-update notifications** -- The dashboard checks npm for new releases every 30 minutes and shows a banner with one-click update when a newer version is available
|
|
22
|
+
- **Broadcast notifications** -- Operator-controlled announcements and alerts delivered directly to the dashboard
|
|
22
23
|
- **State persistence** -- Survives restarts; routing assignments, per-model request counters, cooldowns, and flags are saved to disk
|
|
23
24
|
|
|
24
25
|
## Quick Start
|
|
@@ -360,7 +361,7 @@ pi-antigravity-rotator collects **anonymous usage telemetry** to help understand
|
|
|
360
361
|
|
|
361
362
|
### What is collected
|
|
362
363
|
|
|
363
|
-
**Heartbeat** (at boot, every
|
|
364
|
+
**Heartbeat** (at boot, every 1h, at shutdown):
|
|
364
365
|
- Random install ID (UUID — not tied to any account or person)
|
|
365
366
|
- Rotator version, Node.js version, OS, architecture
|
|
366
367
|
- Account count, models in use, total request count, uptime
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antigravity-rotator",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "Multi-account rotation proxy for Google Antigravity with per-model routing, real-time quota tracking, and infringement detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/dashboard.ts
CHANGED
|
@@ -1033,6 +1033,120 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
1033
1033
|
.btn-update-dismiss:hover {
|
|
1034
1034
|
color: var(--text);
|
|
1035
1035
|
}
|
|
1036
|
+
|
|
1037
|
+
/* ── Admin Notification Banners ── */
|
|
1038
|
+
.notif-container {
|
|
1039
|
+
display: flex;
|
|
1040
|
+
flex-direction: column;
|
|
1041
|
+
gap: 10px;
|
|
1042
|
+
margin-bottom: 2px;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.notif-banner {
|
|
1046
|
+
display: flex;
|
|
1047
|
+
align-items: flex-start;
|
|
1048
|
+
gap: 12px;
|
|
1049
|
+
padding: 12px 18px;
|
|
1050
|
+
border-radius: var(--radius);
|
|
1051
|
+
animation: bannerSlideIn 0.4s ease-out;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
.notif-banner.notif-info {
|
|
1055
|
+
background: linear-gradient(135deg, rgba(96, 165, 250, 0.10), rgba(99, 179, 237, 0.06));
|
|
1056
|
+
border: 1px solid rgba(96, 165, 250, 0.30);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.notif-banner.notif-warning {
|
|
1060
|
+
background: linear-gradient(135deg, rgba(251, 191, 36, 0.10), rgba(246, 224, 94, 0.06));
|
|
1061
|
+
border: 1px solid rgba(251, 191, 36, 0.30);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
.notif-banner.notif-critical {
|
|
1065
|
+
background: linear-gradient(135deg, rgba(248, 113, 113, 0.12), rgba(252, 129, 129, 0.06));
|
|
1066
|
+
border: 1px solid rgba(248, 113, 113, 0.35);
|
|
1067
|
+
animation: bannerSlideIn 0.4s ease-out, notifPulse 3s ease-in-out 1;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
@keyframes notifPulse {
|
|
1071
|
+
0%, 100% { box-shadow: none; }
|
|
1072
|
+
50% { box-shadow: 0 0 16px rgba(248, 113, 113, 0.25); }
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
.notif-icon {
|
|
1076
|
+
font-size: 18px;
|
|
1077
|
+
flex-shrink: 0;
|
|
1078
|
+
margin-top: 1px;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
.notif-content {
|
|
1082
|
+
flex: 1;
|
|
1083
|
+
min-width: 0;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
.notif-title {
|
|
1087
|
+
font-weight: 700;
|
|
1088
|
+
font-size: 13px;
|
|
1089
|
+
margin-bottom: 3px;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
.notif-info .notif-title { color: var(--blue); }
|
|
1093
|
+
.notif-warning .notif-title { color: var(--yellow); }
|
|
1094
|
+
.notif-critical .notif-title { color: var(--red); }
|
|
1095
|
+
|
|
1096
|
+
.notif-msg {
|
|
1097
|
+
font-size: 12px;
|
|
1098
|
+
color: var(--text-dim);
|
|
1099
|
+
line-height: 1.45;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
.notif-actions {
|
|
1103
|
+
display: flex;
|
|
1104
|
+
gap: 8px;
|
|
1105
|
+
margin-top: 8px;
|
|
1106
|
+
align-items: center;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
.notif-action-btn {
|
|
1110
|
+
display: inline-block;
|
|
1111
|
+
padding: 4px 12px;
|
|
1112
|
+
border-radius: 6px;
|
|
1113
|
+
font-size: 11px;
|
|
1114
|
+
font-weight: 600;
|
|
1115
|
+
text-decoration: none;
|
|
1116
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
1117
|
+
color: var(--text);
|
|
1118
|
+
background: rgba(255,255,255,0.06);
|
|
1119
|
+
cursor: pointer;
|
|
1120
|
+
font-family: var(--font);
|
|
1121
|
+
transition: background 0.2s;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
.notif-action-btn:hover {
|
|
1125
|
+
background: rgba(255,255,255,0.12);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.notif-dismiss {
|
|
1129
|
+
background: none;
|
|
1130
|
+
border: none;
|
|
1131
|
+
color: var(--text-dim);
|
|
1132
|
+
cursor: pointer;
|
|
1133
|
+
font-size: 16px;
|
|
1134
|
+
padding: 4px;
|
|
1135
|
+
line-height: 1;
|
|
1136
|
+
opacity: 0.6;
|
|
1137
|
+
transition: opacity 0.2s;
|
|
1138
|
+
flex-shrink: 0;
|
|
1139
|
+
margin-left: auto;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
.notif-dismiss:hover {
|
|
1143
|
+
opacity: 1;
|
|
1144
|
+
color: var(--text);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
.notif-bell-dot {
|
|
1148
|
+
background: var(--yellow);
|
|
1149
|
+
}
|
|
1036
1150
|
</style>
|
|
1037
1151
|
</head>
|
|
1038
1152
|
<body>
|
|
@@ -1043,6 +1157,8 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
1043
1157
|
<div class="update-banner-actions" id="updateActions"></div>
|
|
1044
1158
|
</div>
|
|
1045
1159
|
|
|
1160
|
+
<div class="notif-container" id="notifContainer"></div>
|
|
1161
|
+
|
|
1046
1162
|
<div class="header">
|
|
1047
1163
|
<div class="header-main">
|
|
1048
1164
|
<div class="header-title-row">
|
|
@@ -1402,6 +1518,7 @@ function renderAccounts(data) {
|
|
|
1402
1518
|
'<div class="ops-warning">' + freshPolicyHint + '</div>';
|
|
1403
1519
|
|
|
1404
1520
|
renderUpdateBanner(data.updateInfo);
|
|
1521
|
+
renderNotifications(data.notifications);
|
|
1405
1522
|
renderAttentionPanel(data);
|
|
1406
1523
|
renderTokenChart(data.tokenUsage);
|
|
1407
1524
|
renderHeatmap(data.tokenUsage);
|
|
@@ -2576,6 +2693,76 @@ function clearPendingRestart() {
|
|
|
2576
2693
|
var banner = document.getElementById('updateBanner');
|
|
2577
2694
|
if (banner) banner.className = 'update-banner';
|
|
2578
2695
|
}
|
|
2696
|
+
|
|
2697
|
+
// ── Admin Notifications ──
|
|
2698
|
+
var NOTIF_ICONS = { info: '\u{2139}\ufe0f', warning: '\u26a0\ufe0f', critical: '\u{1f6a8}' };
|
|
2699
|
+
|
|
2700
|
+
function renderNotifications(notifications) {
|
|
2701
|
+
var container = document.getElementById('notifContainer');
|
|
2702
|
+
if (!container) return;
|
|
2703
|
+
if (!notifications || notifications.length === 0) {
|
|
2704
|
+
container.innerHTML = '';
|
|
2705
|
+
updateNotifBellBadge(0);
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
var visibleCount = 0;
|
|
2710
|
+
var html = '';
|
|
2711
|
+
for (var i = 0; i < notifications.length; i++) {
|
|
2712
|
+
var n = notifications[i];
|
|
2713
|
+
// Check if user dismissed this notification
|
|
2714
|
+
if (localStorage.getItem('notif-dismissed-' + n.id)) continue;
|
|
2715
|
+
visibleCount++;
|
|
2716
|
+
var icon = NOTIF_ICONS[n.type] || NOTIF_ICONS.info;
|
|
2717
|
+
var typeClass = 'notif-' + (n.type || 'info');
|
|
2718
|
+
html += '<div class="notif-banner ' + typeClass + '" id="notif-' + escapeHtml(n.id) + '">';
|
|
2719
|
+
html += '<span class="notif-icon">' + icon + '</span>';
|
|
2720
|
+
html += '<div class="notif-content">';
|
|
2721
|
+
html += '<div class="notif-title">' + escapeHtml(n.title) + '</div>';
|
|
2722
|
+
html += '<div class="notif-msg">' + escapeHtml(n.message) + '</div>';
|
|
2723
|
+
if (n.actionUrl) {
|
|
2724
|
+
html += '<div class="notif-actions">';
|
|
2725
|
+
html += '<a class="notif-action-btn" href="' + escapeHtml(n.actionUrl) + '" target="_blank">' + escapeHtml(n.actionLabel || 'Learn More') + '</a>';
|
|
2726
|
+
html += '</div>';
|
|
2727
|
+
}
|
|
2728
|
+
html += '</div>';
|
|
2729
|
+
html += '<button class="notif-dismiss" onclick="dismissNotification(\\'' + escapeHtml(n.id) + '\\')" title="Dismiss">×</button>';
|
|
2730
|
+
html += '</div>';
|
|
2731
|
+
}
|
|
2732
|
+
container.innerHTML = html;
|
|
2733
|
+
updateNotifBellBadge(visibleCount);
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
function dismissNotification(id) {
|
|
2737
|
+
localStorage.setItem('notif-dismissed-' + id, '1');
|
|
2738
|
+
var el = document.getElementById('notif-' + id);
|
|
2739
|
+
if (el) {
|
|
2740
|
+
el.style.opacity = '0';
|
|
2741
|
+
el.style.transform = 'translateY(-10px)';
|
|
2742
|
+
el.style.transition = 'opacity 0.3s, transform 0.3s';
|
|
2743
|
+
setTimeout(function() { el.remove(); }, 300);
|
|
2744
|
+
}
|
|
2745
|
+
// Recount visible
|
|
2746
|
+
var container = document.getElementById('notifContainer');
|
|
2747
|
+
if (container) {
|
|
2748
|
+
var remaining = container.querySelectorAll('.notif-banner').length - 1;
|
|
2749
|
+
updateNotifBellBadge(Math.max(0, remaining));
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function updateNotifBellBadge(count) {
|
|
2754
|
+
// Update the attention bell badge if it exists
|
|
2755
|
+
var bellBtn = document.querySelector('.header-icon-btn.attention');
|
|
2756
|
+
if (!bellBtn) return;
|
|
2757
|
+
var badge = bellBtn.querySelector('.header-icon-badge');
|
|
2758
|
+
if (count > 0) {
|
|
2759
|
+
bellBtn.classList.add('has-items');
|
|
2760
|
+
if (badge) {
|
|
2761
|
+
badge.textContent = String(count);
|
|
2762
|
+
badge.style.display = '';
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2579
2766
|
</script>
|
|
2580
2767
|
</body>
|
|
2581
2768
|
</html>`;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Notification poller — fetches admin broadcast notifications from the telemetry server
|
|
2
|
+
//
|
|
3
|
+
// Polls GET /v1/notifications?version=x.y.z every 30 minutes.
|
|
4
|
+
// Respects PI_ROTATOR_TELEMETRY=off — if telemetry is disabled, no polling.
|
|
5
|
+
// Fire-and-forget: never throws, never blocks, never affects core rotator flow.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { logger } from "./logger.js";
|
|
11
|
+
import { isTelemetryEnabled } from "./telemetry.js";
|
|
12
|
+
|
|
13
|
+
const notifLogger = logger.child("notifications");
|
|
14
|
+
|
|
15
|
+
// Same base URL as telemetry endpoint (just different path)
|
|
16
|
+
const TELEMETRY_BASE = "http://telemetry.dragont.ec:3800";
|
|
17
|
+
const NOTIFICATIONS_URL = `${TELEMETRY_BASE}/v1/notifications`;
|
|
18
|
+
|
|
19
|
+
const POLL_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
20
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
21
|
+
|
|
22
|
+
export interface AdminNotification {
|
|
23
|
+
id: string;
|
|
24
|
+
type: "info" | "warning" | "critical";
|
|
25
|
+
title: string;
|
|
26
|
+
message: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
actionUrl?: string | null;
|
|
29
|
+
actionLabel?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Version ──────────────────────────────────────────────────────────
|
|
33
|
+
let _version: string | null = null;
|
|
34
|
+
function getVersion(): string {
|
|
35
|
+
if (_version) return _version;
|
|
36
|
+
try {
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
39
|
+
_version = pkg.version ?? "unknown";
|
|
40
|
+
} catch {
|
|
41
|
+
_version = "unknown";
|
|
42
|
+
}
|
|
43
|
+
return _version!;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Cached notifications ─────────────────────────────────────────────
|
|
47
|
+
let _notifications: AdminNotification[] = [];
|
|
48
|
+
let _pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fetch notifications from the telemetry server.
|
|
52
|
+
*/
|
|
53
|
+
async function fetchNotifications(): Promise<void> {
|
|
54
|
+
try {
|
|
55
|
+
const version = getVersion();
|
|
56
|
+
const url = version !== "unknown"
|
|
57
|
+
? `${NOTIFICATIONS_URL}?version=${encodeURIComponent(version)}`
|
|
58
|
+
: NOTIFICATIONS_URL;
|
|
59
|
+
|
|
60
|
+
const response = await fetch(url, {
|
|
61
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
notifLogger.log("info", `Notification poll returned ${response.status}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = (await response.json()) as AdminNotification[];
|
|
70
|
+
if (Array.isArray(data)) {
|
|
71
|
+
_notifications = data;
|
|
72
|
+
if (data.length > 0) {
|
|
73
|
+
notifLogger.log("debug", `Fetched ${data.length} notification(s)`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Fire-and-forget: network errors are expected (offline, server down, etc.)
|
|
78
|
+
// Never crash, never surface errors to user.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the cached notifications. Returns immediately.
|
|
84
|
+
*/
|
|
85
|
+
export function getNotifications(): AdminNotification[] {
|
|
86
|
+
return [..._notifications];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start periodic notification polling.
|
|
91
|
+
* Initial fetch after a short delay (15s) to avoid slowing down startup.
|
|
92
|
+
*/
|
|
93
|
+
export function startNotificationPoller(): void {
|
|
94
|
+
if (!isTelemetryEnabled()) {
|
|
95
|
+
notifLogger.log("debug", "Notification polling disabled (telemetry off)");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Initial delayed fetch
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
void fetchNotifications();
|
|
102
|
+
}, 15_000);
|
|
103
|
+
|
|
104
|
+
// Periodic poll
|
|
105
|
+
_pollTimer = setInterval(() => {
|
|
106
|
+
void fetchNotifications();
|
|
107
|
+
}, POLL_INTERVAL_MS);
|
|
108
|
+
|
|
109
|
+
// Don't prevent process exit
|
|
110
|
+
if (_pollTimer.unref) {
|
|
111
|
+
_pollTimer.unref();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stop notification polling.
|
|
117
|
+
*/
|
|
118
|
+
export function stopNotificationPoller(): void {
|
|
119
|
+
if (_pollTimer) {
|
|
120
|
+
clearInterval(_pollTimer);
|
|
121
|
+
_pollTimer = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/proxy.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { logger } from "./logger.js";
|
|
|
21
21
|
import { trackFeature, reportFlagEvent, FLAG_PATTERNS, type FlagPattern } from "./telemetry.js";
|
|
22
22
|
import type { FlagEventData } from "./telemetry.js";
|
|
23
23
|
import { startVersionChecker, performSelfUpdate } from "./version-check.js";
|
|
24
|
+
import { startNotificationPoller } from "./notification-poller.js";
|
|
24
25
|
|
|
25
26
|
const proxyLogger = logger.child("proxy");
|
|
26
27
|
|
|
@@ -632,6 +633,7 @@ function flattenHeaders(headers: IncomingMessage["headers"]): Record<string, str
|
|
|
632
633
|
|
|
633
634
|
export function startProxy(rotator: AccountRotator, port: number): void {
|
|
634
635
|
startVersionChecker();
|
|
636
|
+
startNotificationPoller();
|
|
635
637
|
const sseClients = new Set<ServerResponse>();
|
|
636
638
|
let sseBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
637
639
|
const SSE_THROTTLE_MS = 1000; // max 1 push/second
|
package/src/rotator.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { getOAuthClientConfig } from "./oauth.js";
|
|
|
32
32
|
import { fetchWithRetry } from "./fetch-with-retry.js";
|
|
33
33
|
import { logger } from "./logger.js";
|
|
34
34
|
import { getUpdateInfo } from "./version-check.js";
|
|
35
|
+
import { getNotifications } from "./notification-poller.js";
|
|
35
36
|
|
|
36
37
|
const rotatorLogger = logger.child("rotator");
|
|
37
38
|
|
|
@@ -1391,6 +1392,7 @@ export class AccountRotator {
|
|
|
1391
1392
|
tokenUsage: this.getTokenUsage(),
|
|
1392
1393
|
latencyStats: this.getLatencyStats(),
|
|
1393
1394
|
updateInfo: getUpdateInfo(),
|
|
1395
|
+
notifications: getNotifications(),
|
|
1394
1396
|
};
|
|
1395
1397
|
}
|
|
1396
1398
|
|
package/src/telemetry.ts
CHANGED
|
@@ -27,7 +27,7 @@ const telemetryLogger = logger.child("telemetry");
|
|
|
27
27
|
// Update this URL to your VPS before publishing to npm.
|
|
28
28
|
const TELEMETRY_ENDPOINT = "http://telemetry.dragont.ec:3800/v1/events";
|
|
29
29
|
|
|
30
|
-
const HEARTBEAT_INTERVAL_MS =
|
|
30
|
+
const HEARTBEAT_INTERVAL_MS = 1 * 60 * 60 * 1000; // 1 hour
|
|
31
31
|
const SEND_TIMEOUT_MS = 5000;
|
|
32
32
|
|
|
33
33
|
// ── Known flag patterns (same list used in proxy.ts for detection) ───
|
package/src/types.ts
CHANGED
|
@@ -225,6 +225,17 @@ export interface UpdateInfo {
|
|
|
225
225
|
checkedAt: number;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
// Admin broadcast notification
|
|
229
|
+
export interface AdminNotification {
|
|
230
|
+
id: string;
|
|
231
|
+
type: "info" | "warning" | "critical";
|
|
232
|
+
title: string;
|
|
233
|
+
message: string;
|
|
234
|
+
createdAt: string;
|
|
235
|
+
actionUrl?: string | null;
|
|
236
|
+
actionLabel?: string | null;
|
|
237
|
+
}
|
|
238
|
+
|
|
228
239
|
// Dashboard API response
|
|
229
240
|
export interface StatusResponse {
|
|
230
241
|
proxyPort: number;
|
|
@@ -264,6 +275,7 @@ export interface StatusResponse {
|
|
|
264
275
|
tokenUsage: TokenUsageData;
|
|
265
276
|
latencyStats: Record<string, { ttfb: { p50: number; p95: number }; total: { p50: number; p95: number }; count: number }>;
|
|
266
277
|
updateInfo?: UpdateInfo;
|
|
278
|
+
notifications?: AdminNotification[];
|
|
267
279
|
}
|
|
268
280
|
|
|
269
281
|
export interface AccountStatus {
|
|
@@ -23,12 +23,14 @@
|
|
|
23
23
|
import { createServer } from "node:http";
|
|
24
24
|
import {
|
|
25
25
|
appendFileSync,
|
|
26
|
+
writeFileSync,
|
|
26
27
|
mkdirSync,
|
|
27
28
|
existsSync,
|
|
28
29
|
readdirSync,
|
|
29
30
|
readFileSync,
|
|
30
31
|
} from "node:fs";
|
|
31
32
|
import { join } from "node:path";
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
32
34
|
|
|
33
35
|
const PORT = parseInt(process.env.PORT || "3800", 10);
|
|
34
36
|
const DATA_DIR = process.env.DATA_DIR || "./data";
|
|
@@ -36,6 +38,68 @@ const STATS_TOKEN = process.env.STATS_TOKEN || "";
|
|
|
36
38
|
|
|
37
39
|
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
38
40
|
|
|
41
|
+
// ── Notifications Storage ────────────────────────────────────────────
|
|
42
|
+
const NOTIFICATIONS_FILE = join(DATA_DIR, "notifications.json");
|
|
43
|
+
|
|
44
|
+
function loadNotifications() {
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(NOTIFICATIONS_FILE)) {
|
|
47
|
+
return JSON.parse(readFileSync(NOTIFICATIONS_FILE, "utf-8"));
|
|
48
|
+
}
|
|
49
|
+
} catch { /* corrupted file, start fresh */ }
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveNotifications(notifications) {
|
|
54
|
+
writeFileSync(NOTIFICATIONS_FILE, JSON.stringify(notifications, null, 2), "utf-8");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Simple semver comparison: returns true if a < b.
|
|
59
|
+
*/
|
|
60
|
+
function semverLt(a, b) {
|
|
61
|
+
const pa = a.split(".").map(Number);
|
|
62
|
+
const pb = b.split(".").map(Number);
|
|
63
|
+
for (let i = 0; i < 3; i++) {
|
|
64
|
+
const av = pa[i] ?? 0;
|
|
65
|
+
const bv = pb[i] ?? 0;
|
|
66
|
+
if (av < bv) return true;
|
|
67
|
+
if (av > bv) return false;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function semverLte(a, b) {
|
|
73
|
+
return a === b || semverLt(a, b);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Filter notifications for a client with a given version.
|
|
78
|
+
* Removes expired notifications and applies version targeting.
|
|
79
|
+
*/
|
|
80
|
+
function getActiveNotifications(clientVersion) {
|
|
81
|
+
const all = loadNotifications();
|
|
82
|
+
const now = new Date().toISOString();
|
|
83
|
+
return all.filter((n) => {
|
|
84
|
+
// Filter expired
|
|
85
|
+
if (n.expiresAt && n.expiresAt < now) return false;
|
|
86
|
+
// Version targeting
|
|
87
|
+
if (clientVersion) {
|
|
88
|
+
if (n.minVersion && semverLt(clientVersion, n.minVersion)) return false;
|
|
89
|
+
if (n.maxVersion && !semverLte(clientVersion, n.maxVersion)) return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}).map((n) => ({
|
|
93
|
+
id: n.id,
|
|
94
|
+
type: n.type || "info",
|
|
95
|
+
title: n.title,
|
|
96
|
+
message: n.message,
|
|
97
|
+
createdAt: n.createdAt,
|
|
98
|
+
actionUrl: n.actionUrl || null,
|
|
99
|
+
actionLabel: n.actionLabel || null,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
39
103
|
// ── Validation ───────────────────────────────────────────────────────
|
|
40
104
|
const ALLOWED_EVENTS = new Set(["boot", "heartbeat", "shutdown", "flag"]);
|
|
41
105
|
const MAX_BODY_BYTES = 4096;
|
|
@@ -690,6 +754,357 @@ setInterval(()=>{if(_token){const f={};const i=$('fInstall').value;if(i)f.instal
|
|
|
690
754
|
</script>
|
|
691
755
|
</body></html>`;
|
|
692
756
|
}
|
|
757
|
+
|
|
758
|
+
// ── Notifications Admin UI ───────────────────────────────────────────
|
|
759
|
+
function buildNotificationsAdminHtml() {
|
|
760
|
+
return `<!DOCTYPE html>
|
|
761
|
+
<html lang="en">
|
|
762
|
+
<head>
|
|
763
|
+
<meta charset="UTF-8">
|
|
764
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
765
|
+
<title>Pi Rotator — Notification Manager</title>
|
|
766
|
+
<style>
|
|
767
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
768
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}
|
|
769
|
+
.header{background:#1a1f2e;border-bottom:1px solid #2d3748;padding:14px 24px;display:flex;align-items:center;gap:12px}
|
|
770
|
+
.header h1{font-size:17px;font-weight:700;color:#fff}
|
|
771
|
+
.header .nav{margin-left:auto;display:flex;gap:10px;align-items:center}
|
|
772
|
+
.header .nav a{color:#718096;font-size:13px;text-decoration:none;padding:4px 10px;border-radius:6px;transition:color .2s,background .2s}
|
|
773
|
+
.header .nav a:hover{color:#e2e8f0;background:rgba(255,255,255,.06)}
|
|
774
|
+
.token-bar{background:#1a1f2e;border-bottom:1px solid #2d3748;padding:10px 24px;display:flex;gap:8px;align-items:center}
|
|
775
|
+
.token-bar input[type=password]{flex:1;background:#0f1117;border:1px solid #2d3748;border-radius:6px;padding:7px 12px;color:#e2e8f0;font-size:13px;font-family:monospace}
|
|
776
|
+
.token-bar input[type=password]:focus{outline:none;border-color:#4299e1}
|
|
777
|
+
.token-bar button{background:#4299e1;color:#fff;border:none;border-radius:6px;padding:7px 16px;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
|
|
778
|
+
.token-bar button:hover{background:#3182ce}
|
|
779
|
+
.main{padding:20px 24px;max-width:1100px;margin:0 auto}
|
|
780
|
+
.section{background:#1a1f2e;border:1px solid #2d3748;border-radius:10px;padding:18px;margin-bottom:14px}
|
|
781
|
+
.section h2{font-size:11px;font-weight:700;color:#718096;text-transform:uppercase;letter-spacing:.08em;margin-bottom:14px}
|
|
782
|
+
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
783
|
+
.form-group{display:flex;flex-direction:column;gap:4px}
|
|
784
|
+
.form-group.full{grid-column:1/-1}
|
|
785
|
+
.form-group label{font-size:11px;color:#718096;text-transform:uppercase;letter-spacing:.05em;font-weight:600}
|
|
786
|
+
.form-group input,.form-group textarea,.form-group select{background:#0f1117;border:1px solid #2d3748;border-radius:6px;padding:8px 12px;color:#e2e8f0;font-size:13px;font-family:inherit}
|
|
787
|
+
.form-group input:focus,.form-group textarea:focus,.form-group select:focus{outline:none;border-color:#4299e1}
|
|
788
|
+
.form-group textarea{min-height:80px;resize:vertical}
|
|
789
|
+
.form-actions{display:flex;gap:8px;margin-top:14px;grid-column:1/-1}
|
|
790
|
+
.btn-primary{background:#4299e1;color:#fff;border:none;border-radius:6px;padding:8px 20px;cursor:pointer;font-size:13px;font-weight:600}
|
|
791
|
+
.btn-primary:hover{background:#3182ce}
|
|
792
|
+
.btn-primary:disabled{opacity:.5;cursor:not-allowed}
|
|
793
|
+
.btn-secondary{background:#2d3748;color:#a0aec0;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-size:13px}
|
|
794
|
+
.btn-secondary:hover{background:#3d4a5e}
|
|
795
|
+
.btn-danger{background:rgba(248,113,113,.15);color:#fc8181;border:1px solid rgba(248,113,113,.3);border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-weight:600}
|
|
796
|
+
.btn-danger:hover{background:rgba(248,113,113,.25)}
|
|
797
|
+
.btn-edit{background:rgba(66,153,225,.12);color:#63b3ed;border:1px solid rgba(66,153,225,.3);border-radius:6px;padding:4px 10px;cursor:pointer;font-size:11px;font-weight:600}
|
|
798
|
+
.btn-edit:hover{background:rgba(66,153,225,.22)}
|
|
799
|
+
|
|
800
|
+
table{width:100%;border-collapse:collapse;font-size:12px}
|
|
801
|
+
th{text-align:left;padding:7px 12px;color:#718096;border-bottom:1px solid #2d3748;font-weight:500;white-space:nowrap}
|
|
802
|
+
td{padding:7px 12px;border-bottom:1px solid #1f2535;color:#e2e8f0;vertical-align:top}
|
|
803
|
+
tr:last-child td{border-bottom:none}
|
|
804
|
+
.mono{font-family:monospace;color:#68d391;font-size:11px}
|
|
805
|
+
.type-badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em}
|
|
806
|
+
.type-info{background:rgba(66,153,225,.15);color:#63b3ed}
|
|
807
|
+
.type-warning{background:rgba(251,191,36,.15);color:#fbbf24}
|
|
808
|
+
.type-critical{background:rgba(248,113,113,.15);color:#fc8181}
|
|
809
|
+
.status-active{color:#68d391;font-weight:600;font-size:11px}
|
|
810
|
+
.status-expired{color:#718096;font-style:italic;font-size:11px}
|
|
811
|
+
.empty{color:#4a5568;font-size:12px;padding:20px;text-align:center}
|
|
812
|
+
.error{background:#2d1515;border:1px solid #742a2a;border-radius:8px;padding:12px;color:#fc8181;margin-bottom:14px}
|
|
813
|
+
.success{background:#1c2d1c;border:1px solid #276749;border-radius:8px;padding:12px;color:#68d391;margin-bottom:14px}
|
|
814
|
+
|
|
815
|
+
.preview{margin-top:14px;grid-column:1/-1}
|
|
816
|
+
.preview-label{font-size:10px;color:#718096;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px;font-weight:700}
|
|
817
|
+
.preview-card{border-radius:10px;padding:14px 18px;display:flex;align-items:center;gap:12px}
|
|
818
|
+
.preview-card.p-info{background:linear-gradient(135deg,rgba(66,153,225,.12),rgba(99,179,237,.08));border:1px solid rgba(66,153,225,.35)}
|
|
819
|
+
.preview-card.p-warning{background:linear-gradient(135deg,rgba(251,191,36,.12),rgba(246,224,94,.08));border:1px solid rgba(251,191,36,.35)}
|
|
820
|
+
.preview-card.p-critical{background:linear-gradient(135deg,rgba(248,113,113,.12),rgba(252,129,129,.08));border:1px solid rgba(248,113,113,.35)}
|
|
821
|
+
.preview-icon{font-size:22px;flex-shrink:0}
|
|
822
|
+
.preview-content{flex:1;min-width:0}
|
|
823
|
+
.preview-title{font-weight:700;font-size:14px;margin-bottom:3px}
|
|
824
|
+
.preview-msg{font-size:12px;color:#a0aec0;line-height:1.4}
|
|
825
|
+
.preview-btn{display:inline-block;margin-top:6px;padding:4px 12px;border-radius:6px;font-size:11px;font-weight:600;text-decoration:none;border:1px solid rgba(255,255,255,.15);color:#e2e8f0;background:rgba(255,255,255,.06)}
|
|
826
|
+
.p-info .preview-title{color:#63b3ed}
|
|
827
|
+
.p-warning .preview-title{color:#fbbf24}
|
|
828
|
+
.p-critical .preview-title{color:#fc8181}
|
|
829
|
+
.version-hint{font-size:10px;color:#4a5568;margin-top:2px}
|
|
830
|
+
@media(max-width:700px){.form-grid{grid-template-columns:1fr}}
|
|
831
|
+
</style>
|
|
832
|
+
</head>
|
|
833
|
+
<body>
|
|
834
|
+
<div class="header">
|
|
835
|
+
<h1>\u{1f514} Notification Manager</h1>
|
|
836
|
+
<div class="nav">
|
|
837
|
+
<a href="/">Telemetry Dashboard</a>
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
<div class="token-bar">
|
|
841
|
+
<input type="password" id="tok" placeholder="Paste STATS_TOKEN here\u2026" />
|
|
842
|
+
<button onclick="authenticate()">Connect</button>
|
|
843
|
+
</div>
|
|
844
|
+
|
|
845
|
+
<div class="main">
|
|
846
|
+
<div class="error" id="errMsg" style="display:none"></div>
|
|
847
|
+
<div class="success" id="successMsg" style="display:none"></div>
|
|
848
|
+
|
|
849
|
+
<div id="authedContent" style="display:none">
|
|
850
|
+
<div class="section">
|
|
851
|
+
<h2 id="formTitle">\u2795 Compose New Notification</h2>
|
|
852
|
+
<div class="form-grid">
|
|
853
|
+
<input type="hidden" id="editId" value="" />
|
|
854
|
+
<div class="form-group">
|
|
855
|
+
<label>Type</label>
|
|
856
|
+
<select id="nType" onchange="updatePreview()">
|
|
857
|
+
<option value="info">Info</option>
|
|
858
|
+
<option value="warning">Warning</option>
|
|
859
|
+
<option value="critical">Critical</option>
|
|
860
|
+
</select>
|
|
861
|
+
</div>
|
|
862
|
+
<div class="form-group">
|
|
863
|
+
<label>Expires At</label>
|
|
864
|
+
<input type="datetime-local" id="nExpires" />
|
|
865
|
+
</div>
|
|
866
|
+
<div class="form-group full">
|
|
867
|
+
<label>Title</label>
|
|
868
|
+
<input type="text" id="nTitle" placeholder="Short headline for the notification" oninput="updatePreview()" />
|
|
869
|
+
</div>
|
|
870
|
+
<div class="form-group full">
|
|
871
|
+
<label>Message</label>
|
|
872
|
+
<textarea id="nMessage" placeholder="Full notification message. Explain what users need to do." oninput="updatePreview()"><\/textarea>
|
|
873
|
+
</div>
|
|
874
|
+
<div class="form-group">
|
|
875
|
+
<label>Min Version <span class="version-hint">(show to users ≥ this version)</span></label>
|
|
876
|
+
<input type="text" id="nMinVer" placeholder="e.g. 1.0.0" />
|
|
877
|
+
</div>
|
|
878
|
+
<div class="form-group">
|
|
879
|
+
<label>Max Version <span class="version-hint">(show to users ≤ this version)</span></label>
|
|
880
|
+
<input type="text" id="nMaxVer" placeholder="e.g. 1.5.1" />
|
|
881
|
+
</div>
|
|
882
|
+
<div class="form-group">
|
|
883
|
+
<label>Action URL (optional)</label>
|
|
884
|
+
<input type="text" id="nActionUrl" placeholder="https://github.com/..." oninput="updatePreview()" />
|
|
885
|
+
</div>
|
|
886
|
+
<div class="form-group">
|
|
887
|
+
<label>Action Label (optional)</label>
|
|
888
|
+
<input type="text" id="nActionLabel" placeholder="e.g. View README" oninput="updatePreview()" />
|
|
889
|
+
</div>
|
|
890
|
+
<div class="preview" id="previewArea">
|
|
891
|
+
<div class="preview-label">Live Preview</div>
|
|
892
|
+
<div class="preview-card p-info" id="previewCard">
|
|
893
|
+
<span class="preview-icon" id="previewIcon">\u{2139}\u{fe0f}</span>
|
|
894
|
+
<div class="preview-content">
|
|
895
|
+
<div class="preview-title" id="previewTitle">Notification title</div>
|
|
896
|
+
<div class="preview-msg" id="previewMsg">Notification message will appear here</div>
|
|
897
|
+
</div>
|
|
898
|
+
</div>
|
|
899
|
+
</div>
|
|
900
|
+
<div class="form-actions">
|
|
901
|
+
<button class="btn-primary" id="btnSubmit" onclick="submitNotification()">Create Notification</button>
|
|
902
|
+
<button class="btn-secondary" id="btnCancel" onclick="cancelEdit()" style="display:none">Cancel Edit</button>
|
|
903
|
+
</div>
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
|
|
907
|
+
<div class="section">
|
|
908
|
+
<h2>\u{1f4cb} All Notifications</h2>
|
|
909
|
+
<div id="notifTable"></div>
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
</div>
|
|
913
|
+
|
|
914
|
+
<script>
|
|
915
|
+
var _token = '';
|
|
916
|
+
var _notifications = [];
|
|
917
|
+
|
|
918
|
+
function $(i) { return document.getElementById(i); }
|
|
919
|
+
|
|
920
|
+
function authenticate() {
|
|
921
|
+
var t = $('tok').value.trim();
|
|
922
|
+
if (!t) return;
|
|
923
|
+
_token = t;
|
|
924
|
+
localStorage.setItem('notif_token', t);
|
|
925
|
+
loadAll();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function loadAll() {
|
|
929
|
+
try {
|
|
930
|
+
// We need to verify the token by trying to load raw notifications
|
|
931
|
+
// Use a GET with auth to confirm access
|
|
932
|
+
var r = await fetch('/v1/stats', { headers: { 'Authorization': 'Bearer ' + _token } });
|
|
933
|
+
if (r.status === 401) { showErr('Invalid token'); return; }
|
|
934
|
+
hideErr();
|
|
935
|
+
$('authedContent').style.display = 'block';
|
|
936
|
+
await refreshList();
|
|
937
|
+
} catch(e) { showErr(e.message); }
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function refreshList() {
|
|
941
|
+
try {
|
|
942
|
+
// Load all notifications from the file (we'll load the full list including expired)
|
|
943
|
+
var r = await fetch('/v1/notifications?all=true');
|
|
944
|
+
if (!r.ok) { showErr('Failed to load notifications'); return; }
|
|
945
|
+
_notifications = await r.json();
|
|
946
|
+
renderTable();
|
|
947
|
+
} catch(e) { showErr(e.message); }
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function renderTable() {
|
|
951
|
+
var tb = $('notifTable');
|
|
952
|
+
if (!_notifications || _notifications.length === 0) {
|
|
953
|
+
tb.innerHTML = '<div class="empty">No notifications yet. Create one above!</div>';
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
var now = new Date().toISOString();
|
|
957
|
+
var html = '<table><thead><tr><th>Type</th><th>Title</th><th>Message</th><th>Version Target</th><th>Status</th><th>Created</th><th>Actions</th></tr></thead><tbody>';
|
|
958
|
+
for (var i = 0; i < _notifications.length; i++) {
|
|
959
|
+
var n = _notifications[i];
|
|
960
|
+
var isExpired = n.expiresAt && n.expiresAt < now;
|
|
961
|
+
var typeClass = 'type-' + (n.type || 'info');
|
|
962
|
+
var verTarget = '';
|
|
963
|
+
if (n.minVersion || n.maxVersion) {
|
|
964
|
+
verTarget = (n.minVersion ? '\u2265' + n.minVersion : '') + (n.minVersion && n.maxVersion ? ' ' : '') + (n.maxVersion ? '\u2264' + n.maxVersion : '');
|
|
965
|
+
} else {
|
|
966
|
+
verTarget = 'All';
|
|
967
|
+
}
|
|
968
|
+
html += '<tr>'
|
|
969
|
+
+ '<td><span class="type-badge ' + typeClass + '">' + esc(n.type || 'info') + '</span></td>'
|
|
970
|
+
+ '<td><strong>' + esc(n.title) + '</strong></td>'
|
|
971
|
+
+ '<td style="max-width:280px;white-space:pre-wrap;word-break:break-word;font-size:11px;color:#a0aec0">' + esc(n.message).slice(0, 120) + (n.message.length > 120 ? '\u2026' : '') + '</td>'
|
|
972
|
+
+ '<td class="mono">' + verTarget + '</td>'
|
|
973
|
+
+ '<td><span class="' + (isExpired ? 'status-expired' : 'status-active') + '">' + (isExpired ? 'Expired' : 'Active') + '</span></td>'
|
|
974
|
+
+ '<td class="mono">' + (n.createdAt ? n.createdAt.slice(0, 16) : '\u2014') + '</td>'
|
|
975
|
+
+ '<td style="white-space:nowrap"><button class="btn-edit" onclick="editNotif(' + i + ')">Edit</button> <button class="btn-danger" onclick="deleteNotif(\\'' + esc(n.id) + '\\')">Delete</button></td>'
|
|
976
|
+
+ '</tr>';
|
|
977
|
+
}
|
|
978
|
+
html += '</tbody></table>';
|
|
979
|
+
tb.innerHTML = html;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function editNotif(idx) {
|
|
983
|
+
var n = _notifications[idx];
|
|
984
|
+
if (!n) return;
|
|
985
|
+
$('editId').value = n.id;
|
|
986
|
+
$('nType').value = n.type || 'info';
|
|
987
|
+
$('nTitle').value = n.title || '';
|
|
988
|
+
$('nMessage').value = n.message || '';
|
|
989
|
+
$('nMinVer').value = n.minVersion || '';
|
|
990
|
+
$('nMaxVer').value = n.maxVersion || '';
|
|
991
|
+
$('nActionUrl').value = n.actionUrl || '';
|
|
992
|
+
$('nActionLabel').value = n.actionLabel || '';
|
|
993
|
+
if (n.expiresAt) {
|
|
994
|
+
$('nExpires').value = n.expiresAt.slice(0, 16);
|
|
995
|
+
} else {
|
|
996
|
+
$('nExpires').value = '';
|
|
997
|
+
}
|
|
998
|
+
$('formTitle').textContent = '\u270f\ufe0f Edit Notification';
|
|
999
|
+
$('btnSubmit').textContent = 'Update Notification';
|
|
1000
|
+
$('btnCancel').style.display = '';
|
|
1001
|
+
updatePreview();
|
|
1002
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function cancelEdit() {
|
|
1006
|
+
$('editId').value = '';
|
|
1007
|
+
$('nType').value = 'info';
|
|
1008
|
+
$('nTitle').value = '';
|
|
1009
|
+
$('nMessage').value = '';
|
|
1010
|
+
$('nMinVer').value = '';
|
|
1011
|
+
$('nMaxVer').value = '';
|
|
1012
|
+
$('nActionUrl').value = '';
|
|
1013
|
+
$('nActionLabel').value = '';
|
|
1014
|
+
$('nExpires').value = '';
|
|
1015
|
+
$('formTitle').textContent = '\u2795 Compose New Notification';
|
|
1016
|
+
$('btnSubmit').textContent = 'Create Notification';
|
|
1017
|
+
$('btnCancel').style.display = 'none';
|
|
1018
|
+
updatePreview();
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function submitNotification() {
|
|
1022
|
+
var title = $('nTitle').value.trim();
|
|
1023
|
+
var message = $('nMessage').value.trim();
|
|
1024
|
+
if (!title || !message) { showErr('Title and message are required'); return; }
|
|
1025
|
+
|
|
1026
|
+
var payload = {
|
|
1027
|
+
type: $('nType').value,
|
|
1028
|
+
title: title,
|
|
1029
|
+
message: message,
|
|
1030
|
+
minVersion: $('nMinVer').value.trim() || null,
|
|
1031
|
+
maxVersion: $('nMaxVer').value.trim() || null,
|
|
1032
|
+
actionUrl: $('nActionUrl').value.trim() || null,
|
|
1033
|
+
actionLabel: $('nActionLabel').value.trim() || null,
|
|
1034
|
+
expiresAt: $('nExpires').value ? new Date($('nExpires').value).toISOString() : null,
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
var editId = $('editId').value;
|
|
1038
|
+
if (editId) payload.id = editId;
|
|
1039
|
+
|
|
1040
|
+
$('btnSubmit').disabled = true;
|
|
1041
|
+
try {
|
|
1042
|
+
var r = await fetch('/v1/notifications', {
|
|
1043
|
+
method: 'POST',
|
|
1044
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _token },
|
|
1045
|
+
body: JSON.stringify(payload),
|
|
1046
|
+
});
|
|
1047
|
+
var result = await r.json();
|
|
1048
|
+
if (r.ok && result.ok) {
|
|
1049
|
+
showSuccess(editId ? 'Notification updated!' : 'Notification created!');
|
|
1050
|
+
cancelEdit();
|
|
1051
|
+
await refreshList();
|
|
1052
|
+
} else {
|
|
1053
|
+
showErr(result.error || 'Failed to save notification');
|
|
1054
|
+
}
|
|
1055
|
+
} catch(e) {
|
|
1056
|
+
showErr(e.message);
|
|
1057
|
+
}
|
|
1058
|
+
$('btnSubmit').disabled = false;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function deleteNotif(id) {
|
|
1062
|
+
if (!confirm('Delete this notification?')) return;
|
|
1063
|
+
try {
|
|
1064
|
+
var r = await fetch('/v1/notifications/' + encodeURIComponent(id), {
|
|
1065
|
+
method: 'DELETE',
|
|
1066
|
+
headers: { 'Authorization': 'Bearer ' + _token },
|
|
1067
|
+
});
|
|
1068
|
+
var result = await r.json();
|
|
1069
|
+
if (r.ok && result.ok) {
|
|
1070
|
+
showSuccess('Notification deleted');
|
|
1071
|
+
await refreshList();
|
|
1072
|
+
} else {
|
|
1073
|
+
showErr(result.error || 'Failed to delete');
|
|
1074
|
+
}
|
|
1075
|
+
} catch(e) { showErr(e.message); }
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
var ICONS = { info: '\u{2139}\u{fe0f}', warning: '\u26a0\ufe0f', critical: '\u{1f6a8}' };
|
|
1079
|
+
|
|
1080
|
+
function updatePreview() {
|
|
1081
|
+
var type = $('nType').value;
|
|
1082
|
+
var title = $('nTitle').value || 'Notification title';
|
|
1083
|
+
var msg = $('nMessage').value || 'Notification message will appear here';
|
|
1084
|
+
var actionUrl = $('nActionUrl').value;
|
|
1085
|
+
var actionLabel = $('nActionLabel').value || 'Learn More';
|
|
1086
|
+
var card = $('previewCard');
|
|
1087
|
+
card.className = 'preview-card p-' + type;
|
|
1088
|
+
$('previewIcon').textContent = ICONS[type] || ICONS.info;
|
|
1089
|
+
$('previewTitle').textContent = title;
|
|
1090
|
+
var html = esc(msg);
|
|
1091
|
+
if (actionUrl) html += '<br/><a class="preview-btn" href="#" onclick="return false">' + esc(actionLabel) + '</a>';
|
|
1092
|
+
$('previewMsg').innerHTML = html;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function esc(s) { if (!s) return ''; return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
1096
|
+
function showErr(m) { $('errMsg').textContent = '\u26a0 ' + m; $('errMsg').style.display = ''; setTimeout(function(){ $('errMsg').style.display='none'; }, 8000); }
|
|
1097
|
+
function hideErr() { $('errMsg').style.display = 'none'; }
|
|
1098
|
+
function showSuccess(m) { $('successMsg').textContent = '\u2713 ' + m; $('successMsg').style.display = ''; setTimeout(function(){ $('successMsg').style.display='none'; }, 4000); }
|
|
1099
|
+
|
|
1100
|
+
// Auto-connect from saved token
|
|
1101
|
+
var saved = localStorage.getItem('notif_token');
|
|
1102
|
+
if (saved) { _token = saved; $('tok').value = saved; loadAll(); }
|
|
1103
|
+
updatePreview();
|
|
1104
|
+
<\/script>
|
|
1105
|
+
</body></html>`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
693
1108
|
// ── HTTP Server ──────────────────────────────────────────────────────
|
|
694
1109
|
function readBody(req) {
|
|
695
1110
|
return new Promise((resolve, reject) => {
|
|
@@ -729,6 +1144,119 @@ const server = createServer(async (req, res) => {
|
|
|
729
1144
|
return;
|
|
730
1145
|
}
|
|
731
1146
|
|
|
1147
|
+
// Notifications Admin UI
|
|
1148
|
+
if (method === "GET" && url === "/notifications") {
|
|
1149
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1150
|
+
res.end(buildNotificationsAdminHtml());
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// ── Notifications API ──────────────────────────────────────────
|
|
1155
|
+
// GET /v1/notifications — Public, returns active notifications
|
|
1156
|
+
// Add ?all=true to load all (including expired) for admin UI
|
|
1157
|
+
if (method === "GET" && url.startsWith("/v1/notifications")) {
|
|
1158
|
+
try {
|
|
1159
|
+
const q = parseQueryString(url);
|
|
1160
|
+
if (q.all === "true") {
|
|
1161
|
+
// Return ALL notifications (for admin management UI)
|
|
1162
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1163
|
+
res.end(JSON.stringify(loadNotifications()));
|
|
1164
|
+
} else {
|
|
1165
|
+
const clientVersion = q.version || null;
|
|
1166
|
+
const active = getActiveNotifications(clientVersion);
|
|
1167
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1168
|
+
res.end(JSON.stringify(active));
|
|
1169
|
+
}
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1172
|
+
res.end(JSON.stringify({ error: "Failed to load notifications" }));
|
|
1173
|
+
}
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// POST /v1/notifications — Create/update notification (auth required)
|
|
1178
|
+
if (method === "POST" && url === "/v1/notifications") {
|
|
1179
|
+
if (!STATS_TOKEN) {
|
|
1180
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1181
|
+
res.end(JSON.stringify({ error: "STATS_TOKEN not configured" }));
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const auth = req.headers.authorization || "";
|
|
1185
|
+
if (auth !== `Bearer ${STATS_TOKEN}`) {
|
|
1186
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1187
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
const body = await readBody(req);
|
|
1192
|
+
const data = JSON.parse(body);
|
|
1193
|
+
if (!data.title || !data.message) {
|
|
1194
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1195
|
+
res.end(JSON.stringify({ error: "title and message are required" }));
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const notifications = loadNotifications();
|
|
1199
|
+
const notification = {
|
|
1200
|
+
id: data.id || randomUUID(),
|
|
1201
|
+
type: data.type || "info",
|
|
1202
|
+
title: data.title,
|
|
1203
|
+
message: data.message,
|
|
1204
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
1205
|
+
expiresAt: data.expiresAt || null,
|
|
1206
|
+
minVersion: data.minVersion || null,
|
|
1207
|
+
maxVersion: data.maxVersion || null,
|
|
1208
|
+
actionUrl: data.actionUrl || null,
|
|
1209
|
+
actionLabel: data.actionLabel || null,
|
|
1210
|
+
};
|
|
1211
|
+
// Update if id exists, otherwise add
|
|
1212
|
+
const existingIdx = notifications.findIndex((n) => n.id === notification.id);
|
|
1213
|
+
if (existingIdx >= 0) {
|
|
1214
|
+
notifications[existingIdx] = notification;
|
|
1215
|
+
} else {
|
|
1216
|
+
notifications.push(notification);
|
|
1217
|
+
}
|
|
1218
|
+
saveNotifications(notifications);
|
|
1219
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1220
|
+
res.end(JSON.stringify({ ok: true, notification }));
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1223
|
+
res.end(JSON.stringify({ error: "Bad request" }));
|
|
1224
|
+
}
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// DELETE /v1/notifications/:id — Remove notification (auth required)
|
|
1229
|
+
if (method === "DELETE" && url.startsWith("/v1/notifications/")) {
|
|
1230
|
+
if (!STATS_TOKEN) {
|
|
1231
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
1232
|
+
res.end(JSON.stringify({ error: "STATS_TOKEN not configured" }));
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
const auth = req.headers.authorization || "";
|
|
1236
|
+
if (auth !== `Bearer ${STATS_TOKEN}`) {
|
|
1237
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1238
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
try {
|
|
1242
|
+
const id = decodeURIComponent(url.slice("/v1/notifications/".length));
|
|
1243
|
+
const notifications = loadNotifications();
|
|
1244
|
+
const filtered = notifications.filter((n) => n.id !== id);
|
|
1245
|
+
if (filtered.length === notifications.length) {
|
|
1246
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1247
|
+
res.end(JSON.stringify({ error: "Notification not found" }));
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
saveNotifications(filtered);
|
|
1251
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1252
|
+
res.end(JSON.stringify({ ok: true, deleted: id }));
|
|
1253
|
+
} catch {
|
|
1254
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1255
|
+
res.end(JSON.stringify({ error: "Failed to delete notification" }));
|
|
1256
|
+
}
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
732
1260
|
// Health check
|
|
733
1261
|
if (method === "GET" && url === "/v1/health") {
|
|
734
1262
|
res.writeHead(200, { "Content-Type": "application/json" });
|