@stacksjs/ts-cloud 0.7.105 → 0.7.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/alerts/delivery.d.ts +10 -0
- package/dist/alerts/model.d.ts +1 -1
- package/dist/api/client.d.ts +53 -0
- package/dist/api/handler.d.ts +10 -0
- package/dist/api/service.d.ts +1 -0
- package/dist/aws/index.js +10 -10
- package/dist/aws/lambda.d.ts +19 -0
- package/dist/bin/cli.js +1677 -1370
- package/dist/bin/dashboard-server.js +926 -620
- package/dist/{chunk-fmt40tpw.js → chunk-53jatzaw.js} +2 -2
- package/dist/{chunk-ef2dwfrg.js → chunk-8kh577qh.js} +5 -5
- package/dist/{chunk-s9c2x5jv.js → chunk-9v2pwxv9.js} +2 -2
- package/dist/chunk-aa2dkn7b.js +205 -0
- package/dist/{chunk-vkd2y0fs.js → chunk-cktwk9hy.js} +7169 -14800
- package/dist/{chunk-dmzez5f8.js → chunk-d3vx2ekv.js} +123 -28
- package/dist/chunk-grx1rp09.js +12163 -0
- package/dist/{chunk-c6db5m63.js → chunk-t2s3gdq6.js} +253 -8
- package/dist/chunk-va2yd85b.js +200 -0
- package/dist/{chunk-3g4r09dd.js → chunk-wmbep3b5.js} +1 -1
- package/dist/{chunk-ybcz6sxc.js → chunk-x6yzgv7y.js} +33 -0
- package/dist/chunk-xrtwv3v2.js +420 -0
- package/dist/control-plane/types.d.ts +1 -1
- package/dist/deploy/dashboard-bundle-encoding.test.d.ts +1 -0
- package/dist/deploy/dashboard-payload.d.ts +20 -0
- package/dist/deploy/dashboard-payload.test.d.ts +1 -0
- package/dist/deploy/dashboard-protection.test.d.ts +1 -0
- package/dist/deploy/dashboard-spend.test.d.ts +1 -0
- package/dist/deploy/dashboard-topology.d.ts +85 -0
- package/dist/deploy/dashboard-topology.test.d.ts +1 -0
- package/dist/deploy/egress-collection.d.ts +60 -0
- package/dist/deploy/index.d.ts +2 -0
- package/dist/deploy/index.js +23 -8
- package/dist/deploy/local-dashboard-server.d.ts +2 -0
- package/dist/drivers/index.js +4 -2
- package/dist/drivers/shared/protection.d.ts +43 -0
- package/dist/drivers/shared/protection.test.d.ts +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +364 -102
- package/dist/protection/controls.d.ts +113 -0
- package/dist/protection/controls.test.d.ts +1 -0
- package/dist/protection/ddos.d.ts +130 -0
- package/dist/protection/ddos.test.d.ts +1 -0
- package/dist/protection/index.d.ts +11 -0
- package/dist/protection/index.js +87 -0
- package/dist/protection/ratelimit.d.ts +154 -0
- package/dist/protection/ratelimit.test.d.ts +1 -0
- package/dist/protection/waf.d.ts +143 -0
- package/dist/protection/waf.test.d.ts +1 -0
- package/dist/spend/anomaly-config.d.ts +130 -0
- package/dist/spend/anomaly-config.test.d.ts +1 -0
- package/dist/spend/anomaly.d.ts +114 -0
- package/dist/spend/anomaly.test.d.ts +1 -0
- package/dist/spend/api.d.ts +42 -0
- package/dist/spend/api.test.d.ts +1 -0
- package/dist/spend/appliers.d.ts +126 -0
- package/dist/spend/docs.test.d.ts +1 -0
- package/dist/spend/enforcement.d.ts +111 -0
- package/dist/spend/enforcement.integration.test.d.ts +1 -0
- package/dist/spend/evaluator.d.ts +70 -0
- package/dist/spend/evaluator.test.d.ts +1 -0
- package/dist/spend/focus.d.ts +93 -0
- package/dist/spend/focus.test.d.ts +1 -0
- package/dist/spend/gate.d.ts +96 -0
- package/dist/spend/index.d.ts +20 -0
- package/dist/spend/index.js +173 -0
- package/dist/spend/lease.d.ts +54 -0
- package/dist/spend/lease.test.d.ts +1 -0
- package/dist/spend/meter.d.ts +70 -0
- package/dist/spend/meter.test.d.ts +1 -0
- package/dist/spend/model.d.ts +228 -0
- package/dist/spend/notifications.d.ts +58 -0
- package/dist/spend/pricing.d.ts +109 -0
- package/dist/spend/pricing.test.d.ts +1 -0
- package/dist/spend/projection.d.ts +36 -0
- package/dist/spend/runner.d.ts +130 -0
- package/dist/spend/service.d.ts +129 -0
- package/dist/spend/signals.d.ts +110 -0
- package/dist/spend/signals.test.d.ts +1 -0
- package/dist/spend/sms-sdk.test.d.ts +1 -0
- package/dist/spend/spend.integration.test.d.ts +1 -0
- package/dist/spend/store.d.ts +166 -0
- package/dist/spend/transports.d.ts +169 -0
- package/dist/spend/transports.test.d.ts +1 -0
- package/dist/spend/window.d.ts +52 -0
- package/dist/spend/window.test.d.ts +1 -0
- package/dist/ui/404.html +2 -0
- package/dist/ui/access-denied.html +2 -2
- package/dist/ui/account/automation.html +4 -4
- package/dist/ui/account/security.html +2 -2
- package/dist/ui/applications/compose.html +4 -4
- package/dist/ui/applications/new.html +2 -2
- package/dist/ui/data/backups.html +22 -9
- package/dist/ui/data/services.html +22 -9
- package/dist/ui/data/volumes.html +22 -9
- package/dist/ui/index.html +22 -9
- package/dist/ui/infrastructure/topology.html +2223 -0
- package/dist/ui/integrations.html +2 -2
- package/dist/ui/operations/alerts.html +22 -9
- package/dist/ui/operations/configuration.html +4 -4
- package/dist/ui/operations/jobs.html +22 -9
- package/dist/ui/operations/maintenance.html +4 -4
- package/dist/ui/operations/observability.html +22 -9
- package/dist/ui/operations/previews.html +4 -4
- package/dist/ui/operations/queue.html +4 -4
- package/dist/ui/operations/regions.html +3 -3
- package/dist/ui/operations/releases.html +3 -3
- package/dist/ui/operations/spend.html +1634 -0
- package/dist/ui/operations/workloads.html +22 -9
- package/dist/ui/security.html +2 -2
- package/dist/ui/server/actions.html +4 -4
- package/dist/ui/server/activity.html +2 -2
- package/dist/ui/server/capacity.html +4 -4
- package/dist/ui/server/database.html +4 -4
- package/dist/ui/server/deployments.html +4 -4
- package/dist/ui/server/diagnostics.html +2 -2
- package/dist/ui/server/firewall.html +100 -161
- package/dist/ui/server/fleet.html +22 -9
- package/dist/ui/server/logs.html +22 -9
- package/dist/ui/server/metrics.html +130 -14
- package/dist/ui/server/security.html +2 -2
- package/dist/ui/server/services.html +1 -1
- package/dist/ui/server/sites.html +22 -9
- package/dist/ui/server/ssh-keys.html +3 -3
- package/dist/ui/server/team.html +4 -4
- package/dist/ui/server/terminal.html +2 -2
- package/dist/ui/serverless/alarms.html +4 -4
- package/dist/ui/serverless/assets.html +2 -2
- package/dist/ui/serverless/cost.html +2 -2
- package/dist/ui/serverless/data.html +4 -4
- package/dist/ui/serverless/deployments.html +2 -2
- package/dist/ui/serverless/firewall.html +2 -2
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +22 -9
- package/dist/ui/serverless/metrics.html +2 -2
- package/dist/ui/serverless/queues.html +4 -4
- package/dist/ui/serverless/secrets.html +4 -4
- package/dist/ui/serverless/traces.html +4 -4
- package/dist/ui/serverless.html +4 -4
- package/dist/ui-src/functions/requestJson.ts +41 -0
- package/dist/ui-src/functions/useOpConfirm.ts +132 -0
- package/dist/ui-src/functions/usePolling.ts +98 -0
- package/dist/ui-src/functions/useTerminal.ts +79 -0
- package/dist/ui-src/pages/infrastructure/topology.stx +517 -0
- package/dist/ui-src/pages/operations/spend.stx +225 -0
- package/dist/ui-src/pages/partials/nav.stx +4 -0
- package/dist/ui-src/pages/server/firewall.stx +89 -0
- package/dist/ui-src/pages/server/metrics.stx +140 -6
- package/dist/ui-src/src/topology.ts +485 -0
- 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 & budgets</div>
|
|
63
|
+
|
|
64
|
+
<header class="page">
|
|
65
|
+
<div><h1>Spend & 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 & secrets</a>@endif
|
|
@@ -95,6 +96,7 @@ const isActive = (...keys) => keys.includes(active ?? '')
|
|
|
95
96
|
@if (canView('data:read') || canView('fleet:read') || canView('security:read') || canView('runtime:terminal'))
|
|
96
97
|
<section class="nav-group">
|
|
97
98
|
<h2>Infrastructure</h2>
|
|
99
|
+
@if (canView('fleet:read'))<a class="{{ isActive('topology') ? 'active' : '' }}" href="{{ envHref('/infrastructure/topology') }}">Infrastructure map</a>@endif
|
|
98
100
|
@if (canView('data:read'))<a class="{{ isActive('data-services') ? 'active' : '' }}" href="{{ envHref('/data/services') }}">Data services</a>@endif
|
|
99
101
|
@if (canView('data:read'))<a class="{{ isActive('volumes') ? 'active' : '' }}" href="{{ envHref('/data/volumes') }}">Persistent volumes</a>@endif
|
|
100
102
|
@if (canView('fleet:read'))<a href="{{ envHref('/server/firewall') }}">Network & firewall</a><a href="{{ envHref('/server/ssh-keys') }}">SSH keys</a>@endif
|
|
@@ -125,6 +127,7 @@ const isActive = (...keys) => keys.includes(active ?? '')
|
|
|
125
127
|
@if (canView('secrets:read') || canView('fleet:read') || canView('runtime:read'))
|
|
126
128
|
<section class="nav-group">
|
|
127
129
|
<h2>Settings</h2>
|
|
130
|
+
@if (canView('fleet:read'))<a class="{{ isActive('topology') ? 'active' : '' }}" href="{{ envHref('/infrastructure/topology') }}">Infrastructure map</a>@endif
|
|
128
131
|
@if (canView('config:read'))<a class="{{ isActive('configuration') ? 'active' : '' }}" href="{{ envHref('/operations/configuration') }}">Environment & secrets</a>@endif
|
|
129
132
|
@if (canView('fleet:read'))<a href="{{ envHref('/serverless/firewall') }}">Network</a>@endif
|
|
130
133
|
@if (canView('runtime:read'))<a href="{{ envHref('/serverless/alarms') }}">Alerts</a>@endif
|
|
@@ -156,6 +159,7 @@ const isActive = (...keys) => keys.includes(active ?? '')
|
|
|
156
159
|
@if (canView('runtime:read'))<a class="{{ isActive('workloads') ? 'active' : '' }}" href="{{ envHref('/operations/workloads') }}">Workloads</a>@endif
|
|
157
160
|
@if (canView('runtime:logs'))<a class="{{ isActive('observability') ? 'active' : '' }}" href="{{ envHref('/operations/observability') }}">Observability</a>@endif
|
|
158
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
|
|
159
163
|
@if (canView('automation:read'))<a class="{{ isActive('jobs') ? 'active' : '' }}" href="{{ envHref('/operations/jobs') }}">Workers & schedules</a>@endif
|
|
160
164
|
</section>
|
|
161
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">
|
|
@@ -25,6 +25,27 @@ const bytes = value => {
|
|
|
25
25
|
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
|
|
26
26
|
return Math.round(n / 1024) + ' KB'
|
|
27
27
|
}
|
|
28
|
+
const rate = value => {
|
|
29
|
+
const n = Number(value || 0)
|
|
30
|
+
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(2) + ' GB/s'
|
|
31
|
+
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB/s'
|
|
32
|
+
if (n >= 1024) return Math.round(n / 1024) + ' KB/s'
|
|
33
|
+
return Math.round(n) + ' B/s'
|
|
34
|
+
}
|
|
35
|
+
const monthBytes = Number(m.networkRxBytesMonth || 0) + Number(m.networkTxBytesMonth || 0)
|
|
36
|
+
// Providers quote allowances in decimal TB and bill overage the same way, so the
|
|
37
|
+
// projection has to use the same unit the invoice will.
|
|
38
|
+
const bandwidthBudget = Number(__d?.bandwidthBudgetBytes || 0)
|
|
39
|
+
const bandwidthNote = (() => {
|
|
40
|
+
const now = new Date()
|
|
41
|
+
const dayOfMonth = now.getUTCDate()
|
|
42
|
+
const daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate()
|
|
43
|
+
const projected = dayOfMonth > 0 ? monthBytes / dayOfMonth * daysInMonth : monthBytes
|
|
44
|
+
const tb = value => (value / 1000 ** 4).toFixed(2) + ' TB'
|
|
45
|
+
if (!bandwidthBudget) return `On track for ${tb(projected)} by month end`
|
|
46
|
+
const pct = Math.round(monthBytes / bandwidthBudget * 100)
|
|
47
|
+
return `${pct}% of ${tb(bandwidthBudget)} — on track for ${tb(projected)}`
|
|
48
|
+
})()
|
|
28
49
|
const memUsedGb = gb(m.memUsedMb)
|
|
29
50
|
const memTotalGb = gb(m.memTotalMb)
|
|
30
51
|
const memAvailableGb = gb(m.memAvailableMb || Math.max(0, m.memTotalMb - m.memUsedMb))
|
|
@@ -75,6 +96,10 @@ function historyParams() {
|
|
|
75
96
|
'host.memory.used_percent', 'host.memory.used', 'host.memory.total',
|
|
76
97
|
'host.disk.used_percent', 'host.swap.used_percent',
|
|
77
98
|
'host.disk.inode_used_percent',
|
|
99
|
+
'host.network.rx_rate', 'host.network.tx_rate',
|
|
100
|
+
'storage.egress.day', 'storage.egress.month',
|
|
101
|
+
'storage.egress.budget', 'storage.egress.budget_used_percent',
|
|
102
|
+
'storage.egress.projected_month',
|
|
78
103
|
].join(','),
|
|
79
104
|
aggregation: 'avg',
|
|
80
105
|
bucketMs: String(bucketMs),
|
|
@@ -117,6 +142,27 @@ function liveBytes(value) {
|
|
|
117
142
|
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'
|
|
118
143
|
return Math.round(n / 1024) + ' KB'
|
|
119
144
|
}
|
|
145
|
+
function liveRate(value) {
|
|
146
|
+
const n = Number(value || 0)
|
|
147
|
+
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(2) + ' GB/s'
|
|
148
|
+
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB/s'
|
|
149
|
+
if (n >= 1024) return Math.round(n / 1024) + ' KB/s'
|
|
150
|
+
return Math.round(n) + ' B/s'
|
|
151
|
+
}
|
|
152
|
+
function liveMonthBytes() {
|
|
153
|
+
return Number(liveMetric().networkRxBytesMonth || 0) + Number(liveMetric().networkTxBytesMonth || 0)
|
|
154
|
+
}
|
|
155
|
+
function liveBandwidthNote() {
|
|
156
|
+
const used = liveMonthBytes()
|
|
157
|
+
const budget = Number(currentData()?.bandwidthBudgetBytes || 0)
|
|
158
|
+
const now = new Date()
|
|
159
|
+
const dayOfMonth = now.getUTCDate()
|
|
160
|
+
const daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate()
|
|
161
|
+
const projected = dayOfMonth > 0 ? used / dayOfMonth * daysInMonth : used
|
|
162
|
+
const tb = value => (value / 1000 ** 4).toFixed(2) + ' TB'
|
|
163
|
+
if (!budget) return `On track for ${tb(projected)} by month end`
|
|
164
|
+
return `${Math.round(used / budget * 100)}% of ${tb(budget)} — on track for ${tb(projected)}`
|
|
165
|
+
}
|
|
120
166
|
const liveCards = derived(() => [
|
|
121
167
|
{ label: 'CPU utilization', pct: liveCpu(), detail: `${liveMetric().load} load across ${liveMetric().cpus} vCPU` },
|
|
122
168
|
{ label: 'Memory', pct: liveMemory(), detail: `${liveGb(liveMetric().memUsedMb)} / ${liveGb(liveMetric().memTotalMb)} GB` },
|
|
@@ -133,7 +179,50 @@ function ratioPoints(primary, numerator, denominator, multiplier = 100) {
|
|
|
133
179
|
.filter(point => Number(den.get(point.timestamp)) > 0)
|
|
134
180
|
.map(point => ({ ...point, value: Math.max(0, Math.min(100, Number(point.value) / Number(den.get(point.timestamp)) * multiplier)) }))
|
|
135
181
|
}
|
|
182
|
+
const RATE_METRICS = { 'network-in': 'host.network.rx_rate', 'network-out': 'host.network.tx_rate' }
|
|
183
|
+
const BYTE_METRICS = { 'egress-daily': 'storage.egress.day' }
|
|
184
|
+
function isByteMetric(name) { return Object.hasOwn(BYTE_METRICS, name) }
|
|
185
|
+
function bytesLabel(value) {
|
|
186
|
+
const n = Number(value || 0)
|
|
187
|
+
if (n >= 1000 ** 4) return (n / 1000 ** 4).toFixed(2) + ' TB'
|
|
188
|
+
if (n >= 1000 ** 3) return (n / 1000 ** 3).toFixed(1) + ' GB'
|
|
189
|
+
if (n >= 1000 ** 2) return (n / 1000 ** 2).toFixed(1) + ' MB'
|
|
190
|
+
return Math.round(n / 1000) + ' KB'
|
|
191
|
+
}
|
|
192
|
+
function latestValue(name) {
|
|
193
|
+
const values = points(name)
|
|
194
|
+
return values.length ? Number(values.at(-1).value) : null
|
|
195
|
+
}
|
|
196
|
+
function egressBudget() { return latestValue('storage.egress.budget') }
|
|
197
|
+
function egressMonth() { return latestValue('storage.egress.month') }
|
|
198
|
+
function egressProjected() { return latestValue('storage.egress.projected_month') }
|
|
199
|
+
function egressPercent() { return latestValue('storage.egress.budget_used_percent') }
|
|
200
|
+
function hasEgress() { return points('storage.egress.day').length > 0 || egressMonth() != null }
|
|
201
|
+
function egressTone() {
|
|
202
|
+
const pct = egressPercent()
|
|
203
|
+
if (pct == null) return 'ok'
|
|
204
|
+
return pct >= 90 ? 'bad' : pct >= 75 ? 'warn' : 'ok'
|
|
205
|
+
}
|
|
206
|
+
function egressSummary() {
|
|
207
|
+
const month = egressMonth()
|
|
208
|
+
if (month == null) return 'No egress reported yet.'
|
|
209
|
+
const budget = egressBudget()
|
|
210
|
+
const projected = egressProjected()
|
|
211
|
+
const projectedNote = projected == null ? '' : ` — on track for ${bytesLabel(projected)}`
|
|
212
|
+
if (!budget) return `${bytesLabel(month)} this month${projectedNote}`
|
|
213
|
+
return `${bytesLabel(month)} of ${bytesLabel(budget)} (${egressPercent() ?? 0}%)${projectedNote}`
|
|
214
|
+
}
|
|
215
|
+
// A projection above the allowance is the whole point of the panel: it is the
|
|
216
|
+
// window in which the overrun is still avoidable.
|
|
217
|
+
function egressWillExceed() {
|
|
218
|
+
const budget = egressBudget()
|
|
219
|
+
const projected = egressProjected()
|
|
220
|
+
return !!budget && projected != null && projected > budget
|
|
221
|
+
}
|
|
222
|
+
function isRateMetric(name) { return Object.hasOwn(RATE_METRICS, name) }
|
|
136
223
|
function metricPoints(name) {
|
|
224
|
+
if (isRateMetric(name)) return points(RATE_METRICS[name])
|
|
225
|
+
if (isByteMetric(name)) return points(BYTE_METRICS[name])
|
|
137
226
|
if (name === 'cpu') return ratioPoints('host.cpu.used_percent', 'host.load', 'host.cpu.capacity')
|
|
138
227
|
if (name === 'memory') return ratioPoints('host.memory.used_percent', 'host.memory.used', 'host.memory.total')
|
|
139
228
|
if (name === 'disk') return points('host.disk.used_percent')
|
|
@@ -144,7 +233,9 @@ function metricPoints(name) {
|
|
|
144
233
|
function linePath(metric) {
|
|
145
234
|
const values = metricPoints(metric)
|
|
146
235
|
if (!values.length) return ''
|
|
147
|
-
const max = metric === 'load'
|
|
236
|
+
const max = metric === 'load' || isRateMetric(metric) || isByteMetric(metric)
|
|
237
|
+
? Math.max(1, ...values.map(point => Number(point.value)))
|
|
238
|
+
: 100
|
|
148
239
|
return values.map((point, index) => {
|
|
149
240
|
const x = values.length === 1 ? 50 : index / (values.length - 1) * 100
|
|
150
241
|
const y = 42 - Math.max(0, Math.min(1, Number(point.value) / max)) * 38
|
|
@@ -163,7 +254,12 @@ function peak(metric) {
|
|
|
163
254
|
const values = metricPoints(metric)
|
|
164
255
|
return values.length ? Math.max(...values.map(point => Number(point.value))) : null
|
|
165
256
|
}
|
|
166
|
-
function display(value, suffix = '%') {
|
|
257
|
+
function display(value, suffix = '%') {
|
|
258
|
+
if (value == null) return 'No data'
|
|
259
|
+
if (suffix === 'rate') return liveRate(value)
|
|
260
|
+
if (suffix === 'bytes') return bytesLabel(value)
|
|
261
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)}${suffix}`
|
|
262
|
+
}
|
|
167
263
|
function samples(metric) { return metricPoints(metric).length }
|
|
168
264
|
const historyCards = [
|
|
169
265
|
{ key: 'cpu', label: 'CPU utilization', note: 'Actual busy time, with load-per-core fallback for older samples', tone: 'blue' },
|
|
@@ -172,6 +268,8 @@ const historyCards = [
|
|
|
172
268
|
{ key: 'swap', label: 'Swap usage', note: 'Swap pressure across the selected period', tone: 'violet' },
|
|
173
269
|
{ key: 'load', label: 'Load average', note: 'One-minute runnable and waiting task average', tone: 'cyan', suffix: '' },
|
|
174
270
|
{ key: 'inodes', label: 'Inode usage', note: 'Root filesystem inode capacity consumed', tone: 'rose' },
|
|
271
|
+
{ key: 'network-out', label: 'Network out', note: 'Egress throughput — the metric providers meter and bill', tone: 'amber', suffix: 'rate' },
|
|
272
|
+
{ key: 'network-in', label: 'Network in', note: 'Ingress throughput across all non-loopback interfaces', tone: 'blue', suffix: 'rate' },
|
|
175
273
|
]
|
|
176
274
|
function setRange(value) { historyRange.set(value); refresh() }
|
|
177
275
|
</script>
|
|
@@ -214,8 +312,9 @@ function setRange(value) { historyRange.set(value); refresh() }
|
|
|
214
312
|
<div class="vital"><div class="vlabel">Memory available</div><div class="vval">{{ liveGb(liveMetric().memAvailableMb) }} <small>GB</small></div><div class="vnote">{{ liveGb(liveMetric().memCacheMb) }} GB cache</div></div>
|
|
215
313
|
<div class="vital"><div class="vlabel">Disk inodes</div><div class="vval">{{ liveMetric().inodeUsedPct }}<small>% used</small></div><div class="vnote">Filesystem entry capacity</div></div>
|
|
216
314
|
<div class="vital"><div class="vlabel">Processes</div><div class="vval">{{ liveMetric().processes }}</div><div class="vnote">Current process count</div></div>
|
|
217
|
-
<div class="vital"><div class="vlabel">Network
|
|
218
|
-
<div class="vital"><div class="vlabel">Network
|
|
315
|
+
<div class="vital"><div class="vlabel">Network in</div><div class="vval">{{ liveRate(liveMetric().networkRxBytesPerSec) }}</div><div class="vnote">{{ liveBytes(liveMetric().networkRxBytesToday) }} today</div></div>
|
|
316
|
+
<div class="vital"><div class="vlabel">Network out</div><div class="vval">{{ liveRate(liveMetric().networkTxBytesPerSec) }}</div><div class="vnote">{{ liveBytes(liveMetric().networkTxBytesToday) }} today</div></div>
|
|
317
|
+
<div class="vital"><div class="vlabel">Bandwidth this month</div><div class="vval">{{ liveBytes(liveMonthBytes()) }}</div><div class="vnote">{{ liveBandwidthNote() }}</div></div>
|
|
219
318
|
<div class="vital"><div class="vlabel">Disk free</div><div class="vval">{{ Math.max(0, Number(liveMetric().diskTotalGb) - Number(liveMetric().diskUsedGb)) }} <small>GB</small></div><div class="vnote">{{ liveMetric().diskTotalGb }} GB total</div></div>
|
|
220
319
|
<div class="vital"><div class="vlabel">Swap</div><div class="vval">{{ liveSwap() }}<small>% used</small></div><div class="vnote">{{ liveGb(liveMetric().swapUsedMb) }} / {{ liveGb(liveMetric().swapTotalMb) }} GB</div></div>
|
|
221
320
|
</div>
|
|
@@ -257,8 +356,9 @@ function setRange(value) { historyRange.set(value); refresh() }
|
|
|
257
356
|
<div class="vital"><div class="vlabel">Memory available</div><div class="vval">{{ live ? memAvailableGb : 'Unavailable' }} @if (live)<small>GB</small>@endif</div><div class="vnote">{{ live ? `${gb(m.memCacheMb)} GB cache` : unavailableNote }}</div></div>
|
|
258
357
|
<div class="vital"><div class="vlabel">Swap</div><div class="vval">{{ live ? swapPct : 'Unavailable' }}@if (live)<small>% used</small>@endif</div><div class="vnote">{{ live ? `${gb(m.swapUsedMb)} / ${gb(m.swapTotalMb)} GB` : unavailableNote }}</div></div>
|
|
259
358
|
<div class="vital"><div class="vlabel">Disk inodes</div><div class="vval">{{ live ? m.inodeUsedPct : 'Unavailable' }}@if (live)<small>% used</small>@endif</div><div class="vnote">Filesystem entry capacity</div></div>
|
|
260
|
-
<div class="vital"><div class="vlabel">Network
|
|
261
|
-
<div class="vital"><div class="vlabel">Network
|
|
359
|
+
<div class="vital"><div class="vlabel">Network in</div><div class="vval">{{ live ? rate(m.networkRxBytesPerSec) : 'Unavailable' }}</div><div class="vnote">{{ live ? `${bytes(m.networkRxBytesToday)} today` : unavailableNote }}</div></div>
|
|
360
|
+
<div class="vital"><div class="vlabel">Network out</div><div class="vval">{{ live ? rate(m.networkTxBytesPerSec) : 'Unavailable' }}</div><div class="vnote">{{ live ? `${bytes(m.networkTxBytesToday)} today` : unavailableNote }}</div></div>
|
|
361
|
+
<div class="vital"><div class="vlabel">Bandwidth this month</div><div class="vval">{{ live ? bytes(monthBytes) : 'Unavailable' }}</div><div class="vnote">{{ live ? bandwidthNote : unavailableNote }}</div></div>
|
|
262
362
|
<div class="vital"><div class="vlabel">Processes</div><div class="vval">{{ live ? m.processes : 'Unavailable' }}</div><div class="vnote">Current process count</div></div>
|
|
263
363
|
<div class="vital"><div class="vlabel">Disk free</div><div class="vval">{{ live ? diskFreeGb : 'Unavailable' }} @if (live)<small>GB</small>@endif</div><div class="vnote">{{ live ? `${m.diskTotalGb} GB total` : unavailableNote }}</div></div>
|
|
264
364
|
</div>
|
|
@@ -291,10 +391,44 @@ function setRange(value) { historyRange.set(value); refresh() }
|
|
|
291
391
|
</article>
|
|
292
392
|
</template>
|
|
293
393
|
</div>
|
|
394
|
+
<div class="egress-panel panel" :class="egressTone()" @show="hasEgress()">
|
|
395
|
+
<div class="egress-head">
|
|
396
|
+
<div>
|
|
397
|
+
<h3>Object storage egress</h3>
|
|
398
|
+
<p>Reported by the applications that serve the downloads — these bytes never cross this host's network interface.</p>
|
|
399
|
+
</div>
|
|
400
|
+
<strong>{{ egressSummary() }}</strong>
|
|
401
|
+
</div>
|
|
402
|
+
<div class="egress-track" @show="!!egressBudget()">
|
|
403
|
+
<i :style="'width:' + Math.min(100, egressPercent() ?? 0) + '%'"></i>
|
|
404
|
+
</div>
|
|
405
|
+
<p class="bad notice" role="status" @show="egressWillExceed()">
|
|
406
|
+
Projected to exceed the monthly allowance — {{ bytesLabel(egressProjected()) }} against {{ bytesLabel(egressBudget()) }}.
|
|
407
|
+
</p>
|
|
408
|
+
<svg viewBox="0 0 100 46" preserveAspectRatio="none" role="img" aria-label="Daily object storage egress">
|
|
409
|
+
<line x1="0" x2="100" y1="23" y2="23" class="history-gridline"></line>
|
|
410
|
+
<path :d="areaPath('egress-daily')" class="history-area"></path>
|
|
411
|
+
<path :d="linePath('egress-daily')" class="history-line"></path>
|
|
412
|
+
</svg>
|
|
413
|
+
<div class="history-meta">
|
|
414
|
+
<span>{{ samples('egress-daily') }} days</span>
|
|
415
|
+
<span>peak {{ display(peak('egress-daily'), 'bytes') }}</span>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
294
418
|
<p class="note">Retention, source freshness, comparisons, logs, and exports are available in <a href="/operations/observability">Observability</a>.</p>
|
|
295
419
|
</div>
|
|
296
420
|
</div>
|
|
297
421
|
<style>
|
|
422
|
+
.egress-panel { margin-top:18px; padding:18px; }
|
|
423
|
+
.egress-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
|
424
|
+
.egress-head h3 { margin:0; font-size:14px; }
|
|
425
|
+
.egress-head p { margin:4px 0 0; color:var(--txt3); font-size:11px; max-width:52ch; }
|
|
426
|
+
.egress-head strong { color:var(--txt); font-size:13px; white-space:nowrap; }
|
|
427
|
+
.egress-track { height:7px; margin:14px 0 4px; overflow:hidden; border-radius:999px; background:rgba(255,255,255,.07); }
|
|
428
|
+
.egress-track > i { display:block; height:100%; border-radius:999px; background:var(--ok); }
|
|
429
|
+
.egress-panel.warn .egress-track > i { background:var(--warn); }
|
|
430
|
+
.egress-panel.bad .egress-track > i { background:var(--bad); }
|
|
431
|
+
.egress-panel svg { width:100%; height:88px; margin-top:10px; }
|
|
298
432
|
.host-vitals { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
299
433
|
.live-gauges { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
300
434
|
.live-bars { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px 24px; }
|