Compare commits

...
8 Commits
Author SHA1 Message Date
mrhid6 9d17f539b5 fix: Fixed card header margin
Chart Release / chart (push) Successful in 19s
Server Deploy / deploy (push) Successful in 1m0s
2026-09-07 08:36:18 +00:00
mrhid6 28f746c7e2 feat: Compact vitals
Chart Release / chart (push) Successful in 27s
Server Deploy / deploy (push) Successful in 1m4s
2026-09-07 08:26:07 +00:00
mrhid6 9d218cb19f feat: Reduce server vitials panel height
Chart Release / chart (push) Successful in 18s
Server Deploy / deploy (push) Successful in 1m10s
2026-09-07 08:14:47 +00:00
mrhid6 f9ef9c4929 fix: Fixed mobile scroll bar
Chart Release / chart (push) Successful in 25s
Server Deploy / deploy (push) Successful in 2m17s
2026-09-07 08:02:09 +00:00
mrhid6 049e005873 feat: Updated monitor chart
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 1m9s
2026-08-25 15:02:04 +00:00
mrhid6 c440b59b93 feat: Updated affected components on status page incidents
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Canceled after 6m59s
2026-08-25 14:56:50 +00:00
mrhid6 3e4865884c feat: Updated plans and catalogue pages
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 1m58s
2026-08-25 14:35:47 +00:00
mrhid6 270d55e6a6 feat: Updated status page title 2026-08-25 14:11:21 +00:00
36 changed files with 1455 additions and 851 deletions
+38 -3
View File
@@ -91,7 +91,7 @@ vantage/
│ └── models/ # accounts, instances, licences, plans
├── adminsite/ # staff + customer console (vantage-hq)
│ ├── app/(customer)/ # overview, instance, link, billing
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
│ ├── app/(staff)/staff/ # operations, accounts, licences, pricing, audit
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
│ └── lib/ # api client, session guards, formatters
├── docsite/ # user documentation (Docusaurus, static)
@@ -480,6 +480,20 @@ fetched data (no DB calls inside it) is what makes the boundary testable
without a database, which is the only thing standing between an editor adding
a field to `PublicComponent` and that field being a hostname.
**An incident may only name components the page already carries.**
`services.checkAffectedOnPages` refuses an `affected_monitors` entry that no
page in the incident's `page_ids` lists, and the editor offers only the saved
page's components — labelled by their per-page display name, since that is the
name the reader sees. Naming an arbitrary monitor would publish a machine the
page deliberately does not, which is the same leak `assembleSnapshot`'s
redaction boundary exists to prevent, reached from the authoring side instead
of the read side. It is a separate pass rather than part of `validateIncident`
because it reads the database and `validateIncident` is a pure function of the
document. A component dropped from the page **after** an incident named it
makes the next edit of that incident fail, deliberately: the editor renders the
stale entry flagged and checked so it is one click from being dropped, and the
alternative is a page quietly publishing a component it no longer has.
Monitor-detected outages are **derived at read time, never copied**: each
snapshot assembly reads recent `incidents` for the page's monitors and folds
them into the timeline alongside the authored ones. There is no second
@@ -947,9 +961,9 @@ Notes that are not obvious from the structs:
Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an _index_ of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes.
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. A row carries a `scope`: `plan` rows name a `deployment` and `tier` and belong to that plan alone, `shared` rows leave both empty and are sold by every paid plan. **How many rows a component needs follows from how many Paddle products it is** — the base fee is a different product per plan, every add-on is one product at one price, so the catalogue is four base rows plus five shared rows, nine instead of twenty-four, and an add-on's price ID is typed once rather than four times. `models.CatalogueFor` is the seam: it returns a plan's base row plus every shared row, and **nothing may filter the catalogue by `deployment` and `tier` itself** or it sees a plan priced by its base fee alone. `adminsite/lib/catalogue.ts`'s `rowsForPlan` is the TypeScript half of that and must change in the same commit, the same shape of hazard as `web/lib/targets.ts`. `models.MigrateSharedCatalogue` runs at boot after `SeedCatalogue`, merges the old per-plan copies onto the shared row and deletes them; it **refuses rather than guesses** when the four copies disagree, because four rows meant to be one price and are not is a pricing decision somebody made and picking one silently moves a customer's bill. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle.
**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more `KindFeature` row per paid plan in `SeedCatalogue` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on `(kind, deployment, tier, feature_key)`, so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path.
**No tier bundles a feature.** `console`, `oidc`, `vuln_scanning` and `status_pages` are each a per-customer priceable add-on: every plan row carries an empty `base_features`, and the grant comes from a `catalogue` row the customer buys. Adding a fifth feature therefore means one more shared `KindFeature` row in `SeedCatalogue`'s `seedRows` and one entry in `adminsite/lib/features.ts` — that map is what the customer's grant list, the staff configurator and the purchase form all enumerate, so a feature missing from it exists in the licence and is invisible in the portal. `SeedCatalogue` upserts on the row's natural key `(kind, deployment, tier, limit_key, feature_key)` — a shared row's empty deployment and tier are part of that key, not a wildcard — so a new row reaches an existing database on the next admin boot with no migration; `SeedPlans` is `$setOnInsert` on the whole document and would not, which is the other reason bundling into a tier is the harder path.
### Migrations
@@ -1128,6 +1142,27 @@ Tailwind in all three maps `var(--…)` references only, so **no component in an
`web/` collapses Tailwind's radius scale — `md`, `lg` and `xl` all resolve to site/'s 4px — rather than rewriting the ~140 `rounded-lg` classes across its pages. Every one of them meant "a panel corner", and `tailwind.config.ts` is now where that decision lives. `rounded-full` is untouched: status dots and pills still need it.
**Plans and the catalogue are one page, `/staff/pricing`.** They were two nav
entries and the split asked staff to hold one half in their head while looking
at the other: a tier's allowance is what the metered component charges above,
and a base fee means nothing without the allowance it includes. The page is
`PlansSection` then `CatalogueSection`, in the order the decision is made —
what a tier grants, then what it costs. `next.config.ts` keeps permanent
redirects from `/staff/plans` and `/staff/catalogue`, which are bookmarked in
staff browsers. **The tier list is cards, not forms**: six plans with five
number fields, a select, a checkbox and four feature toggles each was forty-odd
controls on one screen, and the page could not be read for the thing it exists
to answer. A card states what the tier grants and `Modal` — a native
`<dialog>`, for the focus trap and Escape handling a hand-rolled overlay gets
wrong — is where it is changed. Every feature key renders on every card, lit or
unlit: no tier bundles one today, so the unlit row is the information.
**The catalogue's coverage ledger is not decoration.** A missing production
price is invisible in a grid of text inputs — every cell looks like every other
until twenty-six characters of each are read — and it is the one thing staff
come to the page to check before a launch, so each component draws one filled
or empty square per environment and term.
**The `adminsite/` shell.** `AppBar` is the single masthead — identity, nav, environment, account menu — and it belongs to the two authenticated layouts, never to `app/layout.tsx`, so `/login` and `/accept-invite` do not render navigation they cannot use. Nav active state is derived from `usePathname`; do not hardcode it. `PageHeader` gives every screen the same back link, title, actions and **record line** (the reference number in mono, click-to-copy) — the reference is what people paste into support tickets, so it has a fixed slot rather than a per-page treatment. `PageFrame` is the main-plus-320px-rail split; the rail carries only what is true account-wide, which is why there is no plan card in it — **tier, limits and expiry belong to a licence, and a licence belongs to one instance**, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan.
Customer nav is three destinations — Overview, People, Billing. Settings is in the account menu because it is your password, not a place, and appearance lives there too: `AccountMenu` is the only thing that sets `data-theme`, which the token blocks have always supported in both directions.
+4
View File
@@ -86,6 +86,10 @@ func main() {
idxCancel()
log.Fatalf("seed catalogue: %v", err)
}
if err := models.MigrateSharedCatalogue(idxCtx); err != nil {
idxCancel()
log.Fatalf("migrate catalogue: %v", err)
}
if err := models.Backfill(idxCtx); err != nil {
idxCancel()
log.Fatalf("backfill: %v", err)
+21 -1
View File
@@ -485,6 +485,7 @@ func staffListCatalogue(c *gin.Context) {
func staffUpdateCatalogue(c *gin.Context) {
var body struct {
Kind string `json:"kind"`
Scope string `json:"scope"`
Deployment string `json:"deployment"`
Tier string `json:"tier"`
LimitKey string `json:"limit_key"`
@@ -500,11 +501,19 @@ func staffUpdateCatalogue(c *gin.Context) {
// mean a resolved self-hosted monthly price later, which the resolver treats
// as a configuration error — better to refuse it at the point somebody
// pastes it, while they are looking at the screen.
//
// A shared row is sold by both deployments, so both terms are legitimate on
// it: the cloud checkout takes the monthly price and the self-hosted one
// never asks for it. Only a plan row can name a term its own deployment
// does not sell.
for env, byTerm := range body.PriceIDs {
for term, id := range byTerm {
if id == "" {
continue
}
if body.Scope == models.ScopeShared {
continue
}
if !termSold(body.Deployment, term) {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("%s does not sell %s (environment %s)",
@@ -514,6 +523,8 @@ func staffUpdateCatalogue(c *gin.Context) {
}
}
// Addressed by its natural key, so the staff UI never holds a Mongo id. A
// shared row's empty deployment and tier are part of that key.
filter := bson.M{
"kind": body.Kind,
"deployment": body.Deployment,
@@ -534,12 +545,21 @@ func staffUpdateCatalogue(c *gin.Context) {
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: auth.Current(c).Email,
Action: "catalogue.updated",
Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
Target: catalogueTarget(body.Scope, body.Deployment, body.Tier, body.Kind),
Detail: body.LimitKey + body.FeatureKey,
})
c.JSON(http.StatusOK, gin.H{"updated": true})
}
// catalogueTarget names an edited component in the audit log. A shared row has
// no plan to name, so it says so rather than logging "//feature".
func catalogueTarget(scope, deployment, tier, kind string) string {
if scope == models.ScopeShared {
return "shared/" + kind
}
return deployment + "/" + tier + "/" + kind
}
func termSold(deployment, term string) bool {
for _, t := range license.TermsFor(deployment) {
if t == term {
+12 -2
View File
@@ -29,8 +29,18 @@ func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg mod
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
// A plan is identified by its base row, and shared add-on rows exist whether
// or not any plan sells them — so "the catalogue returned something" is no
// longer proof this plan is priced. Check for the base row itself.
hasBase := false
for _, r := range rows {
if r.Kind == models.KindBase {
hasBase = true
break
}
}
if !hasBase {
return nil, fmt.Errorf("%w: %s/%s has no base row",
ErrUnpriced, plan.Deployment, plan.Tier)
}
+183 -46
View File
@@ -2,6 +2,8 @@ package models
import (
"context"
"fmt"
"log"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
@@ -19,6 +21,23 @@ const (
KindFeature = "feature"
)
// Component scopes.
//
// A component is priced by one Paddle product, and how many catalogue rows it
// needs follows from how many products it is. The base fee is a different
// product per plan, so it is a row per plan. Every add-on — the server limit and
// all four features — is ONE product sold to every paid plan at one price, so it
// is one row, and its price ID is typed once instead of four times.
//
// Scope is stored rather than inferred from Kind so the rule is data. Pricing a
// future add-on per tier is then a scope on a row, not a rewrite of every reader.
const (
// ScopePlan rows carry a deployment and a tier and belong to that plan alone.
ScopePlan = "plan"
// ScopeShared rows leave deployment and tier empty and belong to every paid plan.
ScopeShared = "shared"
)
// LimitKeyServers is the only metered limit today.
//
// A limit_key is a field name in license.Limits, which is what lets a second
@@ -27,19 +46,23 @@ const (
// would be 1 in every row that will ever exist.
const LimitKeyServers = "max_servers"
// CatalogueRow is one priceable component of one plan.
// CatalogueRow is one priceable component.
//
// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty
// PriceIDs means the component is free — a feature with no price is a toggle a
// customer may take at no charge, and giving it a price later is a staff edit
// rather than a migration or a deploy.
type CatalogueRow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Kind string `bson:"kind" json:"kind"`
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Kind string `bson:"kind" json:"kind"`
Scope string `bson:"scope" json:"scope"`
// Deployment and Tier are empty on a shared row, and are what a plan row is
// keyed by. Readers must go through CatalogueFor rather than filtering on
// them, or a shared row is invisible to the plan that sells it.
Deployment string `bson:"deployment" json:"deployment"`
Tier string `bson:"tier" json:"tier"`
LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
// PriceIDs is environment -> term -> Paddle price ID, e.g.
// {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}.
//
@@ -67,60 +90,174 @@ func (r CatalogueRow) Priced(env string) bool {
return false
}
// SeedCatalogue inserts the twenty-four rows the four PAID plans need: a base, a
// server limit, and one row per feature key. The count is deliberate — it moves
// whenever shared/license gains a feature, and this comment is how the next
// person knows the number was chosen rather than drifted.
// Shared reports whether this row is sold by every paid plan.
func (r CatalogueRow) Shared() bool { return r.Scope == ScopeShared }
// naturalKey is how a row is addressed everywhere: by what it is, never by its
// ObjectID. A shared row's deployment and tier are empty, and that emptiness is
// part of the key rather than a wildcard.
func (r CatalogueRow) naturalKey() bson.M {
return bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
}
}
// seedRows is the catalogue as it should exist: four base rows, one per paid
// plan, plus five shared add-on rows every paid plan sells.
//
// Nine rows, down from twenty-four. The count moves whenever shared/license
// gains a feature, and this comment is how the next person knows the number was
// chosen rather than drifted.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
// "fix" this by adding zero-priced Free rows.
func seedRows() []CatalogueRow {
rows := []CatalogueRow{}
paid := []string{license.TierProfessional, license.TierEnterprise}
for _, deployment := range license.Deployments() {
for _, tier := range paid {
rows = append(rows, CatalogueRow{
Kind: KindBase, Scope: ScopePlan, Deployment: deployment, Tier: tier,
})
}
}
rows = append(rows, CatalogueRow{
Kind: KindLimit, Scope: ScopeShared, LimitKey: LimitKeyServers,
})
for _, f := range []string{
license.FeatureConsole,
license.FeatureOIDC,
license.FeatureVulnScanning,
license.FeatureStatusPages,
} {
rows = append(rows, CatalogueRow{
Kind: KindFeature, Scope: ScopeShared, FeatureKey: f,
})
}
return rows
}
// SeedCatalogue inserts the nine rows the four paid plans need.
//
// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted
// in by staff and a redeploy must not blank them.
func SeedCatalogue(ctx context.Context) error {
paid := []string{license.TierProfessional, license.TierEnterprise}
for _, deployment := range license.Deployments() {
for _, tier := range paid {
rows := []CatalogueRow{
{Kind: KindBase, Deployment: deployment, Tier: tier},
{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureVulnScanning},
{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureStatusPages},
}
for _, r := range rows {
filter := bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
}
if _, err := db.Admin("catalogue").UpdateOne(ctx, filter,
bson.M{"$setOnInsert": bson.M{
"kind": r.Kind,
"deployment": r.Deployment,
"tier": r.Tier,
"limit_key": r.LimitKey,
"feature_key": r.FeatureKey,
"price_ids": map[string]map[string]string{},
}},
options.UpdateOne().SetUpsert(true)); err != nil {
return err
}
}
for _, r := range seedRows() {
set := r.naturalKey()
set["scope"] = r.Scope
set["price_ids"] = map[string]map[string]string{}
if _, err := db.Admin("catalogue").UpdateOne(ctx, r.naturalKey(),
bson.M{"$setOnInsert": set},
options.UpdateOne().SetUpsert(true)); err != nil {
return err
}
}
return nil
}
// CatalogueFor returns every component of one plan.
// MigrateSharedCatalogue collapses the four per-plan copies of each add-on onto
// the one shared row, and deletes the copies.
//
// It runs after SeedCatalogue, which has already created the shared rows empty,
// and is idempotent: once the per-plan copies are gone there is nothing to move.
//
// It REFUSES rather than guesses when the copies disagree. Four rows that were
// meant to be one price and are not is a real pricing decision somebody made,
// and picking one of them silently would move a customer's bill.
func MigrateSharedCatalogue(ctx context.Context) error {
// Rows seeded before scope existed are all per-plan rows. Naming them so
// keeps CatalogueFor's $or honest for the base rows that survive.
if _, err := db.Admin("catalogue").UpdateMany(ctx,
bson.M{"scope": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"scope": ScopePlan}}); err != nil {
return err
}
for _, shared := range seedRows() {
if !shared.Shared() {
continue
}
cur, err := db.Admin("catalogue").Find(ctx, bson.M{
"kind": shared.Kind,
"limit_key": shared.LimitKey,
"feature_key": shared.FeatureKey,
"deployment": bson.M{"$ne": ""},
})
if err != nil {
return err
}
old := []CatalogueRow{}
if err := cur.All(ctx, &old); err != nil {
return err
}
if len(old) == 0 {
continue
}
var target CatalogueRow
if err := db.Admin("catalogue").FindOne(ctx, shared.naturalKey()).Decode(&target); err != nil {
return err
}
merged := target.PriceIDs
if merged == nil {
merged = map[string]map[string]string{}
}
for _, o := range old {
for env, byTerm := range o.PriceIDs {
for term, id := range byTerm {
if id == "" {
continue
}
if merged[env] == nil {
merged[env] = map[string]string{}
}
if have := merged[env][term]; have != "" && have != id {
return fmt.Errorf(
"catalogue: %s%s was priced differently per plan (%s %s: %q and %q); "+
"decide which price is the shared one and delete the others before upgrading",
shared.LimitKey, shared.FeatureKey, env, term, have, id)
}
merged[env][term] = id
}
}
}
if _, err := db.Admin("catalogue").UpdateOne(ctx, shared.naturalKey(),
bson.M{"$set": bson.M{"price_ids": merged}}); err != nil {
return err
}
ids := make([]bson.ObjectID, 0, len(old))
for _, o := range old {
ids = append(ids, o.ID)
}
if _, err := db.Admin("catalogue").DeleteMany(ctx,
bson.M{"_id": bson.M{"$in": ids}}); err != nil {
return err
}
log.Printf("catalogue: merged %d per-plan rows into shared %s%s",
len(old), shared.LimitKey, shared.FeatureKey)
}
return nil
}
// CatalogueFor returns every component one plan sells: its own base row plus
// every shared add-on.
//
// This is the seam the whole shared-row change rests on. Every reader that used
// to filter the catalogue by deployment and tier must come through here instead,
// or it sees a plan priced by nothing but its base fee.
func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) {
deployment, tier = license.NormaliseTier(deployment, tier)
cur, err := db.Admin("catalogue").Find(ctx,
bson.M{"deployment": deployment, "tier": tier})
cur, err := db.Admin("catalogue").Find(ctx, bson.M{"$or": []bson.M{
{"scope": ScopeShared},
{"deployment": deployment, "tier": tier},
}})
if err != nil {
return nil, err
}
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useMutation, useQuery } from "@tanstack/react-query";
import { rowsForPlan, sharedRows } from "@/lib/catalogue";
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
import { featureDesc, featureLabel } from "@/lib/features";
@@ -60,24 +61,24 @@ export function PurchaseForm() {
const options = optionsQ.data;
const accountId = account.data?.account.account_id ?? "";
// Distinct feature keys offered on this deployment, in a stable order.
// Every feature a paid plan can be sold, in a stable order. Features are
// shared rows now, so they no longer differ by deployment — the list is the
// same on both, and reads from one place rather than four.
const featureKeys = useMemo(() => {
if (!options) return [] as string[];
const keys = new Set<string>();
for (const r of options.catalogue) {
if (r.deployment === dep && r.kind === "feature" && r.feature_key) {
keys.add(r.feature_key);
}
for (const r of sharedRows(options.catalogue)) {
if (r.kind === "feature" && r.feature_key) keys.add(r.feature_key);
}
return [...keys];
}, [options, dep]);
}, [options]);
const activePlans = useMemo(() => (options?.plans ?? []).filter((p) => p.deployment === dep && p.active).sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)), [options, dep]);
const plan = activePlans.find((p) => p.tier === choice.tier);
const baseServers = plan?.base_limits.max_servers ?? 0;
const unlimited = baseServers === -1;
const rows = useMemo(() => (options?.catalogue ?? []).filter((r) => r.deployment === dep && r.tier === choice.tier), [options, dep, choice.tier]);
const rows = useMemo(() => rowsForPlan(options?.catalogue ?? [], dep, choice.tier), [options, dep, choice.tier]);
// Real line items for the current configuration the same builder the
// checkout uses, so the summary can never disagree with the overlay.
@@ -239,7 +240,7 @@ export function PurchaseForm() {
headline={p.tier === "free" ? "£0" : basePrices[p.tier]}
cycleLabel={cycleShort(dep, choice.term)}
featureKeys={featureKeys}
catalogue={options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier)}
catalogue={rowsForPlan(options.catalogue, dep, p.tier)}
env={options.env}
term={choice.term}
onSelect={() =>
@@ -252,7 +253,7 @@ export function PurchaseForm() {
features: c.features.filter((k) => {
const st = featureStateFor(
p,
options.catalogue.filter((r) => r.deployment === dep && r.tier === p.tier),
rowsForPlan(options.catalogue, dep, p.tier),
options.env,
c.term,
k,
@@ -658,7 +659,7 @@ function Receipt({
// Label each real line item from the catalogue, and price it from Paddle.
const base = plan?.base_limits.max_servers ?? 0;
const extra = base === -1 ? 0 : Math.max(0, choice.servers - base);
const rows = options.catalogue.filter((r) => r.deployment === dep && r.tier === choice.tier);
const rows = rowsForPlan(options.catalogue, dep, choice.tier);
const idFor = (predicate: (r: CatalogueRow) => boolean) => {
const row = rows.find(predicate);
return row?.price_ids?.[options.env]?.[choice.term] ?? "";
@@ -1,169 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PageHeader } from "@/components/PageHeader";
import { PageFrame } from "@/components/PageFrame";
import { Panel } from "@/components/Panel";
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
import { api, type CatalogueRow, type Term } from "@/lib/api";
const ENVS = ["sandbox", "production"] as const;
/* Self-hosted sells annual only, so the monthly cell is not rendered for it
* rather than rendered and rejected. The backend refuses one either way; this is
* so nobody types into a field that cannot be saved. */
function termsFor(deployment: string): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
function componentLabel(r: CatalogueRow): string {
if (r.kind === "base") return "Base fee";
if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
return `Feature: ${r.feature_key}`;
}
function rowKey(r: CatalogueRow): string {
return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}
export default function CataloguePage() {
const qc = useQueryClient();
const { data: rows = [], isLoading } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});
const save = useMutation({
mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
});
const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));
return (
<div className="grid gap-6">
<PageHeader
title="Catalogue"
back={{ href: "/staff", label: "Operations" }}
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
/>
<PageFrame
aside={
<aside className="space-y-3 text-[0.82rem] text-ink-2">
<p>
A component with no price ID is free. A feature with no price is a
toggle a customer may take at no charge; giving it a price here is
all it takes to start charging for it.
</p>
<p>
Free is priced by nothing and has no rows. That absence is what
keeps it outside Paddle.
</p>
<p>
Changing a price affects the next checkout only. It cannot touch an
issued licence.
</p>
</aside>
}
>
{isLoading ? (
<p className="text-[0.85rem] text-ink-3">Loading</p>
) : (
<div className="grid gap-4">
{groups.map((g) => {
const [deployment, tier] = g.split("/");
const terms = termsFor(deployment);
return (
<Panel key={g} title={`${deployment === "cloud" ? "Cloud" : "Self-Hosted"} ${tier}`} meta={terms.join(" · ")} bodyless>
<Table className="min-w-[42rem]">
<THead>
<TR className="hover:bg-transparent">
<TH>Component</TH>
{ENVS.map((env) =>
terms.map((t) => (
<TH key={`${env}-${t}`}>
{env} / {t}
</TH>
)),
)}
<TH />
</TR>
</THead>
<TBody>
{rows
.filter(
(r) =>
r.deployment === deployment &&
r.tier === tier,
)
.map((r) => {
const k = rowKey(r);
const ids = drafts[k] ?? r.price_ids ?? {};
const dirty =
JSON.stringify(ids) !==
JSON.stringify(r.price_ids ?? {});
return (
<TR key={k}>
<TD className="text-ink">{componentLabel(r)}</TD>
{ENVS.map((env) =>
terms.map((t) => (
<TD key={`${env}-${t}`}>
<input
value={
ids[env]?.[t] ?? ""
}
placeholder="pri_…"
onChange={(e) =>
setDrafts({
...drafts,
[k]: {
...ids,
[env]: {
...(ids[
env
] ?? {}),
[t]: e
.target
.value,
},
},
})
}
className="w-40 rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.78rem] text-ink focus:border-accent focus:outline-none"
/>
</TD>
)),
)}
<TD numeric>
<button
type="button"
disabled={
!dirty || save.isPending
}
onClick={() =>
save.mutate({
...r,
price_ids: ids,
})
}
className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40"
>
Save
</button>
</TD>
</TR>
);
})}
</TBody>
</Table>
</Panel>
);
})}
</div>
)}
</PageFrame>
</div>
);
}
+1 -2
View File
@@ -7,8 +7,7 @@ const LINKS: NavLink[] = [
{ href: "/staff", label: "Operations" },
{ href: "/staff/accounts", label: "Accounts" },
{ href: "/staff/licenses", label: "Licences" },
{ href: "/staff/plans", label: "Plans" },
{ href: "/staff/catalogue", label: "Catalogue" },
{ href: "/staff/pricing", label: "Pricing" },
{ href: "/staff/audit", label: "Audit" },
];
-153
View File
@@ -1,153 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { PageHeader } from "@/components/PageHeader";
import { Panel } from "@/components/Panel";
const SUPPORT_LEVELS = [
{ value: "community", label: "Community" },
{ value: "email_24_5", label: "Email, 24/5" },
{ value: "email_call_24_7", label: "Email + call, 24/7" },
] as const;
const LIMIT_FIELDS = [
{ key: "max_servers", label: "Servers" },
{ key: "max_monitors", label: "Monitors" },
{ key: "max_secret_groups", label: "Secret groups" },
{ key: "max_channels", label: "Channels" },
{ key: "audit_retention_days", label: "Audit history (days)" },
] as const;
/*
* -1 is Unlimited everywhere in the licence payload, so the form takes it
* literally rather than inventing a checkbox. A staff screen that hides the
* sentinel is a staff screen where nobody can tell whether a plan says
* unlimited or nothing at all.
*/
function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) {
const [draft, setDraft] = useState<Plan>(plan);
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
return (
<div className="grid gap-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{LIMIT_FIELDS.map((f) => (
<label key={f.key} className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
<input
type="number"
value={draft.base_limits[f.key]}
onChange={(e) =>
setDraft({
...draft,
base_limits: {
...draft.base_limits,
[f.key]: Number(e.target.value),
},
})
}
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
/>
<span className="mt-0.5 block text-[0.72rem] text-ink-3">1 is unlimited</span>
</label>
))}
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">Support level</span>
<select
value={draft.support_level}
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
>
{SUPPORT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
</div>
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
Offered to customers
</label>
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
<button
type="button"
disabled={!dirty || saving}
onClick={() => onSave(draft)}
className="justify-self-start rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink disabled:opacity-40"
>
{saving ? "Saving…" : "Save allowances"}
</button>
</div>
);
}
export default function PlansPage() {
const qc = useQueryClient();
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
const licenses = useQuery({
queryKey: ["staff-licenses"],
queryFn: () => api.staff.licenses(),
});
const [draft, setDraft] = useState<Plan | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const save = useMutation({
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["plans"] });
setDraft(null);
setSaving(null);
},
onError: () => setSaving(null),
});
const original = plans.data?.find((p) => p.deployment === draft?.deployment && p.tier === draft?.tier);
return (
<div className="grid gap-6">
<PageHeader
title="Plans"
subtitle="The authoritative tier table six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
/>
{draft && original && (
<ConfirmPlanChange
plan={original}
next={draft}
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier && l.deployment === draft.deployment).length}
onConfirm={() => {
setSaving(`${draft.deployment}/${draft.tier}`);
save.mutate(draft);
}}
onCancel={() => setDraft(null)}
/>
)}
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
<section key={deployment} className="grid gap-3">
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
{(plans.data ?? [])
.filter((p) => p.deployment === deployment)
.map((p) => (
<Panel
key={`${p.deployment}/${p.tier}`}
title={p.name}
meta={`${p.deployment}/${p.tier}`}
actions={!p.active ? <span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-warn">Not offered</span> : undefined}
>
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
</Panel>
))}
</section>
))}
</div>
);
}
@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Panel } from "@/components/Panel";
import { SectionHeading } from "./SectionHeading";
import { planRows, rowKey, sharedRows } from "@/lib/catalogue";
import { featureLabel } from "@/lib/features";
import { api, type CatalogueRow, type Term } from "@/lib/api";
const ENVS = ["sandbox", "production"] as const;
/* A shared row is sold by both deployments, so it holds both terms: the cloud
* checkout takes the monthly price and the self-hosted one never asks for it. A
* plan row offers only the terms its own deployment sells self-hosted is
* annual only, and the field is not rendered rather than rendered and refused. */
function termsFor(r: CatalogueRow): Term[] {
if (r.scope === "shared") return ["monthly", "annual"];
return r.deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
function componentLabel(r: CatalogueRow): string {
if (r.kind === "base") return `${r.tier === "enterprise" ? "Enterprise" : "Professional"} (${r.deployment === "cloud" ? "Cloud" : "Self-hosted"})`;
if (r.kind === "limit") return "Additional server";
return featureLabel(r.feature_key ?? "");
}
function componentDetail(r: CatalogueRow): string {
if (r.kind === "base") return "The plan's own fee, always quantity 1";
if (r.kind === "limit") return `Raises ${r.limit_key} by one per unit`;
return `feature · ${r.feature_key}`;
}
/*
* The coverage ledger: one square per environment and term, filled when that
* cell holds a price ID.
*
* A missing production price is invisible in a grid of text inputs every cell
* looks like every other until you read twenty-six characters of each. This is
* the one thing staff come to this page to check before a launch, so it reads
* before the IDs do.
*/
function Coverage({ row, terms }: { row: CatalogueRow; terms: Term[] }) {
const cells = ENVS.flatMap((env) => terms.map((t) => ({ env, t, filled: Boolean(row.price_ids?.[env]?.[t]) })));
const filled = cells.filter((c) => c.filled).length;
return (
<span className="flex items-center gap-1">
{cells.map((c) => (
<span key={`${c.env}-${c.t}`} title={`${c.env} ${c.t}`} className={["block h-2.5 w-2.5 rounded-[1px] border", c.filled ? "border-valid bg-valid" : "border-rule bg-panel-2"].join(" ")} />
))}
<span className="ml-1.5 font-mono text-[0.62rem] tracking-[0.08em] text-ink-3">
{filled}/{cells.length} priced
</span>
</span>
);
}
function ComponentRow({ row, scopeLabel }: { row: CatalogueRow; scopeLabel: string }) {
const qc = useQueryClient();
const [draft, setDraft] = useState<CatalogueRow["price_ids"] | null>(null);
const ids = draft ?? row.price_ids ?? {};
const dirty = JSON.stringify(ids) !== JSON.stringify(row.price_ids ?? {});
const terms = termsFor(row);
const save = useMutation({
mutationFn: () => api.staff.updateCatalogue({ ...row, price_ids: ids }),
onSuccess: () => {
setDraft(null);
qc.invalidateQueries({ queryKey: ["staff", "catalogue"] });
},
});
const set = (env: string, term: Term, value: string) =>
setDraft({ ...ids, [env]: { ...(ids[env] ?? {}), [term]: value } });
return (
<div className="grid gap-3 border-t border-rule-soft pt-3 first:border-0 first:pt-0 md:grid-cols-[minmax(0,17rem)_1fr]">
<div className="grid content-start gap-1.5">
<span className="text-[0.9rem] font-semibold">{componentLabel(row)}</span>
<span className={["w-max rounded border px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.1em]", row.scope === "shared" ? "border-accent text-accent" : "border-rule text-ink-3"].join(" ")}>{scopeLabel}</span>
<span className="text-[0.78rem] text-ink-3">{componentDetail(row)}</span>
<Coverage row={{ ...row, price_ids: ids }} terms={terms} />
</div>
<div className="grid gap-2">
<div className="grid gap-1.5 sm:grid-cols-2">
{ENVS.map((env) => (
<div key={env} className="grid content-start gap-1.5">
<span className="flex items-center gap-2 font-mono text-[0.62rem] uppercase tracking-[0.12em] text-ink-3">
{env}
<span className="h-px flex-1 bg-rule-soft" />
</span>
{terms.map((t) => (
<label key={t} className="grid gap-1">
<span className="font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3">{t}</span>
<input
value={ids[env]?.[t] ?? ""}
placeholder="pri_…"
onChange={(e) => set(env, t, e.target.value)}
className={["w-full rounded border bg-panel-2 px-2 py-1.5 font-mono text-[0.76rem] text-ink focus:border-accent focus:outline-none", ids[env]?.[t] ? "border-rule" : "border-dashed border-rule"].join(" ")}
aria-label={`${componentLabel(row)} ${env} ${t} price ID`}
/>
</label>
))}
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" disabled={!dirty || save.isPending} onClick={() => save.mutate()} className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40">
{save.isPending ? "Saving…" : "Save"}
</button>
{save.error && <span className="text-[0.78rem] text-expired">{(save.error as Error).message}</span>}
</div>
</div>
</div>
);
}
/*
* The catalogue half of /staff/pricing: every priceable component, grouped by
* what it is rather than by which plan sells it.
*/
export function CatalogueSection() {
const { data: rows = [], isLoading } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const shared = sharedRows(rows);
const bases = planRows(rows);
return (
<section className="grid gap-3">
<SectionHeading
title="Catalogue"
note="Every priceable component, grouped by what it is rather than by which plan sells it. This is the only place a Paddle price ID lives."
/>
<div className="grid gap-1.5 rounded border-l-2 border-accent bg-accent-wash px-3 py-2.5 text-[0.82rem] text-ink-2">
<p>An add-on is one Paddle product sold to every paid plan, so its price is typed once. Only the base fee differs by plan, because only the base fee is a different product per plan.</p>
<p>A component with no price ID is free a feature with no price is a toggle a customer may take at no charge. Free is priced by nothing and has no rows at all, which is what keeps it outside Paddle. Changing a price affects the next checkout only; it cannot touch an issued licence.</p>
</div>
{isLoading ? (
<p className="text-[0.85rem] text-ink-3">Loading</p>
) : (
<div className="grid gap-3">
<Panel title="Add-ons" meta={`${shared.length} rows · every paid plan`}>
{shared.map((r) => (
<ComponentRow key={rowKey(r)} row={r} scopeLabel="All paid plans" />
))}
</Panel>
<Panel title="Base fee" meta={`${bases.length} rows · one per plan`}>
{bases.map((r) => (
<ComponentRow key={rowKey(r)} row={r} scopeLabel="This plan only" />
))}
</Panel>
</div>
)}
</section>
);
}
@@ -0,0 +1,248 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, type Deployment, type Plan } from "@/lib/api";
import { featureDesc, featureLabel, FEATURE_LABEL } from "@/lib/features";
import { limitLabel } from "@/lib/format";
import { Button, controlClass } from "@/components/Button";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { SectionHeading } from "./SectionHeading";
import { Modal } from "@/components/Modal";
const SUPPORT_LEVELS = [
{ value: "community", label: "Community" },
{ value: "email_24_5", label: "Email, 24/5" },
{ value: "email_call_24_7", label: "Email + call, 24/7" },
] as const;
const LIMIT_FIELDS = [
{ key: "max_servers", label: "Servers" },
{ key: "max_monitors", label: "Monitors" },
{ key: "max_secret_groups", label: "Secret groups" },
{ key: "max_channels", label: "Channels" },
{ key: "audit_retention_days", label: "Audit history (days)" },
] as const;
const FEATURE_KEYS = Object.keys(FEATURE_LABEL);
const planKey = (p: Plan) => `${p.deployment}/${p.tier}`;
/*
* The list is tiers, and a tier's settings are behind a button.
*
* Six plans with five number fields, a select, a checkbox and four toggles each
* is forty-odd controls on one screen, and the page it made could not be read
* for the thing it exists to answer: what does each tier give you. The card
* answers that; the modal is where it is changed.
*/
function TierCard({ plan, onOpen }: { plan: Plan; onOpen: () => void }) {
return (
<button
type="button"
onClick={onOpen}
className={[
"grid w-full gap-2.5 rounded border bg-panel p-3.5 text-left",
"transition-[border-color,transform] duration-150 hover:-translate-y-px hover:border-accent",
plan.active ? "border-rule" : "border-dashed border-rule opacity-75",
].join(" ")}
>
<span className="flex flex-wrap items-center gap-2">
<span className="text-[1rem] font-semibold">{plan.name}</span>
{!plan.active && <span className="rounded border border-warn px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.1em] text-warn">Not offered</span>}
<span className="ml-auto font-mono text-[0.68rem] text-ink-3">{planKey(plan)}</span>
</span>
<dl className="grid grid-cols-[1fr_auto] gap-x-3 gap-y-0.5 text-[0.82rem]">
<dt className="text-ink-3">Servers</dt>
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.max_servers)}</dd>
<dt className="text-ink-3">Monitors</dt>
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.max_monitors)}</dd>
<dt className="text-ink-3">Audit history</dt>
<dd className="text-right tabular-nums">{limitLabel(plan.base_limits.audit_retention_days)} days</dd>
</dl>
{/* Every feature key, lit or unlit an absent chip cannot be told
* from a feature nobody has heard of, and no tier bundles one today,
* so the unlit row IS the information. */}
<span className="flex flex-wrap gap-1">
{FEATURE_KEYS.map((k) => {
const on = plan.base_features.includes(k);
return (
<span key={k} className={["rounded border px-1.5 py-px font-mono text-[0.6rem] uppercase tracking-[0.06em]", on ? "border-valid text-valid" : "border-rule text-ink-3"].join(" ")}>
{featureLabel(k)}
</span>
);
})}
</span>
<span className="justify-self-start rounded border border-accent px-2.5 py-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-accent">Open plan</span>
</button>
);
}
/* -1 is Unlimited everywhere in the licence payload, so the form takes it
* literally rather than inventing a checkbox. A staff screen that hides the
* sentinel is a staff screen where nobody can tell whether a plan says
* unlimited or nothing at all. */
function PlanModal({ plan, onClose, onSave }: { plan: Plan; onClose: () => void; onSave: (next: Plan) => void }) {
const [draft, setDraft] = useState<Plan>(plan);
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
const toggleFeature = (key: string, on: boolean) =>
setDraft({
...draft,
base_features: on ? [...draft.base_features, key] : draft.base_features.filter((f) => f !== key),
});
return (
<Modal
open
onClose={onClose}
title={plan.name}
meta={planKey(plan)}
footer={
<>
<p className="mr-auto max-w-md text-[0.78rem] text-ink-3">Applies to licences issued from now on. Issued licences snapshotted their plan and are unaffected.</p>
<Button type="button" variant="line" onClick={onClose}>
Cancel
</Button>
<Button type="button" disabled={!dirty} onClick={() => onSave(draft)}>
Save plan
</Button>
</>
}
>
<section className="grid gap-2">
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Base limits</span>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{LIMIT_FIELDS.map((f) => (
<label key={f.key} className="grid gap-1">
<span className="text-[0.78rem] text-ink-3">{f.label}</span>
<input
type="number"
value={draft.base_limits[f.key]}
onChange={(e) =>
setDraft({
...draft,
base_limits: { ...draft.base_limits, [f.key]: Number(e.target.value) },
})
}
className={controlClass("h-9 text-[0.84rem] tabular-nums")}
/>
</label>
))}
</div>
<p className="text-[0.78rem] text-ink-3">1 is unlimited. A metered dimension starts here and the customer buys upward from it.</p>
</section>
<section className="grid gap-2">
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Base features</span>
<div className="grid gap-1.5">
{FEATURE_KEYS.map((k) => {
const on = draft.base_features.includes(k);
return (
<label key={k} className="flex items-center gap-2.5 rounded border border-rule-soft bg-panel-2 px-2.5 py-2">
<input type="checkbox" checked={on} onChange={(e) => toggleFeature(k, e.target.checked)} />
<span>
<span className="block text-[0.86rem]">{featureLabel(k)}</span>
<span className="block text-[0.75rem] text-ink-3">{featureDesc(k)}</span>
</span>
<span className="ml-auto font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-3">{on ? "Included" : "Sold as add-on"}</span>
</label>
);
})}
</div>
<p className="text-[0.78rem] text-ink-3">No tier bundles a feature today. Including one here grants it with the plan and removes it from the customer&apos;s purchase form.</p>
</section>
<section className="grid gap-2">
<span className="font-mono text-[0.66rem] uppercase tracking-[0.14em] text-ink-3">Availability</span>
<div className="grid gap-2 sm:grid-cols-2">
<label className="grid gap-1">
<span className="text-[0.78rem] text-ink-3">Support level</span>
<select value={draft.support_level} onChange={(e) => setDraft({ ...draft, support_level: e.target.value })} className={controlClass("h-9 text-[0.84rem]")}>
{SUPPORT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 self-end pb-2 text-[0.86rem]">
<input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
Offered to customers
</label>
</div>
</section>
</Modal>
);
}
/*
* The plans half of /staff/pricing. It is a section rather than a page because
* a tier's allowances and a tier's price are one decision made in one sitting,
* and they were two screens with no view showing both.
*/
export function PlansSection() {
const qc = useQueryClient();
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
const licenses = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
/* Two pieces of state, not one: `editing` is the plan whose modal is open,
* `confirming` is the edit awaiting the change summary. Collapsing them put
* the confirmation behind the modal it was confirming. */
const [editing, setEditing] = useState<Plan | null>(null);
const [confirming, setConfirming] = useState<Plan | null>(null);
const save = useMutation({
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["plans"] });
setConfirming(null);
},
});
const original = plans.data?.find((p) => p.deployment === confirming?.deployment && p.tier === confirming?.tier);
return (
<div className="grid gap-6">
<SectionHeading title="Plans" note="What each tier grants. Open a tier to change its base limits and features. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence." />
{confirming && original && (
<ConfirmPlanChange
plan={original}
next={confirming}
issuedCount={(licenses.data ?? []).filter((l) => l.tier === confirming.tier && l.deployment === confirming.deployment).length}
onConfirm={() => save.mutate(confirming)}
onCancel={() => setConfirming(null)}
/>
)}
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
<section key={deployment} className="grid gap-2.5">
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-hosted"}</h2>
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
{(plans.data ?? [])
.filter((p) => p.deployment === deployment)
.map((p) => (
<TierCard key={planKey(p)} plan={p} onOpen={() => setEditing(p)} />
))}
</div>
</section>
))}
{editing && (
<PlanModal
key={planKey(editing)}
plan={editing}
onClose={() => setEditing(null)}
onSave={(next) => {
setEditing(null);
setConfirming(next);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,15 @@
/*
* The heading that separates the two halves of /staff/pricing.
*
* It is not PageHeader: the page has one of those, and a second title-sized
* heading under it would read as a second page. This is the same mono eyebrow
* idiom the deployment groups use, one level up.
*/
export function SectionHeading({ title, note }: { title: string; note: string }) {
return (
<div className="grid gap-1 border-b border-rule pb-2">
<h2 className="text-[1.05rem] font-bold tracking-[-0.01em]">{title}</h2>
<p className="max-w-[68ch] text-[0.84rem] text-ink-3">{note}</p>
</div>
);
}
@@ -0,0 +1,24 @@
"use client";
import { PageHeader } from "@/components/PageHeader";
import { CatalogueSection } from "./CatalogueSection";
import { PlansSection } from "./PlansSection";
/*
* Plans and catalogue on one page.
*
* They were two nav entries, and the split asked staff to hold one half in
* their head while looking at the other: a tier's allowances decide what the
* metered component charges for, and the base fee is meaningless without the
* allowance it includes. One page, two sections, in the order the decision is
* made what a tier grants, then what it costs.
*/
export default function PricingPage() {
return (
<div className="grid gap-7">
<PageHeader title="Pricing" back={{ href: "/staff", label: "Operations" }} subtitle="What each tier grants, and what every priceable component costs." />
<PlansSection />
<CatalogueSection />
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useRef } from "react";
/*
* A native <dialog>, not a div with a fixed overlay.
*
* showModal() gives focus trapping, inert background, Escape and the top layer
* for free all four are things a hand-rolled overlay gets wrong, and the third
* is the one staff will actually reach for. The only wiring needed is keeping
* React state and the element's open state in step, and routing every close
* Escape, backdrop, button through one onClose.
*/
export function Modal({
open,
onClose,
title,
meta,
footer,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
meta?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (open && !el.open) el.showModal();
if (!open && el.open) el.close();
}, [open]);
return (
<dialog
ref={ref}
onCancel={(e) => {
e.preventDefault();
onClose();
}}
/* Clicking the backdrop hits the dialog element itself, never a
* child so this closes on backdrop and not on content. */
onClick={(e) => {
if (e.target === ref.current) onClose();
}}
className="w-[min(44rem,94vw)] rounded border border-rule bg-panel p-0 text-ink shadow-lg backdrop:bg-[rgba(4,12,24,0.55)]"
>
<header className="flex flex-wrap items-center gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
<h2 className="text-[1.02rem] font-bold tracking-[-0.01em]">{title}</h2>
{meta && <span className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">{meta}</span>}
<button type="button" onClick={onClose} className="ml-auto rounded border border-rule px-2 py-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-2 hover:border-ink-3" aria-label="Close">
Esc
</button>
</header>
<div className="grid max-h-[68vh] gap-4 overflow-y-auto p-4">{children}</div>
{footer && <footer className="flex flex-wrap items-center gap-3 border-t border-rule-soft bg-panel-2 px-4 py-3">{footer}</footer>}
</dialog>
);
}
+2 -1
View File
@@ -2,6 +2,7 @@
import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
import { rowsForPlan } from "@/lib/catalogue";
import { featureLabel } from "@/lib/features";
export interface PlanChoice {
@@ -50,7 +51,7 @@ export default function PlanConfigurator({
);
const plan = available.find((p) => p.tier === value.tier);
const rows = useMemo(
() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier),
() => rowsForPlan(catalogue, deployment, value.tier),
[catalogue, deployment, value.tier],
);
const featureRows = rows.filter((r) => r.kind === "feature");
+9 -3
View File
@@ -8,6 +8,9 @@
* customer-facing and should be shown verbatim).
*/
/* catalogue.ts imports only types from here, so this is not a cycle. */
import { rowsForPlan } from "@/lib/catalogue";
export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
export class NotConnected extends Error {
@@ -176,6 +179,11 @@ export interface Plan {
export interface CatalogueRow {
kind: "base" | "limit" | "feature";
/* "plan" rows carry a deployment and tier and belong to that plan alone.
* "shared" rows leave both empty and are sold by every paid plan, which is
* why a price ID is typed once rather than four times. Read them through
* rowsForPlan in lib/catalogue, never by filtering on deployment. */
scope: "plan" | "shared";
deployment: Deployment;
tier: Tier;
limit_key?: string;
@@ -213,9 +221,7 @@ export function lineItemsFor(
const env = opts.env;
const plan = opts.plans.find((p) => p.deployment === deployment && p.tier === choice.tier);
if (!plan) return [];
const rows = opts.catalogue.filter(
(r) => r.deployment === deployment && r.tier === choice.tier,
);
const rows = rowsForPlan(opts.catalogue, deployment, choice.tier);
const priceOf = (r: CatalogueRow) => r.price_ids?.[env]?.[choice.term] ?? "";
const base = plan.base_limits.max_servers;
const items: { priceId: string; quantity: number }[] = [];
+38
View File
@@ -0,0 +1,38 @@
import type { CatalogueRow, Deployment, Tier } from "@/lib/api";
/*
* rowsForPlan is the TypeScript half of Go's models.CatalogueFor, and the two
* must change together the same shape of hazard as web/lib/targets.ts.
*
* A plan sells its own base row plus every shared add-on row. Shared rows leave
* deployment and tier empty, so the filter this replaced `r.deployment === dep
* && r.tier === tier` — now returns a plan priced by its base fee and nothing
* else. There were five copies of that filter; this is why it is a module.
*/
export function rowsForPlan(
catalogue: CatalogueRow[],
deployment: Deployment,
tier: Tier,
): CatalogueRow[] {
return catalogue.filter(
(r) => r.scope === "shared" || (r.deployment === deployment && r.tier === tier),
);
}
/* Every add-on a paid plan can be sold, in one list. The staff catalogue editor
* shows these once; the purchase form reads them per plan through rowsForPlan. */
export function sharedRows(catalogue: CatalogueRow[]): CatalogueRow[] {
return catalogue.filter((r) => r.scope === "shared");
}
/* The base fee rows, which are genuinely one per plan because each is its own
* Paddle product at its own price. */
export function planRows(catalogue: CatalogueRow[]): CatalogueRow[] {
return catalogue.filter((r) => r.scope !== "shared");
}
/* A stable identity for a row, used as a React key and as the draft key in the
* staff editor. Mirrors the natural key the API addresses a row by. */
export function rowKey(r: CatalogueRow): string {
return [r.scope ?? "plan", r.deployment ?? "", r.tier ?? "", r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}
+8
View File
@@ -8,6 +8,14 @@ import type { NextConfig } from "next";
*/
const nextConfig: NextConfig = {
output: "standalone",
/* Plans and catalogue became one page. Both old paths are bookmarked in
* staff browsers, so they redirect rather than 404. */
async redirects() {
return [
{ source: "/staff/plans", destination: "/staff/pricing", permanent: true },
{ source: "/staff/catalogue", destination: "/staff/pricing", permanent: true },
];
},
};
export default nextConfig;
@@ -67,10 +67,66 @@ func validateIncident(inc *models.StatusIncident) error {
return nil
}
// checkAffectedOnPages refuses an incident naming a component none of its pages
// carries.
//
// An incident's affected components are the page's own components, not the
// fleet's monitors: publishing "api-gateway is degraded" on a page that never
// listed api-gateway names a machine to the public that the page deliberately
// does not, which is the same leak assembleSnapshot's redaction boundary exists
// to prevent — reached from the authoring side instead of the read side.
//
// It is a separate pass rather than part of validateIncident because it reads
// the database, and validateIncident is a pure function of the document. The
// UI only offers the page's components, but as elsewhere the API is the
// boundary and the UI is the courtesy.
//
// A monitor dropped from the page AFTER an incident named it makes the next
// edit of that incident fail, and that is intended: the fix is one unchecked
// box, and the alternative is a page quietly publishing a component it no
// longer has.
func checkAffectedOnPages(instanceID string, inc *models.StatusIncident) error {
if len(inc.AffectedMonitors) == 0 {
return nil
}
ctx, cancel := spCtx()
defer cancel()
cur, err := db.Col("status_pages").Find(ctx, bson.M{
"instance_id": instanceID,
"page_id": bson.M{"$in": inc.PageIDs},
})
if err != nil {
return err
}
var pages []models.StatusPage
if err := cur.All(ctx, &pages); err != nil {
return err
}
onPage := map[string]bool{}
for _, p := range pages {
for _, sec := range p.Sections {
for _, e := range sec.Entries {
onPage[e.MonitorID] = true
}
}
}
for _, id := range inc.AffectedMonitors {
if !onPage[id] {
return fmt.Errorf("%w: %s is not a component of this status page; add it to the page first, or leave it out of the incident",
ErrPageInvalid, id)
}
}
return nil
}
func CreateStatusIncident(instanceID string, inc *models.StatusIncident) (*models.StatusIncident, error) {
if err := validateIncident(inc); err != nil {
return nil, err
}
if err := checkAffectedOnPages(instanceID, inc); err != nil {
return nil, err
}
inc.ID = bson.ObjectID{}
inc.InstanceID = instanceID
inc.IncidentID = uuid.NewString()
@@ -172,6 +228,9 @@ func UpdateStatusIncident(instanceID, incidentID string, inc *models.StatusIncid
if err := validateIncident(inc); err != nil {
return nil, err
}
if err := checkAffectedOnPages(instanceID, inc); err != nil {
return nil, err
}
set := bson.M{
"page_ids": inc.PageIDs,
+178 -225
View File
@@ -136,7 +136,7 @@ function PrivateKeyCard({ keyId }: { keyId: string }) {
return (
<Card>
<CardHeader>
<CardHeader className="mb-4">
<CardTitle>Private Key</CardTitle>
{revealed && (
<div className="flex gap-2">
@@ -255,232 +255,185 @@ export default function KeyDetailPage() {
const assignedServerIds = activeAssignments.map((a) => a.server_id);
return (
<div className="p-4 sm:p-6 lg:p-8">
{showAssign && (
<AssignModal
keyId={keyId}
assignedServerIds={assignedServerIds}
onClose={() => setShowAssign(false)}
/>
)}
<div className="p-4 sm:p-6 lg:p-8">
{showAssign && <AssignModal keyId={keyId} assignedServerIds={assignedServerIds} onClose={() => setShowAssign(false)} />}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<Link href="/keys" className="text-text-secondary hover:text-text-primary text-sm">
SSH Keys
</Link>
<div className="mt-2 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{key.label}</h1>
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>
{key.source}
</Badge>
</div>
{/* A fingerprint is one unbreakable token; without break-all it
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<Link href="/keys" className="text-text-secondary hover:text-text-primary text-sm">
SSH Keys
</Link>
<div className="mt-2 flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{key.label}</h1>
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>{key.source}</Badge>
</div>
{/* A fingerprint is one unbreakable token; without break-all it
overflows the column rather than wrapping. */}
<p className="mt-1 break-all font-mono text-xs text-text-secondary">{key.fingerprint}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setShowAssign(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Assign to Server
</Button>
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete Key
</Button>
</div>
<p className="mt-1 break-all font-mono text-xs text-text-secondary">{key.fingerprint}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setShowAssign(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Assign to Server
</Button>
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete Key
</Button>
</div>
</div>
{/*
* Typed, like the server and monitor deletes. Deleting a key revokes it
* from every server at once, and for a generated key the stored private
* half goes with it there is no copy anywhere else, so this is the one
* delete on the fleet side that cannot be undone by re-uploading what
* the operator already has.
*/}
<ConfirmDialog
open={confirmDelete}
title="Delete SSH key"
confirmLabel="Delete key"
requireTyped={key.label}
loading={isDeleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmDelete(false)}
onConfirm={() => deleteKey()}
body={
<>
<p>
<span className="font-mono text-text-primary">{key.label}</span> is deleted
{activeAssignments.length > 0 && (
<>
{" "}
and revoked from{" "}
<span className="text-text-primary">
{activeAssignments.length} server{activeAssignments.length !== 1 ? "s" : ""}
</span>
</>
)}
. Agents rewrite authorized_keys within 30 seconds.
</p>
{key.has_private_key && <p>The stored private key is destroyed with it. If this is the only copy, access it grants is gone for good.</p>}
</>
}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-1">
<Card>
<CardHeader className="mb-4">
<CardTitle>Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Key ID</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{key.key_id}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Source</dt>
<dd className="mt-0.5 text-text-primary capitalize">{key.source}</dd>
</div>
{key.generated_by_server_id && (
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Generated By</dt>
<dd className="mt-0.5">
<Link href={`/servers/${key.generated_by_server_id}`} className="font-mono text-xs text-accent hover:underline">
{key.generated_by_server_id}
</Link>
</dd>
</div>
)}
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Active Assignments</dt>
<dd className="mt-0.5 text-text-primary">{activeAssignments.length}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Created</dt>
<dd className="mt-0.5 text-text-primary">{new Date(key.created_at).toLocaleString()}</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>Public Key</CardTitle>
<button onClick={handleCopyKey} className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary">
{copiedKey ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</CardHeader>
<div className="rounded-lg border border-border bg-well p-3">
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">{key.public_key}</pre>
</div>
</Card>
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">
Server Assignments
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">{activeAssignments.length} active</span>
</h2>
</div>
{!key.assignments || key.assignments.length === 0 ? (
<div className="py-16 text-center">
<p className="text-text-secondary text-sm">Not assigned to any servers.</p>
<Button variant="secondary" size="sm" className="mt-3" onClick={() => setShowAssign(true)}>
Assign to a server
</Button>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>IP Address</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th>Revoked</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{key.assignments.map((assignment) => (
<Tr key={`${assignment.key_id}-${assignment.server_id}`}>
<Td label="Server">
<Link href={`/servers/${assignment.server_id}`} className="font-medium text-text-primary hover:text-accent">
{assignment.server?.hostname ?? assignment.server_id}
</Link>
</Td>
<Td label="IP Address">
<span className="font-mono text-xs text-text-secondary">{assignment.server?.ip_address ?? "n/a"}</span>
</Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>{assignment.revoked_at ? "revoked" : "active"}</Badge>
</Td>
<Td label="Assigned">
<span className="text-text-secondary text-xs">{new Date(assignment.assigned_at).toLocaleDateString()}</span>
</Td>
<Td label="Revoked">
<span className="text-text-secondary text-xs">{assignment.revoked_at ? new Date(assignment.revoked_at).toLocaleDateString() : "n/a"}</span>
</Td>
<Td>
{!assignment.revoked_at && (
<Button variant="danger" size="sm" onClick={() => revokeKey(assignment.server_id)}>
Revoke
</Button>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
</div>
</div>
{/*
* Typed, like the server and monitor deletes. Deleting a key revokes it
* from every server at once, and for a generated key the stored private
* half goes with it there is no copy anywhere else, so this is the one
* delete on the fleet side that cannot be undone by re-uploading what
* the operator already has.
*/}
<ConfirmDialog
open={confirmDelete}
title="Delete SSH key"
confirmLabel="Delete key"
requireTyped={key.label}
loading={isDeleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmDelete(false)}
onConfirm={() => deleteKey()}
body={
<>
<p>
<span className="font-mono text-text-primary">{key.label}</span> is deleted
{activeAssignments.length > 0 && (
<>
{" "}
and revoked from{" "}
<span className="text-text-primary">
{activeAssignments.length} server{activeAssignments.length !== 1 ? "s" : ""}
</span>
</>
)}
. Agents rewrite authorized_keys within 30 seconds.
</p>
{key.has_private_key && (
<p>
The stored private key is destroyed with it. If this is the only copy, access it grants is gone for
good.
</p>
)}
</>
}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-1">
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Key ID</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{key.key_id}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Source</dt>
<dd className="mt-0.5 text-text-primary capitalize">{key.source}</dd>
</div>
{key.generated_by_server_id && (
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Generated By</dt>
<dd className="mt-0.5">
<Link
href={`/servers/${key.generated_by_server_id}`}
className="font-mono text-xs text-accent hover:underline"
>
{key.generated_by_server_id}
</Link>
</dd>
</div>
)}
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Active Assignments</dt>
<dd className="mt-0.5 text-text-primary">{activeAssignments.length}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Created</dt>
<dd className="mt-0.5 text-text-primary">
{new Date(key.created_at).toLocaleString()}
</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader>
<CardTitle>Public Key</CardTitle>
<button
onClick={handleCopyKey}
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copiedKey ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</CardHeader>
<div className="rounded-lg border border-border bg-well p-3">
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
{key.public_key}
</pre>
</div>
</Card>
{key.has_private_key && <PrivateKeyCard keyId={keyId} />}
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">
Server Assignments
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">
{activeAssignments.length} active
</span>
</h2>
</div>
{!key.assignments || key.assignments.length === 0 ? (
<div className="py-16 text-center">
<p className="text-text-secondary text-sm">Not assigned to any servers.</p>
<Button
variant="secondary"
size="sm"
className="mt-3"
onClick={() => setShowAssign(true)}
>
Assign to a server
</Button>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>IP Address</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th>Revoked</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{key.assignments.map((assignment) => (
<Tr key={`${assignment.key_id}-${assignment.server_id}`}>
<Td label="Server">
<Link
href={`/servers/${assignment.server_id}`}
className="font-medium text-text-primary hover:text-accent"
>
{assignment.server?.hostname ?? assignment.server_id}
</Link>
</Td>
<Td label="IP Address">
<span className="font-mono text-xs text-text-secondary">
{assignment.server?.ip_address ?? "n/a"}
</span>
</Td>
<Td label="Status">
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
{assignment.revoked_at ? "revoked" : "active"}
</Badge>
</Td>
<Td label="Assigned">
<span className="text-text-secondary text-xs">
{new Date(assignment.assigned_at).toLocaleDateString()}
</span>
</Td>
<Td label="Revoked">
<span className="text-text-secondary text-xs">
{assignment.revoked_at
? new Date(assignment.revoked_at).toLocaleDateString()
: "n/a"}
</span>
</Td>
<Td>
{!assignment.revoked_at && (
<Button
variant="danger"
size="sm"
onClick={() => revokeKey(assignment.server_id)}
>
Revoke
</Button>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
</div>
</div>
);
}
+9 -3
View File
@@ -16,7 +16,9 @@ import {
formatDuration,
formatMs,
formatPct,
markIncidents,
relativeTime,
slotChartColor,
slotLabel,
statusStripe,
targetSummary,
@@ -206,7 +208,7 @@ function History({ slots, note }: { slots: Slot[]; note?: string }) {
<span
className={`w-full rounded-[1px] transition-opacity ${
hovered !== null && hovered !== i ? "opacity-50" : ""
} ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
} ${slotChartColor(s)}`}
style={{ height: s.pct === null ? "18%" : `${Math.max(s.pct, 12)}%` }}
/>
</button>
@@ -408,10 +410,14 @@ export default function MonitorDetailPage() {
}
const all: Rollup[] = rollups ?? [];
const slots =
/* Marked with the incidents, so a bar is red only where the monitor was
actually down a failed check the retry policy absorbed stays amber. */
const slots = markIncidents(
range.source === "rollups"
? buildSlots(all, Math.round(range.minutes / 60))
: buildSampleSlots(samples ?? [], range.minutes * 60_000, range.bucketMs);
: buildSampleSlots(samples ?? [], range.minutes * 60_000, range.bucketMs),
incidents ?? [],
);
/* Samples expire after 48h and only start accruing once a check runs, so an
empty short range is a real answer and not a failure to load. */
const emptyRange = range.source === "samples" && (samples ?? []).length === 0;
+30 -11
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Monitor, Rollup } from "@/lib/api";
import { api, Incident, Monitor, Rollup } from "@/lib/api";
import { Button } from "@/components/ui";
import {
DisplayStatus,
@@ -14,6 +14,7 @@ import {
displayStatus,
formatMs,
formatPct,
markIncidents,
relativeTime,
statusStripe,
targetSummary,
@@ -79,11 +80,17 @@ const UNGROUPED = "Ungrouped";
interface MonitorGroup {
name: string;
rows: { monitor: Monitor; rollups: Rollup[] }[];
rows: MonitorRowData[];
}
interface MonitorRowData {
monitor: Monitor;
rollups: Rollup[];
incidents: Incident[];
}
/** Alphabetical, with the ungrouped remainder last so it reads as a leftover. */
function groupMonitors(rows: { monitor: Monitor; rollups: Rollup[] }[]): MonitorGroup[] {
function groupMonitors(rows: MonitorRowData[]): MonitorGroup[] {
const byName = new Map<string, MonitorGroup["rows"]>();
for (const row of rows) {
const name = row.monitor.group?.trim() || UNGROUPED;
@@ -170,9 +177,11 @@ function GroupHeader({
);
}
function MonitorRow({ monitor, rollups }: { monitor: Monitor; rollups: Rollup[] }) {
function MonitorRow({ monitor, rollups, incidents }: MonitorRowData) {
const status = displayStatus(monitor);
const slots = buildSlots(rollups);
/* Incidents, not raw check results, decide which hours read as down see
markIncidents. */
const slots = markIncidents(buildSlots(rollups), incidents);
const pct = uptimePct(rollups.slice(-24));
const latency = monitor.state.latency_ms > 0 ? monitor.state.latency_ms : avgLatency(rollups.slice(-1));
@@ -224,9 +233,21 @@ export default function MonitorsPage() {
})),
});
const incidentQueries = useQueries({
queries: (monitors ?? []).map((m) => ({
queryKey: ["monitors", m.monitor_id, "incidents"],
queryFn: () => api.getMonitorIncidents(m.monitor_id),
refetchInterval: 60_000,
})),
});
const { collapsed, toggle } = useCollapsedGroups();
const rows = (monitors ?? []).map((m, i) => ({ monitor: m, rollups: uptimeQueries[i]?.data ?? [] }));
const rows: MonitorRowData[] = (monitors ?? []).map((m, i) => ({
monitor: m,
rollups: uptimeQueries[i]?.data ?? [],
incidents: incidentQueries[i]?.data ?? [],
}));
const grouped = rows.some(({ monitor }) => !!monitor.group?.trim());
const groups = groupMonitors(rows);
@@ -292,17 +313,15 @@ export default function MonitorsPage() {
<div key={group.name} className="overflow-hidden rounded-lg border border-border bg-surface">
<GroupHeader group={group} collapsed={isCollapsed} onToggle={() => toggle(group.name)} />
{!isCollapsed &&
group.rows.map(({ monitor, rollups }) => (
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
))}
group.rows.map((row) => <MonitorRow key={row.monitor.monitor_id} {...row} />)}
</div>
);
})}
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border bg-surface">
{rows.map(({ monitor, rollups }) => (
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
{rows.map((row) => (
<MonitorRow key={row.monitor.monitor_id} {...row} />
))}
</div>
)}
+1 -1
View File
@@ -249,7 +249,7 @@ function AddKeyCard({ group }: { group: string }) {
return (
<Card>
<CardHeader>
<CardHeader className="mb-4">
<CardTitle>Add / Update Key</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">Adding a key that already exists overwrites its value. Others are left untouched.</p>
+4 -4
View File
@@ -273,13 +273,13 @@ export default function ServerDetailPage() {
* (z-40) and under the nav drawer (z-50). The scroll container is
* AppShell's column, not the window, which is what sticky anchors to.
*/}
<div className="sticky top-14 z-20 border-b border-border bg-background/90 px-4 pt-4 backdrop-blur sm:px-6 lg:top-0 lg:px-8">
<div className="sticky top-14 z-20 border-b border-border bg-background/90 px-4 pt-3 backdrop-blur sm:pt-4 sm:px-6 lg:top-0 lg:px-8">
<Link href="/servers" className="text-sm text-text-secondary transition-colors hover:text-text-primary">
Servers
</Link>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-2 sm:mt-2">
<h1 className="text-xl font-bold text-text-primary sm:text-2xl">{server.hostname}</h1>
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
<span className="font-mono text-sm text-text-secondary">{server.ip_address}</span>
<div className="ml-auto">
@@ -287,7 +287,7 @@ export default function ServerDetailPage() {
</div>
</div>
<div className="mt-2">
<div className="mt-1.5 sm:mt-2">
<TagChips serverId={server.server_id} tags={server.tags} editable />
</div>
+122 -132
View File
@@ -25,138 +25,128 @@ export default function NewServerPage() {
};
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Add Server</h1>
<p className="mt-1 text-sm text-text-secondary">
Generate an install command to register a new server with the Vantage agent.
</p>
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Add Server</h1>
<p className="mt-1 text-sm text-text-secondary">Generate an install command to register a new server with the Vantage agent.</p>
</div>
<div className="max-w-2xl space-y-6">
{!result ? (
<Card>
<CardHeader className="mb-4">
<CardTitle>Generate Install Command</CardTitle>
</CardHeader>
<p className="mb-6 text-sm text-text-secondary leading-relaxed">
Click the button below to generate a one-time install command. The command contains a short-lived token (valid for 1 hour) that registers your server and installs the Vantage agent automatically.
</p>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">Failed to generate install command. Make sure the backend is running.</div>}
<Button variant="primary" loading={isPending} onClick={() => createServer()}>
Generate Install Command
</Button>
</Card>
) : (
<>
<Card>
<CardHeader className="mb-4">
<CardTitle>Install Command</CardTitle>
<span className="rounded-full bg-success/15 px-2.5 py-0.5 text-xs font-medium text-success border border-success/30">Valid for 1 hour</span>
</CardHeader>
<div className="mb-4 flex gap-2">
{(["linux", "windows"] as const).map((o) => (
<button
key={o}
onClick={() => {
setOs(o);
setCopied(false);
}}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
os === o ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{o === "linux" ? "Linux (bash)" : "Windows (PowerShell)"}
</button>
))}
</div>
<p className="mb-4 text-sm text-text-secondary">
{os === "windows" ? (
<>
Run this in an <strong className="text-text-primary">elevated PowerShell</strong> (Run as Administrator):
</>
) : (
<>
Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:
</>
)}
</p>
<div className="relative rounded-lg border border-border bg-well p-4 font-mono text-sm">
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
<span className="text-accent">{os === "windows" ? "PS>" : "$"}</span> <span className="text-text-primary">{command}</span>
</pre>
<button
onClick={handleCopy}
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</div>
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>Server Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<dt className="text-text-secondary">Server ID</dt>
<dd className="font-mono text-text-primary">{result.server_id}</dd>
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<dt className="text-text-secondary">Status</dt>
<dd className="text-warning">Pending registration</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader className="mb-4">
<CardTitle>What happens next?</CardTitle>
</CardHeader>
<ol className="space-y-3 text-sm text-text-secondary">
{[
"The install script detects your CPU architecture (amd64 / arm64)",
"Downloads and verifies the latest agent binary from the Gitea release",
"Writes /etc/vantage/config.yaml with the server ID and token",
"Installs and starts the vantage-agent systemd service",
"The agent calls Register() to obtain a persistent auth token",
"The server status changes to active on the first successful sync",
].map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/20 text-xs font-semibold text-accent">{i + 1}</span>
{step}
</li>
))}
</ol>
</Card>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => {
setResult(null);
setCopied(false);
}}
>
Generate Another
</Button>
</div>
</>
)}
</div>
</div>
<div className="max-w-2xl space-y-6">
{!result ? (
<Card>
<CardHeader>
<CardTitle>Generate Install Command</CardTitle>
</CardHeader>
<p className="mb-6 text-sm text-text-secondary leading-relaxed">
Click the button below to generate a one-time install command. The command
contains a short-lived token (valid for 1 hour) that registers your server
and installs the Vantage agent automatically.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
Failed to generate install command. Make sure the backend is running.
</div>
)}
<Button
variant="primary"
loading={isPending}
onClick={() => createServer()}
>
Generate Install Command
</Button>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Install Command</CardTitle>
<span className="rounded-full bg-success/15 px-2.5 py-0.5 text-xs font-medium text-success border border-success/30">
Valid for 1 hour
</span>
</CardHeader>
<div className="mb-4 flex gap-2">
{(["linux", "windows"] as const).map((o) => (
<button
key={o}
onClick={() => { setOs(o); setCopied(false); }}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
os === o
? "border-accent bg-accent/10 text-accent"
: "border-border bg-surface-2 text-text-secondary hover:border-accent/40 hover:text-text-primary"
}`}
>
{o === "linux" ? "Linux (bash)" : "Windows (PowerShell)"}
</button>
))}
</div>
<p className="mb-4 text-sm text-text-secondary">
{os === "windows" ? (
<>Run this in an <strong className="text-text-primary">elevated PowerShell</strong> (Run as Administrator):</>
) : (
<>Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:</>
)}
</p>
<div className="relative rounded-lg border border-border bg-well p-4 font-mono text-sm">
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
<span className="text-accent">{os === "windows" ? "PS>" : "$"}</span>{" "}
<span className="text-text-primary">{command}</span>
</pre>
<button
onClick={handleCopy}
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? (
<span className="text-success">Copied!</span>
) : (
"Copy"
)}
</button>
</div>
</Card>
<Card>
<CardHeader>
<CardTitle>Server Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<dt className="text-text-secondary">Server ID</dt>
<dd className="font-mono text-text-primary">{result.server_id}</dd>
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<dt className="text-text-secondary">Status</dt>
<dd className="text-warning">Pending registration</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader>
<CardTitle>What happens next?</CardTitle>
</CardHeader>
<ol className="space-y-3 text-sm text-text-secondary">
{[
"The install script detects your CPU architecture (amd64 / arm64)",
"Downloads and verifies the latest agent binary from the Gitea release",
"Writes /etc/vantage/config.yaml with the server ID and token",
"Installs and starts the vantage-agent systemd service",
"The agent calls Register() to obtain a persistent auth token",
"The server status changes to active on the first successful sync",
].map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/20 text-xs font-semibold text-accent">
{i + 1}
</span>
{step}
</li>
))}
</ol>
</Card>
<div className="flex gap-3">
<Button variant="secondary" onClick={() => { setResult(null); setCopied(false); }}>
Generate Another
</Button>
</div>
</>
)}
</div>
</div>
);
}
+65 -13
View File
@@ -96,6 +96,40 @@ function unnamedComponent(draft: Draft): { section: number; entry: number } | nu
return null;
}
/*
* The components an incident may name, in page order.
*
* An incident's affected components are the PAGE's components, not the fleet's
* monitors: naming a monitor the page never listed publishes a machine the page
* deliberately does not, which is the leak assembleSnapshot exists to prevent,
* reached from the authoring side. services.checkAffectedOnPages refuses it
* this is what stops an operator getting that far.
*
* It reads the SAVED page rather than the draft. A component added in the
* editor and not yet saved is not on the page, and offering it would produce an
* incident the server refuses.
*
* The label is the per-page display name, which is the name the reader will see
* the monitor's own name is internal and may differ.
*/
interface PageComponent {
monitorId: string;
label: string;
}
function pageComponents(page: StatusPage | undefined): PageComponent[] {
const seen = new Set<string>();
const out: PageComponent[] = [];
for (const section of page?.sections ?? []) {
for (const entry of section.entries) {
if (seen.has(entry.monitor_id)) continue;
seen.add(entry.monitor_id);
out.push({ monitorId: entry.monitor_id, label: (entry.display_name ?? "").trim() || entry.monitor_id });
}
}
return out;
}
const inputClass =
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
@@ -365,13 +399,13 @@ function fromLocalInput(s: string): string | undefined {
function IncidentFormModal({
pageId,
kind,
monitors,
components,
initial,
onClose,
}: {
pageId: string;
kind: "incident" | "maintenance";
monitors: Monitor[];
components: PageComponent[];
initial?: StatusIncident;
onClose: () => void;
}) {
@@ -383,6 +417,21 @@ function IncidentFormModal({
const [impact, setImpact] = useState(initial?.impact ?? "minor");
const [status, setStatus] = useState(initial?.status ?? statuses[0]);
const [affected, setAffected] = useState<string[]>(initial?.affected_monitors ?? []);
/*
* The page's components, plus any this incident already names that have
* since been removed from the page. The server refuses to save one of those,
* so hiding it would leave an incident that could not be edited at all and
* no way to see why. Shown, flagged, and one click from being dropped.
*/
const selectable = useMemo(() => {
const rows = components.map((c) => ({ ...c, stale: false }));
const known = new Set(components.map((c) => c.monitorId));
for (const id of initial?.affected_monitors ?? []) {
if (!known.has(id)) rows.push({ monitorId: id, label: id, stale: true });
}
return rows;
}, [components, initial]);
const [scheduledStart, setScheduledStart] = useState(toLocalInput(initial?.scheduled_start));
const [scheduledEnd, setScheduledEnd] = useState(toLocalInput(initial?.scheduled_end));
@@ -497,16 +546,17 @@ function IncidentFormModal({
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Affected components</label>
<div className="max-h-40 space-y-1 overflow-auto rounded border border-border p-2">
{monitors.length === 0 && <p className="px-1 py-1 text-xs text-text-tertiary">No monitors yet.</p>}
{monitors.map((m) => (
<label key={m.monitor_id} className="flex items-center gap-2 rounded px-1 py-1 hover:bg-surface-2">
{selectable.length === 0 && <p className="px-1 py-1 text-xs text-text-tertiary">This page has no components yet. Add one above, save the page, then open an incident.</p>}
{selectable.map((c) => (
<label key={c.monitorId} className="flex items-center gap-2 rounded px-1 py-1 hover:bg-surface-2">
<input
type="checkbox"
checked={affected.includes(m.monitor_id)}
onChange={() => toggleMonitor(m.monitor_id)}
checked={affected.includes(c.monitorId)}
onChange={() => toggleMonitor(c.monitorId)}
className="h-4 w-4 accent-accent"
/>
<span className="text-sm text-text-primary">{m.name}</span>
<span className="text-sm text-text-primary">{c.label}</span>
{c.stale && <span className="text-xs text-warning">No longer on this page uncheck to save</span>}
</label>
))}
</div>
@@ -654,7 +704,7 @@ function DeleteIncidentButton({ pageId, incident }: { pageId: string; incident:
);
}
function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monitor[] }) {
function IncidentsPanel({ pageId, components }: { pageId: string; components: PageComponent[] }) {
const {
data: incidents,
isLoading,
@@ -669,10 +719,12 @@ function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monito
const [editing, setEditing] = useState<StatusIncident | null>(null);
const [posting, setPosting] = useState<StatusIncident | null>(null);
// Named as the page names them, so the list here reads as the public page
// reads. An id that survives the lookup is a component since removed.
const monitorName = useMemo(() => {
const m = new Map(monitors.map((mon) => [mon.monitor_id, mon.name]));
const m = new Map(components.map((c) => [c.monitorId, c.label]));
return (id: string) => m.get(id) ?? id;
}, [monitors]);
}, [components]);
return (
<Card>
@@ -680,7 +732,7 @@ function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monito
<IncidentFormModal
pageId={pageId}
kind={editing?.kind ?? openForm ?? "incident"}
monitors={monitors}
components={components}
initial={editing ?? undefined}
onClose={() => {
setOpenForm(null);
@@ -900,7 +952,7 @@ export default function StatusPageEditorPage() {
<div className="space-y-5">
<DetailsPanel draft={draft} setDraft={setDraft} pageId={pageId} />
<ComponentsPanel draft={draft} setDraft={setDraft} monitors={monitors ?? []} />
<IncidentsPanel pageId={pageId} monitors={monitors ?? []} />
<IncidentsPanel pageId={pageId} components={pageComponents(page)} />
</div>
</>
)}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function LoginPage() {
const showDivider = showLocal && providers.length > 0;
return (
<div className="relative flex min-h-screen items-center justify-center p-4">
<div className="relative flex min-h-[100dvh] items-center justify-center p-4">
<NetworkBackground />
<div className="relative w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3">
+2 -2
View File
@@ -80,7 +80,7 @@ export default function SetupPage() {
if (created) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-[100dvh] items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-extrabold tracking-[-0.03em] text-text-primary">Instance created</h1>
@@ -116,7 +116,7 @@ export default function SetupPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-[100dvh] items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-extrabold tracking-[-0.03em] text-text-primary">Welcome to Vantage</h1>
+5 -20
View File
@@ -7,10 +7,7 @@ import type { StatusSnapshot } from "@/lib/api";
// auth redirect. This page is served to the public.
export const dynamic = "force-dynamic";
type FetchResult =
| { kind: "ok"; snapshot: StatusSnapshot }
| { kind: "not-found" }
| { kind: "unavailable" };
type FetchResult = { kind: "ok"; snapshot: StatusSnapshot } | { kind: "not-found" } | { kind: "unavailable" };
/*
* The control plane this call is made to is the visitor's own host.
@@ -27,7 +24,6 @@ function apiBase(proto: string, host: string): string {
}
async function fetchSnapshot(base: string, host: string, forwardedFor: string, pageId: string): Promise<FetchResult> {
// The instance is resolved server-side from the visitor's host, so it has
// to be forwarded explicitly — this is a server-to-server call and its own
// Host names the Go service.
@@ -93,34 +89,23 @@ function unavailableSnapshot(): StatusSnapshot {
};
}
export default async function PublicStatusPage({
params,
}: {
params: Promise<{ pageId: string }>;
}) {
export default async function PublicStatusPage({ params }: { params: Promise<{ pageId: string }> }) {
const { pageId } = await params;
const h = await headers();
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "";
const inboundFor = h.get("x-forwarded-for");
const peer = h.get("x-real-ip");
const forwardedFor = [inboundFor, inboundFor ? null : peer]
.filter((v): v is string => !!v)
.join(", ");
const forwardedFor = [inboundFor, inboundFor ? null : peer].filter((v): v is string => !!v).join(", ");
const proto = h.get("x-forwarded-proto")?.split(",")[0].trim() || "https";
const result = await fetchSnapshot(apiBase(proto, host), host, forwardedFor, pageId);
if (result.kind === "not-found") notFound();
return (
<StatusPageView
pageId={pageId}
initial={result.kind === "ok" ? result.snapshot : unavailableSnapshot()}
/>
);
return <StatusPageView pageId={pageId} initial={result.kind === "ok" ? result.snapshot : unavailableSnapshot()} />;
}
export async function generateMetadata({ params }: { params: Promise<{ pageId: string }> }) {
const { pageId } = await params;
return { title: `Status ${pageId}` };
return { title: `Status: ${pageId}` };
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
}
return (
<div className="flex h-screen overflow-hidden">
<div className="flex h-[100dvh] overflow-hidden">
<Sidebar />
<SidebarDrawer open={open} onClose={close} />
+2 -2
View File
@@ -64,7 +64,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex h-[100dvh] items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
@@ -72,7 +72,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (error || !user) {
return (
<div className="flex h-screen items-center justify-center bg-background p-4">
<div className="flex h-[100dvh] items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-surface p-6 text-center">
<h1 className="text-base font-semibold text-text-primary">Can&apos;t load your session</h1>
<p className="mt-2 text-sm text-text-secondary">{error ?? "Unable to load your session."}</p>
+1 -1
View File
@@ -316,7 +316,7 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
/** The permanent sidebar. Below lg the drawer takes over. */
export function Sidebar() {
return (
<aside className="hidden h-screen w-60 shrink-0 flex-col border-r border-border bg-surface lg:flex">
<aside className="hidden h-full w-60 shrink-0 flex-col border-r border-border bg-surface lg:flex">
<SidebarContent />
</aside>
);
+46 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
import { Incident, Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
/*
* Shared vocabulary for the monitors screens.
@@ -117,6 +117,14 @@ export interface Slot {
pct: number | null;
checks: number;
latency: number | null;
/**
* An incident overlapped this slot the monitor was actually down for
* some of it. A failed check on its own is not this: `retries` exists so a
* transient failure never opens an incident, and painting one red taught
* operators the check had gone offline when nothing had. `pct` still
* reports every failed check honestly; `down` is what colour follows.
*/
down: boolean;
}
/**
@@ -147,6 +155,7 @@ export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
checks: r?.checks ?? 0,
pct: r && r.checks > 0 ? (r.up_count / r.checks) * 100 : null,
latency: r && r.checks > 0 ? r.sum_latency / r.checks : null,
down: false,
});
}
return slots;
@@ -184,19 +193,50 @@ export function buildSampleSlots(samples: MonitorSample[], windowMs: number, buc
checks: bucket.checks,
pct: bucket.checks > 0 ? (bucket.up / bucket.checks) * 100 : null,
latency: bucket.checks > 0 ? bucket.latency / bucket.checks : null,
down: false,
}));
}
/**
* Mark the slots an incident ran through. Incidents are the record of what the
* monitor decided after `retries`, so this is the only thing that may paint a
* slot as down the raw check results say only that a check failed, which is
* a different and much more common event.
*
* An unresolved incident runs to now. Returns new slots; the input is left
* alone so the same tape can be built once and marked per view.
*/
export function markIncidents(slots: Slot[], incidents: Incident[]): Slot[] {
if (incidents.length === 0) return slots;
const spans = incidents.map((i) => ({
from: new Date(i.started_at).getTime(),
to: i.resolved_at ? new Date(i.resolved_at).getTime() : Date.now(),
}));
return slots.map((s) => {
const from = s.at.getTime();
const to = from + s.spanMs;
return spans.some((sp) => sp.from < to && sp.to > from) ? { ...s, down: true } : s;
});
}
function slotColor(s: Slot): string {
if (s.down) return "bg-danger";
if (s.pct === null) return "bg-border-soft";
if (s.pct >= 99.5) return "bg-success";
if (s.pct >= 80) return "bg-warning";
return "bg-danger";
return "bg-warning";
}
/** The same three states at the chart's lower contrast. */
export function slotChartColor(s: Slot): string {
if (s.down) return "bg-danger/80";
if (s.pct === null) return "bg-border-soft";
if (s.pct >= 99.5) return "bg-success/60";
return "bg-warning/70";
}
function slotHeight(s: Slot): number {
if (s.pct === null) return 26;
if (s.pct < 80) return 100;
if (s.down || s.pct < 80) return 100;
return 55 + (s.pct - 80) * 2.2;
}
@@ -205,7 +245,8 @@ function slotHeight(s: Slot): number {
export function slotLabel(s: Slot): string {
const when = s.at.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" });
if (s.pct === null) return `${when} · no checks ran`;
return `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
const base = `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
return s.down ? `${base} · incident` : base;
}
/**
+88 -38
View File
@@ -17,7 +17,7 @@ import { formatBytes, relativeAge } from "./format";
function Meter({ pct }: { pct: number }) {
const clamped = Math.max(0, Math.min(100, pct));
return (
<div className="mt-2 h-[3px] w-full overflow-hidden rounded-full bg-well">
<div className="mt-1.5 h-[3px] w-full overflow-hidden rounded-full bg-well sm:mt-2">
<div
className={clsx("h-full rounded-full transition-[width] duration-500", clamped >= 90 ? "bg-danger" : clamped >= 75 ? "bg-warning" : "bg-accent")}
style={{ width: `${clamped}%` }}
@@ -26,12 +26,28 @@ function Meter({ pct }: { pct: number }) {
);
}
/** The phone strip: four readings on one line, no sub-lines and no byte
* totals. A percentage is the reading; "70.9 GB / 100.0 GB · NTFS" is the
* working, and the working belongs on the Overview tab, which is one scroll
* away. Memory is shown as a percentage here for the same reason it is the
* only form that fits a quarter of a phone without wrapping. */
function CompactVital({ label, value, pct }: { label: string; value: string; pct?: number }) {
return (
<div className="min-w-0 bg-surface px-2 py-1.5">
<div className="truncate font-mono text-[0.55rem] uppercase tracking-[0.1em] text-text-secondary">{label}</div>
<div className="truncate font-mono text-[0.8rem] font-semibold tabular-nums leading-tight text-text-primary">{value}</div>
{pct !== undefined ? <Meter pct={pct} /> : <div className="mt-1.5 h-[3px] w-full rounded-full bg-well" />}
</div>
);
}
function Vital({ label, value, pct, sub }: { label: string; value: string; pct?: number; sub?: string }) {
return (
// sm and up only — the phone gets CompactVital instead.
<div className="min-w-0 bg-surface px-4 py-3">
<div className="flex items-baseline justify-between gap-3">
<span className="font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-secondary">{label}</span>
<span className="font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</span>
<span className="truncate font-mono text-[0.62rem] uppercase tracking-[0.16em] text-text-secondary">{label}</span>
<span className="truncate font-mono text-sm font-semibold tabular-nums text-text-primary">{value}</span>
</div>
{pct !== undefined ? <Meter pct={pct} /> : <div className="mt-2 h-[3px] w-full rounded-full bg-well" />}
{sub && <p className="mt-1.5 truncate text-xs text-text-tertiary">{sub}</p>}
@@ -47,6 +63,17 @@ function primaryPartition(inv: Inventory) {
return parts.find((p) => p.mountpoint === "/") ?? parts.reduce((worst, p) => (p.used_bytes / (p.total_bytes || 1) > worst.used_bytes / (worst.total_bytes || 1) ? p : worst));
}
/** "10.8 / 16.0 GB" rather than "10.8 GB / 16.0 GB" when both halves carry the
* same unit the repeated unit is what pushes the memory cell onto a second
* line on a phone, and it says nothing the right-hand half does not. */
function pair(used: number, total: number) {
const u = formatBytes(used);
const t = formatBytes(total);
const uu = u.split(" ")[1];
const tu = t.split(" ")[1];
return uu && uu === tu ? `${u.split(" ")[0]} / ${t}` : `${u} / ${t}`;
}
export function VitalsRail({ server, agentUpToDate }: { server: Server; agentUpToDate?: boolean }) {
const inv = server.inventory;
const disk = inv ? primaryPartition(inv) : undefined;
@@ -56,38 +83,61 @@ export function VitalsRail({ server, agentUpToDate }: { server: Server; agentUpT
const agentSub = server.agent_version ? `agent v${server.agent_version}${agentUpToDate === undefined ? "" : agentUpToDate ? " · up to date" : " · update available"}` : "agent version unknown";
return (
// One hairline grid rather than four cards: these are readings off one
// machine, and four bordered panels would read as four subjects.
<div className="mt-4 grid grid-cols-2 gap-px overflow-hidden rounded border border-border-soft bg-border-soft lg:grid-cols-4">
{inv ? (
<>
<Vital
label="CPU"
value={`${inv.cpu.usage_pct.toFixed(0)}%`}
pct={inv.cpu.usage_pct}
sub={[inv.cpu.cores ? `${inv.cpu.cores} cores` : null, inv.cpu.load1 !== undefined ? `load ${inv.cpu.load1.toFixed(2)}` : null].filter(Boolean).join(" · ") || inv.cpu.model}
/>
<Vital
label="Memory"
value={`${formatBytes(inv.memory.used_bytes)} / ${formatBytes(inv.memory.total_bytes)}`}
pct={memPct}
sub={inv.swap_total_bytes > 0 ? `swap ${formatBytes(inv.swap_used_bytes)} / ${formatBytes(inv.swap_total_bytes)}` : "no swap"}
/>
<Vital
label={disk ? `Disk ${disk.mountpoint}` : "Disk"}
value={disk ? `${diskPct.toFixed(0)}%` : "—"}
pct={disk ? diskPct : undefined}
sub={disk ? `${formatBytes(disk.used_bytes)} / ${formatBytes(disk.total_bytes)}${disk.fstype ? ` · ${disk.fstype}` : ""}` : "no partitions reported"}
/>
</>
) : (
<>
<Vital label="CPU" value="—" sub="no metrics reported" />
<Vital label="Memory" value="—" sub="no metrics reported" />
<Vital label="Disk" value="—" sub="no metrics reported" />
</>
)}
<Vital label="Last seen" value={relativeAge(server.last_seen)} pct={server.status === "active" ? 100 : 0} sub={agentSub} />
</div>
);
}
<>
{/* Two renderings of the same four readings. Below sm the strip is
one line: label, number, hairline. At sm and up it is the grid
it always was, sub-lines included. */}
<div className="mt-3 grid grid-cols-4 gap-px overflow-hidden rounded border border-border-soft bg-border-soft sm:hidden">
{inv ? (
<>
<CompactVital label="CPU" value={`${inv.cpu.usage_pct.toFixed(0)}%`} pct={inv.cpu.usage_pct} />
<CompactVital label="Mem" value={`${memPct.toFixed(0)}%`} pct={memPct} />
<CompactVital label="Disk" value={disk ? `${diskPct.toFixed(0)}%` : "\u2014"} pct={disk ? diskPct : undefined} />
</>
) : (
<>
<CompactVital label="CPU" value="\u2014" />
<CompactVital label="Mem" value="\u2014" />
<CompactVital label="Disk" value="\u2014" />
</>
)}
<CompactVital label="Seen" value={relativeAge(server.last_seen)} pct={server.status === "active" ? 100 : 0} />
</div>
{/* One hairline grid rather than four cards: these are readings off
one machine, and four bordered panels would read as four
subjects. */}
<div className="mt-3 hidden grid-cols-2 gap-px overflow-hidden rounded border border-border-soft bg-border-soft sm:mt-4 sm:grid lg:grid-cols-4">
{inv ? (
<>
<Vital
label="CPU"
value={`${inv.cpu.usage_pct.toFixed(0)}%`}
pct={inv.cpu.usage_pct}
sub={[inv.cpu.cores ? `${inv.cpu.cores} cores` : null, inv.cpu.load1 !== undefined ? `load ${inv.cpu.load1.toFixed(2)}` : null].filter(Boolean).join(" · ") || inv.cpu.model}
/>
<Vital
label="Memory"
value={pair(inv.memory.used_bytes, inv.memory.total_bytes)}
pct={memPct}
sub={inv.swap_total_bytes > 0 ? `swap ${pair(inv.swap_used_bytes, inv.swap_total_bytes)}` : "no swap"}
/>
<Vital
label={disk ? `Disk ${disk.mountpoint}` : "Disk"}
value={disk ? `${diskPct.toFixed(0)}%` : "—"}
pct={disk ? diskPct : undefined}
sub={disk ? `${pair(disk.used_bytes, disk.total_bytes)}${disk.fstype ? ` · ${disk.fstype}` : ""}` : "no partitions reported"}
/>
</>
) : (
<>
<Vital label="CPU" value="—" sub="no metrics reported" />
<Vital label="Memory" value="—" sub="no metrics reported" />
<Vital label="Disk" value="—" sub="no metrics reported" />
</>
)}
<Vital label="Last seen" value={relativeAge(server.last_seen)} pct={server.status === "active" ? 100 : 0} sub={agentSub} />
</div>
</>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function Card({ className, padding = true, children, ...props }: CardProp
export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx("mb-4 flex flex-wrap items-center justify-between gap-2", className)} {...props}>
<div className={clsx("flex flex-wrap items-center justify-between gap-2", className)} {...props}>
{children}
</div>
);
File diff suppressed because one or more lines are too long