@stacksjs/ts-cloud 0.5.35 → 0.6.1

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 (77) hide show
  1. package/dist/aws/cloudwatch.d.ts +2 -0
  2. package/dist/aws/xray.d.ts +45 -0
  3. package/dist/bin/cli.js +718 -701
  4. package/dist/deploy/dashboard-database.d.ts +25 -0
  5. package/dist/deploy/dashboard-operations.d.ts +7 -0
  6. package/dist/deploy/firewall-config-editor.d.ts +15 -0
  7. package/dist/deploy/firewall-config-editor.test.d.ts +1 -0
  8. package/dist/deploy/index.d.ts +1 -0
  9. package/dist/deploy/management-dashboard.d.ts +26 -3
  10. package/dist/deploy/serverless-operations.d.ts +150 -0
  11. package/dist/deploy/serverless-operations.test.d.ts +1 -0
  12. package/dist/deploy/site-config-editor.d.ts +6 -0
  13. package/dist/deploy/ssh-config-editor.d.ts +1 -0
  14. package/dist/deploy/terminal-session.d.ts +23 -0
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1003 -33
  17. package/dist/ui/index.html +4 -4
  18. package/dist/ui/server/actions.html +34 -5
  19. package/dist/ui/server/activity.html +1 -1
  20. package/dist/ui/server/backups.html +4 -4
  21. package/dist/ui/server/database.html +27 -5
  22. package/dist/ui/server/deployments.html +4 -4
  23. package/dist/ui/server/diagnostics.html +1 -1
  24. package/dist/ui/server/firewall.html +1233 -0
  25. package/dist/ui/server/logs.html +4 -4
  26. package/dist/ui/server/metrics.html +1 -1
  27. package/dist/ui/server/security.html +1 -1
  28. package/dist/ui/server/services.html +4 -4
  29. package/dist/ui/server/sites.html +24 -5
  30. package/dist/ui/server/ssh-keys.html +4 -4
  31. package/dist/ui/server/terminal.html +1191 -0
  32. package/dist/ui/server/workers.html +4 -4
  33. package/dist/ui/serverless/alarms.html +1236 -0
  34. package/dist/ui/serverless/assets.html +53 -1
  35. package/dist/ui/serverless/cost.html +1 -1
  36. package/dist/ui/serverless/data.html +58 -1
  37. package/dist/ui/serverless/deployments.html +53 -1
  38. package/dist/ui/serverless/firewall.html +1 -1
  39. package/dist/ui/serverless/functions.html +63 -1
  40. package/dist/ui/serverless/logs.html +4 -4
  41. package/dist/ui/serverless/metrics.html +1 -1
  42. package/dist/ui/serverless/queues.html +94 -1
  43. package/dist/ui/serverless/scheduler.html +55 -1
  44. package/dist/ui/serverless/secrets.html +99 -1
  45. package/dist/ui/serverless/traces.html +1177 -0
  46. package/dist/ui/serverless.html +87 -1
  47. package/dist/ui-src/pages/index.stx +1 -1
  48. package/dist/ui-src/pages/partials/head.stx +47 -35
  49. package/dist/ui-src/pages/partials/nav.stx +4 -3
  50. package/dist/ui-src/pages/server/actions.stx +34 -1
  51. package/dist/ui-src/pages/server/backups.stx +1 -1
  52. package/dist/ui-src/pages/server/database.stx +32 -9
  53. package/dist/ui-src/pages/server/deployments.stx +2 -2
  54. package/dist/ui-src/pages/server/firewall.stx +134 -0
  55. package/dist/ui-src/pages/server/logs.stx +2 -2
  56. package/dist/ui-src/pages/server/metrics.stx +5 -5
  57. package/dist/ui-src/pages/server/security.stx +1 -1
  58. package/dist/ui-src/pages/server/services.stx +1 -1
  59. package/dist/ui-src/pages/server/sites.stx +24 -3
  60. package/dist/ui-src/pages/server/ssh-keys.stx +2 -2
  61. package/dist/ui-src/pages/server/terminal.stx +65 -0
  62. package/dist/ui-src/pages/server/workers.stx +1 -1
  63. package/dist/ui-src/pages/serverless/alarms.stx +153 -0
  64. package/dist/ui-src/pages/serverless/assets.stx +29 -2
  65. package/dist/ui-src/pages/serverless/cost.stx +4 -4
  66. package/dist/ui-src/pages/serverless/data.stx +56 -3
  67. package/dist/ui-src/pages/serverless/deployments.stx +30 -6
  68. package/dist/ui-src/pages/serverless/firewall.stx +1 -1
  69. package/dist/ui-src/pages/serverless/functions.stx +62 -4
  70. package/dist/ui-src/pages/serverless/logs.stx +2 -2
  71. package/dist/ui-src/pages/serverless/metrics.stx +5 -1
  72. package/dist/ui-src/pages/serverless/queues.stx +90 -26
  73. package/dist/ui-src/pages/serverless/scheduler.stx +31 -2
  74. package/dist/ui-src/pages/serverless/secrets.stx +94 -14
  75. package/dist/ui-src/pages/serverless/traces.stx +88 -0
  76. package/dist/ui-src/pages/serverless.stx +87 -24
  77. package/package.json +3 -3
@@ -6,7 +6,7 @@ const app = __d?.app ?? { name: 'acme', env: 'production' }
6
6
  const functions = __d?.functionsDetail ?? [
7
7
  {
8
8
  key: 'http', name: 'acme-production-http', runtime: 'provided.al2023', arch: 'x86_64',
9
- memory: 1024, timeout: 28, ephemeral: 512, version: '$LATEST', concurrency: '',
9
+ memory: 1024, timeout: 28, ephemeral: 512, version: '$LATEST', concurrency: '-',
10
10
  invocations: 171200, errors: 21, throttles: 0, p50: 32, p95: 86, p99: 180, maxMem: 184,
11
11
  spark: [40, 52, 48, 61, 73, 66, 80, 72, 95, 88, 70, 64],
12
12
  env: ['APP_ENV', 'ASSET_URL', 'TSCLOUD_CACHE_TABLE', 'DB_HOST', 'QUEUE_CONNECTION'],
@@ -29,7 +29,7 @@ const functions = __d?.functionsDetail ?? [
29
29
  },
30
30
  {
31
31
  key: 'cli', name: 'acme-production-cli', runtime: 'provided.al2023', arch: 'x86_64',
32
- memory: 1024, timeout: 900, ephemeral: 512, version: '$LATEST', concurrency: '',
32
+ memory: 1024, timeout: 900, ephemeral: 512, version: '$LATEST', concurrency: '-',
33
33
  invocations: 1440, errors: 0, throttles: 0, p50: 800, p95: 1100, p99: 1400, maxMem: 312,
34
34
  spark: [2, 1, 1, 1, 2, 1, 1, 1, 1, 2, 1, 1],
35
35
  env: ['APP_ENV', 'TSCLOUD_SCHEDULER'],
@@ -41,11 +41,40 @@ const functions = __d?.functionsDetail ?? [
41
41
  ]
42
42
 
43
43
  function fmt(n) { return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n) }
44
+ const modes = (__d?.functionsDetail ?? functions).map(f => f.key)
45
+ const initialMode = modes[0] ?? 'http'
46
+ </script>
47
+ <script client>
48
+ // Per-function config runs live against AWS through /api/serverless/functions/config.
49
+ const mode = state(initialMode)
50
+ const mem = state('1024')
51
+ const to = state('28')
52
+ const opOutput = state('')
53
+ const opShown = state(false)
54
+ const pending = state(null)
55
+ const typed = state('')
56
+ const confirmTok = derived(() => { const p = pending(); return p ? p.confirm : '' })
57
+ const confirmVerb = derived(() => { const p = pending(); return p ? p.verb : '' })
58
+ const canRun = derived(() => { const p = pending(); return !!p && typed() === p.confirm })
59
+ function askOp(operation, confirm, verb) { pending.set({ operation, confirm, verb }); typed.set('') }
60
+ function cancelOp() { pending.set(null) }
61
+ function applyConfig() { const m = mode(); askOp(m, m, 'update the ' + m + ' function') }
62
+ async function runOp() {
63
+ const p = pending()
64
+ if (!p || typed() !== p.confirm) return
65
+ pending.set(null); typed.set('')
66
+ opShown.set(true); opOutput.set('Updating ' + p.operation + '...')
67
+ try {
68
+ const res = await fetch('/api/serverless/functions/config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mode: p.operation, memory: Number(mem()), timeout: Number(to()), confirm: p.confirm }) })
69
+ const body = await res.json()
70
+ opOutput.set((body.ok ? 'OK ' : 'FAILED ') + (body.command || p.operation) + '\n\n' + (body.stdout || '') + (body.error ? '\n' + body.error : ''))
71
+ } catch (e) { opOutput.set('FAILED ' + p.operation + '\n\n' + ((e && e.message) || e)) }
72
+ }
44
73
  </script>
45
74
  <!DOCTYPE html>
46
75
  <html lang="en" data-theme="dark">
47
76
  <head>
48
- @include('../partials/head', { title: 'ts-cloud Functions' })
77
+ @include('../partials/head', { title: 'ts-cloud · Functions' })
49
78
  </head>
50
79
  <body>
51
80
  @include('../partials/nav', { active: 'serverless', name: app.name, env: app.env })
@@ -55,7 +84,7 @@ function fmt(n) { return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k
55
84
  <header class="page">
56
85
  <div>
57
86
  <h1>Functions</h1>
58
- <p>The three Lambda functions sharing one artifact HTTP, queue worker, and CLI/scheduler.</p>
87
+ <p>The three Lambda functions sharing one artifact - HTTP, queue worker, and CLI/scheduler.</p>
59
88
  </div>
60
89
  </header>
61
90
 
@@ -113,6 +142,35 @@ function fmt(n) { return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k
113
142
  </div>
114
143
  </div>
115
144
  @endforeach
145
+
146
+ <div class="section">
147
+ <h2>Configure function</h2>
148
+ <div class="panel">
149
+ <div class="form-grid">
150
+ <div class="field-block">
151
+ <label>Function</label>
152
+ <select :value="mode()" @change="mode.set($event.target.value)">
153
+ @foreach (modes as m)
154
+ <option value="{{ m }}">{{ m }}</option>
155
+ @endforeach
156
+ </select>
157
+ </div>
158
+ <div class="field-block">
159
+ <label>Memory (MB)</label>
160
+ <input type="number" min="128" max="10240" :value="mem()" @input="mem.set($event.target.value)" placeholder="1024" autocomplete="off">
161
+ </div>
162
+ <div class="field-block">
163
+ <label>Timeout (s)</label>
164
+ <input type="number" min="1" max="900" :value="to()" @input="to.set($event.target.value)" placeholder="28" autocomplete="off">
165
+ </div>
166
+ <div class="form-actions">
167
+ <button class="btn" type="button" @click="applyConfig()">Apply</button>
168
+ </div>
169
+ </div>
170
+ @include('../partials/op-confirm')
171
+ <p class="note">Memory is 128 to 10240 MB, timeout is 1 to 900 s. Changes are applied to the live function immediately via <span class="mono">cloud functions:config</span>.</p>
172
+ </div>
173
+ </div>
116
174
  </div>
117
175
  </body>
118
176
  </html>
@@ -7,7 +7,7 @@ const emptyReason = __d?.serverlessLogsEmptyReason ?? 'No function logs are avai
7
7
  </script>
8
8
  <script client>
9
9
  // Reactive aggregated CloudWatch logs across the functions, with level + function
10
- // filtering and auto-refresh seeded from build-time data (stx server→client
10
+ // filtering and auto-refresh - seeded from build-time data (stx server→client
11
11
  // bridge), then live-refreshed. Signals + directives only (no manual DOM).
12
12
  const logs = state(initialLogs)
13
13
  const level = state('all')
@@ -28,7 +28,7 @@ onMount(() => { refresh(); const id = setInterval(refresh, 8000); return () => c
28
28
  </script>
29
29
  <!DOCTYPE html>
30
30
  <html lang="en" data-theme="dark">
31
- <head>@include('../partials/head', { title: 'ts-cloud Function logs' })</head>
31
+ <head>@include('../partials/head', { title: 'ts-cloud - Function logs' })</head>
32
32
  <body>
33
33
  @include('../partials/nav', { active: 'serverless', name: app.name, env: app.env })
34
34
  <div class="wrap">
@@ -16,7 +16,7 @@ function pct(v, max) { return Math.round((v / max) * 100) }
16
16
  <!DOCTYPE html>
17
17
  <html lang="en" data-theme="dark">
18
18
  <head>
19
- @include('../partials/head', { title: 'ts-cloud Metrics' })
19
+ @include('../partials/head', { title: 'ts-cloud · Metrics' })
20
20
  </head>
21
21
  <body>
22
22
  @include('../partials/nav', { active: 'serverless', name: app.name, env: app.env })
@@ -27,6 +27,10 @@ function pct(v, max) { return Math.round((v / max) * 100) }
27
27
  <h1>Metrics</h1>
28
28
  <p>CloudWatch metrics across the function trio, last {{ window }}.</p>
29
29
  </div>
30
+ <div class="header-actions">
31
+ <a class="btn ghost sm" href="/serverless/traces">Traces →</a>
32
+ <a class="btn ghost sm" href="/serverless/alarms">Alarms →</a>
33
+ </div>
30
34
  </header>
31
35
 
32
36
  <div class="grid cards">
@@ -6,15 +6,67 @@ const queues = __d?.queuesDetail ?? [
6
6
  { name: 'acme-production-default', short: 'default', visible: 0, inFlight: 2, delayed: 0, processed: 12830, oldestSec: 0, concurrency: 10, tries: 3, visTimeout: 720, dlq: 0 },
7
7
  { name: 'acme-production-emails', short: 'emails', visible: 4, inFlight: 1, delayed: 0, processed: 9210, oldestSec: 14, concurrency: 5, tries: 3, visTimeout: 720, dlq: 1 },
8
8
  ]
9
- const dlqItems = __d?.dlqItems ?? [
10
- { id: 'b1f3…9a', queue: 'emails', job: 'App\\Jobs\\SendInvoice', failed: '2h ago', attempts: 3, error: 'SMTP timeout' },
11
- ]
12
9
  function fmt(n) { return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n) }
10
+ const initialQueues = queues
11
+ const queueNames = queues.map(q => q.short)
12
+ </script>
13
+ <script client>
14
+ // Live dead-letter queue: peek at messages, redrive them back onto a source
15
+ // queue, or purge the DLQ. Per-queue purge runs the queue:purge:<name> op.
16
+ const rows = state(initialQueues)
17
+ const dlq = state([])
18
+ const dlqLoaded = state(false)
19
+ const dlqMsg = state('')
20
+ const target = state(queueNames[0] || '')
21
+ const busy = state(false)
22
+
23
+ // Op runner (redrive / purge / per-queue purge) - typed-confirm bar.
24
+ const opOutput = state('')
25
+ const opShown = state(false)
26
+ const pending = state(null)
27
+ const typed = state('')
28
+ const confirmTok = derived(() => { const p = pending(); return p ? p.confirm : '' })
29
+ const confirmVerb = derived(() => { const p = pending(); return p ? p.verb : '' })
30
+ const canRun = derived(() => { const p = pending(); return !!p && typed() === p.confirm })
31
+ function askOp(kind, confirm, verb) { pending.set({ kind, confirm, verb }); typed.set('') }
32
+ function cancelOp() { pending.set(null) }
33
+
34
+ async function loadDlq() {
35
+ dlqMsg.set('Loading dead-letter queue...')
36
+ try {
37
+ const r = await fetch('/api/serverless/dlq?max=10')
38
+ const x = await r.json()
39
+ if (x.ok) { dlq.set(x.messages || []); dlqMsg.set('') } else { dlq.set([]); dlqMsg.set(x.error || 'DLQ unavailable.') }
40
+ } catch (e) { dlqMsg.set('Failed to load DLQ: ' + ((e && e.message) || e)) }
41
+ finally { dlqLoaded.set(true) }
42
+ }
43
+ onMount(() => { loadDlq() })
44
+
45
+ async function runOp() {
46
+ const p = pending()
47
+ if (!p || typed() !== p.confirm) return
48
+ pending.set(null); typed.set('')
49
+ opShown.set(true); opOutput.set('Running...')
50
+ busy.set(true)
51
+ try {
52
+ let res
53
+ if (p.kind === 'redrive')
54
+ res = await fetch('/api/serverless/dlq/redrive', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'redrive', targetQueue: target(), max: 25 }) })
55
+ else if (p.kind === 'purge-dlq')
56
+ res = await fetch('/api/serverless/dlq/purge', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'purge' }) })
57
+ else
58
+ res = await fetch('/api/serverless/operations/run', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ operation: 'queue:purge:' + p.kind.replace('purge-queue:', ''), confirm: p.confirm }) })
59
+ const body = await res.json()
60
+ opOutput.set((body.ok ? 'OK ' : 'FAILED ') + (body.command || '') + '\n\n' + (body.stdout || '') + (body.error ? '\n' + body.error : ''))
61
+ if (body.ok) loadDlq()
62
+ } catch (e) { opOutput.set('FAILED\n\n' + ((e && e.message) || e)) }
63
+ finally { busy.set(false) }
64
+ }
13
65
  </script>
14
66
  <!DOCTYPE html>
15
67
  <html lang="en" data-theme="dark">
16
68
  <head>
17
- @include('../partials/head', { title: 'ts-cloud Queues' })
69
+ @include('../partials/head', { title: 'ts-cloud · Queues' })
18
70
  </head>
19
71
  <body>
20
72
  @include('../partials/nav', { active: 'serverless', name: app.name, env: app.env })
@@ -30,7 +82,7 @@ function fmt(n) { return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k
30
82
  <div class="section">
31
83
  <div class="panel">
32
84
  <table>
33
- <thead><tr><th>Queue</th><th>Visible</th><th>In-flight</th><th>Oldest</th><th>Concurrency</th><th>Tries</th><th>Vis. timeout</th><th>Processed</th><th>DLQ</th></tr></thead>
85
+ <thead><tr><th>Queue</th><th>Visible</th><th>In-flight</th><th>Oldest</th><th>Concurrency</th><th>Tries</th><th>Vis. timeout</th><th>Processed</th><th>DLQ</th><th class="table-actions">Action</th></tr></thead>
34
86
  <tbody>
35
87
  @foreach (queues as q)
36
88
  <tr>
@@ -43,6 +95,7 @@ function fmt(n) { return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k
43
95
  <td class="mono">{{ q.visTimeout }}s</td>
44
96
  <td style="color:var(--txt2)">{{ fmt(q.processed) }}</td>
45
97
  <td>@if (q.dlq > 0)<span class="bad pill">{{ q.dlq }}</span>@else<span style="color:var(--txt3)">0</span>@endif</td>
98
+ <td class="table-actions"><button class="btn danger sm" type="button" @click="askOp('purge-queue:{{ q.short }}', '{{ q.short }}', 'purge the {{ q.short }} queue')">Purge</button></td>
46
99
  </tr>
47
100
  @endforeach
48
101
  </tbody>
@@ -53,27 +106,38 @@ function fmt(n) { return n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k
53
106
  <div class="section">
54
107
  <h2>Dead-letter queue</h2>
55
108
  <div class="panel">
56
- @if (dlqItems.length === 0)
57
- <p style="color:var(--txt3)">No failed messages. 🎉</p>
58
- @else
59
- <table>
60
- <thead><tr><th>Message</th><th>Queue</th><th>Job</th><th>Attempts</th><th>Error</th><th>Failed</th><th></th></tr></thead>
61
- <tbody>
62
- @foreach (dlqItems as m)
63
- <tr>
64
- <td class="mono">{{ m.id }}</td>
65
- <td>{{ m.queue }}</td>
66
- <td class="mono">{{ m.job }}</td>
67
- <td>{{ m.attempts }}</td>
68
- <td><span style="color:var(--bad)">{{ m.error }}</span></td>
69
- <td style="color:var(--txt3)">{{ m.failed }}</td>
70
- <td><span class="cmd">cloud queue:redrive {{ m.queue }}</span></td>
71
- </tr>
72
- @endforeach
73
- </tbody>
74
- </table>
75
- @endif
76
- <p class="note">Inspect with <span class="mono">cloud queue:stats</span> · purge with <span class="mono">cloud queue:purge &lt;name&gt;</span></p>
109
+ <div class="filters">
110
+ <div class="select-filter">
111
+ <span>Redrive into</span>
112
+ <select :value="target()" @change="target.set($event.target.value)">
113
+ <template :for="n in queueNames">
114
+ <option :value="n">{{ n }}</option>
115
+ </template>
116
+ </select>
117
+ </div>
118
+ <div class="row-actions">
119
+ <button class="btn ghost sm" type="button" :disabled="busy()" @click="loadDlq()">Refresh</button>
120
+ <button class="btn ghost sm" type="button" :disabled="busy()" @click="askOp('redrive', 'redrive', 'move DLQ messages back onto the source queue')">Redrive</button>
121
+ <button class="btn danger sm" type="button" :disabled="busy()" @click="askOp('purge-dlq', 'purge', 'permanently purge the dead-letter queue')">Purge DLQ</button>
122
+ </div>
123
+ </div>
124
+
125
+ <table @show="dlq().length > 0">
126
+ <thead><tr><th>Message ID</th><th>Body</th></tr></thead>
127
+ <tbody>
128
+ <template :for="m in dlq()">
129
+ <tr>
130
+ <td class="mono" style="font-size:11px">{{ m.id }}</td>
131
+ <td class="mono" style="font-size:11px;color:var(--txt2);max-width:640px;overflow:hidden;text-overflow:ellipsis">{{ m.body }}</td>
132
+ </tr>
133
+ </template>
134
+ </tbody>
135
+ </table>
136
+ <div class="compact empty" @show="dlqLoaded() && dlq().length === 0 && !dlqMsg()"><strong>No failed messages</strong><span>The dead-letter queue is empty.</span></div>
137
+ <p class="note" @show="dlqMsg() !== ''">{{ dlqMsg() }}</p>
138
+
139
+ @include('../partials/op-confirm')
140
+ <p class="note">Redrive receives messages from the DLQ and re-sends them to the selected source queue (peeked messages stay put). Purge is irreversible.</p>
77
141
  </div>
78
142
  </div>
79
143
  </div>
@@ -3,7 +3,7 @@ let __d = null
3
3
  try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
4
4
  const app = __d?.app ?? { name: 'acme', env: 'production' }
5
5
  const scheduler = { ...{ enabled: true, mode: 'sub-minute', expression: 'rate(1 minute)', lastRun: '36s ago', nextRun: '24s' }, ...(__d?.scheduler ?? {}) }
6
- // Tasks Laravel's schedule:run would fire (illustrative discovered from the app).
6
+ // Tasks Laravel's schedule:run would fire (illustrative - discovered from the app).
7
7
  const tasks = __d?.schedulerTasks ?? [
8
8
  { command: 'emails:digest', cron: '0 8 * * *', desc: 'Daily 08:00', lastRun: '8h ago', lastStatus: 'ok' },
9
9
  { command: 'metrics:rollup', cron: '*/5 * * * *', desc: 'Every 5 min', lastRun: '3m ago', lastStatus: 'ok' },
@@ -12,10 +12,33 @@ const tasks = __d?.schedulerTasks ?? [
12
12
  ]
13
13
  function tone(s) { return s === 'ok' ? 'ok' : (s === 'failed' ? 'bad' : 'warn') }
14
14
  </script>
15
+ <script client>
16
+ // Scheduler control runs live against AWS through /api/serverless/scheduler.
17
+ const opOutput = state('')
18
+ const opShown = state(false)
19
+ const pending = state(null)
20
+ const typed = state('')
21
+ const confirmTok = derived(() => { const p = pending(); return p ? p.confirm : '' })
22
+ const confirmVerb = derived(() => { const p = pending(); return p ? p.verb : '' })
23
+ const canRun = derived(() => { const p = pending(); return !!p && typed() === p.confirm })
24
+ function askOp(operation, confirm, verb) { pending.set({ operation, confirm, verb }); typed.set('') }
25
+ function cancelOp() { pending.set(null) }
26
+ async function runOp() {
27
+ const p = pending()
28
+ if (!p || typed() !== p.confirm) return
29
+ pending.set(null); typed.set('')
30
+ opShown.set(true); opOutput.set('Running ' + p.operation + '...')
31
+ try {
32
+ const res = await fetch('/api/serverless/scheduler', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ action: p.operation, confirm: p.confirm }) })
33
+ const body = await res.json()
34
+ opOutput.set((body.ok ? 'OK ' : 'FAILED ') + (body.command || p.operation) + '\n\n' + (body.stdout || '') + (body.error ? '\n' + body.error : ''))
35
+ } catch (e) { opOutput.set('FAILED ' + p.operation + '\n\n' + ((e && e.message) || e)) }
36
+ }
37
+ </script>
15
38
  <!DOCTYPE html>
16
39
  <html lang="en" data-theme="dark">
17
40
  <head>
18
- @include('../partials/head', { title: 'ts-cloud Scheduler' })
41
+ @include('../partials/head', { title: 'ts-cloud · Scheduler' })
19
42
  </head>
20
43
  <body>
21
44
  @include('../partials/nav', { active: 'serverless', name: app.name, env: app.env })
@@ -26,6 +49,11 @@ function tone(s) { return s === 'ok' ? 'ok' : (s === 'failed' ? 'bad' : 'warn')
26
49
  <h1>Scheduler</h1>
27
50
  <p>An EventBridge rule invokes the CLI function with <span class="mono">schedule:run</span> every minute.</p>
28
51
  </div>
52
+ <div class="header-actions">
53
+ <button class="btn sm" type="button" @click="askOp('run', 'run', 'run the scheduler now')">Run now</button>
54
+ <button class="btn sm" type="button" @click="askOp('enable', 'enable', 'enable the scheduler')">Enable</button>
55
+ <button class="btn danger sm" type="button" @click="askOp('disable', 'disable', 'disable the scheduler')">Disable</button>
56
+ </div>
29
57
  </header>
30
58
 
31
59
  <div class="grid cards">
@@ -52,6 +80,7 @@ function tone(s) { return s === 'ok' ? 'ok' : (s === 'failed' ? 'bad' : 'warn')
52
80
  @endforeach
53
81
  </tbody>
54
82
  </table>
83
+ @include('../partials/op-confirm')
55
84
  <p class="note">Sub-minute mode loops <span class="mono">schedule:run</span> within each invocation. Run manually: <span class="mono">cloud command "schedule:run"</span></p>
56
85
  </div>
57
86
  </div>
@@ -2,18 +2,68 @@
2
2
  let __d = null
3
3
  try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
4
4
  const app = __d?.app ?? { name: 'acme', env: 'production' }
5
- // Secret keys (names only values never leave Secrets Manager).
5
+ // Secret keys (names only - values never leave Secrets Manager).
6
6
  const secrets = __d?.secretsList ?? [
7
7
  { key: 'APP_KEY', source: 'acme/production/app-key', updated: '14d ago' },
8
8
  { key: 'DB_PASSWORD', source: 'acme/production/db', updated: '14d ago' },
9
9
  { key: 'STRIPE_SECRET', source: 'acme/production/stripe', updated: '3d ago' },
10
10
  { key: 'MAIL_PASSWORD', source: 'acme/production/mail', updated: '21d ago' },
11
11
  ]
12
+ const initialSecrets = secrets
13
+ </script>
14
+ <script client>
15
+ // Secrets editor: set (create/update) a value in Secrets Manager, or delete one.
16
+ // The value is write-only - it is sent to the server and never read back.
17
+ const rows = state(initialSecrets)
18
+ const secretId = state('')
19
+ const value = state('')
20
+ const msg = state('')
21
+ const msgOk = state(false)
22
+ const busy = state(false)
23
+
24
+ // Delete flow uses a typed-confirm (the secret id) before firing.
25
+ const pendingDelete = state(null)
26
+ const typedDelete = state('')
27
+ const canDelete = derived(() => { const p = pendingDelete(); return !!p && typedDelete() === p })
28
+
29
+ async function refresh() {
30
+ try { const r = await fetch('/api/serverless/secrets'); if (!r.ok) return; const x = await r.json(); if (Array.isArray(x.secrets)) rows.set(x.secrets) } catch {}
31
+ }
32
+ onMount(() => { refresh() })
33
+
34
+ async function setSecret() {
35
+ const id = secretId().trim()
36
+ if (!id) { msg.set('Enter the secret id (source path).'); msgOk.set(false); return }
37
+ if (!value()) { msg.set('Enter a value.'); msgOk.set(false); return }
38
+ busy.set(true); msg.set('Saving ' + id + '...'); msgOk.set(false)
39
+ try {
40
+ const res = await fetch('/api/serverless/secrets', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ secretId: id, value: value() }) })
41
+ const body = await res.json()
42
+ msg.set(body.ok ? (body.stdout || 'Saved.') : (body.error || 'Failed to save.')); msgOk.set(!!body.ok)
43
+ if (body.ok) { value.set(''); refresh() }
44
+ } catch (e) { msg.set('Failed: ' + ((e && e.message) || e)); msgOk.set(false) }
45
+ finally { busy.set(false) }
46
+ }
47
+ function pickSource(src) { secretId.set(src) }
48
+ function askDelete(src) { pendingDelete.set(src); typedDelete.set('') }
49
+ function cancelDelete() { pendingDelete.set(null); typedDelete.set('') }
50
+ async function confirmDelete() {
51
+ const id = pendingDelete()
52
+ if (!id || typedDelete() !== id) return
53
+ pendingDelete.set(null); typedDelete.set('')
54
+ msg.set('Deleting ' + id + '...'); msgOk.set(false)
55
+ try {
56
+ const res = await fetch('/api/serverless/secrets', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ secretId: id, confirm: id }) })
57
+ const body = await res.json()
58
+ msg.set(body.ok ? (body.stdout || 'Deleted.') : (body.error || 'Failed to delete.')); msgOk.set(!!body.ok)
59
+ if (body.ok) refresh()
60
+ } catch (e) { msg.set('Failed: ' + ((e && e.message) || e)); msgOk.set(false) }
61
+ }
12
62
  </script>
13
63
  <!DOCTYPE html>
14
64
  <html lang="en" data-theme="dark">
15
65
  <head>
16
- @include('../partials/head', { title: 'ts-cloud Secrets' })
66
+ @include('../partials/head', { title: 'ts-cloud · Secrets' })
17
67
  </head>
18
68
  <body>
19
69
  @include('../partials/nav', { active: 'serverless', name: app.name, env: app.env })
@@ -22,32 +72,62 @@ const secrets = __d?.secretsList ?? [
22
72
  <header class="page">
23
73
  <div>
24
74
  <h1>Secrets</h1>
25
- <p>Stored in AWS Secrets Manager and injected into the functions at deploy. Values are never displayed.</p>
75
+ <p>Stored in AWS Secrets Manager and injected into the functions at deploy. Values are write-only and never displayed.</p>
26
76
  </div>
27
77
  </header>
28
78
 
29
79
  <div class="section">
30
80
  <div class="panel">
31
81
  <table>
32
- <thead><tr><th>Key</th><th>Secret source</th><th>Value</th><th>Updated</th><th></th></tr></thead>
82
+ <thead><tr><th>Key</th><th>Secret source</th><th>Value</th><th>Updated</th><th class="table-actions">Actions</th></tr></thead>
33
83
  <tbody>
34
- @foreach (secrets as s)
84
+ <template :for="s in rows()">
35
85
  <tr>
36
86
  <td class="mono"><b>{{ s.key }}</b></td>
37
- <td class="mono" style="color:var(--txt3)">{{ s.source }}</td>
87
+ <td class="mono" style="color:var(--txt3)">{{ s.source ?? s.secretId }}</td>
38
88
  <td class="mono" style="letter-spacing:2px;color:var(--txt3)">••••••••</td>
39
- <td style="color:var(--txt3)">{{ s.updated }}</td>
40
- <td><span class="cmd">cloud secrets:set {{ s.key }} &lt;value&gt;</span></td>
89
+ <td style="color:var(--txt3)">{{ s.updated ?? '-' }}</td>
90
+ <td class="table-actions">
91
+ <div class="row-actions" style="justify-content:flex-end">
92
+ <button class="btn ghost sm" type="button" @click="pickSource(s.source ?? s.secretId)">Set value</button>
93
+ <button class="btn danger sm" type="button" @click="askDelete(s.source ?? s.secretId)">Delete</button>
94
+ </div>
95
+ </td>
41
96
  </tr>
42
- @endforeach
97
+ </template>
98
+ <tr @show="rows().length === 0">
99
+ <td colspan="5"><div class="compact empty"><strong>No secrets referenced</strong><span>Add secrets under <span class="mono">app.secrets</span> in your cloud config, then set their values here.</span></div></td>
100
+ </tr>
43
101
  </tbody>
44
102
  </table>
45
- <div class="cmds" style="margin-top:14px;">
46
- <span class="accent cmd">cloud secrets:set KEY value</span>
47
- <span class="cmd">cloud secrets:list</span>
48
- <span class="cmd">cloud env:pull</span>
103
+
104
+ <div class="op-confirm" @show="pendingDelete() !== null" style="margin-top:14px">
105
+ <span>Type <b class="mono">{{ pendingDelete() }}</b> to delete this secret:</span>
106
+ <input class="op-confirm-input" :value="typedDelete()" @input="typedDelete.set($event.target.value)" @keydown.enter="confirmDelete()" placeholder="confirm" autocomplete="off">
107
+ <button class="btn danger sm" :disabled="!canDelete()" @click="confirmDelete()">Delete</button>
108
+ <button class="btn ghost sm" @click="cancelDelete()">Cancel</button>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="section">
114
+ <h2>Set a secret value</h2>
115
+ <div class="panel">
116
+ <div class="form-grid">
117
+ <div class="field-block">
118
+ <label>Secret id (source path)</label>
119
+ <input :value="secretId()" @input="secretId.set($event.target.value)" placeholder="acme/production/stripe" autocomplete="off">
120
+ </div>
121
+ <div class="field-block">
122
+ <label>Value (write-only)</label>
123
+ <input type="password" :value="value()" @input="value.set($event.target.value)" placeholder="new secret value" autocomplete="off">
124
+ </div>
125
+ <div class="form-actions">
126
+ <button class="btn" type="button" :disabled="busy()" @click="setSecret()">Save secret</button>
127
+ <span class="msg" :class="{ ok: msgOk(), bad: msg() && !msgOk() }">{{ msg() }}</span>
128
+ </div>
49
129
  </div>
50
- <p class="note">Secrets are set from the CLI (the dashboard is a read-only static site). Rotate by re-setting; remove with <span class="mono">cloud secrets:delete KEY</span>.</p>
130
+ <p class="note">Setting a value creates the Secrets Manager entry if it does not exist. The new value is injected into the functions on the next <span class="mono">cloud deploy:serverless</span>.</p>
51
131
  </div>
52
132
  </div>
53
133
  </div>
@@ -0,0 +1,88 @@
1
+ <script server>
2
+ let __d = null
3
+ try { __d = JSON.parse(process.env.TSCLOUD_DASHBOARD_DATA || '') } catch { __d = null }
4
+ const app = __d?.app ?? { name: 'acme', env: 'production' }
5
+ const initialTraces = __d?.traces ?? [
6
+ { id: '1-6630a1b2-abc123', method: 'GET', url: 'https://acme.com/', httpStatus: 200, responseMs: 84, durationMs: 86, status: 'ok' },
7
+ { id: '1-6630a1c4-def456', method: 'POST', url: 'https://acme.com/checkout', httpStatus: 500, responseMs: 240, durationMs: 512, status: 'fault' },
8
+ { id: '1-6630a1d9-9a0f21', method: 'GET', url: 'https://acme.com/api/orders', httpStatus: 429, responseMs: 12, durationMs: 12, status: 'throttle' },
9
+ ]
10
+ </script>
11
+ <script client>
12
+ // X-Ray traces for the HTTP function, refreshed over /api/serverless/traces.
13
+ const traces = state(initialTraces)
14
+ const minutes = state(30)
15
+ const note = state('')
16
+ const loading = state(false)
17
+ const filter = state('all')
18
+ const shown = derived(() => traces().filter(t => filter() === 'all' || t.status === filter()))
19
+ function tone(s) { return s === 'ok' ? 'ok' : (s === 'throttle' ? 'warn' : 'bad') }
20
+ async function load() {
21
+ loading.set(true); note.set('')
22
+ try {
23
+ const r = await fetch('/api/serverless/traces?minutes=' + minutes())
24
+ const d = await r.json()
25
+ if (d.ok) { traces.set(d.traces || []); if (!(d.traces || []).length) note.set('No traces in the last ' + minutes() + ' minutes. Enable active tracing on the functions to populate this view.') }
26
+ else { note.set(d.error || 'Traces unavailable.') }
27
+ } catch (e) { note.set('Failed to load traces: ' + ((e && e.message) || e)) }
28
+ finally { loading.set(false) }
29
+ }
30
+ function setWindow(m) { minutes.set(m); load() }
31
+ onMount(() => { load() })
32
+ </script>
33
+ <!DOCTYPE html>
34
+ <html lang="en" data-theme="dark">
35
+ <head>@include('../partials/head', { title: 'ts-cloud · Traces' })</head>
36
+ <body>
37
+ @include('../partials/nav', { active: 'serverless', name: app.name, env: app.env })
38
+ <div class="wrap">
39
+ <div class="crumbs"><a href="/serverless">Serverless</a> <span class="sep">/</span> Traces</div>
40
+ <header class="page">
41
+ <div><h1>Traces</h1><p>Distributed traces from AWS X-Ray for the HTTP function, newest first.</p></div>
42
+ <div class="header-actions">
43
+ <button class="btn ghost sm" type="button" :disabled="loading()" @click="load()">Refresh</button>
44
+ </div>
45
+ </header>
46
+
47
+ <div class="section">
48
+ <div class="panel">
49
+ <div class="filters">
50
+ <div class="segmented" role="group" aria-label="Time window">
51
+ <button type="button" :class="{ active: minutes() === 15 }" @click="setWindow(15)">15m</button>
52
+ <button type="button" :class="{ active: minutes() === 30 }" @click="setWindow(30)">30m</button>
53
+ <button type="button" :class="{ active: minutes() === 60 }" @click="setWindow(60)">1h</button>
54
+ <button type="button" :class="{ active: minutes() === 180 }" @click="setWindow(180)">3h</button>
55
+ </div>
56
+ <div class="segmented" role="group" aria-label="Status filter">
57
+ <button type="button" :class="{ active: filter() === 'all' }" @click="filter.set('all')">All</button>
58
+ <button type="button" :class="{ active: filter() === 'ok' }" @click="filter.set('ok')">OK</button>
59
+ <button type="button" :class="{ active: filter() === 'error' }" @click="filter.set('error')">Error</button>
60
+ <button type="button" :class="{ active: filter() === 'fault' }" @click="filter.set('fault')">Fault</button>
61
+ <button type="button" :class="{ active: filter() === 'throttle' }" @click="filter.set('throttle')">Throttle</button>
62
+ </div>
63
+ </div>
64
+
65
+ <table @show="shown().length > 0">
66
+ <thead><tr><th>Trace</th><th>Method</th><th>URL</th><th>HTTP</th><th>Response</th><th>Duration</th><th>Status</th></tr></thead>
67
+ <tbody>
68
+ <template :for="t in shown()">
69
+ <tr>
70
+ <td class="mono" style="font-size:11px">{{ t.id }}</td>
71
+ <td class="mono">{{ t.method }}</td>
72
+ <td class="mono" style="font-size:11px;color:var(--txt2);max-width:420px;overflow:hidden;text-overflow:ellipsis">{{ t.url }}</td>
73
+ <td class="mono">{{ t.httpStatus }}</td>
74
+ <td class="mono">{{ t.responseMs }}ms</td>
75
+ <td class="mono">{{ t.durationMs }}ms</td>
76
+ <td><span class="pill" :class="tone(t.status)">{{ t.status }}</span></td>
77
+ </tr>
78
+ </template>
79
+ </tbody>
80
+ </table>
81
+ <div class="compact empty" @show="shown().length === 0"><strong>No traces</strong><span>{{ note() || 'No traces match this filter.' }}</span></div>
82
+ <p class="note" @show="note() !== '' && shown().length > 0">{{ note() }}</p>
83
+ <p class="note">Traces need active tracing enabled on the Lambda functions. Set tracing in your serverless app config and redeploy.</p>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </body>
88
+ </html>