@stacksjs/ts-cloud 0.7.108 → 0.7.110

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 (135) hide show
  1. package/dist/alerts/delivery.d.ts +10 -0
  2. package/dist/alerts/model.d.ts +1 -1
  3. package/dist/api/client.d.ts +53 -0
  4. package/dist/api/handler.d.ts +10 -0
  5. package/dist/api/service.d.ts +1 -0
  6. package/dist/aws/index.js +10 -10
  7. package/dist/aws/lambda.d.ts +19 -0
  8. package/dist/bin/cli.js +1520 -1204
  9. package/dist/bin/dashboard-server.js +935 -621
  10. package/dist/chunk-19bs955w.js +12203 -0
  11. package/dist/{chunk-fmt40tpw.js → chunk-53jatzaw.js} +2 -2
  12. package/dist/{chunk-ef2dwfrg.js → chunk-8kh577qh.js} +5 -5
  13. package/dist/{chunk-s9c2x5jv.js → chunk-9v2pwxv9.js} +2 -2
  14. package/dist/chunk-aa2dkn7b.js +205 -0
  15. package/dist/{chunk-33sy72r8.js → chunk-d3vx2ekv.js} +71 -25
  16. package/dist/chunk-d48vtdj4.js +433 -0
  17. package/dist/{chunk-c6db5m63.js → chunk-t2s3gdq6.js} +253 -8
  18. package/dist/chunk-va2yd85b.js +200 -0
  19. package/dist/{chunk-3g4r09dd.js → chunk-wmbep3b5.js} +1 -1
  20. package/dist/{chunk-eg8ck25v.js → chunk-wmz8x3cy.js} +6935 -15274
  21. package/dist/{chunk-ybcz6sxc.js → chunk-x6yzgv7y.js} +33 -0
  22. package/dist/control-plane/types.d.ts +1 -1
  23. package/dist/deploy/dashboard-protection.test.d.ts +1 -0
  24. package/dist/deploy/dashboard-spend.test.d.ts +1 -0
  25. package/dist/deploy/index.js +11 -8
  26. package/dist/deploy/local-dashboard-server.d.ts +2 -0
  27. package/dist/drivers/index.js +4 -2
  28. package/dist/drivers/shared/protection.d.ts +43 -0
  29. package/dist/drivers/shared/protection.test.d.ts +1 -0
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.js +364 -102
  32. package/dist/protection/controls.d.ts +127 -0
  33. package/dist/protection/controls.test.d.ts +1 -0
  34. package/dist/protection/ddos.d.ts +130 -0
  35. package/dist/protection/ddos.test.d.ts +1 -0
  36. package/dist/protection/index.d.ts +11 -0
  37. package/dist/protection/index.js +89 -0
  38. package/dist/protection/ratelimit.d.ts +154 -0
  39. package/dist/protection/ratelimit.test.d.ts +1 -0
  40. package/dist/protection/waf.d.ts +143 -0
  41. package/dist/protection/waf.test.d.ts +1 -0
  42. package/dist/spend/anomaly-config.d.ts +130 -0
  43. package/dist/spend/anomaly-config.test.d.ts +1 -0
  44. package/dist/spend/anomaly.d.ts +114 -0
  45. package/dist/spend/anomaly.test.d.ts +1 -0
  46. package/dist/spend/api.d.ts +42 -0
  47. package/dist/spend/api.test.d.ts +1 -0
  48. package/dist/spend/appliers.d.ts +126 -0
  49. package/dist/spend/docs.test.d.ts +1 -0
  50. package/dist/spend/enforcement.d.ts +111 -0
  51. package/dist/spend/enforcement.integration.test.d.ts +1 -0
  52. package/dist/spend/evaluator.d.ts +70 -0
  53. package/dist/spend/evaluator.test.d.ts +1 -0
  54. package/dist/spend/focus.d.ts +93 -0
  55. package/dist/spend/focus.test.d.ts +1 -0
  56. package/dist/spend/gate.d.ts +96 -0
  57. package/dist/spend/index.d.ts +20 -0
  58. package/dist/spend/index.js +173 -0
  59. package/dist/spend/lease.d.ts +54 -0
  60. package/dist/spend/lease.test.d.ts +1 -0
  61. package/dist/spend/meter.d.ts +70 -0
  62. package/dist/spend/meter.test.d.ts +1 -0
  63. package/dist/spend/model.d.ts +228 -0
  64. package/dist/spend/notifications.d.ts +58 -0
  65. package/dist/spend/pricing.d.ts +109 -0
  66. package/dist/spend/pricing.test.d.ts +1 -0
  67. package/dist/spend/projection.d.ts +36 -0
  68. package/dist/spend/runner.d.ts +130 -0
  69. package/dist/spend/service.d.ts +129 -0
  70. package/dist/spend/signals.d.ts +131 -0
  71. package/dist/spend/signals.test.d.ts +1 -0
  72. package/dist/spend/sms-sdk.test.d.ts +1 -0
  73. package/dist/spend/spend.integration.test.d.ts +1 -0
  74. package/dist/spend/store.d.ts +166 -0
  75. package/dist/spend/transports.d.ts +169 -0
  76. package/dist/spend/transports.test.d.ts +1 -0
  77. package/dist/spend/window.d.ts +52 -0
  78. package/dist/spend/window.test.d.ts +1 -0
  79. package/dist/ui/404.html +1 -0
  80. package/dist/ui/access-denied.html +1 -1
  81. package/dist/ui/account/automation.html +3 -3
  82. package/dist/ui/account/security.html +2 -2
  83. package/dist/ui/applications/compose.html +4 -4
  84. package/dist/ui/applications/new.html +2 -2
  85. package/dist/ui/data/backups.html +4 -4
  86. package/dist/ui/data/services.html +4 -4
  87. package/dist/ui/data/volumes.html +4 -4
  88. package/dist/ui/index.html +4 -4
  89. package/dist/ui/infrastructure/topology.html +4 -4
  90. package/dist/ui/integrations.html +2 -2
  91. package/dist/ui/operations/alerts.html +4 -4
  92. package/dist/ui/operations/configuration.html +4 -4
  93. package/dist/ui/operations/jobs.html +3 -3
  94. package/dist/ui/operations/maintenance.html +4 -4
  95. package/dist/ui/operations/observability.html +4 -4
  96. package/dist/ui/operations/previews.html +4 -4
  97. package/dist/ui/operations/queue.html +3 -3
  98. package/dist/ui/operations/regions.html +4 -4
  99. package/dist/ui/operations/releases.html +4 -4
  100. package/dist/ui/operations/spend.html +1634 -0
  101. package/dist/ui/operations/workloads.html +4 -4
  102. package/dist/ui/security.html +2 -2
  103. package/dist/ui/server/actions.html +4 -4
  104. package/dist/ui/server/activity.html +2 -2
  105. package/dist/ui/server/capacity.html +4 -4
  106. package/dist/ui/server/database.html +4 -4
  107. package/dist/ui/server/deployments.html +4 -4
  108. package/dist/ui/server/diagnostics.html +2 -2
  109. package/dist/ui/server/firewall.html +100 -161
  110. package/dist/ui/server/fleet.html +4 -4
  111. package/dist/ui/server/logs.html +4 -4
  112. package/dist/ui/server/metrics.html +4 -4
  113. package/dist/ui/server/security.html +2 -2
  114. package/dist/ui/server/services.html +2 -2
  115. package/dist/ui/server/sites.html +4 -4
  116. package/dist/ui/server/ssh-keys.html +4 -4
  117. package/dist/ui/server/team.html +4 -4
  118. package/dist/ui/server/terminal.html +2 -2
  119. package/dist/ui/serverless/alarms.html +4 -4
  120. package/dist/ui/serverless/assets.html +2 -2
  121. package/dist/ui/serverless/cost.html +2 -2
  122. package/dist/ui/serverless/data.html +4 -4
  123. package/dist/ui/serverless/deployments.html +1 -1
  124. package/dist/ui/serverless/firewall.html +2 -2
  125. package/dist/ui/serverless/functions.html +4 -4
  126. package/dist/ui/serverless/logs.html +4 -4
  127. package/dist/ui/serverless/metrics.html +2 -2
  128. package/dist/ui/serverless/queues.html +4 -4
  129. package/dist/ui/serverless/secrets.html +4 -4
  130. package/dist/ui/serverless/traces.html +4 -4
  131. package/dist/ui/serverless.html +3 -3
  132. package/dist/ui-src/pages/operations/spend.stx +225 -0
  133. package/dist/ui-src/pages/partials/nav.stx +2 -0
  134. package/dist/ui-src/pages/server/firewall.stx +89 -0
  135. package/package.json +11 -3
@@ -0,0 +1,225 @@
1
+ <script client>
2
+ import { usePolling } from '../../functions/usePolling.ts'
3
+
4
+ const report=state(null),budgets=state([]),enforcement=state([]),anomalies=state([]),notice=state(''),actionError=state(''),tab=state('overview'),busy=state(false),period=state('monthly')
5
+
6
+ async function api(url,options){const response=await fetch(url,options),body=await response.json();if(!response.ok||body.ok===false)throw new Error(body.error||'Request failed.');return body}
7
+ async function optional(url,key,fallback=[]){try{return(await api(url))[key]??fallback}catch{return fallback}}
8
+
9
+ async function load(){
10
+ const [usage,budgetList,gate,anomalyList]=await Promise.all([
11
+ api(`/api/usage?period=${encodeURIComponent(period())}`).catch(()=>null),
12
+ optional('/api/spend/budgets','budgets'),
13
+ optional('/api/spend/enforcement','enforcement'),
14
+ optional('/api/spend/anomalies','anomalies'),
15
+ ])
16
+ report.set(usage);budgets.set(budgetList);enforcement.set(gate);anomalies.set(anomalyList)
17
+ }
18
+
19
+ const {pollingState,pollingError,lastUpdatedAt,refresh}=usePolling(load,30000)
20
+
21
+ function form(node){return Object.fromEntries(new FormData(node))}
22
+ function money(cents,currency){if(cents==null)return '—';return new Intl.NumberFormat('en-US',{style:'currency',currency:currency||report()?.currency||'USD'}).format(cents/100)}
23
+ function pct(value){return value==null?'—':`${Number(value).toFixed(value<10?1:0)}%`}
24
+ function scopeOf(item){return item.environmentId?'environment':item.projectId?'project':'organization'}
25
+ function tone(level){return level==='hard_capped'?'bad':level==='soft_capped'||level==='warning'?'warn':'ok'}
26
+ function severityTone(value){return value==='critical'?'bad':value==='warning'?'warn':'ok'}
27
+ function when(value){return value?new Date(value).toLocaleString():'—'}
28
+ /** Confidence below this is why a projection is shown greyed rather than acted on. */
29
+ function trusted(budget){return (budget?.projectionConfidence??0)>=0.35}
30
+
31
+ async function guard(work,message){
32
+ busy.set(true);actionError.set('')
33
+ try{await work();notice.set(message);await refresh()}
34
+ catch(error){actionError.set(error.message)}
35
+ finally{busy.set(false)}
36
+ }
37
+
38
+ async function createBudget(event){
39
+ event.preventDefault()
40
+ const value=form(event.currentTarget),node=event.currentTarget
41
+ await guard(()=>api('/api/spend/budgets',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({
42
+ name:value.name,
43
+ scope:value.scope,
44
+ period:value.period,
45
+ timezone:value.timezone||undefined,
46
+ softLimitCents:value.soft?Math.round(Number(value.soft)*100):undefined,
47
+ hardLimitCents:value.hard?Math.round(Number(value.hard)*100):undefined,
48
+ graceSeconds:value.graceSeconds?Number(value.graceSeconds):undefined,
49
+ dryRun:value.dryRun==='on',
50
+ })}).then(()=>node.reset()),'Budget created.')
51
+ }
52
+
53
+ async function patchBudget(id,patch){await guard(()=>api('/api/spend/budgets',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({id,...patch})}),'Budget updated.')}
54
+ async function removeBudget(id,name){if(!confirm(`Delete the budget "${name}"? Anything it is enforcing is lifted.`))return;await guard(()=>api('/api/spend/budgets',{method:'DELETE',headers:{'content-type':'application/json'},body:JSON.stringify({id})}),'Budget deleted and enforcement lifted.')}
55
+ async function runCheck(apply){const result=await api('/api/spend/check',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({apply})});notice.set(apply?`Applied ${result.result.applied.join(', ')||'nothing'}; released ${result.result.released.join(', ')||'nothing'}.`:'Preview complete — nothing was applied.');await refresh()}
56
+ async function release(budgetId,action){await guard(()=>api('/api/spend/release',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({budgetId,action})}),'Lifted. The next cycle re-applies it if spend is still over.')}
57
+ async function acknowledge(id){await guard(()=>api('/api/spend/anomalies/acknowledge',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id})}),'Anomaly acknowledged.')}
58
+ function setPeriod(event){period.set(event.currentTarget.value);void refresh()}
59
+ </script>
60
+ <!DOCTYPE html><html lang="en" data-theme="dark"><head>@include('../partials/head',{title:'ts-cloud · Spend & budgets'})</head><body>
61
+ @include('../partials/nav',{active:'spend',env:'production'})
62
+ <main class="wrap" id="main-content"><div class="crumbs">Operations <span class="sep">/</span> Spend &amp; budgets</div>
63
+
64
+ <header class="page">
65
+ <div><h1>Spend &amp; budgets</h1><p>Metered usage, soft and hard caps, enforcement in force, and anomaly detection. Costs are estimated from observed usage, so they update within the hour rather than waiting on a provider invoice.</p></div>
66
+ <div class="header-actions">
67
+ <select aria-label="Budget period" @change="setPeriod($event)"><option value="monthly">Monthly</option><option value="weekly">Weekly</option><option value="daily">Daily</option></select>
68
+ <span class="pill" :class="pollingState()==='live'?'ok':'warn'">{{ pollingState() }}</span>
69
+ <small>{{ lastUpdatedAt()?.toLocaleTimeString()||'connecting' }}</small>
70
+ <button class="btn ghost sm" @click="refresh()">Refresh</button>
71
+ </div>
72
+ </header>
73
+
74
+ <p class="notice" role="status" @show="!!notice()">{{ notice() }}</p>
75
+ <p class="bad notice" role="alert" @show="!!actionError()">{{ actionError() }}</p>
76
+ <p class="bad notice" role="alert" @show="!!pollingError()">{{ pollingError() }}</p>
77
+
78
+ <div class="notice panel warn" @show="enforcement().some(item=>!item.simulated)">
79
+ <strong>Spend enforcement is in force.</strong>
80
+ <span>{{ enforcement().filter(item=>!item.simulated).map(item=>item.action).join(', ') }} — see the Enforcement tab.</span>
81
+ </div>
82
+
83
+ <nav class="tabs" aria-label="Spend workspace">
84
+ <button :class="{active:tab()==='overview'}" @click="tab.set('overview')">Overview</button>
85
+ <button :class="{active:tab()==='budgets'}" @click="tab.set('budgets')">Budgets</button>
86
+ <button :class="{active:tab()==='enforcement'}" @click="tab.set('enforcement')">Enforcement</button>
87
+ <button :class="{active:tab()==='anomalies'}" @click="tab.set('anomalies')">Anomalies</button>
88
+ </nav>
89
+
90
+ <section @show="tab()==='overview'">
91
+ <div class="summary">
92
+ <article class="panel"><b>Spent this period</b><strong>{{ money(report()?.totalCents) }}</strong><small>{{ report()?.window?.label||'—' }}</small></article>
93
+ <article class="panel"><b>Budgets</b><strong>{{ report()?.budgets?.length||0 }}</strong><small>{{ (report()?.budgets||[]).filter(b=>b.dryRun).length }} in dry run</small></article>
94
+ <article class="panel"><b>Enforcement</b><strong>{{ report()?.enforcement?.strongestAction||'none' }}</strong><small>{{ enforcement().length }} action(s) recorded</small></article>
95
+ <article class="panel"><b>Anomalies</b><strong>{{ anomalies().filter(a=>!a.acknowledgedAt).length }}</strong><small>unacknowledged</small></article>
96
+ </div>
97
+
98
+ <h2>Budget headroom</h2>
99
+ <div class="cards-list">
100
+ <template :for="budget in (report()?.budgets||[])">
101
+ <article class="row-card panel">
102
+ <div>
103
+ <span class="pill" :class="tone(budget.level)">{{ budget.level }}</span>
104
+ <span class="pill" @show="budget.dryRun">dry run</span>
105
+ <b>{{ budget.name }}</b>
106
+ <code>{{ budget.period }}</code>
107
+ </div>
108
+ <p class="note">{{ money(budget.spentCents) }} of {{ money(budget.limitCents) }} · {{ pct(budget.usedPercent) }} used · {{ money(budget.remainingCents) }} remaining</p>
109
+ <progress :value="Math.min(100,budget.usedPercent||0)" max="100"></progress>
110
+ <p class="note" :class="trusted(budget)?'':'muted'">
111
+ Projected {{ money(budget.projectedCents) }} ({{ pct(budget.projectedPercent) }}) at {{ pct((budget.projectionConfidence||0)*100) }} confidence.
112
+ <template :if="!trusted(budget)"><span> Too early in the period to act on this forecast.</span></template>
113
+ <template :if="budget.timeToCap"><span> Reaches the cap in {{ budget.timeToCap }}.</span></template>
114
+ </p>
115
+ <div class="actions" @show="!!budget.activeActions?.length">
116
+ <span class="bad pill">enforcing {{ budget.activeActions.join(', ') }}</span>
117
+ </div>
118
+ </article>
119
+ </template>
120
+ <div class="empty panel" @show="!(report()?.budgets||[]).length">
121
+ <strong>No budgets</strong>
122
+ <span>Usage is metered regardless, but nothing is capped. Create a budget to get warnings and, if you want them, hard caps.</span>
123
+ </div>
124
+ </div>
125
+
126
+ <h2>Usage by meter</h2>
127
+ <div class="panel table-wrap">
128
+ <table>
129
+ <thead><tr><th scope="col">Meter</th><th scope="col">Provider</th><th scope="col">Quantity</th><th scope="col">Cost</th></tr></thead>
130
+ <tbody>
131
+ <template :for="row in (report()?.byMeter||[])">
132
+ <tr><td>{{ row.meter }}</td><td>{{ row.provider||'—' }}</td><td>{{ row.quantity.toLocaleString(undefined,{maximumFractionDigits:3}) }}</td><td>{{ money(row.costCents) }}</td></tr>
133
+ </template>
134
+ </tbody>
135
+ </table>
136
+ <div class="empty" @show="!(report()?.byMeter||[]).length"><strong>No metered usage yet</strong><span>Usage rolls up hourly. A freshly deployed project shows nothing until its first full hour.</span></div>
137
+ </div>
138
+ </section>
139
+
140
+ <section @show="tab()==='budgets'" class="split">
141
+ <div>
142
+ <template :for="item in budgets()">
143
+ <article class="row-card panel">
144
+ <div>
145
+ <span class="pill" :class="item.enabled?'ok':'warn'">{{ item.enabled?'enabled':'disabled' }}</span>
146
+ <span class="pill" @show="item.dryRun">dry run</span>
147
+ <b>{{ item.name }}</b>
148
+ <code>{{ scopeOf(item) }} · {{ item.period }} · {{ item.timezone }}</code>
149
+ </div>
150
+ <p class="note">Soft {{ money(item.softLimitCents,item.currency) }} · hard {{ money(item.hardLimitCents,item.currency) }} · grace {{ item.graceSeconds }}s · hysteresis {{ item.hysteresisPercent }}%</p>
151
+ <p class="note">Ladder: <template :for="threshold in item.thresholds"><span class="pill">{{ threshold.atPercent }}%{{ threshold.onProjection?' (forecast)':'' }} → {{ threshold.actions.join('+') }}</span></template></p>
152
+ <div class="actions">
153
+ <button class="btn ghost sm" :disabled="busy()" @click="patchBudget(item.id,{dryRun:!item.dryRun})">{{ item.dryRun?'Start enforcing':'Return to dry run' }}</button>
154
+ <button class="btn ghost sm" :disabled="busy()" @click="patchBudget(item.id,{enabled:!item.enabled})">{{ item.enabled?'Disable':'Enable' }}</button>
155
+ <button class="btn danger ghost sm" :disabled="busy()" @click="removeBudget(item.id,item.name)">Delete</button>
156
+ </div>
157
+ </article>
158
+ </template>
159
+ <div class="empty panel" @show="!budgets().length"><strong>No budgets configured</strong><span>Nothing is capping this project.</span></div>
160
+ </div>
161
+
162
+ <form class="editor panel" @submit="createBudget($event)">
163
+ <h2>New budget</h2>
164
+ <label>Name<input name="name" required placeholder="Production monthly"></label>
165
+ <label>Scope<select name="scope"><option value="project">This project</option><option value="environment">This environment</option><option value="organization">Whole organization</option></select></label>
166
+ <label>Period<select name="period"><option value="monthly">Monthly</option><option value="weekly">Weekly</option><option value="daily">Daily</option></select></label>
167
+ <label>Timezone<input name="timezone" value="UTC" placeholder="America/Los_Angeles"></label>
168
+ <label>Soft limit (warn only, dollars)<input name="soft" type="number" step="0.01" min="0"></label>
169
+ <label>Hard limit (enforces, dollars)<input name="hard" type="number" step="0.01" min="0"></label>
170
+ <label>Grace seconds<input name="graceSeconds" type="number" min="0" value="300"></label>
171
+ <label class="checkbox"><input name="dryRun" type="checkbox" checked> Dry run — evaluate and report, enforce nothing</label>
172
+ <button class="btn" type="submit" :disabled="busy()">Create budget</button>
173
+ <p class="note">New budgets start in dry run. Watch one for a full period before letting it enforce: the first thing enforcement does is stop your deployments.</p>
174
+ </form>
175
+ </section>
176
+
177
+ <section @show="tab()==='enforcement'">
178
+ <div class="header-actions">
179
+ <button class="btn ghost sm" :disabled="busy()" @click="runCheck(false)">Preview a cycle</button>
180
+ <button class="btn sm" :disabled="busy()" @click="runCheck(true)">Run and apply</button>
181
+ </div>
182
+ <div class="panel table-wrap">
183
+ <table>
184
+ <thead><tr><th scope="col">Action</th><th scope="col">Scope</th><th scope="col">Mode</th><th scope="col">Since</th><th scope="col">Reason</th><th scope="col"></th></tr></thead>
185
+ <tbody>
186
+ <template :for="item in enforcement()">
187
+ <tr>
188
+ <td><span class="pill" :class="item.simulated?'warn':'bad'">{{ item.action }}</span></td>
189
+ <td>{{ scopeOf(item) }}</td>
190
+ <td>{{ item.simulated?'dry run':'enforcing' }}</td>
191
+ <td>{{ when(item.appliedAt) }}</td>
192
+ <td>{{ item.reason }}</td>
193
+ <td><button class="btn ghost sm" :disabled="busy()" @click="release(item.budgetId,item.action)">Lift</button></td>
194
+ </tr>
195
+ </template>
196
+ </tbody>
197
+ </table>
198
+ <div class="empty" @show="!enforcement().length"><strong>Nothing is being enforced</strong><span>Builds, deployments, and traffic are unrestricted by spend.</span></div>
199
+ </div>
200
+ <p class="note">Lifting an action is a stopgap. The next spend cycle re-applies it unless the underlying spend falls back below the threshold, or the budget is raised, disabled, or deleted.</p>
201
+ </section>
202
+
203
+ <section @show="tab()==='anomalies'">
204
+ <div class="cards-list">
205
+ <template :for="item in anomalies()">
206
+ <article class="row-card panel">
207
+ <div>
208
+ <span class="pill" :class="severityTone(item.severity)">{{ item.severity }}</span>
209
+ <span class="pill">{{ item.direction }}</span>
210
+ <b>{{ item.signal }}</b>
211
+ <code>{{ when(item.bucketStart) }}</code>
212
+ </div>
213
+ <p class="note">Observed {{ money(item.observed) }} against an expected {{ money(item.expected) }} for this hour · score {{ item.score.toFixed(1) }}</p>
214
+ <p class="muted note">The baseline is the median of the same hour on previous days, so a normal daily peak is not an anomaly.</p>
215
+ <div class="actions">
216
+ <button class="btn ghost sm" @show="!item.acknowledgedAt" :disabled="busy()" @click="acknowledge(item.id)">Acknowledge</button>
217
+ <span class="note" @show="!!item.acknowledgedAt">Acknowledged {{ when(item.acknowledgedAt) }}</span>
218
+ </div>
219
+ </article>
220
+ </template>
221
+ <div class="empty panel" @show="!anomalies().length"><strong>No anomalies detected</strong><span>Detection needs several days of history before it reports anything, so a new project stays quiet at first.</span></div>
222
+ </div>
223
+ </section>
224
+
225
+ </main></body></html>
@@ -84,6 +84,7 @@ const isActive = (...keys) => keys.includes(active ?? '')
84
84
  @if (canView('runtime:read'))<a class="{{ isActive('workloads') ? 'active' : '' }}" href="{{ envHref('/operations/workloads') }}">Workloads</a>@endif
85
85
  @if (canView('runtime:logs'))<a class="{{ isActive('observability') ? 'active' : '' }}" href="{{ envHref('/operations/observability') }}">Observability</a>@endif
86
86
  @if (canView('runtime:read'))<a class="{{ isActive('alerts') ? 'active' : '' }}" href="{{ envHref('/operations/alerts') }}">Alerts & health</a>@endif
87
+ @if (canView('billing:read'))<a class="{{ isActive('spend') ? 'active' : '' }}" href="{{ envHref('/operations/spend') }}">Spend & budgets</a>@endif
87
88
  @if (canView('runtime:read'))<a href="{{ envHref('/server/metrics') }}">Metrics</a><a href="{{ envHref('/server/services') }}">Runtime services</a>@endif
88
89
  @if (canView('automation:read'))<a class="{{ isActive('jobs') ? 'active' : '' }}" href="{{ envHref('/operations/jobs') }}">Workers & schedules</a>@endif
89
90
  @if (canView('config:read'))<a class="{{ isActive('configuration') ? 'active' : '' }}" href="{{ envHref('/operations/configuration') }}">Environment &amp; secrets</a>@endif
@@ -158,6 +159,7 @@ const isActive = (...keys) => keys.includes(active ?? '')
158
159
  @if (canView('runtime:read'))<a class="{{ isActive('workloads') ? 'active' : '' }}" href="{{ envHref('/operations/workloads') }}">Workloads</a>@endif
159
160
  @if (canView('runtime:logs'))<a class="{{ isActive('observability') ? 'active' : '' }}" href="{{ envHref('/operations/observability') }}">Observability</a>@endif
160
161
  @if (canView('runtime:read'))<a class="{{ isActive('alerts') ? 'active' : '' }}" href="{{ envHref('/operations/alerts') }}">Alerts & health</a>@endif
162
+ @if (canView('billing:read'))<a class="{{ isActive('spend') ? 'active' : '' }}" href="{{ envHref('/operations/spend') }}">Spend & budgets</a>@endif
161
163
  @if (canView('automation:read'))<a class="{{ isActive('jobs') ? 'active' : '' }}" href="{{ envHref('/operations/jobs') }}">Workers & schedules</a>@endif
162
164
  </section>
163
165
  @endif
@@ -18,13 +18,65 @@ const newPort = state('')
18
18
  const message = state('')
19
19
  const messageKind = state('')
20
20
  const reserved = [22, 80, 443]
21
+ const posture = state(null)
22
+ const ipRules = state({ allow: [], block: [] })
23
+ const busy = state(false)
21
24
  function setMessage(text, kind) { message.set(text); messageKind.set(kind || '') }
22
25
  function applyLine(apply) {
23
26
  if (!apply) return ''
24
27
  if (apply.ok === false) return ' Live UFW apply failed: ' + (apply.error || 'unknown error') + '.'
25
28
  return ' Applied to UFW live on the box.'
26
29
  }
30
+ async function loadProtection() {
31
+ try {
32
+ const data = await requestJson('/api/protection')
33
+ posture.set(data.posture || null)
34
+ ipRules.set(data.controls?.ipRules || { allow: [], block: [] })
35
+ } catch { posture.set(null) }
36
+ }
37
+ async function protect(path, body, note) {
38
+ busy.set(true)
39
+ try {
40
+ await requestJson(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
41
+ setMessage(note, 'ok')
42
+ await loadProtection()
43
+ } catch (e) { setMessage((e && e.message) || 'Request failed.', 'bad') }
44
+ finally { busy.set(false) }
45
+ }
46
+ async function toggleAttackMode(on) {
47
+ if (on && !confirm('Attack mode challenges every visitor, including legitimate ones. Continue?')) return
48
+ await protect('/api/protection/attack-mode', on ? { enabled: true, reason: 'Enabled from the dashboard.' } : { enabled: false }, on ? 'Attack mode is on. It lifts itself automatically.' : 'Attack mode is off.')
49
+ }
50
+ async function togglePause(on) {
51
+ if (on) {
52
+ const reason = prompt('Pausing mitigation serves and bills all traffic, including attacks. Why are you pausing it?')
53
+ if (!reason) return
54
+ await protect('/api/protection/pause', { enabled: true, reason }, 'Mitigation paused. It resumes automatically.')
55
+ return
56
+ }
57
+ await protect('/api/protection/pause', { enabled: false }, 'Automatic mitigation resumed.')
58
+ }
59
+ async function addIpRule(list, cidr) {
60
+ if (!cidr) return
61
+ await protect('/api/protection/ip-rules', { list, cidr }, `Added ${cidr} to the ${list} list.`)
62
+ }
63
+ async function removeIpRule(list, cidr) {
64
+ busy.set(true)
65
+ try {
66
+ await requestJson('/api/protection/ip-rules', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ list, cidr }) })
67
+ setMessage(`Removed ${cidr} from the ${list} list.`, 'ok')
68
+ await loadProtection()
69
+ } catch (e) { setMessage((e && e.message) || 'Request failed.', 'bad') }
70
+ finally { busy.set(false) }
71
+ }
72
+ function submitIpRule(event) {
73
+ event.preventDefault()
74
+ const value = Object.fromEntries(new FormData(event.currentTarget))
75
+ void addIpRule(value.list, String(value.cidr || '').trim()).then(() => event.currentTarget.reset())
76
+ }
77
+
27
78
  async function load() {
79
+ void loadProtection()
28
80
  try {
29
81
  const res = await fetch('/api/firewall')
30
82
  if (!res.ok) throw new Error('Could not read firewall config.')
@@ -75,6 +127,43 @@ onMount(() => { load() })
75
127
  <div class="cmds header-actions"><span class="accent cmd">cloud server:secure {{ server.name }}</span></div>
76
128
  </header>
77
129
 
130
+ <div class="section">
131
+ <h2>Mitigation posture</h2>
132
+ <div class="panel">
133
+ <p class="notice" :class="posture()?.attackMode || posture()?.mitigationPaused ? 'warn' : ''" @show="!!posture()">{{ posture()?.summary }}</p>
134
+ <p class="note" @show="!posture()">Protection controls are unavailable for this account.</p>
135
+ <div class="actions" @show="!!posture()">
136
+ <button class="btn ghost sm" :disabled="busy()" @click="toggleAttackMode(!posture()?.attackMode)">{{ posture()?.attackMode ? 'Turn attack mode off' : 'Turn attack mode on' }}</button>
137
+ <button class="btn danger ghost sm" :disabled="busy()" @click="togglePause(!posture()?.mitigationPaused)">{{ posture()?.mitigationPaused ? 'Resume mitigation' : 'Pause mitigation' }}</button>
138
+ </div>
139
+ <p class="note">Attack mode challenges every visitor, so it is time-boxed and lifts itself. Pausing mitigation serves and bills all traffic, including attacks, and is capped at 24 hours.</p>
140
+ </div>
141
+ </div>
142
+
143
+ <div class="section">
144
+ <h2>IP rules</h2>
145
+ <div class="panel">
146
+ <form class="inline-form" @submit="submitIpRule($event)">
147
+ <select name="list" aria-label="List"><option value="block">Block</option><option value="allow">Allow</option></select>
148
+ <input name="cidr" placeholder="203.0.113.0/24" aria-label="IP or CIDR" required>
149
+ <button class="btn sm" type="submit" :disabled="busy()">Add</button>
150
+ </form>
151
+ <table>
152
+ <thead><tr><th scope="col">List</th><th scope="col">CIDR</th><th scope="col"></th></tr></thead>
153
+ <tbody>
154
+ <template :for="cidr in ipRules().allow">
155
+ <tr><td><span class="ok pill">allow</span></td><td class="mono">{{ cidr }}</td><td><button class="btn ghost sm" :disabled="busy()" @click="removeIpRule('allow', cidr)">Remove</button></td></tr>
156
+ </template>
157
+ <template :for="cidr in ipRules().block">
158
+ <tr><td><span class="bad pill">block</span></td><td class="mono">{{ cidr }}</td><td><button class="btn ghost sm" :disabled="busy()" @click="removeIpRule('block', cidr)">Remove</button></td></tr>
159
+ </template>
160
+ </tbody>
161
+ </table>
162
+ <div class="empty" @show="!ipRules().allow.length && !ipRules().block.length"><strong>No IP rules</strong><span>Allowed ranges bypass every limit; blocked ranges are dropped at the kernel.</span></div>
163
+ <p class="note">Allow wins over block, so a range cannot sit on both lists. Rules apply on the next deploy.</p>
164
+ </div>
165
+ </div>
166
+
78
167
  <div class="section">
79
168
  <h2>Always open</h2>
80
169
  <div class="panel">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ts-cloud",
3
3
  "type": "module",
4
- "version": "0.7.108",
4
+ "version": "0.7.110",
5
5
  "description": "A lightweight, performant infrastructure-as-code library and CLI for deploying both server-based (EC2) and serverless applications.",
6
6
  "author": "Chris Breuer <chris@stacksjs.com>",
7
7
  "license": "MIT",
@@ -50,6 +50,14 @@
50
50
  "types": "./dist/push/index.d.ts",
51
51
  "import": "./dist/push/index.js"
52
52
  },
53
+ "./spend": {
54
+ "types": "./dist/spend/index.d.ts",
55
+ "import": "./dist/spend/index.js"
56
+ },
57
+ "./protection": {
58
+ "types": "./dist/protection/index.d.ts",
59
+ "import": "./dist/protection/index.js"
60
+ },
53
61
  "./*": {
54
62
  "types": "./dist/*.d.ts",
55
63
  "import": "./dist/*"
@@ -90,8 +98,8 @@
90
98
  },
91
99
  "dependencies": {
92
100
  "@stacksjs/ts-xml": "^0.1.0",
93
- "@ts-cloud/aws-types": "0.7.108",
94
- "@ts-cloud/core": "0.7.108"
101
+ "@ts-cloud/aws-types": "0.7.110",
102
+ "@ts-cloud/core": "0.7.110"
95
103
  },
96
104
  "devDependencies": {
97
105
  "better-dx": "^0.2.16",