Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions src/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(لا يلزم إجراء أي تغييرات في إعدادات نظام أسماء النطاقات)"
},
"y867Vs": {
"defaultMessage": "حجم"
},
"y9xHR9": {
"defaultMessage": "المدفوعات"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(Keine DNS-Änderung erforderlich)"
},
"y867Vs": {
"defaultMessage": "Volumen"
},
"y9xHR9": {
"defaultMessage": "Auszahlungen"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(No DNS change required)"
},
"y867Vs": {
"defaultMessage": "Volume"
},
"y9xHR9": {
"defaultMessage": "Payouts"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(No se requiere ningún cambio en la configuración de DNS)"
},
"y867Vs": {
"defaultMessage": "Volumen"
},
"y9xHR9": {
"defaultMessage": "Pagos"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(Aucune modification du DNS n’est nécessaire)"
},
"y867Vs": {
"defaultMessage": "Volume"
},
"y9xHR9": {
"defaultMessage": "Versements"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(DNSの変更は不要です)"
},
"y867Vs": {
"defaultMessage": "音量"
},
"y9xHR9": {
"defaultMessage": "支払い額"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(DNS 변경이 필요하지 않음)"
},
"y867Vs": {
"defaultMessage": "볼륨"
},
"y9xHR9": {
"defaultMessage": "지급 내역"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(Não é necessário alterar as configurações de DNS)"
},
"y867Vs": {
"defaultMessage": "Volume"
},
"y9xHR9": {
"defaultMessage": "Pagamentos"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(Изменения в настройках DNS не требуются)"
},
"y867Vs": {
"defaultMessage": "Объём"
},
"y9xHR9": {
"defaultMessage": "Выплаты"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(DNS değişikliğine gerek yoktur)"
},
"y867Vs": {
"defaultMessage": "Hacim"
},
"y9xHR9": {
"defaultMessage": "Ödemeler"
},
Expand Down
3 changes: 0 additions & 3 deletions src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2417,9 +2417,6 @@
"y43e4q": {
"defaultMessage": "(无需更改 DNS 设置)"
},
"y867Vs": {
"defaultMessage": "音量"
},
"y9xHR9": {
"defaultMessage": "支付款项"
},
Expand Down
179 changes: 126 additions & 53 deletions src/pages/account-app-deployment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {
FormattedDate,
FormattedMessage,
FormattedRelativeTime,
useIntl,
} from "react-intl";
import {
App,
AppDeployment,
AppDeploymentServiceUsage,
AppDeploymentUsage,
AppUpgradeQuote,
MAX_RESOURCE_MULTIPLIER,
Expand All @@ -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";
Expand All @@ -37,7 +40,6 @@ import {
deploymentPermissions,
lifecycleStepState,
usageBarReading,
usageBreakdown,
} from "../utils/app-deployment";

/**
Expand Down Expand Up @@ -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 (
<div className="flex flex-1 min-w-0 items-center gap-1.5" title={title}>
<div className="h-1.5 flex-1 overflow-hidden rounded-full border border-cyber-border bg-cyber-panel-light">
<div
className={`h-full rounded-full ${high ? "bg-cyber-danger" : mid ? "bg-cyber-warning" : "bg-cyber-primary"}`}
style={{ width: `${reading.pct}%` }}
/>
</div>
<span
className={`w-8 flex-shrink-0 text-right font-mono text-[0.65rem] tabular-nums ${high ? "text-cyber-danger" : mid ? "text-cyber-warning" : "text-cyber-muted"}`}
>
{formatNumber(reading.pct / 100, {
style: "percent",
maximumFractionDigits: 0,
})}
</span>
</div>
);
}

/** 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 (
<div className="flex items-center gap-2">
<span className="w-16 flex-shrink-0 truncate font-mono text-[0.65rem] text-cyber-text">
{service.service}
</span>
<CompactUsageBar
used={service.cpu_milli}
quota={quotaCpuMilli}
title={intl.formatMessage(
{ defaultMessage: "{cores} vCPU" },
{ cores: vcpu(service.cpu_milli) },
)}
/>
<CompactUsageBar
used={service.memory_bytes}
quota={quotaMemBytes}
title={formatBytesText(intl, service.memory_bytes)}
/>
</div>
);
}

/** Readable age for a usage reading. */
function UsageAge({ collected }: { collected: string }) {
const now = useRef(Date.now()).current;
Expand Down Expand Up @@ -592,6 +667,9 @@ function DeploymentUsageCard({
quotaMemBytes: number;
quotaStorageBytes: number;
}) {
const intl = useIntl();
const services = usage.services ?? [];
const volumes = usage.volumes ?? [];
return (
<SectionCard title={<FormattedMessage defaultMessage="Usage" />}>
<div className="flex flex-col gap-3">
Expand Down Expand Up @@ -623,69 +701,64 @@ function DeploymentUsageCard({
/>
</p>

{usageBreakdown(usage).services.length > 0 && (
{services.length > 0 && (
<div className="border-t border-cyber-border pt-3 mt-1">
<p className="m-0 mb-2 text-[0.65rem] uppercase tracking-[0.2em] text-cyber-text">
<FormattedMessage defaultMessage="By service" />
</p>
<table className="w-full text-xs tabular-nums">
<thead>
<tr className="text-left text-cyber-muted">
<th className="font-normal pb-1 pr-3">
<FormattedMessage defaultMessage="Service" />
</th>
<th className="font-normal pb-1 pr-3">
<FormattedMessage defaultMessage="CPU" />
</th>
<th className="font-normal pb-1">
<FormattedMessage defaultMessage="RAM" />
</th>
</tr>
</thead>
<tbody>
{usageBreakdown(usage).services.map((s) => (
<tr key={s.service} className="text-cyber-text">
<td className="py-0.5 pr-3 font-mono">{s.service}</td>
<td className="py-0.5 pr-3">{cpuLabel(s.cpu_milli)}</td>
<td className="py-0.5">
<BytesSize value={s.memory_bytes} />
</td>
</tr>
))}
</tbody>
</table>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-cyber-muted">
<span className="w-16 flex-shrink-0" />
<span className="flex-1 text-[0.6rem] uppercase tracking-wider">
<FormattedMessage defaultMessage="CPU" />
</span>
<span className="flex-1 text-[0.6rem] uppercase tracking-wider">
<FormattedMessage defaultMessage="RAM" />
</span>
</div>
{services.map((s) => (
<ServiceUsageRow
key={s.service}
service={s}
quotaCpuMilli={quotaCpuMilli}
quotaMemBytes={quotaMemBytes}
/>
))}
</div>
</div>
)}

{usageBreakdown(usage).volumes.length > 0 && (
{volumes.length > 0 && quotaStorageBytes > 0 && (
<div className="border-t border-cyber-border pt-3 mt-1">
<p className="m-0 mb-2 text-[0.65rem] uppercase tracking-[0.2em] text-cyber-text">
<FormattedMessage defaultMessage="By volume" />
</p>
<table className="w-full text-xs tabular-nums">
<thead>
<tr className="text-left text-cyber-muted">
<th className="font-normal pb-1 pr-3">
<FormattedMessage defaultMessage="Volume" />
</th>
<th className="font-normal pb-1">
<FormattedMessage defaultMessage="Used" />
</th>
</tr>
</thead>
<tbody>
{usageBreakdown(usage).volumes.map((v) => (
<tr key={`${v.service}/${v.name}`} className="text-cyber-text">
<td className="py-0.5 pr-3 font-mono">
{v.service}/{v.name}
</td>
<td className="py-0.5">
<BytesSize value={v.storage_bytes} />
</td>
</tr>
))}
</tbody>
</table>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-cyber-muted">
<span className="w-24 flex-shrink-0" />
<span className="flex-1 text-[0.6rem] uppercase tracking-wider">
<FormattedMessage defaultMessage="Used" />
</span>
</div>
{volumes.map((v) => (
<div
key={`${v.service}/${v.name}`}
className="flex items-center gap-2"
>
<span
className="w-24 flex-shrink-0 truncate font-mono text-[0.65rem] text-cyber-text"
title={`${v.service}/${v.name}`}
>
{v.service}/{v.name}
</span>
<CompactUsageBar
used={v.storage_bytes}
quota={quotaStorageBytes}
title={formatBytesText(intl, v.storage_bytes)}
/>
</div>
))}
</div>
</div>
)}
</div>
Expand Down
40 changes: 1 addition & 39 deletions src/utils/app-deployment.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading