diff --git a/src/locales/ar.json b/src/locales/ar.json index 2ea6e87..23ae06c 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(لا يلزم إجراء أي تغييرات في إعدادات نظام أسماء النطاقات)" }, - "y867Vs": { - "defaultMessage": "حجم" - }, "y9xHR9": { "defaultMessage": "المدفوعات" }, diff --git a/src/locales/de.json b/src/locales/de.json index 559d406..3cdc875 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(Keine DNS-Änderung erforderlich)" }, - "y867Vs": { - "defaultMessage": "Volumen" - }, "y9xHR9": { "defaultMessage": "Auszahlungen" }, diff --git a/src/locales/en.json b/src/locales/en.json index 389b0de..052bdcd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(No DNS change required)" }, - "y867Vs": { - "defaultMessage": "Volume" - }, "y9xHR9": { "defaultMessage": "Payouts" }, diff --git a/src/locales/es.json b/src/locales/es.json index 938b102..7f0ca6c 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(No se requiere ningún cambio en la configuración de DNS)" }, - "y867Vs": { - "defaultMessage": "Volumen" - }, "y9xHR9": { "defaultMessage": "Pagos" }, diff --git a/src/locales/fr.json b/src/locales/fr.json index a8e5af1..656d948 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(Aucune modification du DNS n’est nécessaire)" }, - "y867Vs": { - "defaultMessage": "Volume" - }, "y9xHR9": { "defaultMessage": "Versements" }, diff --git a/src/locales/ja.json b/src/locales/ja.json index 903f86e..545ef1b 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(DNSの変更は不要です)" }, - "y867Vs": { - "defaultMessage": "音量" - }, "y9xHR9": { "defaultMessage": "支払い額" }, diff --git a/src/locales/ko.json b/src/locales/ko.json index 22bc5ed..539131d 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(DNS 변경이 필요하지 않음)" }, - "y867Vs": { - "defaultMessage": "볼륨" - }, "y9xHR9": { "defaultMessage": "지급 내역" }, diff --git a/src/locales/pt.json b/src/locales/pt.json index 41d5278..fa9299b 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(Não é necessário alterar as configurações de DNS)" }, - "y867Vs": { - "defaultMessage": "Volume" - }, "y9xHR9": { "defaultMessage": "Pagamentos" }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 7aec037..7df6356 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(Изменения в настройках DNS не требуются)" }, - "y867Vs": { - "defaultMessage": "Объём" - }, "y9xHR9": { "defaultMessage": "Выплаты" }, diff --git a/src/locales/tr.json b/src/locales/tr.json index 56f01fb..d0be9ae 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(DNS değişikliğine gerek yoktur)" }, - "y867Vs": { - "defaultMessage": "Hacim" - }, "y9xHR9": { "defaultMessage": "Ödemeler" }, diff --git a/src/locales/zh.json b/src/locales/zh.json index 24eb601..719c009 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2417,9 +2417,6 @@ "y43e4q": { "defaultMessage": "(无需更改 DNS 设置)" }, - "y867Vs": { - "defaultMessage": "音量" - }, "y9xHR9": { "defaultMessage": "支付款项" }, diff --git a/src/pages/account-app-deployment.tsx b/src/pages/account-app-deployment.tsx index 07dc479..5945706 100644 --- a/src/pages/account-app-deployment.tsx +++ b/src/pages/account-app-deployment.tsx @@ -4,10 +4,12 @@ import { FormattedDate, FormattedMessage, FormattedRelativeTime, + useIntl, } from "react-intl"; import { App, AppDeployment, + AppDeploymentServiceUsage, AppDeploymentUsage, AppUpgradeQuote, MAX_RESOURCE_MULTIPLIER, @@ -18,6 +20,7 @@ import { ConfigInput } from "../components/deploy-app-form"; import { parseComposeSchema } from "lnvps-compose"; import Spinner from "../components/spinner"; import BytesSize from "../components/bytes"; +import { formatBytesText } from "../utils/bytes"; import Seo from "../components/seo"; import { PageHeader, SectionCard } from "../components/section"; import { StatusPill } from "../components/billing"; @@ -37,7 +40,6 @@ import { deploymentPermissions, lifecycleStepState, usageBarReading, - usageBreakdown, } from "../utils/app-deployment"; /** @@ -551,6 +553,79 @@ function UsageBar({ ); } +/** + * A thin, unlabelled usage bar with just a percentage — the compact form for + * a per-service breakdown, where a header row above the list already says + * which column is which. `title` puts the absolute reading on hover: a + * fraction of the app's own quota rounds small services away to "0%". + */ +function CompactUsageBar({ + used, + quota, + title, +}: { + used: number; + quota: number; + title: string; +}) { + const { formatNumber } = useIntl(); + const reading = usageBarReading(used, quota); + if (!reading) return null; + const high = reading.level === "critical"; + const mid = reading.level === "warning"; + return ( +
+
+
+
+ + {formatNumber(reading.pct / 100, { + style: "percent", + maximumFractionDigits: 0, + })} + +
+ ); +} + +/** One service's CPU and RAM, each as a fraction of the app's own quota. */ +function ServiceUsageRow({ + service, + quotaCpuMilli, + quotaMemBytes, +}: { + service: AppDeploymentServiceUsage; + quotaCpuMilli: number; + quotaMemBytes: number; +}) { + const intl = useIntl(); + return ( +
+ + {service.service} + + + +
+ ); +} + /** Readable age for a usage reading. */ function UsageAge({ collected }: { collected: string }) { const now = useRef(Date.now()).current; @@ -592,6 +667,9 @@ function DeploymentUsageCard({ quotaMemBytes: number; quotaStorageBytes: number; }) { + const intl = useIntl(); + const services = usage.services ?? []; + const volumes = usage.volumes ?? []; return ( }>
@@ -623,69 +701,64 @@ function DeploymentUsageCard({ />

- {usageBreakdown(usage).services.length > 0 && ( + {services.length > 0 && (

- - - - - - - - - - {usageBreakdown(usage).services.map((s) => ( - - - - - - ))} - -
- - - - - -
{s.service}{cpuLabel(s.cpu_milli)} - -
+
+
+ + + + + + + +
+ {services.map((s) => ( + + ))} +
)} - {usageBreakdown(usage).volumes.length > 0 && ( + {volumes.length > 0 && quotaStorageBytes > 0 && (

- - - - - - - - - {usageBreakdown(usage).volumes.map((v) => ( - - - - - ))} - -
- - - -
- {v.service}/{v.name} - - -
+
+
+ + + + +
+ {volumes.map((v) => ( +
+ + {v.service}/{v.name} + + +
+ ))} +
)}
diff --git a/src/utils/app-deployment.test.ts b/src/utils/app-deployment.test.ts index 846f560..8ff1522 100644 --- a/src/utils/app-deployment.test.ts +++ b/src/utils/app-deployment.test.ts @@ -1,10 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { AppDeploymentUsage } from "../api"; -import { - lifecycleStepState, - usageBarReading, - usageBreakdown, -} from "./app-deployment"; +import { lifecycleStepState, usageBarReading } from "./app-deployment"; describe("lifecycleStepState", () => { test("marks the last step done once the deployment reaches it", () => { @@ -42,36 +37,3 @@ describe("usageBarReading", () => { expect(usageBarReading(90, 100)).toEqual({ pct: 90, level: "critical" }); }); }); - -describe("usageBreakdown", () => { - // Regression: a reading stored before the per-service/volume split shipped - // has only the totals, and DeploymentUsageCard crashed reading - // `usage.services.length` of `undefined`. - test("normalises absent services/volumes to empty arrays", () => { - const totalsOnly: AppDeploymentUsage = { - cpu_milli: 500, - memory_bytes: 1024, - collected: "2026-01-01T00:00:00Z", - }; - expect(usageBreakdown(totalsOnly)).toEqual({ services: [], volumes: [] }); - }); - - test("passes a full reading through untouched", () => { - const services: AppDeploymentUsage["services"] = [ - { service: "web", cpu_milli: 500, memory_bytes: 1024 }, - ]; - const volumes: AppDeploymentUsage["volumes"] = [ - { service: "web", name: "data", storage_bytes: 2048 }, - ]; - const full: AppDeploymentUsage = { - cpu_milli: 500, - memory_bytes: 1024, - storage_bytes: 2048, - collected: "2026-01-01T00:00:00Z", - services, - volumes, - }; - expect(usageBreakdown(full).services).toBe(services); - expect(usageBreakdown(full).volumes).toBe(volumes); - }); -}); diff --git a/src/utils/app-deployment.ts b/src/utils/app-deployment.ts index 6c86f16..7f3cd0a 100644 --- a/src/utils/app-deployment.ts +++ b/src/utils/app-deployment.ts @@ -1,4 +1,4 @@ -import { AppDeployment, AppDeploymentUsage } from "../api"; +import { AppDeployment } from "../api"; /** * Where a deployment is in its life, as one value. @@ -111,22 +111,6 @@ export interface UsageBarReading { level: UsageLevel; } -/** - * A usage reading's per-service and per-volume breakdown with the absent case - * normalised to empty arrays. Readings stored before the operator reported - * the breakdown carry only the totals — `services`/`volumes` are absent — and - * the usage card crashed on them reading `.length` of `undefined`. - */ -export function usageBreakdown(usage: AppDeploymentUsage): { - services: NonNullable; - volumes: NonNullable; -} { - return { - services: usage.services ?? [], - volumes: usage.volumes ?? [], - }; -} - /** * A usage bar's percentage and colour tier, or `null` when the quota side is * zero and there is no proportion to show.