diff --git a/cost-dashboard/src/App.tsx b/cost-dashboard/src/App.tsx index 2c8fa58..0c185cf 100644 --- a/cost-dashboard/src/App.tsx +++ b/cost-dashboard/src/App.tsx @@ -9,7 +9,7 @@ import { buildHtml, buildMarkdown, ghNewFileUrl, slugName, REPORT_DIR } from "./ import { ResourceCard } from "./components/ResourceCard"; import { SourcePanel } from "./components/SourcePanel"; -const ADD: Service[] = ["ec2", "s3", "rds", "lambda"]; +const ADD: Service[] = ["ec2", "s3", "rds", "lambda", "estimate"]; const WORKFLOW_URL = "https://github.com/NASA-IMPACT/veda-github-actions/actions/workflows/aws-pricing.yml"; diff --git a/cost-dashboard/src/components/ResourceCard.tsx b/cost-dashboard/src/components/ResourceCard.tsx index 458da4d..2284ec3 100644 --- a/cost-dashboard/src/components/ResourceCard.tsx +++ b/cost-dashboard/src/components/ResourceCard.tsx @@ -1,7 +1,7 @@ // One resource in the cart: a per-service input form + its live monthly subtotal + a breakdown. import { useMemo } from "react"; import type { PricingDoc, Resource } from "../types"; -import { SERVICE_LABEL, costOf } from "../compute"; +import { SERVICE_LABEL, costOf, ESTIMATE_CATEGORIES } from "../compute"; import { money, rate } from "../format"; interface Props { @@ -151,6 +151,17 @@ export function ResourceCard({ resource, pricing, onChange, onRemove }: Props) { set({ memoryMB: n })} suffix="MB" /> )} + + {resource.service === "estimate" && ( + <> + set({ category: v })} + options={ESTIMATE_CATEGORIES.map((c) => ({ value: c, label: c }))} /> + set({ monthlyUSD: n })} suffix="$/mo" + hint="Rough flat figure — added to the total" /> + + )}
diff --git a/cost-dashboard/src/components/SourcePanel.tsx b/cost-dashboard/src/components/SourcePanel.tsx index 250c8f0..977650c 100644 --- a/cost-dashboard/src/components/SourcePanel.tsx +++ b/cost-dashboard/src/components/SourcePanel.tsx @@ -2,8 +2,10 @@ // plus the exact bulk-file URL and the AWS-published version stamp for this region's data. import type { PricingDoc, Service } from "../types"; -const ORDER: Service[] = ["ec2", "s3", "rds", "lambda"]; -const SHORT: Record = { ec2: "EC2", s3: "S3", rds: "RDS", lambda: "Lambda" }; +// Only the live-priced services have an AWS source row ("estimate" is a manual flat figure). +type PricedService = Exclude; +const ORDER: PricedService[] = ["ec2", "s3", "rds", "lambda"]; +const SHORT: Record = { ec2: "EC2", s3: "S3", rds: "RDS", lambda: "Lambda" }; function fmtVersion(v: string): string { // AWS versions look like "20260728175247" (YYYYMMDDHHMMSS) → "2026-07-28". diff --git a/cost-dashboard/src/compute.ts b/cost-dashboard/src/compute.ts index 44de330..a2253f5 100644 --- a/cost-dashboard/src/compute.ts +++ b/cost-dashboard/src/compute.ts @@ -10,8 +10,16 @@ export const SERVICE_LABEL: Record = { s3: "S3 — object storage", rds: "RDS — managed database", lambda: "Lambda — serverless functions", + estimate: "Estimate — flat monthly figure", }; +// Presets for the flat "Estimate" line item — supporting services we don't price live but that still +// belong in the total. "Other" lets you name anything via the card's name field. +export const ESTIMATE_CATEGORIES = [ + "CloudFront", "Amplify", "Secrets Manager", "Route 53", "CloudWatch", + "SNS / SQS", "KMS", "WAF", "Data transfer", "Other", +]; + export function costOf(r: Resource, p: PricingDoc): Cost { const b: LineItem[] = []; @@ -78,6 +86,14 @@ export function costOf(r: Resource, p: PricingDoc): Cost { return total(b); } + if (r.service === "estimate") { + const amount = Math.max(0, r.params.monthlyUSD || 0); + return total( + [{ label: `${r.params.category || "Other"} — flat monthly estimate`, amount }], + "Manual estimate — a flat figure you entered, not from live AWS pricing.", + ); + } + // lambda const lam = p.lambda; const reqCost = r.params.requests * lam.requestUsd; diff --git a/cost-dashboard/src/defaults.ts b/cost-dashboard/src/defaults.ts index bd05e16..25a447e 100644 --- a/cost-dashboard/src/defaults.ts +++ b/cost-dashboard/src/defaults.ts @@ -36,5 +36,7 @@ export function makeResource(service: Service, p: PricingDoc): Resource { params: { instanceKey: pickRds(p), count: 1, hours: DEFAULT_HOURS, storageType: firstKey(Object.keys(p.rds.storage), "gp3"), storageGB: 20 }, }; + if (service === "estimate") + return { id, name, service, params: { category: "CloudFront", monthlyUSD: 10 } }; return { id, name, service, params: { requests: 1000000, durationMs: 200, memoryMB: 512 } }; } diff --git a/cost-dashboard/src/export.ts b/cost-dashboard/src/export.ts index 955940c..81cba4f 100644 --- a/cost-dashboard/src/export.ts +++ b/cost-dashboard/src/export.ts @@ -25,6 +25,8 @@ export function configSummary(r: Resource): string { `${r.params.storageGB} GB ${r.params.storageType}`; case "lambda": return `${r.params.requests.toLocaleString()} reqs/mo, ${r.params.durationMs} ms, ${r.params.memoryMB} MB`; + case "estimate": + return `${r.params.category} — flat monthly estimate`; } } diff --git a/cost-dashboard/src/report.ts b/cost-dashboard/src/report.ts index c00a2ce..3ea0ad6 100644 --- a/cost-dashboard/src/report.ts +++ b/cost-dashboard/src/report.ts @@ -104,7 +104,7 @@ export function buildHtml(resources: Resource[], p: PricingDoc, name: string, ex .lines{margin-top:6px} .li{display:flex;justify-content:space-between;gap:12px;font-size:12px;color:#5a6572} .b{font-size:11px;font-weight:800;color:#fff;padding:2px 7px;border-radius:5px;background:#5a6572} - .b-ec2{background:#ff9900;color:#232f3e}.b-s3{background:#3f9c35}.b-rds{background:#2e73b8}.b-lambda{background:#c8511b} + .b-ec2{background:#ff9900;color:#232f3e}.b-s3{background:#3f9c35}.b-rds{background:#2e73b8}.b-lambda{background:#c8511b}.b-estimate{background:#7d5ba6} .total{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;background:#232f3e;color:#fff;border-radius:12px;margin-top:16px} .total .g{font-size:26px;font-weight:800;color:#ff9900} .note{font-size:12px;color:#5a6572;margin:14px 2px} diff --git a/cost-dashboard/src/styles.css b/cost-dashboard/src/styles.css index f5ce785..a37de25 100644 --- a/cost-dashboard/src/styles.css +++ b/cost-dashboard/src/styles.css @@ -11,6 +11,7 @@ --s3: #3f9c35; --rds: #2e73b8; --lambda: #c8511b; + --estimate: #7d5ba6; --ok: #1a8754; --shadow: 0 1px 2px rgba(16, 25, 31, 0.06), 0 4px 16px rgba(16, 25, 31, 0.06); --radius: 12px; @@ -90,6 +91,7 @@ body { .add-btn[data-service="s3"] { border-left-color: var(--s3); } .add-btn[data-service="rds"] { border-left-color: var(--rds); } .add-btn[data-service="lambda"] { border-left-color: var(--lambda); } +.add-btn[data-service="estimate"] { border-left-color: var(--estimate); } .ghost-btn { border: 1px solid var(--line); background: transparent; color: var(--muted); font-size: 13px; padding: 8px 12px; border-radius: 8px; cursor: pointer; @@ -132,6 +134,7 @@ body { .card[data-service="s3"] { border-left-color: var(--s3); } .card[data-service="rds"] { border-left-color: var(--rds); } .card[data-service="lambda"] { border-left-color: var(--lambda); } +.card[data-service="estimate"] { border-left-color: var(--estimate); } .card-head { display: flex; align-items: center; gap: 12px; @@ -145,6 +148,7 @@ body { .card[data-service="s3"] .badge { background: var(--s3); } .card[data-service="rds"] .badge { background: var(--rds); } .card[data-service="lambda"] .badge { background: var(--lambda); } +.card[data-service="estimate"] .badge { background: var(--estimate); } .card-title { font-weight: 600; font-size: 14px; color: var(--ink); white-space: nowrap; } .name-input { flex: 1; min-width: 130px; max-width: 340px; @@ -245,6 +249,7 @@ span.field-input input { flex: 1; border: 1px solid var(--line); border-radius: .chip[data-service="s3"] { border-left-color: var(--s3); } .chip[data-service="rds"] { border-left-color: var(--rds); } .chip[data-service="lambda"] { border-left-color: var(--lambda); } +.chip[data-service="estimate"] { border-left-color: var(--estimate); } .summary-right { margin-left: auto; text-align: right; } .grand { display: block; font-size: 34px; font-weight: 800; color: var(--orange); font-variant-numeric: tabular-nums; line-height: 1.05; } .grand small { font-size: 15px; color: #c4ccd6; font-weight: 600; margin-left: 2px; } diff --git a/cost-dashboard/src/types.ts b/cost-dashboard/src/types.ts index 50393b3..f1f9995 100644 --- a/cost-dashboard/src/types.ts +++ b/cost-dashboard/src/types.ts @@ -67,7 +67,7 @@ export interface Dataset { // ---- the "resource cart" (what the user builds) ---- -export type Service = "ec2" | "s3" | "rds" | "lambda"; +export type Service = "ec2" | "s3" | "rds" | "lambda" | "estimate"; export interface Ec2Params { instanceType: string; @@ -94,13 +94,20 @@ export interface LambdaParams { durationMs: number; memoryMB: number; } +// A flat, user-entered monthly figure for services we don't price live (CloudFront, Amplify, Secrets +// Manager, …). It just contributes its `monthlyUSD` to the grand total so the picture stays complete. +export interface EstimateParams { + category: string; + monthlyUSD: number; +} // `name` is a user label so a cart with several EC2s stays legible ("Disasters Hub API", "worker"). export type Resource = | { id: string; name: string; service: "ec2"; params: Ec2Params } | { id: string; name: string; service: "s3"; params: S3Params } | { id: string; name: string; service: "rds"; params: RdsParams } - | { id: string; name: string; service: "lambda"; params: LambdaParams }; + | { id: string; name: string; service: "lambda"; params: LambdaParams } + | { id: string; name: string; service: "estimate"; params: EstimateParams }; export interface LineItem { label: string;