@getjack/jack 0.1.34 → 0.1.35

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.
Files changed (88) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/down.ts +39 -7
  4. package/src/commands/link.ts +2 -4
  5. package/src/commands/logs.ts +2 -4
  6. package/src/commands/mcp.ts +12 -10
  7. package/src/commands/services.ts +4 -2
  8. package/src/commands/sync.ts +5 -6
  9. package/src/lib/auth/client.ts +5 -2
  10. package/src/lib/binding-validator.ts +39 -3
  11. package/src/lib/build-helper.ts +18 -19
  12. package/src/lib/control-plane.ts +1 -0
  13. package/src/lib/do-config.ts +110 -0
  14. package/src/lib/do-export-validator.ts +26 -0
  15. package/src/lib/jsonc-edit.ts +292 -0
  16. package/src/lib/managed-deploy.ts +36 -1
  17. package/src/lib/project-link.ts +37 -0
  18. package/src/lib/project-operations.ts +13 -38
  19. package/src/lib/resources.ts +4 -5
  20. package/src/lib/schema.ts +8 -12
  21. package/src/lib/services/db-create.ts +2 -2
  22. package/src/lib/services/db-execute.ts +9 -6
  23. package/src/lib/services/db-list.ts +6 -4
  24. package/src/lib/services/endpoint-test.ts +275 -0
  25. package/src/lib/services/project-delete.ts +190 -0
  26. package/src/lib/services/project-environment.ts +457 -0
  27. package/src/lib/services/storage-config.ts +7 -309
  28. package/src/lib/services/storage-create.ts +2 -1
  29. package/src/lib/services/storage-delete.ts +3 -2
  30. package/src/lib/services/storage-info.ts +2 -1
  31. package/src/lib/services/storage-list.ts +6 -3
  32. package/src/lib/services/vectorize-config.ts +7 -264
  33. package/src/lib/services/vectorize-create.ts +2 -1
  34. package/src/lib/services/vectorize-delete.ts +6 -4
  35. package/src/lib/services/vectorize-list.ts +6 -3
  36. package/src/lib/storage/index.ts +21 -23
  37. package/src/lib/telemetry.ts +1 -0
  38. package/src/lib/wrangler-config.ts +43 -312
  39. package/src/lib/zip-packager.ts +28 -0
  40. package/src/mcp/test-utils.ts +31 -0
  41. package/src/mcp/tools/index.ts +271 -0
  42. package/src/templates/index.ts +5 -0
  43. package/src/templates/types.ts +4 -0
  44. package/templates/AI-BINDINGS.md +34 -76
  45. package/templates/CLAUDE.md +1 -1
  46. package/templates/ai-chat/src/index.ts +7 -14
  47. package/templates/ai-chat/src/jack-ai.ts +0 -6
  48. package/templates/chat/.jack.json +45 -0
  49. package/templates/chat/bun.lock +1588 -0
  50. package/templates/chat/components.json +23 -0
  51. package/templates/chat/index.html +12 -0
  52. package/templates/chat/package.json +41 -0
  53. package/templates/chat/src/chat-agent.ts +61 -0
  54. package/templates/chat/src/client/app.tsx +189 -0
  55. package/templates/chat/src/client/chat.tsx +222 -0
  56. package/templates/chat/src/client/components/prompt-kit/chat-container.tsx +47 -0
  57. package/templates/chat/src/client/components/prompt-kit/loader.tsx +33 -0
  58. package/templates/chat/src/client/components/prompt-kit/markdown.tsx +84 -0
  59. package/templates/chat/src/client/components/prompt-kit/message.tsx +54 -0
  60. package/templates/chat/src/client/components/prompt-kit/prompt-suggestion.tsx +20 -0
  61. package/templates/chat/src/client/components/prompt-kit/reasoning.tsx +134 -0
  62. package/templates/chat/src/client/components/prompt-kit/scroll-button.tsx +28 -0
  63. package/templates/chat/src/client/components/ui/button.tsx +38 -0
  64. package/templates/chat/src/client/lib/utils.ts +6 -0
  65. package/templates/chat/src/client/main.tsx +11 -0
  66. package/templates/chat/src/client/styles.css +125 -0
  67. package/templates/chat/src/index.ts +25 -0
  68. package/templates/chat/src/jack-ai.ts +94 -0
  69. package/templates/chat/tsconfig.json +18 -0
  70. package/templates/chat/vite.config.ts +14 -0
  71. package/templates/chat/wrangler.jsonc +18 -0
  72. package/templates/cron/.jack.json +18 -28
  73. package/templates/cron/schema.sql +10 -20
  74. package/templates/cron/src/admin.ts +321 -0
  75. package/templates/cron/src/index.ts +151 -81
  76. package/templates/cron/src/monitor.ts +124 -0
  77. package/templates/semantic-search/src/index.ts +5 -43
  78. package/templates/semantic-search/src/jack-ai.ts +0 -6
  79. package/templates/telegram-bot/.jack.json +56 -0
  80. package/templates/telegram-bot/bun.lock +41 -0
  81. package/templates/telegram-bot/package.json +16 -0
  82. package/templates/telegram-bot/src/index.ts +236 -0
  83. package/templates/telegram-bot/src/jack-ai.ts +100 -0
  84. package/templates/telegram-bot/tsconfig.json +11 -0
  85. package/templates/telegram-bot/wrangler.jsonc +8 -0
  86. package/templates/cron/src/jobs.ts +0 -139
  87. package/templates/cron/src/webhooks.ts +0 -95
  88. package/templates/semantic-search/src/jack-vectorize.ts +0 -169
@@ -1,42 +1,31 @@
1
1
  {
2
2
  "name": "cron",
3
- "description": "Background tasks with cron scheduling and webhook ingestion",
3
+ "description": "Uptime monitor with cron checks, latency tracking, and live dashboard",
4
4
  "secrets": [],
5
5
  "capabilities": ["db"],
6
6
  "requires": ["DB", "CRON"],
7
+ "crons": ["*/15 * * * *"],
7
8
  "intent": {
8
9
  "keywords": [
9
10
  "cron",
10
- "background",
11
- "jobs",
12
- "webhook",
11
+ "uptime",
12
+ "monitor",
13
13
  "scheduled",
14
- "queue",
15
- "worker",
16
- "tasks"
14
+ "health",
15
+ "ping",
16
+ "status"
17
17
  ],
18
18
  "examples": [
19
- "background job processor",
20
- "scheduled tasks",
21
- "webhook handler",
22
- "job queue"
19
+ "uptime monitor",
20
+ "scheduled health checks",
21
+ "status page"
23
22
  ]
24
23
  },
25
24
  "agentContext": {
26
- "summary": "A background task worker with cron scheduling, D1 job queue, and webhook ingestion.",
27
- "full_text": "## Project Structure\n\n- `src/index.ts` - Hono API with cron handler, webhook endpoint, and job status routes\n- `src/jobs.ts` - Job queue: create, process, retry with D1 backend\n- `src/webhooks.ts` - Webhook ingestion with HMAC-SHA256 signature verification\n- `schema.sql` - D1 schema (jobs, webhook_events)\n\n## Cron\n\nThe `POST /__scheduled` route runs on a cron schedule (default: every 5 minutes). It processes pending jobs and retries failed ones.\n\n## Jobs\n\n```typescript\n// Create a job\nawait createJob(db, { type: 'process-order', payload: { orderId: '123' } });\n\n// Jobs are processed automatically by cron\n// Failed jobs retry up to 3 times with exponential backoff\n```\n\n## Webhooks\n\n```\nPOST /webhook\nX-Signature: sha256=abc123...\nContent-Type: application/json\n\n{ \"event\": \"order.completed\", \"data\": { ... } }\n```\n\nWebhooks are verified using HMAC-SHA256, logged to D1, and can create jobs for async processing.\n\n## Endpoints\n\n- `POST /__scheduled` - Cron handler (called by scheduler)\n- `POST /webhook` - Inbound webhook receiver with signature verification\n- `GET /jobs` - List recent jobs with status\n- `GET /health` - Health check\n\n## Environment Variables\n\n- `WEBHOOK_SECRET` - HMAC signing secret for webhook verification (auto-generated)\n\n## Resources\n\n- [Hono Documentation](https://hono.dev)"
25
+ "summary": "An uptime monitor that checks URLs every 15 minutes, tracks latency and uptime percentage, with a live dashboard.",
26
+ "full_text": "## Project Structure\n\n- `src/index.ts` - Hono API with routes, cron handler, admin page\n- `src/monitor.ts` - URL checking with groups, fetch with timeout, D1 storage\n- `src/admin.ts` - Inline HTML/CSS/JS dashboard (no external deps)\n- `schema.sql` - D1 schema (checks table with group_name)\n\n## Routes\n\n| Method | Path | Purpose |\n|--------|------|---------|\n| GET / | Live dashboard (HTML) |\n| GET /api/status | Per-group uptime stats (URLs hidden) |\n| GET /api/checks | Recent 100 checks (URLs hidden) |\n| POST /api/trigger | Manually trigger checks |\n| POST /__scheduled | Cron handler (called every 15 min) |\n| GET /health | Health check |\n\n## How It Works\n\nEvery 15 minutes the cron checks all monitored URLs grouped by name. URLs are never exposed publicly — only group names appear on the dashboard and in API responses.\n\nBy default it monitors `https://1.1.1.1` (Cloudflare DNS) in the \"Default\" group.\n\n## Configuring Monitor Groups\n\nSet the `MONITOR_URLS` secret with named groups separated by semicolons:\n```\nProduction=https://api.example.com,https://app.example.com;Staging=https://staging.example.com/health\n```\n\nPlain URLs without a group name go into \"Default\":\n```\nhttps://example.com,https://api.example.com\n```\n\n## Dashboard\n\nThe dashboard auto-refreshes every 10 seconds and shows:\n- Status banner (all systems operational / issues detected)\n- Per-group cards with endpoint count, uptime %, avg latency\n- Recent checks table with group name and source (cron/manual)\n\nActual URLs are never shown only group names are visible publicly.\n\n## Resources\n\n- [Hono Documentation](https://hono.dev)"
28
27
  },
29
28
  "hooks": {
30
- "preCreate": [
31
- {
32
- "action": "require",
33
- "source": "secret",
34
- "key": "WEBHOOK_SECRET",
35
- "message": "Generating webhook signing secret...",
36
- "onMissing": "generate",
37
- "generateCommand": "openssl rand -hex 32"
38
- }
39
- ],
40
29
  "postDeploy": [
41
30
  {
42
31
  "action": "clipboard",
@@ -50,15 +39,16 @@
50
39
  },
51
40
  {
52
41
  "action": "box",
53
- "title": "Background worker live: {{name}}",
42
+ "title": "Uptime monitor live: {{name}}",
54
43
  "lines": [
55
44
  "{{url}}",
56
45
  "",
57
- "Endpoints:",
58
- " POST {{url}}/webhook — inbound webhook receiver",
59
- " GET {{url}}/jobs — job queue status",
46
+ "Dashboard: {{url}}/",
47
+ "API: {{url}}/api/status",
60
48
  "",
61
- "Cron: runs every 5 minutes"
49
+ "Checks every 15 minutes automatically",
50
+ "Configure: set MONITOR_URLS secret",
51
+ " Format: Group=url1,url2;Other=url3"
62
52
  ]
63
53
  }
64
54
  ]
@@ -1,24 +1,14 @@
1
- CREATE TABLE IF NOT EXISTS jobs (
1
+ CREATE TABLE IF NOT EXISTS checks (
2
2
  id TEXT PRIMARY KEY,
3
- type TEXT NOT NULL,
4
- payload TEXT NOT NULL DEFAULT '{}',
5
- status TEXT NOT NULL DEFAULT 'pending',
6
- attempts INTEGER NOT NULL DEFAULT 0,
7
- max_attempts INTEGER NOT NULL DEFAULT 3,
8
- last_error TEXT,
9
- run_at INTEGER NOT NULL DEFAULT (unixepoch()),
10
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
11
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
12
- );
13
-
14
- CREATE TABLE IF NOT EXISTS webhook_events (
15
- id TEXT PRIMARY KEY,
16
- source TEXT NOT NULL DEFAULT 'unknown',
17
- event_type TEXT,
18
- payload TEXT NOT NULL,
19
- status TEXT NOT NULL DEFAULT 'received',
3
+ url TEXT NOT NULL,
4
+ group_name TEXT NOT NULL DEFAULT 'Default',
5
+ status_code INTEGER,
6
+ latency_ms INTEGER NOT NULL,
7
+ ok INTEGER NOT NULL DEFAULT 1,
8
+ source TEXT NOT NULL DEFAULT 'cron',
9
+ error TEXT,
20
10
  created_at INTEGER NOT NULL DEFAULT (unixepoch())
21
11
  );
22
12
 
23
- CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at ON jobs(status, run_at);
24
- CREATE INDEX IF NOT EXISTS idx_webhook_events_created ON webhook_events(created_at);
13
+ CREATE INDEX IF NOT EXISTS idx_checks_group_created ON checks(group_name, created_at);
14
+ CREATE INDEX IF NOT EXISTS idx_checks_created ON checks(created_at);
@@ -0,0 +1,321 @@
1
+ export function adminHTML(): string {
2
+ return `<!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>Uptime Monitor</title>
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0d1117; color: #e6edf3; }
11
+ .container { max-width: 960px; margin: 0 auto; padding: 48px 24px; }
12
+
13
+ header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
14
+ h1 { font-size: 1.25rem; font-weight: 600; letter-spacing: -0.02em; }
15
+ button { font-family: inherit; font-size: 0.8125rem; cursor: pointer; transition: all 0.15s; }
16
+ .btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 6px; font-weight: 500; }
17
+ .btn-primary { background: #238636; color: #fff; border: 1px solid #2ea043; }
18
+ .btn-primary:hover { background: #2ea043; }
19
+
20
+ /* Global status banner */
21
+ .banner { display: flex; align-items: center; gap: 10px; padding: 14px 18px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; margin-bottom: 28px; }
22
+ .banner .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
23
+ .banner .dot.up { background: #3fb950; box-shadow: 0 0 8px rgba(63,185,80,0.4); }
24
+ .banner .dot.down { background: #f85149; box-shadow: 0 0 8px rgba(248,81,73,0.4); }
25
+ .banner .dot.unknown { background: #484f58; }
26
+ .banner-text { font-size: 0.875rem; font-weight: 500; }
27
+ .banner-sub { font-size: 0.75rem; color: #8b949e; margin-left: auto; }
28
+
29
+ /* Monitor group card */
30
+ .group-card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; margin-bottom: 20px; overflow: hidden; }
31
+ .group-header { display: flex; align-items: center; gap: 10px; padding: 18px 20px 0; }
32
+ .group-name { font-size: 1rem; font-weight: 600; }
33
+ .group-endpoints { font-size: 0.75rem; color: #8b949e; margin-left: auto; }
34
+ .group-badge { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 9999px; }
35
+ .group-badge.up { background: rgba(63,185,80,0.15); color: #3fb950; border: 1px solid rgba(63,185,80,0.3); }
36
+ .group-badge.down { background: rgba(248,81,73,0.15); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
37
+ .group-streak { padding: 4px 20px 16px; font-size: 0.75rem; color: #8b949e; }
38
+
39
+ /* Status bar (24h checks) */
40
+ .status-bar-section { padding: 0 20px 16px; }
41
+ .status-bar-label { display: flex; justify-content: space-between; font-size: 0.75rem; color: #8b949e; margin-bottom: 6px; }
42
+ .status-bar { display: flex; gap: 2px; height: 28px; align-items: flex-end; }
43
+ .status-block { flex: 1; min-width: 3px; border-radius: 2px; transition: opacity 0.15s; }
44
+ .status-block.up { background: #238636; }
45
+ .status-block.down { background: #f85149; }
46
+ .status-block:hover { opacity: 0.7; }
47
+
48
+ /* Period cards row */
49
+ .periods { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid #30363d; }
50
+ .period { padding: 16px 20px; }
51
+ .period:not(:last-child) { border-right: 1px solid #30363d; }
52
+ .period-label { font-size: 0.6875rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
53
+ .period-value { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
54
+ .period-detail { font-size: 0.6875rem; color: #8b949e; margin-top: 2px; }
55
+ .pct-good { color: #3fb950; }
56
+ .pct-warn { color: #d29922; }
57
+ .pct-bad { color: #f85149; }
58
+ .pct-none { color: #484f58; }
59
+
60
+ /* Response time section */
61
+ .response-section { border-top: 1px solid #30363d; padding: 16px 20px; }
62
+ .response-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
63
+ .response-title { font-size: 0.8125rem; font-weight: 500; }
64
+ .response-stats { display: flex; gap: 16px; font-size: 0.6875rem; color: #8b949e; }
65
+ .response-stats span { font-variant-numeric: tabular-nums; }
66
+ .response-stats .val { color: #e6edf3; font-weight: 600; }
67
+ .sparkline { display: flex; align-items: flex-end; gap: 1px; height: 40px; }
68
+ .spark-bar { flex: 1; min-width: 2px; border-radius: 1px 1px 0 0; background: #238636; transition: opacity 0.15s; }
69
+ .spark-bar.slow { background: #d29922; }
70
+ .spark-bar.timeout { background: #f85149; }
71
+ .spark-bar:hover { opacity: 0.7; }
72
+
73
+ /* Recent checks table */
74
+ .section { margin-top: 28px; }
75
+ .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
76
+ .section-header h2 { font-size: 0.875rem; font-weight: 500; }
77
+ .section-header .count { font-size: 0.75rem; color: #8b949e; }
78
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; overflow: hidden; }
79
+ table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
80
+ th { text-align: left; padding: 10px 16px; font-weight: 500; font-size: 0.75rem; color: #8b949e; border-bottom: 1px solid #30363d; }
81
+ td { padding: 10px 16px; border-bottom: 1px solid #21262d; color: #c9d1d9; }
82
+ tr:last-child td { border-bottom: none; }
83
+ tr:hover td { background: #1c2129; }
84
+ .mono { font-family: 'SF Mono', SFMono-Regular, ui-monospace, monospace; font-size: 0.75rem; }
85
+ .ok { color: #3fb950; }
86
+ .fail { color: #f85149; }
87
+
88
+ .empty-state { padding: 48px 24px; text-align: center; }
89
+ .empty-state p { color: #8b949e; font-size: 0.875rem; margin-bottom: 4px; }
90
+ .empty-state .hint { color: #484f58; font-size: 0.8125rem; }
91
+
92
+ .toast { position: fixed; bottom: 24px; right: 24px; background: #e6edf3; color: #0d1117; padding: 10px 20px; border-radius: 8px; font-size: 0.8125rem; font-weight: 500; opacity: 0; transition: opacity 0.2s; pointer-events: none; }
93
+ .toast.show { opacity: 1; }
94
+ .toast.error { background: #f85149; color: #fff; }
95
+
96
+ @media (max-width: 640px) {
97
+ .periods { grid-template-columns: 1fr; }
98
+ .period:not(:last-child) { border-right: none; border-bottom: 1px solid #30363d; }
99
+ .container { padding: 24px 16px; }
100
+ header { flex-direction: column; align-items: flex-start; gap: 12px; }
101
+ .response-stats { flex-wrap: wrap; gap: 8px; }
102
+ }
103
+ </style>
104
+ </head>
105
+ <body>
106
+ <div class="container">
107
+
108
+ <header>
109
+ <h1>Uptime Monitor</h1>
110
+ <button class="btn btn-primary" onclick="trigger()">Check now</button>
111
+ </header>
112
+
113
+ <div class="banner" id="banner">
114
+ <div class="dot unknown" id="status-dot"></div>
115
+ <span class="banner-text" id="status-text">Loading...</span>
116
+ <span class="banner-sub">Checked every 15 min</span>
117
+ </div>
118
+
119
+ <div id="monitors"></div>
120
+
121
+ <div class="section">
122
+ <div class="section-header">
123
+ <h2>Recent Checks</h2>
124
+ <span class="count" id="checks-count"></span>
125
+ </div>
126
+ <div class="card">
127
+ <table>
128
+ <thead><tr><th>Time</th><th>Group</th><th>Status</th><th>Latency</th><th>Source</th></tr></thead>
129
+ <tbody id="checks"></tbody>
130
+ </table>
131
+ <div class="empty-state" id="checks-empty">
132
+ <p>No checks yet</p>
133
+ <p class="hint">Click "Check now" or wait for the first automatic check.</p>
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ </div>
139
+
140
+ <div class="toast" id="toast"></div>
141
+
142
+ <script>
143
+ function toast(msg, isError) {
144
+ var el = document.getElementById('toast');
145
+ el.textContent = msg;
146
+ el.className = 'toast show' + (isError ? ' error' : '');
147
+ setTimeout(function() { el.className = 'toast'; }, 2500);
148
+ }
149
+
150
+ function timeAgo(unix) {
151
+ var diff = Math.floor(Date.now() / 1000) - unix;
152
+ if (diff < 5) return 'just now';
153
+ if (diff < 60) return diff + 's ago';
154
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
155
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
156
+ return Math.floor(diff / 86400) + 'd ago';
157
+ }
158
+
159
+ function duration(seconds) {
160
+ if (seconds < 60) return seconds + 's';
161
+ if (seconds < 3600) return Math.floor(seconds / 60) + 'm';
162
+ if (seconds < 86400) {
163
+ var h = Math.floor(seconds / 3600);
164
+ var m = Math.floor((seconds % 3600) / 60);
165
+ return h + 'h ' + m + 'm';
166
+ }
167
+ var d = Math.floor(seconds / 86400);
168
+ var hrs = Math.floor((seconds % 86400) / 3600);
169
+ return d + 'd ' + hrs + 'h';
170
+ }
171
+
172
+ function pctClass(pct) {
173
+ if (pct === null) return 'pct-none';
174
+ if (pct >= 99.5) return 'pct-good';
175
+ if (pct >= 95) return 'pct-warn';
176
+ return 'pct-bad';
177
+ }
178
+
179
+ function renderStatusBar(checks) {
180
+ if (!checks.length) return '<div class="status-bar"></div>';
181
+ return '<div class="status-bar">' + checks.map(function(c) {
182
+ return '<div class="status-block ' + (c.ok ? 'up' : 'down') + '" title="' + c.latency_ms + 'ms"></div>';
183
+ }).join('') + '</div>';
184
+ }
185
+
186
+ function renderSparkline(checks) {
187
+ if (!checks.length) return '';
188
+ var maxMs = Math.max.apply(null, checks.map(function(c) { return c.latency_ms; }));
189
+ if (maxMs === 0) maxMs = 1;
190
+ return '<div class="sparkline">' + checks.map(function(c) {
191
+ var h = Math.max(3, Math.round(40 * c.latency_ms / maxMs));
192
+ var cls = 'spark-bar';
193
+ if (c.latency_ms > 5000) cls += ' timeout';
194
+ else if (c.latency_ms > 1000) cls += ' slow';
195
+ return '<div class="' + cls + '" style="height:' + h + 'px" title="' + c.latency_ms + 'ms"></div>';
196
+ }).join('') + '</div>';
197
+ }
198
+
199
+ function renderPeriod(label, p) {
200
+ var val = p.uptime_pct !== null ? p.uptime_pct + '%' : '--.--%';
201
+ var detail = p.total_checks > 0
202
+ ? p.failed_checks + ' failed / ' + p.total_checks + ' checks'
203
+ : 'No data';
204
+ return '<div class="period">'
205
+ + '<div class="period-label">' + label + '</div>'
206
+ + '<div class="period-value ' + pctClass(p.uptime_pct) + '">' + val + '</div>'
207
+ + '<div class="period-detail">' + detail + '</div>'
208
+ + '</div>';
209
+ }
210
+
211
+ async function refresh() {
212
+ try {
213
+ var responses = await Promise.all([
214
+ fetch('/api/status'), fetch('/api/checks')
215
+ ]);
216
+ var statusData = await responses[0].json();
217
+ var checksData = await responses[1].json();
218
+
219
+ var monitors = statusData.monitors || [];
220
+ var checks = checksData.checks || [];
221
+
222
+ // Banner
223
+ var dot = document.getElementById('status-dot');
224
+ var statusText = document.getElementById('status-text');
225
+ if (monitors.length === 0) {
226
+ dot.className = 'dot unknown';
227
+ statusText.textContent = 'No data yet';
228
+ } else {
229
+ var allUp = monitors.every(function(m) { return m.all_up; });
230
+ dot.className = 'dot ' + (allUp ? 'up' : 'down');
231
+ statusText.textContent = allUp ? 'All systems operational' : 'Issues detected';
232
+ }
233
+
234
+ // Group cards
235
+ var monitorsEl = document.getElementById('monitors');
236
+ if (monitors.length === 0) {
237
+ monitorsEl.innerHTML = '';
238
+ } else {
239
+ monitorsEl.innerHTML = monitors.map(function(m) {
240
+ var endpoints = m.endpoint_count || 1;
241
+ var streakText = m.all_up
242
+ ? 'Currently up for ' + duration(m.streak_seconds)
243
+ : 'Currently experiencing issues';
244
+ var pct24 = m.periods['24h'].uptime_pct;
245
+ var barLabel = pct24 !== null ? pct24 + '%' : 'No data';
246
+
247
+ return '<div class="group-card">'
248
+ + '<div class="group-header">'
249
+ + '<span class="group-badge ' + (m.all_up ? 'up' : 'down') + '">' + (m.all_up ? 'Up' : 'Down') + '</span>'
250
+ + '<span class="group-name">' + m.group_name + '</span>'
251
+ + '<span class="group-endpoints">' + endpoints + ' endpoint' + (endpoints === 1 ? '' : 's') + '</span>'
252
+ + '</div>'
253
+ + '<div class="group-streak">' + streakText + '</div>'
254
+
255
+ + '<div class="status-bar-section">'
256
+ + '<div class="status-bar-label"><span>Last 24 hours</span><span>' + barLabel + '</span></div>'
257
+ + renderStatusBar(m.recent_checks)
258
+ + '</div>'
259
+
260
+ + '<div class="periods">'
261
+ + renderPeriod('24 hours', m.periods['24h'])
262
+ + renderPeriod('7 days', m.periods['7d'])
263
+ + renderPeriod('30 days', m.periods['30d'])
264
+ + '</div>'
265
+
266
+ + '<div class="response-section">'
267
+ + '<div class="response-header">'
268
+ + '<span class="response-title">Response time</span>'
269
+ + '<div class="response-stats">'
270
+ + '<span>avg <span class="val">' + m.latency.avg + 'ms</span></span>'
271
+ + '<span>min <span class="val">' + m.latency.min + 'ms</span></span>'
272
+ + '<span>max <span class="val">' + m.latency.max + 'ms</span></span>'
273
+ + '</div></div>'
274
+ + renderSparkline(m.recent_checks)
275
+ + '</div>'
276
+
277
+ + '</div>';
278
+ }).join('');
279
+ }
280
+
281
+ // Checks table
282
+ var checksBody = document.getElementById('checks');
283
+ var checksEmpty = document.getElementById('checks-empty');
284
+ var checksCount = document.getElementById('checks-count');
285
+ if (checks.length) {
286
+ checksEmpty.style.display = 'none';
287
+ checksCount.textContent = checks.length + ' recent';
288
+ checksBody.innerHTML = checks.map(function(c) { return '<tr>'
289
+ + '<td>' + timeAgo(c.created_at) + '</td>'
290
+ + '<td>' + c.group_name + '</td>'
291
+ + '<td>' + (c.ok ? '<span class="ok">' + (c.status_code || 'OK') + '</span>' : '<span class="fail">' + (c.error || c.status_code || 'FAIL') + '</span>') + '</td>'
292
+ + '<td class="mono">' + c.latency_ms + 'ms</td>'
293
+ + '<td>' + (c.source === 'manual' ? '<span style="color:#d29922">manual</span>' : '<span style="color:#3fb950">cron</span>') + '</td>'
294
+ + '</tr>'; }).join('');
295
+ } else {
296
+ checksEmpty.style.display = '';
297
+ checksCount.textContent = '';
298
+ checksBody.innerHTML = '';
299
+ }
300
+ } catch (e) {
301
+ console.error('Refresh error:', e);
302
+ }
303
+ }
304
+
305
+ async function trigger() {
306
+ try {
307
+ var res = await fetch('/api/trigger', { method: 'POST' });
308
+ var data = await res.json();
309
+ if (res.ok) {
310
+ toast(data.all_ok ? 'All ' + data.checked + ' checks passed' : 'Issues detected');
311
+ refresh();
312
+ } else toast('Check failed', true);
313
+ } catch (e) { toast('Network error', true); }
314
+ }
315
+
316
+ refresh();
317
+ setInterval(refresh, 10000);
318
+ </script>
319
+ </body>
320
+ </html>`;
321
+ }