Compare commits

...

8 Commits

Author SHA1 Message Date
Ben Duncan 72f2786360 fix(ui): open a DNS record for editing from the keyboard 2026-11-02 14:27:56 +10:00
Ben Duncan 8916904285 feat(ui): size RDS instance classes and give each record value its own input
An RDS class is a db.-prefixed EC2 type, so the class picker shows its vCPUs
and memory in every option and under the field, as the EC2 launch form does,
on create, modify and restore.

A Route 53 record's values were one textarea, which let a line break into a
value. Each value is now a single-line input with Add value; Enter starts the
next one, a pasted list splits into rows, and CNAME takes exactly one.
2026-11-02 14:27:56 +10:00
Ben Duncan 2630d29e13 feat(ui): console feedback round: state tones, inline tags, filter bar and form fixes
Settled states go green and transitions yellow with a spinner, status text is
lowercase, tags are edited inline row by row, every list filters through one
filter bar, long cells truncate with a tooltip and times are relative.

EC2 launch takes a Name and offers ICMP echo, and leaves Placement Group out
when no group is available. RDS create is reordered with engine tiles and
window pickers. Route 53 records get bulk delete, a TTL select, value labels
that follow the type, and NS. Tooltips use the popover tokens, and the console
is sized over a 110% root.
2026-11-02 14:27:56 +10:00
Julian Sommer e7c0f0ebae fix(ec2): omit network interface PrivateDnsName while the VPC has enableDnsHostnames off (#1424) 2026-11-02 15:15:20 +11:00
Julian Sommer d79e537330 fix(ecs): always return clusters and failures lists from DescribeClusters (#1418) 2026-11-02 14:53:10 +11:00
Julian Sommer fe349fb994 fix: return ClusterNotFoundException from ECS ListTasks on a missing cluster (#1419) 2026-11-02 14:52:49 +11:00
Julian Sommer 061ea49832 fix(ecs): reject task definitions whose container has no image or no memory setting (#1420) 2026-11-02 14:52:35 +11:00
Julian Sommer f4af10713d fix(elbv2): default TCP target groups to thresholds 5/2 and omit empty HealthCheckPath and Matcher (#1421) 2026-11-02 14:52:21 +11:00
170 changed files with 5756 additions and 1904 deletions
+78 -21
View File
@@ -24,7 +24,7 @@ All colours are OKLCH and come from mulgadc.com, so product and site match.
| Token | Use |
|---|---|
| `background` | The app page. Warm off-white in light, ink `#14181D` in dark. |
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. |
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. `popover` is every floating surface: tooltips, popovers, menus and pickers. |
| `muted`, `secondary`, `accent` | Hover rows, secondary buttons, quiet fills. |
| `border` | Every divider and card edge. `#DCD8CE` in light, `oklch(1 0 0 / 8%)` in dark. |
| `brand` | Brand orange `#EE4B12`, for the logo and decoration only. It fails text contrast, so it never carries text. |
@@ -39,15 +39,22 @@ Each status has a solid token plus `-foreground` (text on a tinted background),
| Status | Hue | AWS states |
|---|---|---|
| `success` | green | running, available, completed, healthy |
| `warning` | brand yellow `#F5B500` | pending, stopping, creating, modifying, deleting, draining |
| `danger` | cool red, hue 17 | stopped, error, failed, unhealthy, unavailable |
| `success` | green | settled and good: running, available, active, associated, attached, in-use, healthy, ok, main |
| `warning` | brand yellow `#F5B500` | changing state and will settle: pending, stopping, creating, modifying, deleting, draining |
| `caution` | orange, hue 58 | works but needs attention: degraded, impaired, insufficient-data |
| `danger` | cool red, hue 17 | stopped, error, failed, update-failed, unhealthy, unavailable, blackhole |
| `info` | blue | informational notes |
| `neutral` | grey `#9AA1A8` | terminated (muted text), and any unmapped state |
| `neutral` | grey `#9AA1A8` | gone or absent (terminated, deleted, no) in muted text, and any unmapped state |
`stopped` is danger because EC2 reads it that way, although RDS treats it as a resting state; splitting the map by service is its own change.
`StateBadge` (`src/components/state-badge.tsx`) is the only mapping from an AWS state to a tone, and it exposes the result as `data-tone` for tests.
Its tones are `success`, `progress` (the yellow `warning` token), `caution`, `danger`, `muted` and `neutral`.
A `progress` state carries the submit button's spinning loader, so a launching instance reads as work under way; `StatusBadge` does not, because its yellow can mean waiting on the user, as a zone's `pending nameservers` does.
It shows every state lowercase with hyphens, as EC2 writes them, so EKS's `ACTIVE` reads `active` and Bedrock's `IN_PROGRESS` reads `in-progress`; the API value, filters and CLI panels keep AWS's case.
A good or settled state is always green, never grey, and a status that is not an API state, such as a cluster's health, uses `StatusBadge` with a tone rather than a `Badge`.
The orange of `caution` is kept clear of the primary accent's hue, which marks actions and never a status.
`Badge`'s default variant is the brand primary and is never used for a status; a label that is not a status, such as `default v3` or `Leader`, uses a `secondary` or `outline` `Badge`.
Every status badge measures above 4.8:1 against its tinted background in both themes.
**Danger is separated from the primary accent by hue alone in dark mode**: the two have the same lightness.
@@ -67,18 +74,21 @@ Colour is never the only key: label every series.
| UI text, tables and forms | Geist (`font-sans`) |
| IDs, ARNs, IPs, CIDRs, code and eyebrow labels | JetBrains Mono (`font-mono`), with `tabular-nums` for figures |
| Step | Size | Use |
|---|---|---|
| `2xl` | 26px | page title (`PageHeading`) |
| `xl` | 22px | section heading |
| `lg` | 17px | |
| `base` | 15px | |
| `sm` | 14px | body and table default |
| `xs` | 13px | badges, secondary text |
| `2xs` | 12px | mono eyebrow labels |
| Step | rem | At the default 16px browser font | Use |
|---|---|---|---|
| `2xl` | 1.625 | 28.6px | page title (`PageHeading`) |
| `xl` | 1.375 | 24.2px | section heading |
| `lg` | 1.0625 | 18.7px | |
| `base` | 0.9375 | 16.5px | |
| `sm` | 0.875 | 15.4px | body and table default |
| `xs` | 0.8125 | 14.3px | badges, secondary text |
| `2xs` | 0.75 | 13.2px | mono eyebrow labels |
The scale sits a step above Tailwind's defaults, as consoles such as Vultr's do, because 13px body text read as cramped on a wide screen.
Code blocks are set at 12px (`--text-code-block`) so a long command still fits.
The root font size is 110% (`html` in `src/styles.css`), so one rem is 17.6px at the browser default.
Everything is sized in rem, so text, spacing and breakpoints scale together, as they would at 110% browser zoom, and a user's own font-size setting still applies.
The scale sits above Tailwind's defaults, as consoles such as Vultr's do, because the console read as small on a wide screen at 100%.
Code blocks are set at 0.75rem (`--text-code-block`) so a long command still fits.
Size new things in rem through the type and spacing scales, never in px, or they will not scale with the rest.
### Primary and contrast
@@ -169,6 +179,22 @@ Every other list is a `DataTable`, and the test also fails when an allowed file
| S13 | a component outside `ui/` renders a raw `<input>`, `<select>` or `<textarea>` |
| S15 | a route imports the `ui/alert` primitive instead of using `ErrorBanner` |
Rules that ADR-0016 does not yet number are named by what they hold until their amendment is accepted:
| Rule | Fails when |
|---|---|
| status pills come from StateBadge or StatusBadge | a component outside `ui/` renders `<Badge>` with the default variant |
| status pills come from StateBadge or StatusBadge | a file outside `state-badge.tsx` builds a `rounded-full` pill on a status colour |
| floating surfaces follow the theme | a file fills with `bg-foreground` or `text-background`, or a tooltip, popover, hover card, menu, select or combobox is not on `bg-popover` and `text-popover-foreground` |
| expanding a panel keeps its tab | a `fullPage` link does not pass `search={{ tab }}` |
| resource tags are edited in place | `PanelTags` or `TagsEditor` comes back |
| lists filter through one FilterBar | a component other than `FilterBar` imports `ui/toggle-group` |
| a component handed a form reads errors through `useFormState` | a file typed with `UseFormReturn` reads `form.formState` |
The last one matters because the console builds with the React compiler and the unit tests run without it.
With the compiler on, a child handed a react-hook-form object is cached on that object, whose identity never changes, so errors read from `form.formState` never draw and the submit looks dead.
`useFormState({ control })` subscribes the child itself.
An exception is named in that rule's allowlist with its reason, and the test fails when an allowed file stops needing it.
S11, S14 and S16 are held by review, since none has a shape in the source a test can read without false alarms.
@@ -177,7 +203,7 @@ S11, S14 and S16 are held by review, since none has a shape in the source a test
`src/lib/nav.ts` is the one description of the console's navigation.
The sidebar, the command palette and the breadcrumbs all read it, and `nav.test.ts` checks every destination against the generated route tree, so a renamed route fails a test rather than leaving a dead link.
- **Sidebar.** Groups take generic names rather than AWS's service marks: Compute, Databases, Authentication and Object Storage, not EC2, RDS, IAM and S3. Pages and the rest of the console keep the AWS names, and each group's `service` keeps its AWS name findable in the command palette, where typing it exactly ranks that group first. Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). Navigating into a folded group unfolds it so the active item shows, and it can then be folded again, the current group included. On a phone the sidebar is a sheet, and any navigation closes it.
- **Sidebar.** Groups take generic names rather than AWS's service marks: Compute, Databases, Authentication and Object Storage, not EC2, RDS, IAM and S3. Pages and the rest of the console keep the AWS names, and each group's `service` keeps its AWS name findable in the command palette, where typing it exactly ranks that group first. Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). Navigating into a folded group unfolds it so the active item shows, and it can then be folded again, the current group included. Folding animates through the `nav` variant of `CollapsibleContent`, opening over 200ms and closing over 150ms, and switches at once under reduced motion. On a phone the sidebar is a sheet, and any navigation closes it.
- **Command palette.** ⌘K or Ctrl K, or the Search button in the header. It lists every page the user can reach, every create flow (`create` on a nav item), and a theme toggle. It is navigation only, with no resource search yet.
- **Breadcrumbs.** Service, then list page, then the resource or the create flow. A resource ID is set in mono. On a phone only the last crumb shows.
- **Sign in.** The sign-in card sits over the shell on a light, barely blurred overlay rather than on a page of its own, so the console stays in view. The shell behind it is `inert`, which keeps pointer, keyboard and screen reader out of it until the user signs in.
@@ -202,8 +228,13 @@ So columns drop out when a side panel narrows the list as well as on a small scr
List pages keep their filter, state and sort in the URL, so a filtered view can be bookmarked, shared and survives a reload.
EC2 Instances is the reference page (`ec2/(instances)/-components/instances-page.tsx`):
- The first column names the resource: the `Name` tag in the primary colour, with the ID in mono beneath it, opening its side panel.
- The first column is the instance ID in mono, opening its side panel, and the second its `Name` tag.
- A long value ends in an ellipsis and shows in full on hover (`TruncatedText`), so it never widens its column.
- A time column shows a relative time, `33 min ago` or `8 months ago`, with the full timestamp on hover (`RelativeTime`).
- A Tags column shows one tag key as a pill and `+N` for the rest, and hover lists every tag with its value (`TagPills`); it leaves out `Name`, which has its own column.
- Below `md`, only the ID, Name and State columns remain.
- Bulk actions appear above the table once a row is selected. Each action counts only the selected instances it applies to, so a mixed selection never sends a call that must fail, and Terminate asks first and lists every ID.
- Search and state filters sit in one `FilterBar` (`src/components/filter-bar.tsx`) above the table: a search box, an optional set of choices with counts, and Clear filters while anything is filtered.
- An empty account gets a call to action, and an empty filter result says the filters are why.
VPCs and Internet gateways follow the same pattern.
@@ -219,6 +250,12 @@ Every list with a delete action has row checkboxes. Selecting a row shows `Selec
- **The bulk delete and the panel's delete are the same function.** Where a single delete is several calls, such as removing dependants first, the sequence lives in one place and both use it.
- The verb follows the AWS console: Release for an Elastic IP, Deregister for an image.
### Lists inside a panel
A list inside a panel tab, such as a hosted zone's records, is a `DataTable` with row checkboxes, and a click on a row opens it for editing in a `FormPanel`.
Rows the user cannot change, such as a zone's own SOA and NS, are muted and cannot be selected (`isRowActionable`).
A batch delete uses the service's batch call where it has one, so Route 53 removes the whole selection in one `ChangeResourceRecordSets`.
### Typed confirmation
A delete that cannot be walked back asks the user to type a word first, as the AWS console does before deleting a volume.
@@ -264,13 +301,24 @@ Modelled on UniFi's console: a click on a row opens the resource in a panel besi
- **URL.** The panel is search state on the list route: `selected=<id>`, `tab=overview|monitoring|settings`, and `create=true`. Defaults are stripped, so a closed panel leaves the plain list address. Opening or closing a panel pushes a history entry, so Back closes it; a tab or filter change replaces the entry.
- **Layout.** From `lg` (1024px) the panel is fixed to the right edge, full height below the header, flush with no outer margin, and it slides in over the list. The list keeps its width and columns underneath, so nothing reflows when a panel opens, and a soft gradient on the panel's left edge reads as the panel lying on top. Below `lg` there is no room to share, so the panel replaces the list as a full page with a back arrow. `useWideLayout` decides, through `matchMedia`.
- **`DetailPanel`.** A header with the resource's own page (left) and close (right), the title and its ID, a summary of state and quick actions, then icon tabs with tooltips. Escape closes it unless a dialog or menu is open over it. Closing slides the panel out over 150ms, or at once under reduced motion. Each tab, and a create form, sits in its own error boundary, so a failed query shows in the panel and the list keeps working.
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `PanelTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `InlineTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
- **One component is both the panel and the page.** Each resource has a single details component (`InstanceDetails`, `VpcDetails`, `InternetGatewayDetails`) that takes `layout="auto" | "page"`. The list renders it as the panel; `/ec2/describe-instances/$id` and its kind render the same component as a page, as one centred column under a standard left-aligned heading, with state and actions on the right and text tabs. A feature added to the panel is on the page by construction, and the page ignores Escape and has a back arrow in place of close. Expanding the panel to its page keeps the tab, and query results such as fetched console output come along from the cache.
- **A click on the list's background closes the panel**, as UniFi's does. A row selects another resource instead, and links, buttons and inputs keep their own behaviour. Selecting rows for a bulk action closes it too, so the bulk bar and the panel never compete.
- **Actions.** State actions sit in the summary as a button group, and the rest go in an Actions menu, Unikraft style. Stop, Reboot and every destructive action confirm in a dialog.
- **Loading.** A panel or page still loading shows `PanelSpinner`, a centred spinner on the background, not a grey skeleton block.
- **`DetailCard`, `DetailRow` and `ListCard` are retired.** Every resource page uses the panel sections instead.
### Tags
`InlineTags` (`src/components/inline-tags.tsx`) shows a resource's tags in its panel and edits them in place.
Each row has a pencil, which turns the key and value into inputs and becomes a tick to save, and a bin to remove the tag; `+ Add tag` appends a row being edited.
Enter saves and Escape cancels the row without closing the panel.
Every row saves on its own request through the service's own tagging call, and a failure shows beside its row.
AWS has no rename, so changing a key puts the new key and then removes the old one, and a failure between the two leaves both rather than neither.
Keys are 1 to 128 characters with no `aws:` prefix and no duplicates, and values up to 256.
Without `onPut` and `onRemove` it is read-only, as for a system image or an AWS-managed policy.
Panels use the per-service wrappers in `src/components/resource-tags.tsx` and the writers in `src/mutations/tags.ts` rather than wiring the calls themselves.
### Forms from a resource's panel
A form that acts on an open resource, such as attaching a volume, scaling a node group or adding a listener, opens in `FormPanel` (`src/components/form-panel.tsx`), never in a dialog.
@@ -285,8 +333,16 @@ Every create flow opens in the side panel over its list, through `CreatePanel`,
- **URL.** `create=true` opens it, plus a list-local field where the flow needs context (`volume` for a snapshot, `instance` for an RDS snapshot, `launch=service|task` on ECS clusters). The old `create-*` addresses redirect to the list with the panel open, so links and bookmarks keep working.
- **Form.** The fields come in the order AWS asks for them. Settings that are on or off are `Switch`es, and a multi-select picker (subnets, security groups) is a `SwitchList`, a boxed list with one switch per row. Checkboxes only select table rows.
- **CLI.** Every create form ends with a `CliCommandPanel` showing the AWS CLI commands that send the same request, built from the form's live values and only the flags the console actually sends.
- **Submit.** `FormActions` turns the submit button into a spinner with its pending label in place, and `FormLock`, a native `<fieldset disabled>`, disables every control above it until the request settles. Nothing above the form appears or moves, and the panel does not scroll.
- **CLI.** Every create form ends with a `CliCommandPanel` showing the AWS CLI commands that send the same request, built from the form's live values and only the flags the console actually sends. The commands wrap to the panel's width and never scroll sideways; Copy takes the unwrapped text.
- **Sections.** Related fields are grouped in `PanelSection` boxes, as the instance launch form groups Image, Instance, Network and Root volume.
- **Nothing to pick, no field.** An optional picker with nothing usable to choose, such as Launch Template or Placement Group with none available, is left out of the form rather than shown holding only `none`.
- **Advanced settings.** Settings most users leave at their defaults sit in a collapsed Advanced settings section, which opens by itself when a field inside it fails validation.
- **Choices with a mark.** A few named products, such as database engines or distributions, are `ChoiceTile`s with the product's own mark from `src/assets/`, not a select. One that cannot be used stays visible and disabled, saying why.
- **Durations and windows.** A duration is a select of presets with the AWS default first and named (`Auto (5 min)` for a record's TTL), keeping a custom value the API returned. A time window, such as an RDS backup or maintenance window, is a day, start time and length picker that refuses an overlap before submitting.
- **Labels by type.** Where one field means different things by type, its label follows the type, as a record's value reads `IPv4 address` for A and `Mail server` for MX.
- **Long pickers.** A `SwitchList` that can run long, such as security groups, scrolls inside its box rather than pushing the form down.
- **Help.** A field's longer explanation goes in an (i) beside its label, through the `info` prop of `TextField` and `SelectField`, rather than in text under the field. Text under a field is for what the user must know before submitting.
- **Submit.** `FormActions` cross-fades the submit label into a spinner with its pending label over 150ms, at one width, so the button never changes size, and `FormLock`, a native `<fieldset disabled>`, disables every control above it until the request settles. Nothing above the form appears or moves, and the panel does not scroll.
- **Failure.** A refused request shows beside the button through `FormActions`'s `error`, and the form unlocks. The handler wraps only the mutation call in `try`, with an empty `catch` that returns, because react-hook-form rethrows whatever the handler throws and the error is already on screen from the mutation's state.
- **Success.** `onClose(id)` closes the panel and selects the new resource, where the list has a panel to select it in.
- **Appliance images.** A create that needs an ECS, EKS or RDS appliance image shows the missing-image callout inside its panel, never on the list, and only the step that needs the image is blocked.
@@ -306,6 +362,7 @@ A gap of more than three sample periods breaks the line rather than drawing acro
## Dates
`formatDateTime` writes the AWS console's form in the viewer's zone, `2026/06/04 02:00 GMT-5`, with a 24-hour clock and UTC as plain `GMT`.
In a table the time is relative (`relativeTime` in `src/lib/relative-time.ts`), rounded down to the largest whole unit, and the full form shows on hover.
## Testing
+22 -5
View File
@@ -224,7 +224,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(ctx context.Context, input *ec2.
groupNames := make(map[string]string)
s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames)
return &ec2.CreateNetworkInterfaceOutput{
NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames),
NetworkInterface: s.eniRecordToEC2(&record, accountID, groupNames, s.vpcDNSHostnames(ctx, accountID, record.VpcId)),
}, nil
}
@@ -595,7 +595,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(ctx context.Context, input *e
}
s.fillSGNames(ctx, accountID, record.SecurityGroupIds, groupNames)
enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames))
enis = append(enis, s.eniRecordToEC2(&record, accountID, groupNames, s.vpcDNSHostnames(ctx, accountID, record.VpcId)))
}
// If specific ENI IDs were requested but not found, return error
@@ -1076,9 +1076,26 @@ func (s *VPCServiceImpl) fillSGNames(ctx context.Context, accountID string, ids
}
}
// vpcDNSHostnames reports whether the VPC has enableDnsHostnames on. An
// unreadable VPC reads as off, so its interfaces go unnamed.
func (s *VPCServiceImpl) vpcDNSHostnames(ctx context.Context, accountID, vpcID string) bool {
entry, err := s.vpcKV.Get(ctx, kvutil.AccountKey(accountID, vpcID))
if err != nil {
slog.WarnContext(ctx, "vpcDNSHostnames: VPC read failed", "vpcId", vpcID, "err", err)
return false
}
var rec VPCRecord
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
slog.WarnContext(ctx, "vpcDNSHostnames: VPC unmarshal failed", "vpcId", vpcID, "err", err)
return false
}
return rec.EnableDnsHostnames
}
// eniRecordToEC2 converts an ENI record to an EC2 NetworkInterface, naming
// each security group from groupNames.
func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string) *ec2.NetworkInterface {
// each security group from groupNames. AWS names the interface only while its
// VPC has enableDnsHostnames on.
func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, groupNames map[string]string, dnsHostnames bool) *ec2.NetworkInterface {
// ENIs with spinifex:managed-by tag are system-managed (e.g. by ELBv2)
requesterManaged := record.Tags["spinifex:managed-by"] != ""
@@ -1116,7 +1133,7 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string, gro
eni.Groups = groups
}
if s.privateDNSName != nil && record.PrivateIpAddress != "" {
if dnsHostnames && s.privateDNSName != nil && record.PrivateIpAddress != "" {
if name := s.privateDNSName(record.PrivateIpAddress); name != "" {
eni.PrivateDnsName = aws.String(name)
eni.PrivateIpAddresses[0].PrivateDnsName = aws.String(name)
+56 -13
View File
@@ -55,15 +55,11 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
sgId := createTestSG(t, svc, vpcId, "web")
setHostnames(t, svc, vpcId, true)
// Without a namer an interface carries no private name.
unnamed := createTestENI(t, svc, subnetId)
desc, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{aws.String(unnamed)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
assert.Nil(t, desc.NetworkInterfaces[0].PrivateDnsName)
assert.Nil(t, describeTestENI(t, svc, unnamed).PrivateDnsName)
svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip })
out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{
@@ -72,13 +68,8 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
desc, err = svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{out.NetworkInterface.NetworkInterfaceId},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc.NetworkInterfaces[0]} {
desc := describeTestENI(t, svc, *out.NetworkInterface.NetworkInterfaceId)
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": desc} {
want := "name-of-" + aws.StringValue(eni.PrivateIpAddress)
assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName), name)
require.Len(t, eni.PrivateIpAddresses, 1, name)
@@ -89,6 +80,58 @@ func TestNetworkInterface_NamesGroupsAndPrivateAddress(t *testing.T) {
}
}
func TestNetworkInterface_PrivateDnsNameFollowsVpcDnsHostnames(t *testing.T) {
t.Parallel()
svc := setupTestVPCService(t)
svc.SetPrivateDNSNamer(func(ip string) string { return "name-of-" + ip })
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
// A new VPC has enableDnsHostnames off, so its interfaces go unnamed.
out, err := svc.CreateNetworkInterface(context.Background(), &ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetId),
}, testAccountID)
require.NoError(t, err)
eniId := *out.NetworkInterface.NetworkInterfaceId
for name, eni := range map[string]*ec2.NetworkInterface{"create": out.NetworkInterface, "describe": describeTestENI(t, svc, eniId)} {
assert.Nil(t, eni.PrivateDnsName, name)
require.Len(t, eni.PrivateIpAddresses, 1, name)
assert.Nil(t, eni.PrivateIpAddresses[0].PrivateDnsName, name)
}
// Turning the flag on names the existing interface.
setHostnames(t, svc, vpcId, true)
eni := describeTestENI(t, svc, eniId)
want := "name-of-" + aws.StringValue(eni.PrivateIpAddress)
assert.Equal(t, want, aws.StringValue(eni.PrivateDnsName))
assert.Equal(t, want, aws.StringValue(eni.PrivateIpAddresses[0].PrivateDnsName))
// And turning it off again drops the name.
setHostnames(t, svc, vpcId, false)
eni = describeTestENI(t, svc, eniId)
assert.Nil(t, eni.PrivateDnsName)
assert.Nil(t, eni.PrivateIpAddresses[0].PrivateDnsName)
}
func setHostnames(t *testing.T, svc *VPCServiceImpl, vpcId string, on bool) {
t.Helper()
_, err := svc.ModifyVpcAttribute(context.Background(), &ec2.ModifyVpcAttributeInput{
VpcId: aws.String(vpcId),
EnableDnsHostnames: &ec2.AttributeBooleanValue{Value: aws.Bool(on)},
}, testAccountID)
require.NoError(t, err)
}
func describeTestENI(t *testing.T, svc *VPCServiceImpl, eniId string) *ec2.NetworkInterface {
t.Helper()
desc, err := svc.DescribeNetworkInterfaces(context.Background(), &ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{aws.String(eniId)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
return desc.NetworkInterfaces[0]
}
func TestCreateNetworkInterface_SequentialIPs(t *testing.T) {
t.Parallel()
svc := setupTestVPCService(t)
+14 -3
View File
@@ -288,7 +288,7 @@ func (s *Service) DescribeClusters(ctx context.Context, input *ecs.DescribeClust
names = []string{defaultCluster}
}
includeSettings := slices.Contains(awsStringSlice(input.Include), ecs.ClusterFieldSettings)
out := &ecs.DescribeClustersOutput{}
out := &ecs.DescribeClustersOutput{Clusters: []*ecs.Cluster{}, Failures: []*ecs.Failure{}}
for _, name := range names {
name = ClusterShortName(name)
var rec ClusterRecord
@@ -458,7 +458,7 @@ func (s *Service) RegisterTaskDefinition(ctx context.Context, input *ecs.Registe
if family == "" {
return nil, errors.New(awserrors.ErrorECSInvalidParameter)
}
if err := validateContainerDefs(input.ContainerDefinitions); err != nil {
if err := validateContainerDefs(input.ContainerDefinitions, aws.StringValue(input.Memory)); err != nil {
return nil, err
}
warnUnsupportedLogDrivers(ctx, family, input.ContainerDefinitions)
@@ -497,12 +497,23 @@ func (s *Service) RegisterTaskDefinition(ctx context.Context, input *ecs.Registe
// The test here is per value, not per field: an empty list or a false/unset
// flag requests nothing and is accepted, because there is nothing to enforce
// or refuse. Only a value that asks for the restriction is rejected.
func validateContainerDefs(defs []*ecs.ContainerDefinition) error {
//
// Ahead of those, AWS's own rules: every container needs an image, and without
// a task-level memory every container needs memory or memoryReservation.
func validateContainerDefs(defs []*ecs.ContainerDefinition, taskMemory string) error {
for _, c := range defs {
if c == nil {
continue
}
name := aws.StringValue(c.Name)
if aws.StringValue(c.Image) == "" {
return awserrors.Errorf(awserrors.ErrorECSClientException,
"Container.image should not be null or empty.")
}
if taskMemory == "" && c.Memory == nil && c.MemoryReservation == nil {
return awserrors.Errorf(awserrors.ErrorECSClientException,
"Invalid setting for container '%s'. At least one of 'memory' or 'memoryReservation' must be specified.", name)
}
if len(c.Secrets) > 0 {
return errors.New(awserrors.ErrorECSInvalidParameter)
}
+24
View File
@@ -812,6 +812,12 @@ func TestService_ListTasks_EmptyIsPresentNotAbsent(t *testing.T) {
assert.Empty(t, out.TaskArns)
}
func TestService_ListTasks_ClusterNotFound(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.ListTasks(context.Background(), &ecs.ListTasksInput{Cluster: aws.String("ghost")}, testAccountID)
require.EqualError(t, err, awserrors.ErrorECSClusterNotFound)
}
func TestService_ListContainerInstances_EmptyIsPresentNotAbsent(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
@@ -829,3 +835,21 @@ func TestAccountIDFromBucket(t *testing.T) {
_, ok = accountIDFromBucket("some-other-bucket")
assert.False(t, ok)
}
func TestService_DescribeClusters_ListsAlwaysPresent(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.CreateCluster(context.Background(), &ecs.CreateClusterInput{ClusterName: aws.String("web")}, testAccountID)
require.NoError(t, err)
found, err := svc.DescribeClusters(context.Background(), &ecs.DescribeClustersInput{Clusters: []*string{aws.String("web")}}, testAccountID)
require.NoError(t, err)
require.Len(t, found.Clusters, 1)
assert.NotNil(t, found.Failures)
assert.Empty(t, found.Failures)
miss, err := svc.DescribeClusters(context.Background(), &ecs.DescribeClustersInput{Clusters: []*string{aws.String("nope")}}, testAccountID)
require.NoError(t, err)
require.Len(t, miss.Failures, 1)
assert.NotNil(t, miss.Clusters)
assert.Empty(t, miss.Clusters)
}
+2 -2
View File
@@ -313,7 +313,7 @@ func TestService_CreateService_ProjectsNetworkConfigurationAndManagedTags(t *tes
Family: aws.String("app"),
NetworkMode: aws.String(NetworkModeAwsvpc),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, testAccountID)
require.NoError(t, err)
@@ -352,7 +352,7 @@ func TestService_UpdateService_ChangesNetworkConfiguration(t *testing.T) {
Family: aws.String("app"),
NetworkMode: aws.String(NetworkModeAwsvpc),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, testAccountID)
require.NoError(t, err)
@@ -16,6 +16,8 @@ import (
// agent to reach anything other than PENDING.
func seedTasks(t *testing.T, svc *ecsdomain.Service, cluster string, recs ...*ecsdomain.TaskRecord) {
t.Helper()
_, err := svc.CreateCluster(t.Context(), &ecs.CreateClusterInput{ClusterName: aws.String(cluster)}, ecsdomain.TestAccountID)
require.NoError(t, err)
kv, err := svc.Bucket(t.Context(), ecsdomain.TestAccountID)
require.NoError(t, err)
for _, r := range recs {
+57 -18
View File
@@ -19,7 +19,7 @@ func TestRegisterTaskDefinition_RejectsSecrets(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Secrets: []*ecs.Secret{{
Name: aws.String("DB_PASSWORD"), ValueFrom: aws.String("arn:aws:ssm:::parameter/db"),
}},
@@ -37,7 +37,7 @@ func TestRegisterTaskDefinition_AcceptsUnsupportedLogDriver(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
LogConfiguration: &ecs.LogConfiguration{
LogDriver: aws.String("awslogs"),
Options: map[string]*string{"awslogs-group": aws.String("/ecs/app")},
@@ -67,7 +67,7 @@ func TestRegisterTaskDefinition_EchoesRequiresCompatibilities(t *testing.T) {
Family: aws.String("app"),
RequiresCompatibilities: aws.StringSlice([]string{"EC2"}),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, testAccountID)
require.NoError(t, err)
@@ -91,7 +91,7 @@ func TestRegisterTaskDefinition_EchoesRuntimePlatform(t *testing.T) {
OperatingSystemFamily: aws.String("LINUX"),
},
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, testAccountID)
require.NoError(t, err)
@@ -113,7 +113,7 @@ func TestRegisterTaskDefinition_RuntimePlatformOmittedWhenUnset(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, testAccountID)
require.NoError(t, err)
@@ -253,7 +253,7 @@ func TestRegisterTaskDefinition_EchoesRuntimeFields(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
User: aws.String("1000:1000"),
ReadonlyRootFilesystem: aws.Bool(true),
Privileged: aws.Bool(false),
@@ -310,7 +310,7 @@ func TestRegisterTaskDefinition_RuntimeFieldsOmittedWhenUnset(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
}},
}, testAccountID)
require.NoError(t, err)
@@ -347,7 +347,7 @@ func TestRegisterTaskDefinition_EchoesEmptyCollections(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
Environment: []*ecs.KeyValuePair{},
MountPoints: []*ecs.MountPoint{},
VolumesFrom: []*ecs.VolumeFrom{},
@@ -386,7 +386,7 @@ func TestRegisterTaskDefinition_FillsAWSDefaults(t *testing.T) {
Family: aws.String("app"),
NetworkMode: aws.String("awsvpc"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(80)}},
}},
}, testAccountID)
@@ -420,7 +420,7 @@ func TestRegisterTaskDefinition_BridgeLeavesHostPortUnset(t *testing.T) {
Family: aws.String("app"),
NetworkMode: aws.String("bridge"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
PortMappings: []*ecs.PortMapping{{ContainerPort: aws.Int64(53), Protocol: aws.String("udp")}},
}},
}, testAccountID)
@@ -435,7 +435,7 @@ func TestRegisterTaskDefinition_AcceptsEmptyRuntimeFields(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
MountPoints: []*ecs.MountPoint{},
VolumesFrom: []*ecs.VolumeFrom{},
SystemControls: []*ecs.SystemControl{},
@@ -455,7 +455,7 @@ func TestRegisterTaskDefinition_RejectsMountPoints(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
MountPoints: []*ecs.MountPoint{{
SourceVolume: aws.String("data"), ContainerPath: aws.String("/data"),
}},
@@ -476,7 +476,7 @@ func TestRegisterTaskDefinition_RejectsVolumesFrom(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
VolumesFrom: []*ecs.VolumeFrom{{SourceContainer: aws.String("other")}},
}},
}, testAccountID)
@@ -494,7 +494,7 @@ func TestRegisterTaskDefinition_RejectsLinuxParametersDevices(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
LinuxParameters: &ecs.LinuxParameters{
Devices: []*ecs.Device{{HostPath: aws.String("/dev/fuse")}},
},
@@ -514,7 +514,7 @@ func TestRegisterTaskDefinition_RejectsInitProcessEnabled(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
LinuxParameters: &ecs.LinuxParameters{InitProcessEnabled: aws.Bool(true)},
}},
}, testAccountID)
@@ -533,7 +533,7 @@ func TestRegisterTaskDefinition_RejectsSharedMemoryAndTmpfs(t *testing.T) {
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("shm"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
LinuxParameters: &ecs.LinuxParameters{SharedMemorySize: aws.Int64(64)},
}},
}, testAccountID)
@@ -545,7 +545,7 @@ func TestRegisterTaskDefinition_RejectsSharedMemoryAndTmpfs(t *testing.T) {
_, err = svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("tmpfs"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
LinuxParameters: &ecs.LinuxParameters{
Tmpfs: []*ecs.Tmpfs{{ContainerPath: aws.String("/tmp"), Size: aws.Int64(64)}},
},
@@ -567,7 +567,7 @@ func TestRunTask_AssignCarriesRuntimeFields(t *testing.T) {
_, err = svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"),
ContainerDefinitions: []*ecs.ContainerDefinition{{
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true),
Name: aws.String("app"), Image: aws.String("registry/app:1"), Essential: aws.Bool(true), Memory: aws.Int64(256),
User: aws.String("1000"),
ReadonlyRootFilesystem: aws.Bool(true),
Privileged: aws.Bool(true),
@@ -608,3 +608,42 @@ func TestRunTask_AssignCarriesRuntimeFields(t *testing.T) {
assert.Equal(t, "net.core.somaxconn", ac.SystemControls[0].Namespace)
assert.Equal(t, []string{"SYS_PTRACE"}, ac.CapAdd)
}
// AWS rejects a container with no image before checking its memory, and
// without task-level memory requires memory or memoryReservation per container.
func TestRegisterTaskDefinition_RequiresImageAndMemory(t *testing.T) {
tests := []struct {
name string
taskMemory *string
container *ecs.ContainerDefinition
wantErr string
}{
{"no image or memory", nil, &ecs.ContainerDefinition{Name: aws.String("web")},
"Container.image should not be null or empty."},
{"empty image", aws.String("512"), &ecs.ContainerDefinition{Name: aws.String("web"), Image: aws.String("")},
"Container.image should not be null or empty."},
{"no memory", nil, &ecs.ContainerDefinition{Name: aws.String("web"), Image: aws.String("registry/web:1")},
"Invalid setting for container 'web'. At least one of 'memory' or 'memoryReservation' must be specified."},
{"memoryReservation only", nil, &ecs.ContainerDefinition{
Name: aws.String("web"), Image: aws.String("registry/web:1"), MemoryReservation: aws.Int64(128)}, ""},
{"task-level memory", aws.String("512"), &ecs.ContainerDefinition{
Name: aws.String("web"), Image: aws.String("registry/web:1")}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc, _ := newTestService(t)
_, err := svc.RegisterTaskDefinition(context.Background(), &ecs.RegisterTaskDefinitionInput{
Family: aws.String("app"), Memory: tc.taskMemory,
ContainerDefinitions: []*ecs.ContainerDefinition{tc.container},
}, testAccountID)
if tc.wantErr == "" {
require.NoError(t, err)
return
}
code, msg, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorECSClientException, code)
assert.Equal(t, tc.wantErr, msg)
})
}
}
+8
View File
@@ -303,6 +303,14 @@ func (s *Service) ListTasks(ctx context.Context, input *ecs.ListTasksInput, acco
if err != nil {
return nil, err
}
var clusterRec ClusterRecord
found, err := getJSON(ctx, kv, ClusterMetaKey(cluster), &clusterRec)
if err != nil {
return nil, err
}
if !found {
return nil, errors.New(awserrors.ErrorECSClusterNotFound)
}
keys, err := keysWithPrefix(ctx, kv, TasksPrefix(cluster))
if err != nil {
return nil, err
+8 -4
View File
@@ -3503,14 +3503,18 @@ func (s *ELBv2ServiceImpl) tgRecordToSDK(r *TargetGroupRecord, lbArns []string)
HealthCheckEnabled: aws.Bool(r.HealthCheck.Enabled),
HealthCheckProtocol: aws.String(r.HealthCheck.Protocol),
HealthCheckPort: aws.String(r.HealthCheck.Port),
HealthCheckPath: aws.String(r.HealthCheck.Path),
HealthCheckIntervalSeconds: aws.Int64(r.HealthCheck.IntervalSeconds),
HealthCheckTimeoutSeconds: aws.Int64(r.HealthCheck.TimeoutSeconds),
HealthyThresholdCount: aws.Int64(r.HealthCheck.HealthyThreshold),
UnhealthyThresholdCount: aws.Int64(r.HealthCheck.UnhealthyThreshold),
Matcher: &elbv2.Matcher{
HttpCode: aws.String(r.HealthCheck.Matcher),
},
}
// AWS omits both keys on TCP target groups, which have no path or matcher.
if r.HealthCheck.Path != "" {
tg.HealthCheckPath = aws.String(r.HealthCheck.Path)
}
if r.HealthCheck.Matcher != "" {
tg.Matcher = &elbv2.Matcher{HttpCode: aws.String(r.HealthCheck.Matcher)}
}
// Omitted for NLB (TCP/UDP/TLS) target groups, matching AWS, which returns
+5 -4
View File
@@ -459,11 +459,12 @@ func TestCreateTargetGroup_TCPProtocol(t *testing.T) {
assert.Equal(t, int64(5432), *tg.Port)
// NLB health check defaults: TCP protocol, no path, no matcher.
assert.Equal(t, "TCP", *tg.HealthCheckProtocol)
assert.Empty(t, *tg.HealthCheckPath)
assert.Empty(t, *tg.Matcher.HttpCode)
assert.Nil(t, tg.HealthCheckPath)
assert.Nil(t, tg.Matcher)
assert.Equal(t, int64(30), *tg.HealthCheckIntervalSeconds)
assert.Equal(t, int64(10), *tg.HealthCheckTimeoutSeconds)
assert.Equal(t, int64(3), *tg.HealthyThresholdCount)
assert.Equal(t, int64(3), *tg.UnhealthyThresholdCount)
assert.Equal(t, int64(5), *tg.HealthyThresholdCount)
assert.Equal(t, int64(2), *tg.UnhealthyThresholdCount)
}
func TestCreateTargetGroup_NLBProtocols(t *testing.T) {
+2 -2
View File
@@ -99,8 +99,8 @@ const (
// Default health check values (NLB).
DefaultNLBHealthCheckInterval = 30
DefaultNLBHealthCheckTimeout = 10
DefaultNLBHealthyThreshold = 3
DefaultNLBUnhealthyThreshold = 3
DefaultNLBHealthyThreshold = 5
DefaultNLBUnhealthyThreshold = 2
DefaultNLBHealthCheckProtocol = ProtocolTCP
DefaultNLBHealthCheckPort = "traffic-port"
+1 -1
View File
@@ -1268,7 +1268,7 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorEKSResourceInUse: {HTTPCode: 409, Message: "A cluster with this name already exists in this account and Region. Delete the existing cluster, or choose a different name."},
ErrorEKSResourceNotFound: {HTTPCode: 404, Message: "The specified cluster could not be found. You can view your available clusters with ListClusters. Amazon EKS clusters are Region specific."},
ErrorECSClientException: {HTTPCode: 400, Message: "Unable to describe task definition."},
ErrorECSClusterNotFound: {HTTPCode: 400, Message: "The specified cluster was not found. You can view your available clusters with ListClusters. Amazon ECS clusters are Region specific."},
ErrorECSClusterNotFound: {HTTPCode: 400, Message: "Cluster not found."},
ErrorECSInvalidParameter: {HTTPCode: 400, Message: "The specified parameter is not valid. Review the available parameters for the API request."},
ErrorECSServiceNotFound: {HTTPCode: 400, Message: "The specified service was not found. You can view your available services with ListServices. Amazon ECS services are cluster specific and Region specific."},
ErrorRetryableError: {HTTPCode: 400, Message: "A request submitted by an AWS service on your behalf could not be completed. The requesting service might automatically retry the request."},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 590 KiB

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 167 KiB

@@ -263,14 +263,46 @@ export async function useTheme(page: Page, theme: Theme): Promise<void> {
// The page must never scroll sideways: wide content scrolls in its own box.
export async function expectNoHorizontalOverflow(page: Page): Promise<void> {
const widths = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
}))
const widths = await page.evaluate(() => {
const client = document.documentElement.clientWidth
expect(widths.scroll, "page scrolls horizontally").toBeLessThanOrEqual(
widths.client,
)
// The outermost elements past the edge, so a failure names its culprit.
// Content inside its own scrolling box is allowed past it.
const past = [...document.body.querySelectorAll<HTMLElement>("*")].filter(
(el) => {
if (el.getBoundingClientRect().right <= client + 1) {
return false
}
for (let up = el.parentElement; up; up = up.parentElement) {
if (
up.getBoundingClientRect().right > client + 1 ||
getComputedStyle(up).overflowX !== "visible"
) {
return false
}
}
return true
},
)
return {
scroll: document.documentElement.scrollWidth,
client,
past: past
.slice(0, 5)
.map(
(el) =>
`${el.tagName.toLowerCase()}.${(el.getAttribute("class") ?? "").slice(0, 120)} "${(el.textContent ?? "").slice(0, 40)}"`,
),
}
})
expect(
widths.scroll,
`page scrolls horizontally past: ${widths.past.join(" | ")}`,
).toBeLessThanOrEqual(widths.client)
}
export const test = base.extend<{ api: ConsoleApi }>({
@@ -17,6 +17,7 @@ interface FakeInstance {
privateIp: string
publicIp?: string
launched: string
tags?: Record<string, string>
}
const INSTANCES: FakeInstance[] = [
@@ -35,6 +36,10 @@ const INSTANCES: FakeInstance[] = [
state: "running",
type: "t3.micro",
privateIp: "10.200.0.10",
tags: {
"spinifex:eks-cluster": "test-cluster",
"spinifex:eks-nodegroup": "node-gap",
},
launched: "2026-10-21T09:00:00Z",
},
{
@@ -71,9 +76,15 @@ const STATE_CODES = {
} satisfies Record<FakeState, number>
function instanceXml(instance: FakeInstance): string {
const tags = instance.name
? `<tagSet><item><key>Name</key><value>${instance.name}</value></item></tagSet>`
: ""
const pairs = [
...(instance.name ? [["Name", instance.name]] : []),
...Object.entries(instance.tags ?? {}),
]
const tags =
pairs.length > 0
? `<tagSet>${pairs.map(([key, value]) => `<item><key>${key}</key><value>${value}</value></item>`).join("")}</tagSet>`
: ""
return `<item><instanceId>${instance.id}</instanceId><imageId>ami-ubuntu-26.04-x86_64</imageId>
<instanceState><code>${STATE_CODES[instance.state]}</code><name>${instance.state}</name></instanceState>
@@ -107,13 +118,18 @@ test.describe("instances list", () => {
const table = page.getByRole("table", { name: "Instances" })
await expect(table.getByRole("row")).toHaveCount(INSTANCES.length + 1)
await expect(
table
.getByRole("link", { name: /web-01/u })
.getByText("i-0a1b2c3d4e5f60001"),
).toBeVisible()
await expect(table.getByRole("columnheader", { name: "Zone" })).toHaveCount(
0,
)
// An instance with no Name tag is named by its ID alone.
const web01 = table.getByRole("row").filter({ hasText: "web-01" })
await expect(
web01.getByRole("link", { name: "i-0a1b2c3d4e5f60001" }),
).toBeVisible()
await expect(web01.getByRole("cell", { name: "web-01" })).toBeVisible()
await expect(web01.getByText(/ ago$/u)).toBeVisible()
// An instance with no Name tag is reached by its ID.
await table.getByRole("link", { name: "i-0a1b2c3d4e5f60004" }).click()
await expect(page).toHaveURL(/\?selected=i-0a1b2c3d4e5f60004$/u)
@@ -322,7 +338,17 @@ test.describe("instances list", () => {
test("shows the panel as its own page on a phone", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto("/ec2/describe-instances")
await page.getByRole("link", { name: /web-02/u }).click()
const table = page.getByRole("table", { name: "Instances" })
await expect(
table.getByRole("columnheader", { name: "Name" }),
).toBeVisible()
await expect(
table.getByRole("columnheader", { name: "State" }),
).toBeVisible()
await expect(table.getByRole("columnheader", { name: "Type" })).toBeHidden()
await expectNoHorizontalOverflow(page)
await page.getByRole("link", { name: "i-0a1b2c3d4e5f60002" }).click()
const panel = page.getByRole("region", { name: "Instance details" })
await expect(panel).toBeVisible()
@@ -361,6 +387,44 @@ test.describe("instances list", () => {
await expect(rows).toHaveCount(INSTANCES.length + 1)
})
test("shows tags other than Name as one pill and a count", async ({
page,
}) => {
await page.goto("/ec2/describe-instances")
const row = page
.getByRole("table", { name: "Instances" })
.getByRole("row")
.filter({ hasText: "web-02" })
await expect(row.getByText("spinifex:eks-cluster")).toBeVisible()
await expect(row.getByText("+1")).toBeVisible()
await row.getByText("spinifex:eks-cluster").hover()
await expect(page.getByText("node-gap")).toBeVisible()
})
test("keeps a long name to its column, in full on hover", async ({
page,
}) => {
await page.goto("/ec2/describe-instances")
const long = INSTANCES[4]?.name ?? ""
const cell = page
.getByRole("table", { name: "Instances" })
.getByRole("row")
.filter({ hasText: "i-0a1b2c3d4e5f60005" })
.getByText(long)
const box = await cell.boundingBox()
expect(box?.width).toBeLessThan(260)
await cell.hover()
await expect(page.locator("[data-slot=tooltip-content]")).toContainText(
long,
)
})
test("counts each state on its filter", async ({ page }) => {
await page.goto("/ec2/describe-instances")
const states = page.getByRole("group", { name: "Instance state" })
@@ -0,0 +1,142 @@
import { type ApiHandler, ec2, expect, test, xml } from "./console"
const ELB_NAMESPACE =
"http://elasticloadbalancing.amazonaws.com/doc/2015-12-01/"
const VPC_ID = "vpc-0e2e000000000001"
const TG_ARN =
"arn:aws:elasticloadbalancing:ap-southeast-2:000000000001:targetgroup/web/0e2e"
const LB_ARN =
"arn:aws:elasticloadbalancing:ap-southeast-2:000000000001:loadbalancer/app/web/0e2e"
function elb(action: string, inner: string): ApiHandler {
return () => ({
body: xml(
action,
`<${action}Result>${inner}</${action}Result>`,
ELB_NAMESPACE,
),
})
}
test.beforeEach(({ api }) => {
api.handle(
"DescribeVpcs",
ec2(
"DescribeVpcs",
`<vpcSet><item><vpcId>${VPC_ID}</vpcId><state>available</state>
<cidrBlock>172.31.0.0/16</cidrBlock><isDefault>true</isDefault></item></vpcSet>`,
),
)
api.handle(
"DescribeSubnets",
ec2(
"DescribeSubnets",
`<subnetSet><item><subnetId>subnet-0e2e000000000001</subnetId>
<vpcId>${VPC_ID}</vpcId><cidrBlock>172.31.0.0/20</cidrBlock>
<availabilityZone>ap-southeast-2a</availabilityZone><state>available</state></item></subnetSet>`,
),
)
api.handle(
"DescribeLoadBalancers",
elb("DescribeLoadBalancers", "<LoadBalancers/>"),
)
api.handle(
"DescribeTargetGroups",
elb("DescribeTargetGroups", "<TargetGroups/>"),
)
api.handle(
"DescribeSSLPolicies",
elb("DescribeSSLPolicies", "<SslPolicies/>"),
)
api.handle("ListCertificates", () => ({
body: JSON.stringify({ CertificateSummaryList: [] }),
contentType: "application/x-amz-json-1.1",
}))
})
// Unit tests run without the React compiler, which is what hid a form whose
// errors never rendered; these run the compiled components in a browser.
test.describe("create load balancer", () => {
test("sends the target group, load balancer and listener", async ({
api,
page,
}) => {
const calls: string[] = []
const record =
(action: string, inner: string): ApiHandler =>
(params) => {
calls.push(action)
return elb(action, inner)(params)
}
api.handle(
"CreateTargetGroup",
record(
"CreateTargetGroup",
`<TargetGroups><member><TargetGroupArn>${TG_ARN}</TargetGroupArn></member></TargetGroups>`,
),
)
api.handle(
"CreateLoadBalancer",
record(
"CreateLoadBalancer",
`<LoadBalancers><member><LoadBalancerArn>${LB_ARN}</LoadBalancerArn></member></LoadBalancers>`,
),
)
api.handle("CreateListener", record("CreateListener", "<Listeners/>"))
await page.goto("/ec2/describe-load-balancers?create=true")
const panel = page.getByRole("region", { name: "Create load balancer" })
await panel.locator("#lb-name").fill("web")
await panel.locator("#tg-name").fill("web")
await panel.getByRole("switch").first().click()
await panel.getByRole("button", { name: "Create Load Balancer" }).click()
await expect
.poll(() => calls)
.toStrictEqual([
"CreateTargetGroup",
"CreateLoadBalancer",
"CreateListener",
])
})
test("shows a missing target group name and says so beside the button", async ({
api,
page,
}) => {
await page.goto("/ec2/describe-load-balancers?create=true")
const panel = page.getByRole("region", { name: "Create load balancer" })
await panel.locator("#lb-name").fill("web")
await panel.getByRole("switch").first().click()
await panel.getByRole("button", { name: "Create Load Balancer" }).click()
await expect(panel.getByText("Name is required")).toBeVisible()
await expect(
panel.getByText("Fix the highlighted fields above."),
).toBeVisible()
expect(api.unhandled).toStrictEqual([])
})
test("shows both forms' errors on an empty submit", async ({ page }) => {
await page.goto("/ec2/describe-load-balancers?create=true")
const panel = page.getByRole("region", { name: "Create load balancer" })
await panel.getByRole("button", { name: "Create Load Balancer" }).click()
await expect(panel.getByText("Name is required")).toHaveCount(2)
await expect(panel.getByText("At least 1 subnet is required")).toBeVisible()
})
})
test("create target group shows a missing name", async ({ page }) => {
await page.goto("/ec2/describe-target-groups?create=true")
const panel = page.getByRole("region", { name: "Create target group" })
await panel.getByRole("button", { name: "Create Target Group" }).click()
await expect(panel.getByText("Name is required")).toBeVisible()
})
@@ -0,0 +1,13 @@
<svg viewBox="0 -43 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid">
<g>
<path d="M250.382523,0.00447241672 C246.426131,0.130891567 247.677353,1.27087056 239.128415,3.37469592 C230.495553,5.49917829 219.950359,4.84773528 210.654095,8.74649903 C182.903099,20.3847485 177.335232,60.1626339 152.106938,74.4118517 C133.249415,85.0635193 114.223916,85.9130759 97.1188786,91.2730771 C85.8778244,94.7980074 73.5811418,102.026905 63.3964279,110.803626 C55.49096,117.618586 55.2845466,123.610697 47.0245784,132.158212 C38.1894743,141.300822 11.9101646,132.312705 0,146.305625 C3.83670733,150.185042 5.51875114,151.271649 13.0796841,150.265122 C11.5142932,153.232113 2.28663486,155.732479 4.09296236,160.097129 C5.99360595,164.689675 28.3022154,167.802917 48.5816837,155.559279 C58.0261053,149.857249 65.5486285,141.638595 80.2576532,139.676806 C99.2917078,137.139881 121.218611,141.30404 143.253683,144.481588 C139.986431,154.22355 133.426672,160.702176 128.172006,168.461009 C126.544787,170.213508 131.440311,170.409956 137.025262,169.350783 C147.071883,166.866533 154.312169,164.86632 161.894457,160.453039 C171.209327,155.030397 172.62088,141.127864 184.04984,138.119701 C190.417778,147.907219 207.737102,150.219223 218.48411,142.390618 C209.053925,139.721295 206.447626,119.648695 209.630855,110.803626 C212.646122,102.431204 215.625486,89.0383196 218.662065,77.9709494 C221.922199,66.0849867 223.124932,51.1038191 227.070434,45.0492956 C233.00651,35.9401552 239.565643,32.81205 245.260156,27.675489 C250.954656,22.538928 256.166954,17.538894 255.995904,5.78538669 C255.940809,1.99964564 253.983391,-0.11060033 250.382523,0.00447241672 L250.382523,0.00447241672 Z" fill="#002B64">
</path>
<path d="M241.905484,6.96809574 C242.853676,10.2001831 244.337002,11.6835082 250.750076,12.2768382 C249.813239,20.407447 244.389521,24.8545834 238.308598,29.1214497 C232.957272,32.8744751 227.094944,36.4883945 223.327724,42.3507224 C219.46824,48.3564147 217.01827,68.9100487 211.033869,89.2081817 C205.861394,106.746904 198.050161,124.088323 184.409248,131.686638 C182.98412,128.099688 184.590937,121.479374 181.756296,119.303358 C179.922367,124.53403 177.848551,129.524816 175.419872,134.163578 C167.415594,149.462409 155.564607,160.917369 135.760443,164.414894 C145.157201,151.699462 154.142319,138.568131 154.336783,116.651825 C147.723566,118.082631 147.864092,133.703676 141.069185,137.879698 C136.712894,138.353794 132.299824,138.350955 127.858366,138.084099 C109.618435,136.991122 90.9072468,131.509207 73.84404,136.984025 C62.2258429,140.71292 52.7240456,149.509251 42.8858386,153.776117 C31.323,158.791033 22.5664139,160.853494 8.16751449,158.791033 C6.33926307,156.328288 18.7055102,153.150139 17.9659769,147.803072 C12.3307609,147.179933 9.058929,148.545444 4.16040754,146.319747 C4.70121793,145.323293 5.49610985,144.492915 6.49682201,143.801643 C15.4748424,137.587291 40.9766785,142.333932 47.8013935,135.632709 C52.0143206,131.499271 54.7779895,127.172788 57.6396004,122.966958 C60.4146249,118.886039 63.2833331,114.918677 67.6538192,111.343083 C69.2677337,110.022994 71.0221737,108.71852 72.8844919,107.445273 C80.3323453,102.348029 89.5459944,97.7248808 98.6134401,94.5382159 C110.965493,90.1961188 123.482202,89.8384174 136.647599,84.8078871 C144.781047,81.6992919 153.625639,77.8596801 160.835025,72.4870623 C162.546881,71.2095575 164.166473,69.8483051 165.663993,68.3891106 C186.250274,48.3209285 190.331193,12.9212684 222.449085,9.62246697 C226.3327,9.22360156 229.512267,9.3527715 232.406525,9.26476561 C235.742233,9.16540412 238.694688,8.77789431 241.905484,6.96809574 Z M202.75118,120.267107 C203.134432,126.40197 206.695831,138.573752 209.839913,141.531886 C203.682339,143.029405 193.074791,140.555304 190.353705,136.211788 C191.751863,129.940658 199.027963,124.2075 202.75118,120.267107 Z" fill="#C49A6C" fill-rule="nonzero">
</path>
<path d="M244.218787,13.8370641 C242.980829,16.4335799 240.610981,19.7812981 240.610981,26.3910072 C240.60081,27.5258023 239.749351,28.3031588 239.734821,26.5537435 C239.798753,20.0936937 241.508937,17.3010225 243.32519,13.6307377 C244.169385,12.12688 244.677936,12.7473121 244.218787,13.8370641 Z M242.972111,12.8591933 C241.511843,15.3365629 237.995576,19.8554012 237.414375,26.4404093 C237.306853,27.5693924 236.388555,28.2682867 236.528044,26.5232305 C237.161553,20.0951467 239.97166,16.0717822 242.104668,12.5744048 C243.072368,11.1519152 243.527158,11.8144844 242.972111,12.8591933 Z M241.835862,11.5631149 C240.172174,13.9082613 234.759739,19.3352263 233.62785,25.8490372 C233.42443,26.9634903 232.450918,27.5853754 232.73716,25.8577553 C233.90828,19.5037746 238.573871,14.5098044 240.993121,11.2071293 C242.077061,9.86891382 242.473731,10.5678081 241.835862,11.5631149 Z M240.821667,10.1173773 L240.274318,10.6995682 C237.854262,13.2941372 232.232203,19.6224619 230.358594,25.4145894 C229.99825,26.4898114 228.947729,26.9693023 229.475169,25.2983492 C231.526809,19.17249 237.177536,12.5744048 240.037045,9.64515141 C241.299704,8.47257825 241.593211,9.22087463 240.821667,10.1173773 Z M211.771784,23.2321794 C213.025725,17.8458985 217.214732,15.391777 224.446326,15.9904141 C226.191383,24.0298779 216.425752,27.2729799 211.771784,23.2321794 Z" fill="#002B64">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,18 @@
<svg viewBox="0 0 432.071 445.383" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<g id="orginal" style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
</g>
<g id="Layer_x0020_3" style="fill-rule:nonzero;clip-rule:nonzero;fill:none;stroke:#FFFFFF;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;">
<path style="fill:#000000;stroke:#000000;stroke-width:37.3953;stroke-linecap:butt;stroke-linejoin:miter;" d="M323.205,324.227c2.833-23.601,1.984-27.062,19.563-23.239l4.463,0.392c13.517,0.615,31.199-2.174,41.587-7c22.362-10.376,35.622-27.7,13.572-23.148c-50.297,10.376-53.755-6.655-53.755-6.655c53.111-78.803,75.313-178.836,56.149-203.322 C352.514-5.534,262.036,26.049,260.522,26.869l-0.482,0.089c-9.938-2.062-21.06-3.294-33.554-3.496c-22.761-0.374-40.032,5.967-53.133,15.904c0,0-161.408-66.498-153.899,83.628c1.597,31.936,45.777,241.655,98.47,178.31 c19.259-23.163,37.871-42.748,37.871-42.748c9.242,6.14,20.307,9.272,31.912,8.147l0.897-0.765c-0.281,2.876-0.157,5.689,0.359,9.019c-13.572,15.167-9.584,17.83-36.723,23.416c-27.457,5.659-11.326,15.734-0.797,18.367c12.768,3.193,42.305,7.716,62.268-20.224 l-0.795,3.188c5.325,4.26,4.965,30.619,5.72,49.452c0.756,18.834,2.017,36.409,5.856,46.771c3.839,10.36,8.369,37.05,44.036,29.406c29.809-6.388,52.6-15.582,54.677-101.107"/>
<path style="fill:#336791;stroke:none;" d="M402.395,271.23c-50.302,10.376-53.76-6.655-53.76-6.655c53.111-78.808,75.313-178.843,56.153-203.326c-52.27-66.785-142.752-35.2-144.262-34.38l-0.486,0.087c-9.938-2.063-21.06-3.292-33.56-3.496c-22.761-0.373-40.026,5.967-53.127,15.902 c0,0-161.411-66.495-153.904,83.63c1.597,31.938,45.776,241.657,98.471,178.312c19.26-23.163,37.869-42.748,37.869-42.748c9.243,6.14,20.308,9.272,31.908,8.147l0.901-0.765c-0.28,2.876-0.152,5.689,0.361,9.019c-13.575,15.167-9.586,17.83-36.723,23.416 c-27.459,5.659-11.328,15.734-0.796,18.367c12.768,3.193,42.307,7.716,62.266-20.224l-0.796,3.188c5.319,4.26,9.054,27.711,8.428,48.969c-0.626,21.259-1.044,35.854,3.147,47.254c4.191,11.4,8.368,37.05,44.042,29.406c29.809-6.388,45.256-22.942,47.405-50.555 c1.525-19.631,4.976-16.729,5.194-34.28l2.768-8.309c3.192-26.611,0.507-35.196,18.872-31.203l4.463,0.392c13.517,0.615,31.208-2.174,41.591-7c22.358-10.376,35.618-27.7,13.573-23.148z"/>
<path d="M215.866,286.484c-1.385,49.516,0.348,99.377,5.193,111.495c4.848,12.118,15.223,35.688,50.9,28.045c29.806-6.39,40.651-18.756,45.357-46.051c3.466-20.082,10.148-75.854,11.005-87.281"/>
<path d="M173.104,38.256c0,0-161.521-66.016-154.012,84.109c1.597,31.938,45.779,241.664,98.473,178.316c19.256-23.166,36.671-41.335,36.671-41.335"/>
<path d="M260.349,26.207c-5.591,1.753,89.848-34.889,144.087,34.417c19.159,24.484-3.043,124.519-56.153,203.329"/>
<path style="stroke-linejoin:bevel;" d="M348.282,263.953c0,0,3.461,17.036,53.764,6.653c22.04-4.552,8.776,12.774-13.577,23.155c-18.345,8.514-59.474,10.696-60.146-1.069c-1.729-30.355,21.647-21.133,19.96-28.739c-1.525-6.85-11.979-13.573-18.894-30.338 c-6.037-14.633-82.796-126.849,21.287-110.183c3.813-0.789-27.146-99.002-124.553-100.599c-97.385-1.597-94.19,119.762-94.19,119.762"/>
<path d="M188.604,274.334c-13.577,15.166-9.584,17.829-36.723,23.417c-27.459,5.66-11.326,15.733-0.797,18.365c12.768,3.195,42.307,7.718,62.266-20.229c6.078-8.509-0.036-22.086-8.385-25.547c-4.034-1.671-9.428-3.765-16.361,3.994z"/>
<path d="M187.715,274.069c-1.368-8.917,2.93-19.528,7.536-31.942c6.922-18.626,22.893-37.255,10.117-96.339c-9.523-44.029-73.396-9.163-73.436-3.193c-0.039,5.968,2.889,30.26-1.067,58.548c-5.162,36.913,23.488,68.132,56.479,64.938"/>
<path style="fill:#FFFFFF;stroke-width:4.155;stroke-linecap:butt;stroke-linejoin:miter;" d="M172.517,141.7c-0.288,2.039,3.733,7.48,8.976,8.207c5.234,0.73,9.714-3.522,9.998-5.559c0.284-2.039-3.732-4.285-8.977-5.015c-5.237-0.731-9.719,0.333-9.996,2.367z"/>
<path style="fill:#FFFFFF;stroke-width:2.0775;stroke-linecap:butt;stroke-linejoin:miter;" d="M331.941,137.543c0.284,2.039-3.732,7.48-8.976,8.207c-5.238,0.73-9.718-3.522-10.005-5.559c-0.277-2.039,3.74-4.285,8.979-5.015c5.239-0.73,9.718,0.333,10.002,2.368z"/>
<path d="M350.676,123.432c0.863,15.994-3.445,26.888-3.988,43.914c-0.804,24.748,11.799,53.074-7.191,81.435"/>
<path style="stroke-width:3;" d="M0,60.232"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -197,8 +197,9 @@ describe("AdminDashboardPanel", () => {
it("renders add-ons as active for commercial", () => {
setupMocks({ license: "commercial" })
renderWithClient(<AdminDashboardPanel />, createTestQueryClient())
const activeBadges = screen.getAllByText("Active")
const activeBadges = screen.getAllByText("active")
expect(activeBadges).toHaveLength(3)
expect(activeBadges[0]).toHaveAttribute("data-tone", "success")
})
it("does not show nodes section when there are no nodes", () => {
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Lock, Monitor, Server } from "lucide-react"
import { useState } from "react"
import { NodesTable } from "@/components/nodes-table"
import { StatusBadge } from "@/components/state-badge"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useAdmin } from "@/hooks/use-admin"
@@ -89,7 +90,7 @@ export function AdminDashboardPanel() {
<div className="space-y-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">License</span>
<Badge variant={isOpenSource ? "secondary" : "default"}>
<Badge variant={isOpenSource ? "secondary" : "outline"}>
{isOpenSource ? "Open Source (AGPLv3)" : "Commercial"}
</Badge>
</div>
@@ -196,7 +197,7 @@ export function AdminDashboardPanel() {
{isOpenSource ? (
<Lock className="size-3 text-muted-foreground" />
) : (
<Badge variant="outline">Active</Badge>
<StatusBadge tone="success">active</StatusBadge>
)}
</div>
))}
@@ -150,7 +150,7 @@ export function CliCommandPanel({ commands }: CliCommandPanelProps) {
{copied ? "Copied" : "Copy"}
</Button>
</div>
<div className="overflow-x-auto p-3">
<div className="p-3">
{commands.map((cmd, i) => (
<div key={cmd.label}>
{commands.length > 1 && (
@@ -158,7 +158,8 @@ export function CliCommandPanel({ commands }: CliCommandPanelProps) {
{cmd.label}
</div>
)}
<pre className="font-mono text-xs/relaxed">
{/* Wraps to the panel; copying takes the unwrapped text. */}
<pre className="font-mono text-xs/relaxed wrap-anywhere whitespace-pre-wrap">
<code>
{cmd.parts.map((part, j) => (
// oxlint-disable-next-line no-array-index-key -- positional tokens of one command, never reordered
@@ -63,6 +63,9 @@ interface DataTableProps<T> {
// The row whose detail panel is open, and what a click on a row opens.
activeId?: string
onRowClick?: (id: string) => void
// Rows it rejects, such as records the zone manages, get no checkbox and do
// not open on click.
isRowActionable?: (row: T) => boolean
}
const features = tableFeatures({
@@ -140,6 +143,7 @@ export function DataTable<T extends RowData>({
onSelectedChange,
activeId,
onRowClick,
isRowActionable,
}: DataTableProps<T>) {
const selectable = selected !== undefined && onSelectedChange !== undefined
@@ -164,7 +168,10 @@ export function DataTable<T extends RowData>({
data: rows,
getRowId,
enableSortingRemoval: false,
enableRowSelection: selectable,
enableRowSelection:
selectable && isRowActionable
? (row: Row<Features, T>) => isRowActionable(row.original)
: selectable,
state: { sorting, rowSelection },
onSortingChange: (updater: Updater<SortingState>) => {
const [next] = functionalUpdate(updater, sorting)
@@ -245,20 +252,23 @@ export function DataTable<T extends RowData>({
)}
{visibleRows.map((row) => {
const toggle = row.getToggleSelectedHandler()
const actionable = isRowActionable?.(row.original) ?? true
const clickable = onRowClick !== undefined && actionable
return (
<TableRow
data-active={row.id === activeId ? "" : undefined}
data-clickable={onRowClick ? "" : undefined}
data-clickable={clickable ? "" : undefined}
data-state={row.getIsSelected() ? "selected" : undefined}
key={row.id}
onClick={(event) => {
if (onRowClick && !fromControl(event)) {
if (clickable && !fromControl(event)) {
onRowClick(row.id)
}
}}
>
{selectable && (
{selectable && !actionable && <TableCell />}
{selectable && actionable && (
<TableCell>
<Checkbox
aria-label={`Select ${row.id}`}
@@ -448,28 +448,6 @@ export function PanelField({
)
}
interface PanelTagsProps {
tags: readonly { Key?: string; Value?: string }[] | undefined
}
export function PanelTags({ tags }: PanelTagsProps) {
return (
<PanelSection title="Tags">
{tags?.length ? (
<PanelFields>
{tags.map((tag) => (
<PanelField key={tag.Key} label={tag.Key ?? ""}>
{tag.Value}
</PanelField>
))}
</PanelFields>
) : (
<p className="text-sm text-muted-foreground">No tags.</p>
)}
</PanelSection>
)
}
// What a panel shows while its form loads: a spinner on the panel's own
// background, so nothing flashes a grey block before the content.
export function PanelSpinner() {
@@ -5,7 +5,7 @@ import type {
} from "@aws-sdk/client-elastic-load-balancing-v2"
import { useState } from "react"
import type { UseFormReturn } from "react-hook-form"
import { Controller, useWatch } from "react-hook-form"
import { Controller, useFormState, useWatch } from "react-hook-form"
import { CertificateImportForm } from "@/components/elbv2/certificate-import-form"
import { Button } from "@/components/ui/button"
@@ -83,13 +83,8 @@ export function ListenerForm({
certificates = EMPTY_CERTIFICATES,
sslPolicies = EMPTY_SSL_POLICIES,
}: ListenerFormProps) {
const {
control,
register,
getValues,
setValue,
formState: { errors },
} = form
const { control, register, getValues, setValue } = form
const { errors } = useFormState({ control })
const [importOpen, setImportOpen] = useState(false)
@@ -11,6 +11,7 @@ import {
Controller,
useFieldArray,
useForm,
useFormState,
useWatch,
} from "react-hook-form"
@@ -373,8 +374,14 @@ interface ConditionRowProps {
}
function ConditionRow({ form, index, onRemove }: ConditionRowProps) {
const fieldName = form.watch(`conditions.${index}.field`)
const errors = form.formState.errors.conditions?.[index]
const fieldName = useWatch({
control: form.control,
name: `conditions.${index}.field`,
})
const errors = useFormState({ control: form.control }).errors.conditions?.[
index
]
return (
<div className="space-y-2 rounded-md border p-3">
@@ -1,30 +1,15 @@
import { useSuspenseQuery } from "@tanstack/react-query"
import { TagsEditor } from "@/components/tags-editor"
import { useUpdateTags } from "@/mutations/elbv2"
import { InlineTags } from "@/components/inline-tags"
import { useElbv2TagWriter } from "@/mutations/tags"
import { elbv2TagsQueryOptions } from "@/queries/elbv2"
// ELBv2 tags live behind their own call, keyed by ARN, so the editor loads them.
export function Elbv2TagsSection({ arn }: { arn: string }) {
const { data } = useSuspenseQuery(elbv2TagsQueryOptions([arn]))
const updateTags = useUpdateTags()
const writer = useElbv2TagWriter(arn)
const tags =
data.TagDescriptions?.find((td) => td.ResourceArn === arn)?.Tags ?? []
const tags = data.TagDescriptions?.find((td) => td.ResourceArn === arn)?.Tags
return (
<TagsEditor
error={updateTags.error}
isPending={updateTags.isPending}
isSuccess={updateTags.isSuccess}
onSubmit={(next) => {
updateTags.mutate({
resourceArn: arn,
tags: next,
initialKeys: tags.map((t) => t.Key ?? "").filter((k) => k.length > 0),
})
}}
tags={tags}
/>
)
return <InlineTags tags={tags} {...writer} />
}
@@ -1,6 +1,7 @@
import type { Vpc } from "@aws-sdk/client-ec2"
import { useState } from "react"
import type { UseFormReturn } from "react-hook-form"
import { Controller, useWatch } from "react-hook-form"
import { Controller, useFormState, useWatch } from "react-hook-form"
import { TagsFieldArray } from "@/components/tags-field-array"
import {
@@ -40,12 +41,11 @@ export function TargetGroupForm({
vpcs,
allowedProtocols = DEFAULT_ALLOWED_PROTOCOLS,
}: TargetGroupFormProps) {
const {
control,
register,
setValue,
formState: { errors },
} = form
const { control, register, setValue } = form
// Read through the hook: the compiler memoises this component on `form`,
// whose identity never changes, so `form.formState` would never re-render it.
const { errors } = useFormState({ control })
const [healthCheckOpen, setHealthCheckOpen] = useState(false)
const protocol = useWatch({ control, name: "protocol" })
// Path + Matcher only apply to HTTP(S) health checks; L4 target groups (TCP/
@@ -155,7 +155,11 @@ export function TargetGroupForm({
<FieldError errors={[errors.vpcId]} />
</Field>
<Collapsible>
{/* Held open by an error inside, or Create appears to do nothing. */}
<Collapsible
onOpenChange={setHealthCheckOpen}
open={healthCheckOpen || errors.healthCheck !== undefined}
>
<CollapsibleTrigger
variant="subtle"
render={<button aria-label="Health check settings" type="button" />}
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { FilterBar } from "./filter-bar"
const CHOICES = [
{ value: "all", label: "All", count: 3 },
{ value: "manual", label: "Manual", count: 2 },
] as const
function renderBar(query = "", choice: "all" | "manual" = "all") {
const onQueryChange = vi.fn()
const onChoiceChange = vi.fn()
render(
<FilterBar
choice={choice}
choices={CHOICES}
choicesLabel="Type"
onChoiceChange={onChoiceChange}
onQueryChange={onQueryChange}
placeholder="Filter"
query={query}
searchLabel="Filter things"
/>,
)
return { onQueryChange, onChoiceChange }
}
describe("FilterBar", () => {
it("offers Clear filters only while something is filtered", () => {
renderBar()
expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull()
})
it("clears both the search and the choice back to the first", () => {
const { onQueryChange, onChoiceChange } = renderBar("db", "manual")
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }))
expect(onQueryChange).toHaveBeenCalledWith("")
expect(onChoiceChange).toHaveBeenCalledWith("all")
})
it("keeps the choice when its pressed item is clicked again", () => {
const { onChoiceChange } = renderBar()
fireEvent.click(screen.getByRole("button", { name: "All 3" }))
expect(onChoiceChange).not.toHaveBeenCalled()
})
it("passes on the item chosen", () => {
const { onChoiceChange } = renderBar()
fireEvent.click(screen.getByRole("button", { name: "Manual 2" }))
expect(onChoiceChange).toHaveBeenCalledWith("manual")
})
})
@@ -0,0 +1,118 @@
import { Search } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
export interface FilterChoice<T extends string> {
value: T
label: string
// How many rows the choice keeps, shown beside its label.
count?: number
}
interface FilterBarProps<T extends string> {
query: string
onQueryChange: (query: string) => void
// Names the search box for assistive tech, as in "Filter instances".
searchLabel: string
placeholder: string
// The first choice is the unfiltered one that Clear filters returns to.
choices?: readonly FilterChoice<T>[]
choice?: T
onChoiceChange?: (choice: T) => void
choicesLabel?: string
// Clears both in one step, for a list that keeps its filter in the URL.
onClear?: () => void
}
const NO_CHOICES: readonly never[] = []
// A list's search box, one-of filter and Clear filters, laid out as the
// instances list has them so every list filters the same way.
export function FilterBar<T extends string>({
query,
onQueryChange,
searchLabel,
placeholder,
choices = NO_CHOICES,
choice,
onChoiceChange,
choicesLabel,
onClear,
}: FilterBarProps<T>) {
const unfiltered = choices[0]?.value
const filtered =
query !== "" || (choice !== undefined && choice !== unfiltered)
return (
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="w-full sm:w-72">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
aria-label={searchLabel}
onChange={(event) => {
onQueryChange(event.target.value)
}}
placeholder={placeholder}
type="search"
value={query}
/>
</InputGroup>
{choices.length > 0 && choice !== undefined && (
<ToggleGroup
aria-label={choicesLabel}
onValueChange={(values) => {
// A click on the pressed item empties the group; keep the choice.
const next = choices.find((item) => item.value === values.at(-1))
if (next) {
onChoiceChange?.(next.value)
}
}}
value={[choice]}
variant="outline"
>
{choices.map((item) => (
<ToggleGroupItem key={item.value} value={item.value}>
<span>{item.label}</span>
{item.count !== undefined && (
<span className="text-muted-foreground tabular-nums">
{item.count}
</span>
)}
</ToggleGroupItem>
))}
</ToggleGroup>
)}
{filtered && (
<Button
onClick={() => {
if (onClear) {
onClear()
return
}
onQueryChange("")
if (unfiltered !== undefined) {
onChoiceChange?.(unfiltered)
}
}}
size="sm"
variant="ghost"
>
Clear filters
</Button>
)}
</div>
)
}
@@ -79,3 +79,39 @@ describe("FormActions", () => {
expect(onCancel).toHaveBeenCalledOnce()
})
})
// Both labels stay rendered, so the button keeps one width through the swap.
function labels(isPending: boolean) {
render(
<FormActions
isPending={isPending}
isSubmitting={false}
onCancel={vi.fn()}
pendingLabel="Creating..."
submitLabel="Create"
/>,
)
const button = screen.getByRole("button", { name: /Creat/u })
return {
submit: button.querySelector('[data-label="submit"]'),
pending: button.querySelector('[data-label="pending"]'),
}
}
describe("FormActions pending transition", () => {
it("keeps both labels in the button and fades the idle one out", () => {
const { submit, pending } = labels(false)
expect(submit).not.toHaveClass("opacity-0")
expect(pending).toHaveClass("opacity-0")
expect(pending).toHaveAttribute("aria-hidden", "true")
})
it("fades the submit label out while pending", () => {
const { submit, pending } = labels(true)
expect(submit).toHaveClass("opacity-0")
expect(submit).toHaveAttribute("aria-hidden", "true")
expect(pending).not.toHaveClass("opacity-0")
})
})
@@ -2,6 +2,12 @@ import { LoaderCircle } from "lucide-react"
import { ErrorBanner } from "@/components/error-banner"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// Both labels share one grid cell, so the button is as wide as the longer and
// keeps that width while they cross-fade.
const LAYER =
"col-start-1 row-start-1 inline-flex items-center justify-center gap-1 transition-opacity duration-150 ease-out motion-reduce:transition-none"
interface FormActionsProps {
isSubmitting: boolean
@@ -16,8 +22,8 @@ interface FormActionsProps {
submitDisabled?: boolean
}
// The submit button turns into a spinner with the pending label in place, so
// nothing above it moves while the request is in flight.
// The submit button cross-fades to a spinner with the pending label in place,
// so neither it nor anything beside it moves while the request is in flight.
export function FormActions({
isSubmitting,
isPending,
@@ -49,14 +55,29 @@ export function FormActions({
disabled={disabled || submitDisabled}
type="submit"
>
{disabled && (
<LoaderCircle
aria-hidden
className="animate-spin motion-reduce:animate-none"
data-icon="inline-start"
/>
)}
{disabled ? pendingLabel : submitLabel}
<span className="grid">
<span
aria-hidden={disabled}
className={cn(LAYER, disabled && "opacity-0")}
data-label="submit"
>
{submitLabel}
</span>
<span
aria-hidden={!disabled}
className={cn(LAYER, !disabled && "opacity-0")}
data-label="pending"
>
<LoaderCircle
aria-hidden
className={cn(
"motion-reduce:animate-none",
disabled && "animate-spin",
)}
/>
{pendingLabel}
</span>
</span>
</Button>
</div>
</div>
@@ -6,6 +6,7 @@ import {
Controller,
} from "react-hook-form"
import { InfoTip } from "@/components/info-tip"
import { type PickerNotice, PickerNoticeText } from "@/components/picker-notice"
import {
Field,
@@ -25,6 +26,8 @@ import {
export interface SelectFieldOption {
value: string
label: string
// Shown muted after the label in the list, never in the closed trigger.
detail?: string
}
// For pickers whose values are already what the user should read.
@@ -43,12 +46,31 @@ export interface SelectFieldProps<T extends FieldValues> {
// an inert dropdown does not say what it is.
notice?: PickerNotice
description?: ReactNode
// An (i) beside the label, for help that would otherwise crowd the form.
info?: ReactNode
// Rendered under the control, for a change whose cost the user should see
// before submitting rather than after.
warning?: ReactNode
onValueChange?: (value: string) => void
}
export function FieldLabel({
id,
label,
info,
}: {
id: string
label: string
info?: ReactNode
}) {
return (
<FieldTitle>
<label htmlFor={id}>{label}</label>
{info && <InfoTip label={label}>{info}</InfoTip>}
</FieldTitle>
)
}
// The base-ui Select is not a native input and cannot be wired up with
// `register`, so every form-bound picker goes through this Controller.
export function SelectField<T extends FieldValues>({
@@ -60,6 +82,7 @@ export function SelectField<T extends FieldValues>({
placeholder,
notice,
description,
info,
warning,
onValueChange,
}: SelectFieldProps<T>) {
@@ -82,9 +105,7 @@ export function SelectField<T extends FieldValues>({
return (
<Field>
<FieldTitle>
<label htmlFor={id}>{label}</label>
</FieldTitle>
<FieldLabel id={id} info={info} label={label} />
{notice ? (
<PickerNoticeText notice={notice} />
) : (
@@ -104,6 +125,11 @@ export function SelectField<T extends FieldValues>({
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{option.detail && (
<span className="text-muted-foreground">
{option.detail}
</span>
)}
</SelectItem>
))}
</SelectContent>
@@ -126,6 +152,7 @@ type TextFieldProps = ComponentProps<typeof Input> & {
label: string
error?: { message?: string }
description?: ReactNode
info?: ReactNode
warning?: ReactNode
}
@@ -136,14 +163,13 @@ export function TextField({
label,
error,
description,
info,
warning,
...inputProps
}: TextFieldProps) {
return (
<Field>
<FieldTitle>
<label htmlFor={id}>{label}</label>
</FieldTitle>
<FieldLabel id={id} info={info} label={label} />
<Input aria-invalid={!!error} id={id} {...inputProps} />
{description && <FieldDescription>{description}</FieldDescription>}
{warning && <p className="text-xs text-warning-foreground">{warning}</p>}
@@ -0,0 +1,206 @@
import { act, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import { DetailPanel } from "./detail-panel"
import { InlineTags, validateTag } from "./inline-tags"
const TAGS = [
{ Key: "env", Value: "prod" },
{ Key: "team", Value: "web" },
]
function setup(
overrides: {
onPut?: (key: string, value: string) => Promise<void>
onRemove?: (key: string) => Promise<void>
} = {},
) {
const onPut = vi.fn(overrides.onPut ?? (async () => {}))
const onRemove = vi.fn(overrides.onRemove ?? (async () => {}))
render(<InlineTags onPut={onPut} onRemove={onRemove} tags={TAGS} />)
return { onPut, onRemove, user: userEvent.setup() }
}
describe("validateTag", () => {
it("refuses what AWS refuses", () => {
expect(validateTag("", "v", [])).toBe("Tag key is required")
expect(validateTag("k".repeat(129), "", [])).toMatch(/128/u)
expect(validateTag("k".repeat(128), "v".repeat(256), [])).toBeUndefined()
expect(validateTag("k", "v".repeat(257), [])).toMatch(/256/u)
expect(validateTag("AWS:name", "", [])).toMatch(/reserved/u)
expect(validateTag("env", "", ["env"])).toMatch(/already exists/u)
})
})
describe("InlineTags", () => {
it("adds a tag with one put", async () => {
const { onPut, onRemove, user } = setup()
await user.click(screen.getByRole("button", { name: "Add tag" }))
await user.type(screen.getByRole("textbox", { name: "Tag key" }), "owner")
await user.type(
screen.getByRole("textbox", { name: "Tag value" }),
"ops{Enter}",
)
expect(onPut).toHaveBeenCalledExactlyOnceWith("owner", "ops")
expect(onRemove).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.queryByRole("textbox", { name: "Tag key" })).toBeNull()
})
})
it("edits a value without removing the key", async () => {
const { onPut, onRemove, user } = setup()
await user.click(screen.getByRole("button", { name: "Edit tag env" }))
const value = screen.getByRole("textbox", { name: "Tag value" })
await user.clear(value)
await user.type(value, "staging")
await user.click(screen.getByRole("button", { name: "Save tag env" }))
expect(onPut).toHaveBeenCalledExactlyOnceWith("env", "staging")
expect(onRemove).not.toHaveBeenCalled()
})
it("renames by putting the new key before removing the old", async () => {
const calls: string[] = []
const { user } = setup({
onPut: async (key) => {
calls.push(`put ${key}`)
},
onRemove: async (key) => {
calls.push(`remove ${key}`)
},
})
await user.click(screen.getByRole("button", { name: "Edit tag env" }))
const key = screen.getByRole("textbox", { name: "Tag key" })
await user.clear(key)
await user.type(key, "stage{Enter}")
await waitFor(() => {
expect(calls).toStrictEqual(["put stage", "remove env"])
})
})
it("keeps the row open and says what happened when a rename half fails", async () => {
const { onPut, user } = setup({
onRemove: async () => {
throw new Error("AccessDenied")
},
})
await user.click(screen.getByRole("button", { name: "Edit tag env" }))
const key = screen.getByRole("textbox", { name: "Tag key" })
await user.clear(key)
await user.type(key, "stage{Enter}")
await expect(screen.findByRole("alert")).resolves.toHaveTextContent(
"Saved stage, but env could not be removed: AccessDenied",
)
expect(onPut).toHaveBeenCalledExactlyOnceWith("stage", "prod")
expect(screen.getByRole("textbox", { name: "Tag key" })).toHaveValue(
"stage",
)
})
it("removes a tag", async () => {
const { onPut, onRemove, user } = setup()
await user.click(screen.getByRole("button", { name: "Remove tag team" }))
expect(onRemove).toHaveBeenCalledExactlyOnceWith("team")
expect(onPut).not.toHaveBeenCalled()
})
it("shows a failed remove beside the row", async () => {
const { user } = setup({
onRemove: async () => {
throw new Error("Throttling")
},
})
await user.click(screen.getByRole("button", { name: "Remove tag team" }))
await expect(screen.findByRole("alert")).resolves.toHaveTextContent(
"Throttling",
)
})
it("refuses an invalid tag without calling the API", async () => {
const { onPut, user } = setup()
await user.click(screen.getByRole("button", { name: "Add tag" }))
await user.type(
screen.getByRole("textbox", { name: "Tag key" }),
"aws:name{Enter}",
)
expect(screen.getByRole("alert")).toHaveTextContent(/reserved/u)
const key = screen.getByRole("textbox", { name: "Tag key" })
await user.clear(key)
await user.type(key, "team{Enter}")
expect(screen.getByRole("alert")).toHaveTextContent(/already exists/u)
await user.clear(key)
await user.keyboard("{Enter}")
expect(screen.getByRole("alert")).toHaveTextContent("Tag key is required")
expect(onPut).not.toHaveBeenCalled()
})
it("cancels an edit on Escape without closing the panel", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
const onClose = vi.fn()
const onPut = vi.fn(async () => {})
render(
<DetailPanel
closeLabel="Close"
label="Volume details"
onClose={onClose}
title="vol-1"
>
<InlineTags onPut={onPut} onRemove={vi.fn()} tags={TAGS} />
</DetailPanel>,
)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await user.click(screen.getByRole("button", { name: "Edit tag env" }))
await user.type(screen.getByRole("textbox", { name: "Tag value" }), "x")
await user.keyboard("{Escape}")
act(() => {
vi.advanceTimersByTime(500)
})
expect(screen.queryByRole("textbox", { name: "Tag value" })).toBeNull()
expect(screen.getByText("prod")).toBeInTheDocument()
expect(onPut).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
// With no row editing, the same key closes the panel.
await user.keyboard("{Escape}")
act(() => {
vi.advanceTimersByTime(500)
})
expect(onClose).toHaveBeenCalledOnce()
vi.useRealTimers()
})
it("is read-only without callbacks", () => {
render(<InlineTags tags={TAGS} />)
expect(screen.getByText("env")).toBeInTheDocument()
expect(screen.getByText("prod")).toBeInTheDocument()
expect(screen.queryByRole("button", { name: /tag/u })).toBeNull()
})
it("says when there are no tags", () => {
render(<InlineTags tags={[]} />)
expect(screen.getByText("No tags.")).toBeInTheDocument()
})
})
@@ -0,0 +1,320 @@
import { Check, LoaderCircle, Pencil, Plus, Trash2, X } from "lucide-react"
import { type KeyboardEvent, useId, useState } from "react"
import { PanelSection } from "@/components/detail-panel"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface Tag {
Key?: string
Value?: string
}
interface InlineTagsProps {
tags: readonly Tag[] | undefined
// Both absent means the tags cannot be changed, and the rows are read-only.
onPut?: (key: string, value: string) => Promise<void>
onRemove?: (key: string) => Promise<void>
}
const KEY_MAX = 128
const VALUE_MAX = 256
export function validateTag(
key: string,
value: string,
otherKeys: readonly string[],
): string | undefined {
if (key.length === 0) {
return "Tag key is required"
}
if (key.length > KEY_MAX) {
return `Tag key must be ${KEY_MAX} characters or less`
}
if (value.length > VALUE_MAX) {
return `Tag value must be ${VALUE_MAX} characters or less`
}
if (key.toLowerCase().startsWith("aws:")) {
return "Keys starting aws: are reserved"
}
return otherKeys.includes(key)
? `A tag with key ${key} already exists`
: undefined
}
interface TagRowProps {
// Absent for a row being added.
original?: { key: string; value: string }
otherKeys: readonly string[]
editable: boolean
onPut?: (key: string, value: string) => Promise<void>
onRemove?: (key: string) => Promise<void>
onDone?: () => void
}
function TagRow({
original,
otherKeys,
editable,
onPut,
onRemove,
onDone,
}: TagRowProps) {
const id = useId()
const [editing, setEditing] = useState(original === undefined)
const [key, setKey] = useState(original?.key ?? "")
const [value, setValue] = useState(original?.value ?? "")
const [pending, setPending] = useState(false)
const [error, setError] = useState<string>()
const name = original?.key ?? "new tag"
function cancel() {
setKey(original?.key ?? "")
setValue(original?.value ?? "")
setError(undefined)
setEditing(false)
onDone?.()
}
async function save() {
const invalid = validateTag(key, value, otherKeys)
if (invalid) {
setError(invalid)
return
}
if (!onPut || !onRemove) {
return
}
setPending(true)
setError(undefined)
try {
await onPut(key, value)
} catch (error_) {
setError(error_ instanceof Error ? error_.message : "The request failed")
setPending(false)
return
}
// AWS has no rename: the new key is written first, so a failure here leaves
// both keys rather than neither.
if (original && original.key !== key) {
try {
await onRemove(original.key)
} catch (error_) {
setError(
`Saved ${key}, but ${original.key} could not be removed: ${error_ instanceof Error ? error_.message : "The request failed"}`,
)
setPending(false)
return
}
}
setPending(false)
setEditing(false)
onDone?.()
}
async function remove() {
if (!original || !onRemove) {
return
}
setPending(true)
setError(undefined)
try {
await onRemove(original.key)
} catch (error_) {
setError(error_ instanceof Error ? error_.message : "The request failed")
setPending(false)
}
}
// Escape stays with the row, so cancelling an edit never closes the panel.
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.stopPropagation()
cancel()
} else if (event.key === "Enter") {
event.preventDefault()
void save()
}
}
const errorText = error && (
<p className="col-span-full text-xs text-danger-foreground" role="alert">
{error}
</p>
)
if (editing) {
return (
<div className="grid grid-cols-[1fr_1fr_auto] items-center gap-1.5 py-1.5">
<Input
aria-invalid={error !== undefined}
aria-label="Tag key"
autoFocus
disabled={pending}
id={`${id}-key`}
onChange={(event) => {
setKey(event.target.value)
}}
onKeyDown={onKeyDown}
placeholder="Key"
value={key}
/>
<Input
aria-label="Tag value"
disabled={pending}
id={`${id}-value`}
onChange={(event) => {
setValue(event.target.value)
}}
onKeyDown={onKeyDown}
placeholder="Value"
value={value}
/>
<div className="flex items-center gap-0.5">
<Button
aria-label={`Save tag ${name}`}
disabled={pending}
onClick={() => {
void save()
}}
size="icon-sm"
variant="ghost"
>
{pending ? (
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
) : (
<Check />
)}
</Button>
<Button
aria-label={`Cancel editing ${name}`}
disabled={pending}
onClick={cancel}
size="icon-sm"
variant="ghost"
>
<X />
</Button>
</div>
{errorText}
</div>
)
}
return (
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)_auto] items-center gap-2 py-1.5 text-sm">
<span className="truncate text-muted-foreground">{original?.key}</span>
<span className="min-w-0 truncate">
{original?.value || <span className="text-muted-foreground">—</span>}
</span>
{editable ? (
<div className="flex items-center gap-0.5">
<Button
aria-label={`Edit tag ${name}`}
disabled={pending}
onClick={() => {
setEditing(true)
}}
size="icon-sm"
variant="ghost"
>
<Pencil />
</Button>
<Button
aria-label={`Remove tag ${name}`}
disabled={pending}
onClick={() => {
void remove()
}}
size="icon-sm"
variant="ghost"
>
{pending ? (
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
) : (
<Trash2 />
)}
</Button>
</div>
) : (
<span />
)}
{errorText}
</div>
)
}
// A resource's tags, edited a row at a time: the pencil turns a row into two
// inputs and a tick, the bin removes it, and each save is its own request.
export function InlineTags({ tags, onPut, onRemove }: InlineTagsProps) {
const editable = onPut !== undefined && onRemove !== undefined
const [adding, setAdding] = useState(false)
const rows = (tags ?? []).flatMap((tag) =>
tag.Key ? [{ key: tag.Key, value: tag.Value ?? "" }] : [],
)
const keys = rows.map((row) => row.key)
return (
<PanelSection title="Tags">
{rows.length === 0 && !adding && (
<p className="text-sm text-muted-foreground">No tags.</p>
)}
{(rows.length > 0 || adding) && (
<div className="divide-y">
{rows.map((row) => (
<TagRow
editable={editable}
// The value is in the key so a refetch resets a row's draft.
key={`${row.key}=${row.value}`}
onPut={onPut}
onRemove={onRemove}
original={row}
otherKeys={keys.filter((k) => k !== row.key)}
/>
))}
{adding && (
<TagRow
editable
onDone={() => {
setAdding(false)
}}
onPut={onPut}
onRemove={onRemove}
otherKeys={keys}
/>
)}
</div>
)}
{editable && !adding && (
<Button
className="mt-2"
onClick={() => {
setAdding(true)
}}
size="sm"
variant="outline"
>
<Plus data-icon="inline-start" />
Add tag
</Button>
)}
</PanelSection>
)
}
@@ -1,4 +1,4 @@
import { Badge } from "@/components/ui/badge"
import { StatusBadge } from "@/components/state-badge"
import type { NodeInfo } from "@/queries/admin"
function formatUptime(seconds: number): string {
@@ -56,11 +56,11 @@ export function NodesTable({
{node.node}
</td>
<td className="py-1.5 pr-4">
<Badge
variant={node.status === "Ready" ? "default" : "secondary"}
<StatusBadge
tone={node.status === "Ready" ? "success" : "caution"}
>
{node.status}
</Badge>
</StatusBadge>
</td>
<td className="py-1.5 pr-4 font-mono text-muted-foreground">
{roles || "-"}
@@ -0,0 +1,96 @@
import type { QueryKey } from "@tanstack/react-query"
import { InlineTags } from "@/components/inline-tags"
import {
type IamTaggable,
useEc2TagWriter,
useEcsTagWriter,
useEksTagWriter,
useIamTagWriter,
} from "@/mutations/tags"
interface Tag {
Key?: string
Value?: string
}
// Every EC2 resource tags by its id through CreateTags and DeleteTags.
export function Ec2Tags({
id,
invalidate,
tags,
readOnly = false,
}: {
id: string | undefined
invalidate: QueryKey
tags: readonly Tag[] | undefined
readOnly?: boolean
}) {
const writer = useEc2TagWriter(id ?? "", invalidate)
return readOnly || !id ? (
<InlineTags tags={tags} />
) : (
<InlineTags tags={tags} {...writer} />
)
}
export function IamTags({
kind,
id,
tags,
readOnly = false,
}: {
kind: IamTaggable
id: string
tags: readonly Tag[] | undefined
readOnly?: boolean
}) {
const writer = useIamTagWriter(kind, id)
return readOnly ? (
<InlineTags tags={tags} />
) : (
<InlineTags tags={tags} {...writer} />
)
}
// EKS keeps its tags as a map; the editor reads EC2's list.
export function EksTags({
arn,
tags,
}: {
arn: string | undefined
tags: Record<string, string> | undefined
}) {
const writer = useEksTagWriter(arn ?? "")
const list = Object.entries(tags ?? {}).map(([Key, Value]) => ({
Key,
Value,
}))
return arn ? (
<InlineTags tags={list} {...writer} />
) : (
<InlineTags tags={list} />
)
}
// ECS spells its tag fields in lower case.
export function EcsTags({
arn,
tags,
}: {
arn: string | undefined
tags: readonly { key?: string; value?: string }[] | undefined
}) {
const writer = useEcsTagWriter(arn ?? "")
const list = tags?.map((tag) => ({ Key: tag.key, Value: tag.value }))
return arn ? (
<InlineTags tags={list} {...writer} />
) : (
<InlineTags tags={list} />
)
}
@@ -1,31 +1,26 @@
import { type StateTone, StatusBadge } from "@/components/state-badge"
import type { ActivationState } from "@/lib/route53-activation"
import { cn } from "@/lib/utils"
import { ACTIVATION_LABELS } from "@/types/route53"
const STATE_CLASSES: Record<ActivationState, string> = {
ACTIVE: "border-success bg-success/10 text-success-foreground",
PENDING_NAMESERVERS: "border-warning bg-warning/10 text-warning-foreground",
NAMESERVERS_LOST: "border-destructive bg-destructive/10 text-destructive",
SUSPENDED: "border-border bg-muted text-muted-foreground",
const STATE_TONES: Record<ActivationState, StateTone> = {
ACTIVE: "success",
PENDING_NAMESERVERS: "progress",
NAMESERVERS_LOST: "danger",
SUSPENDED: "muted",
}
const UNKNOWN_CLASS = "border-border bg-muted text-muted-foreground"
export function ActivationBadge({
state,
fallback = "Unknown",
fallback = "unknown",
}: {
state: ActivationState | undefined
fallback?: string
}) {
return (
<span
className={cn(
"inline-block rounded-full border px-2 py-1 text-xs whitespace-nowrap",
state ? STATE_CLASSES[state] : UNKNOWN_CLASS,
)}
>
{state ? ACTIVATION_LABELS[state] : fallback}
</span>
<StatusBadge tone={state ? STATE_TONES[state] : "neutral"}>
<span className="whitespace-nowrap">
{state ? ACTIVATION_LABELS[state] : fallback}
</span>
</StatusBadge>
)
}
@@ -76,7 +76,7 @@ export function ActivationPanel({
<div className="flex items-center gap-3">
<ActivationBadge
fallback={activationQuery.isPending ? "Loading…" : "Unknown"}
fallback={activationQuery.isPending ? "loading…" : "unknown"}
state={activation?.state}
/>
<Button
@@ -1,7 +1,13 @@
import type { Change, ResourceRecordSet } from "@aws-sdk/client-route-53"
import { zodResolver } from "@hookform/resolvers/zod"
import type { SubmitEvent } from "react"
import { type DeepPartial, useForm, useWatch } from "react-hook-form"
import {
type Control,
Controller,
type DeepPartial,
useForm,
useWatch,
} from "react-hook-form"
import {
type CliCommand,
@@ -10,36 +16,111 @@ import {
shellSingleQuote,
} from "@/components/cli-command-panel"
import { FormActions } from "@/components/form-actions"
import { SelectField, selectOptions, TextField } from "@/components/form-fields"
import {
FieldLabel,
SelectField,
selectOptions,
TextField,
} from "@/components/form-fields"
import { FormLock } from "@/components/form-lock"
import { FormPanel } from "@/components/form-panel"
import { InfoList } from "@/components/info-tip"
import { RecordValuesField } from "@/components/route53/record-values-field"
import { Field, FieldDescription, FieldError } from "@/components/ui/field"
import {
Field,
FieldDescription,
FieldError,
FieldTitle,
} from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
DEFAULT_MX_PRIORITY,
DEFAULT_TTL_SECONDS,
EDITABLE_RECORD_TYPES,
ensureTrailingDot,
RECORD_TYPE_HELP,
RECORD_VALUE_HINTS,
RECORD_VALUE_LABELS,
type RecordSetFormData,
recordSetSchema,
sameName,
TTL_OPTIONS,
toFormValues,
toRecordSet,
} from "@/types/route53"
const DEFAULT_TTL_SECONDS = 300
const NEW_RECORD_DEFAULTS: RecordSetFormData = {
name: "",
type: "A",
ttl: DEFAULT_TTL_SECONDS,
priority: DEFAULT_MX_PRIORITY,
values: "",
}
// The preset TTLs, plus a record's own value when it is not one of them, so
// opening and saving a record never rewrites its TTL.
export function ttlOptions(current: number | undefined) {
const custom =
current !== undefined &&
Number.isFinite(current) &&
!TTL_OPTIONS.some((option) => option.value === current)
const options = custom
? [...TTL_OPTIONS, { value: current, label: `${current} seconds` }]
: TTL_OPTIONS
return options.map((option) => ({
value: String(option.value),
label: option.label,
}))
}
function TtlField({
control,
current,
}: {
control: Control<RecordSetFormData>
current: number | undefined
}) {
const options = ttlOptions(current)
return (
<Controller
control={control}
name="ttl"
render={({ field, fieldState }) => (
<Field>
<FieldLabel id="record-ttl" label="TTL" />
<Select
items={options}
onValueChange={(next) => {
field.onChange(Number(next ?? DEFAULT_TTL_SECONDS))
}}
value={String(field.value)}
>
<SelectTrigger
aria-invalid={!!fieldState.error}
className="w-full"
id="record-ttl"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)
}
// editChanges is the batch that turns `original` into `updated`. A record set
// is keyed by name and type, so changing either is a delete and a create in one
// atomic batch; otherwise it is an UPSERT.
@@ -121,31 +202,41 @@ export function RecordForm({
<SelectField
control={control}
id="record-type"
info={<InfoList items={RECORD_TYPE_HELP} />}
label="Record type"
name="type"
options={selectOptions(EDITABLE_RECORD_TYPES)}
/>
<TextField
error={errors.ttl}
id="record-ttl"
label="TTL (seconds)"
type="number"
{...register("ttl", { valueAsNumber: true })}
/>
{type === "MX" && (
<TextField
description="Lower is tried first."
error={errors.priority}
id="record-priority"
label="Priority"
type="number"
{...register("priority", { valueAsNumber: true })}
/>
)}
<Field>
<FieldTitle>
<label htmlFor="record-values">Value</label>
</FieldTitle>
<Textarea
aria-invalid={!!errors.values}
id="record-values"
rows={4}
variant="code"
{...register("values")}
<FieldLabel id="record-values" label={RECORD_VALUE_LABELS[type]} />
<Controller
control={control}
name="values"
render={({ field }) => (
<RecordValuesField
id="record-values"
invalid={!!errors.values}
label={RECORD_VALUE_LABELS[type]}
multiple={type !== "CNAME"}
onChange={field.onChange}
value={field.value}
/>
)}
/>
<FieldDescription>{RECORD_VALUE_HINTS[type]}</FieldDescription>
<FieldError errors={[errors.values]} />
</Field>
<TtlField control={control} current={original?.TTL} />
<CliCommandPanel
commands={buildRecordCommands(values, zoneId, zoneName, original)}
@@ -178,6 +269,9 @@ export function buildRecordCommands(
ttl: Number.isFinite(values.ttl)
? (values.ttl ?? 0)
: DEFAULT_TTL_SECONDS,
priority: Number.isFinite(values.priority)
? (values.priority ?? DEFAULT_MX_PRIORITY)
: DEFAULT_MX_PRIORITY,
values: values.values ?? "",
},
zoneName,
@@ -0,0 +1,142 @@
import { Plus, Trash2 } from "lucide-react"
import { type ClipboardEvent, type KeyboardEvent, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
// One single-line input per value of the record set. The form still holds the
// values as newline-joined text, so the schema and the API mapping are as before.
export function RecordValuesField({
id,
label,
value,
onChange,
multiple,
invalid,
}: {
id: string
label: string
value: string
onChange: (next: string) => void
multiple: boolean
invalid: boolean
}) {
const lines = value === "" ? [""] : value.split("\n")
const inputs = useRef<(HTMLInputElement | null)[]>([])
const commit = (next: string[], focus?: number) => {
onChange(next.join("\n"))
if (focus !== undefined) {
requestAnimationFrame(() => inputs.current[focus]?.focus())
}
}
const setLine = (index: number, text: string) => {
commit(lines.map((line, i) => (i === index ? text : line)))
}
const addAfter = (index: number) => {
commit(
[...lines.slice(0, index + 1), "", ...lines.slice(index + 1)],
index + 1,
)
}
const remove = (index: number) => {
commit(
lines.filter((_, i) => i !== index),
Math.max(0, index - 1),
)
}
const onKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") {
return
}
// Enter would submit the form; with several values allowed it starts the next one.
event.preventDefault()
if (multiple) {
addAfter(index)
}
}
// A pasted list becomes one row per line rather than one value with breaks in it.
const onPaste = (index: number, event: ClipboardEvent<HTMLInputElement>) => {
const text = event.clipboardData.getData("text")
if (!text.includes("\n")) {
return
}
event.preventDefault()
const pasted = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
const rows = multiple ? pasted : pasted.slice(0, 1)
const next = [...lines.slice(0, index), ...rows, ...lines.slice(index + 1)]
commit(next, index + rows.length - 1)
}
return (
<div className="space-y-2">
{lines.map((line, index) => (
// oxlint-disable-next-line no-array-index-key -- a value row has no identity beyond its position
<div className="flex items-center gap-2" key={index}>
<Input
aria-invalid={invalid}
aria-label={index === 0 ? undefined : `${label} ${index + 1}`}
autoComplete="off"
variant="code"
id={index === 0 ? id : undefined}
onChange={(e) => {
setLine(index, e.target.value)
}}
onKeyDown={(e) => {
onKeyDown(index, e)
}}
onPaste={(e) => {
onPaste(index, e)
}}
ref={(el) => {
inputs.current[index] = el
}}
spellCheck={false}
value={line}
/>
{lines.length > 1 && (
<Button
aria-label={`Remove ${label.toLowerCase()} ${index + 1}`}
onClick={() => {
remove(index)
}}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2 />
</Button>
)}
</div>
))}
{multiple && (
<Button
onClick={() => {
addAfter(lines.length - 1)
}}
size="sm"
type="button"
variant="outline"
>
<Plus data-icon="inline-start" />
Add value
</Button>
)}
</div>
)
}
@@ -109,24 +109,49 @@ describe("RecordsTab", () => {
sdk.setHandler("ListHostedZonesCommand", () => ({ HostedZones: [] }))
})
it("keeps the apex NS and SOA read-only", () => {
it("keeps the apex NS and SOA out of selection and editing", async () => {
renderTab([APEX_NS, APEX_SOA, WWW, SUB_NS])
const user = userEvent.setup()
expect(screen.getAllByText("Managed by the zone")).toHaveLength(2)
expect(screen.queryByLabelText(`Edit record ${ZONE} NS`)).toBeNull()
expect(screen.queryByLabelText(`Delete record ${ZONE} NS`)).toBeNull()
expect(screen.queryByLabelText(`Edit record ${ZONE} SOA`)).toBeNull()
expect(screen.queryByLabelText(`Delete record ${ZONE} SOA`)).toBeNull()
expect(screen.queryByLabelText(`Select ${ZONE}|NS`)).toBeNull()
expect(screen.queryByLabelText(`Select ${ZONE}|SOA`)).toBeNull()
expect(
screen.getByLabelText("Edit record www.example.com. A"),
screen.getByLabelText("Select www.example.com.|A"),
).toBeInTheDocument()
// A sub-delegation is the customer's, but not a type the editor offers.
// A delegation below the apex is the customer's.
expect(
screen.queryByLabelText("Edit record sub.example.com. NS"),
).toBeNull()
expect(
screen.getByLabelText("Delete record sub.example.com. NS"),
screen.getByLabelText("Select sub.example.com.|NS"),
).toBeInTheDocument()
await user.click(screen.getByText("ns1.spx3.net., ns2.spx3.net."))
expect(screen.queryByRole("region", { name: "Edit record" })).toBeNull()
})
it("opens a delegation's NS record for editing on a row click", async () => {
renderTab([APEX_NS, SUB_NS])
const user = userEvent.setup()
await user.click(screen.getByText("sub.example.com."))
const panel = await screen.findByRole("region", { name: "Edit record" })
expect(within(panel).getByLabelText("Name server")).toHaveValue(
"ns.elsewhere.example.",
)
})
it("opens a record for editing from the keyboard", async () => {
renderTab([APEX_NS, WWW])
const user = userEvent.setup()
expect(screen.queryByRole("button", { name: `Edit ${ZONE} NS` })).toBeNull()
screen.getByRole("button", { name: "Edit www.example.com. A" }).focus()
await user.keyboard("{Enter}")
await expect(
screen.findByRole("region", { name: "Edit record" }),
).resolves.toBeInTheDocument()
})
it("warns that records are not served while the zone is pending", () => {
@@ -159,7 +184,7 @@ describe("RecordsTab", () => {
)
await user.click(await screen.findByRole("option", { name: "TXT" }))
await user.type(
within(panel).getByLabelText("Value"),
within(panel).getByLabelText("Content"),
'v=DMARC1; p=none; say "hi"',
)
await user.click(within(panel).getByRole("button", { name: "Add record" }))
@@ -189,7 +214,7 @@ describe("RecordsTab", () => {
name: "Add record",
})
await user.type(within(panel).getByLabelText("Value"), "not-an-ip")
await user.type(within(panel).getByLabelText("IPv4 address"), "not-an-ip")
await user.click(within(panel).getByRole("button", { name: "Add record" }))
await expect(
@@ -202,13 +227,13 @@ describe("RecordsTab", () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByLabelText("Edit record www.example.com. A"))
await user.click(screen.getByText("www.example.com."))
const panel = await screen.findByRole("region", {
name: "Edit record",
})
const values = within(panel).getByLabelText("Value")
const values = within(panel).getByLabelText("IPv4 address")
expect(values).toHaveValue("192.0.2.10")
await user.clear(values)
await user.type(values, "192.0.2.20{enter}192.0.2.21")
@@ -233,7 +258,7 @@ describe("RecordsTab", () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByLabelText("Edit record www.example.com. A"))
await user.click(screen.getByText("www.example.com."))
const panel = await screen.findByRole("region", {
name: "Edit record",
@@ -264,28 +289,129 @@ describe("RecordsTab", () => {
renderTab([APEX_NS, TXT])
const user = userEvent.setup()
await user.click(screen.getByLabelText(`Edit record ${ZONE} TXT`))
await user.click(screen.getByText('"hello \\"world\\""'))
const panel = await screen.findByRole("region", {
name: "Edit record",
})
expect(within(panel).getByLabelText("Value")).toHaveValue('hello "world"')
expect(within(panel).getByLabelText("Content")).toHaveValue('hello "world"')
expect(within(panel).getByLabelText("Record name")).toHaveValue("")
})
it("deletes a record set with the exact set the API returned", async () => {
renderTab()
it("deletes the selected sets in one batch, exactly as the API returned them", async () => {
renderTab([APEX_NS, APEX_SOA, WWW, SUB_NS])
const user = userEvent.setup()
await user.click(screen.getByLabelText("Delete record www.example.com. A"))
await user.click(await screen.findByRole("button", { name: "Delete" }))
await user.click(screen.getByLabelText("Select www.example.com.|A"))
await user.click(screen.getByLabelText("Select sub.example.com.|NS"))
await user.click(screen.getByRole("button", { name: "Delete" }))
await user.click(await screen.findByRole("button", { name: "Delete 2" }))
await expect(submittedChanges()).resolves.toStrictEqual([
{ Action: "DELETE", ResourceRecordSet: WWW },
{ Action: "DELETE", ResourceRecordSet: SUB_NS },
])
})
it("selecting every row leaves the zone's own records out", async () => {
renderTab([APEX_NS, APEX_SOA, WWW])
const user = userEvent.setup()
await user.click(screen.getByLabelText("Select all rows"))
expect(screen.getByText("1 selected")).toBeInTheDocument()
})
it("keeps a TTL that is not a preset when the record is saved", async () => {
renderTab([APEX_NS, { ...WWW, TTL: 45 }])
const user = userEvent.setup()
await user.click(screen.getByText("www.example.com."))
const panel = await screen.findByRole("region", { name: "Edit record" })
expect(
within(panel).getByRole("combobox", { name: "TTL" }),
).toHaveTextContent("45 seconds")
await user.click(
within(panel).getByRole("button", { name: "Save changes" }),
)
await expect(submittedChanges()).resolves.toStrictEqual([
{ Action: "UPSERT", ResourceRecordSet: { ...WWW, TTL: 45 } },
])
})
it("picks a TTL from the presets, defaulting to Auto", async () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByRole("button", { name: "Add record" }))
const panel = await screen.findByRole("region", { name: "Add record" })
const ttl = within(panel).getByRole("combobox", { name: "TTL" })
expect(ttl).toHaveTextContent("Auto (5 min)")
await user.click(ttl)
await user.click(await screen.findByRole("option", { name: "1 hr" }))
await user.type(within(panel).getByLabelText("Record name"), "api")
await user.type(within(panel).getByLabelText("IPv4 address"), "192.0.2.5")
await user.click(within(panel).getByRole("button", { name: "Add record" }))
await expect(submittedChanges()).resolves.toMatchObject([
{ ResourceRecordSet: { Name: "api.example.com.", TTL: 3600 } },
])
})
it("asks for an MX priority and puts it before the mail server", async () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByRole("button", { name: "Add record" }))
const panel = await screen.findByRole("region", { name: "Add record" })
expect(within(panel).queryByLabelText("Priority")).toBeNull()
await user.click(
within(panel).getByRole("combobox", { name: "Record type" }),
)
await user.click(await screen.findByRole("option", { name: "MX" }))
const priority = within(panel).getByLabelText("Priority")
await user.clear(priority)
await user.type(priority, "5")
await user.type(
within(panel).getByLabelText("Mail server"),
"mx.example.com",
)
await user.click(within(panel).getByRole("button", { name: "Add record" }))
await expect(submittedChanges()).resolves.toMatchObject([
{
ResourceRecordSet: {
Type: "MX",
ResourceRecords: [{ Value: "5 mx.example.com" }],
},
},
])
})
it("explains each record type beside the picker", async () => {
renderTab()
const user = userEvent.setup()
await user.click(screen.getByRole("button", { name: "Add record" }))
const panel = await screen.findByRole("region", { name: "Add record" })
await user.click(
within(panel).getByRole("button", { name: "About Record type" }),
)
await expect(
screen.findByText("Delegates a subdomain to other name servers."),
).resolves.toBeInTheDocument()
})
it("shows the API's refusal in the form and keeps it open", async () => {
sdk.setHandler("ChangeResourceRecordSetsCommand", () => {
throw Object.assign(
@@ -305,7 +431,7 @@ describe("RecordsTab", () => {
})
await user.type(within(panel).getByLabelText("Record name"), "www")
await user.type(within(panel).getByLabelText("Value"), "192.0.2.10")
await user.type(within(panel).getByLabelText("IPv4 address"), "192.0.2.10")
await user.click(within(panel).getByRole("button", { name: "Add record" }))
await expect(
@@ -1,12 +1,14 @@
import type { Change, ResourceRecordSet } from "@aws-sdk/client-route-53"
import { useSuspenseQuery } from "@tanstack/react-query"
import { Pencil, Plus, Trash2 } from "lucide-react"
import { Plus } from "lucide-react"
import { useState } from "react"
import { BulkDelete } from "@/components/bulk-delete"
import { type DataColumn, DataTable } from "@/components/data-table"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { ErrorBanner } from "@/components/error-banner"
import { editChanges, RecordForm } from "@/components/route53/record-form"
import { SelectionBar } from "@/components/selection-bar"
import { TruncatedText } from "@/components/table-cells"
import { Button } from "@/components/ui/button"
import type { ActivationState } from "@/lib/route53-activation"
import { useChangeResourceRecordSets } from "@/mutations/route53"
@@ -41,106 +43,76 @@ function recordValues(recordSet: ResourceRecordSet): string[] {
return (recordSet.ResourceRecords ?? []).map((r) => r.Value ?? "")
}
interface RecordActionsProps {
recordSet: ResourceRecordSet
zoneName: string
onEdit: () => void
onDelete: () => void
}
// The zone's own NS and SOA are not the customer's to change.
function RecordActions({
recordSet,
zoneName,
onEdit,
onDelete,
}: RecordActionsProps) {
const label = recordLabel(recordSet)
if (isZoneOwnedRecord(recordSet, zoneName)) {
return (
<span className="text-xs text-muted-foreground">Managed by the zone</span>
)
}
const editable = !recordSet.AliasTarget && isEditableType(recordSet.Type)
function isEditableSet(set: ResourceRecordSet, zoneName: string): boolean {
return (
<div className="flex justify-end gap-1">
{editable && (
<Button
aria-label={`Edit record ${label}`}
onClick={onEdit}
size="icon-sm"
variant="ghost"
>
<Pencil />
</Button>
)}
<Button
aria-label={`Delete record ${label}`}
onClick={onDelete}
size="icon-sm"
variant="ghost"
>
<Trash2 />
</Button>
</div>
!isZoneOwnedRecord(set, zoneName) &&
!set.AliasTarget &&
isEditableType(set.Type)
)
}
// An editable record's name is a button, so the edit pane opens from the
// keyboard as well as from a row click.
function recordColumns(
zoneName: string,
onEdit: (recordSet: ResourceRecordSet) => void,
onDelete: (recordSet: ResourceRecordSet) => void,
onOpen: (set: ResourceRecordSet) => void,
): DataColumn<ResourceRecordSet>[] {
return [
{
id: "name",
header: "Name",
cell: (recordSet) => (
<span className="font-mono text-xs break-all">
{decodeRecordName(recordSet.Name ?? "")}
</span>
),
cell: (recordSet) => {
const name = (
<TruncatedText
className="font-mono text-xs"
text={decodeRecordName(recordSet.Name ?? "")}
/>
)
return isEditableSet(recordSet, zoneName) ? (
<button
aria-label={`Edit ${recordLabel(recordSet)}`}
className="max-w-full rounded-sm text-left font-medium text-primary hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
onClick={() => {
onOpen(recordSet)
}}
type="button"
>
{name}
</button>
) : (
name
)
},
},
{ id: "type", header: "Type", cell: (recordSet) => recordSet.Type },
{
id: "value",
header: "Value",
cell: (recordSet) =>
isZoneOwnedRecord(recordSet, zoneName) ? (
<span className="flex flex-col">
<TruncatedText
className="font-mono text-xs text-muted-foreground"
text={recordValues(recordSet).join(", ")}
/>
<span className="text-2xs text-muted-foreground">
Managed by the zone
</span>
</span>
) : (
<TruncatedText
className="font-mono text-xs"
text={recordValues(recordSet).join(", ")}
/>
),
},
{
id: "ttl",
header: "TTL",
hideBelow: "sm",
cell: (recordSet) => recordSet.TTL ?? "—",
},
{
id: "value",
header: "Value",
cell: (recordSet) => (
<span className="font-mono text-xs break-all">
{recordValues(recordSet).map((value) => (
<span className="block" key={value}>
{value}
</span>
))}
</span>
),
},
{
id: "actions",
header: "Actions",
align: "end",
cell: (recordSet) => (
<RecordActions
onDelete={() => {
onDelete(recordSet)
}}
onEdit={() => {
onEdit(recordSet)
}}
recordSet={recordSet}
zoneName={zoneName}
/>
),
},
]
}
@@ -158,9 +130,7 @@ export function RecordsTab({
const [addOpen, setAddOpen] = useState(false)
const [editTarget, setEditTarget] = useState<ResourceRecordSet | undefined>()
const [deleteTarget, setDeleteTarget] = useState<
ResourceRecordSet | undefined
>()
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const notServed =
activationState !== undefined && activationState !== "ACTIVE"
@@ -202,26 +172,52 @@ export function RecordsTab({
}
}
const handleDelete = async () => {
if (!deleteTarget) {
return
}
const byKey = new Map(recordSets.map((set) => [recordKey(set), set]))
await submitChanges([{ Action: "DELETE", ResourceRecordSet: deleteTarget }])
setDeleteTarget(undefined)
// The sets go in one batch, so the zone takes all of them or none.
const deleteRecords = async (
keys: readonly string[],
): Promise<ReadonlyMap<string, string>> => {
const changes: Change[] = keys.flatMap((key) => {
const set = byKey.get(key)
return set ? [{ Action: "DELETE", ResourceRecordSet: set }] : []
})
try {
await changeMutation.mutateAsync({ zoneId, changes })
return new Map()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return new Map(keys.map((key) => [key, message]))
}
}
const columns = recordColumns(
zoneName,
(recordSet) => {
changeMutation.reset()
setEditTarget(recordSet)
},
(recordSet) => {
changeMutation.reset()
setDeleteTarget(recordSet)
},
)
const editRecord = (set: ResourceRecordSet) => {
changeMutation.reset()
setEditTarget(set)
}
const columns = recordColumns(zoneName, editRecord)
const actionable = (set: ResourceRecordSet) =>
!isZoneOwnedRecord(set, zoneName)
const openRecord = (key: string) => {
const set = byKey.get(key)
if (set && isEditableSet(set, zoneName)) {
editRecord(set)
}
}
const label = (key: string) => {
const set = byKey.get(key)
return set ? recordLabel(set) : key
}
return (
<div className="flex flex-col gap-3">
@@ -256,12 +252,40 @@ export function RecordsTab({
</Button>
</div>
{selected.size > 0 && (
<SelectionBar
count={selected.size}
onClear={() => {
setSelected(new Set())
}}
>
<BulkDelete
deleteMany={deleteRecords}
description="They stop resolving once the change is in sync."
ids={[...selected]}
label={label}
noun={["record set", "record sets"]}
onDeleted={(keys) => {
setSelected(
(current) =>
new Set([...current].filter((key) => !keys.includes(key))),
)
}}
/>
</SelectionBar>
)}
<DataTable
activeId={editTarget ? recordKey(editTarget) : undefined}
caption="Records"
columns={columns}
empty="No records."
getRowId={recordKey}
isRowActionable={actionable}
onRowClick={openRecord}
onSelectedChange={setSelected}
rows={recordSets}
selected={selected}
/>
{/* Mounted only while open so every session starts from blank defaults. */}
@@ -294,23 +318,6 @@ export function RecordsTab({
zoneName={zoneName}
/>
)}
<DeleteConfirmationDialog
description={
deleteTarget
? `Delete the ${recordLabel(deleteTarget)} record set? It stops resolving once the change is in sync.`
: ""
}
isPending={changeMutation.isPending}
onConfirm={handleDelete}
onOpenChange={(open) => {
if (!open) {
setDeleteTarget(undefined)
}
}}
open={deleteTarget !== undefined}
title="Delete record"
/>
</div>
)
}
@@ -56,6 +56,7 @@ export function SecurityGroupPicker({
detail: group.Description,
}))}
onChange={onChange}
scroll
selected={selected}
/>
)
@@ -1,93 +1,68 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { StateBadge } from "./state-badge"
import { StateBadge, StatusBadge } from "./state-badge"
describe("StateBadge", () => {
it.each(["running", "available", "completed", "Active"])(
"renders success for success state '%s'",
(state) => {
render(<StateBadge state={state} />)
const badge = screen.getByText(state)
expect(badge).toHaveAttribute("data-tone", "success")
},
)
it.each(["stopped", "error"])(
"renders danger for error state '%s'",
(state) => {
render(<StateBadge state={state} />)
const badge = screen.getByText(state)
expect(badge).toHaveAttribute("data-tone", "danger")
},
)
it.each(["pending", "shutting-down", "stopping"])(
"renders warning for warning state '%s'",
(state) => {
render(<StateBadge state={state} />)
const badge = screen.getByText(state)
expect(badge).toHaveAttribute("data-tone", "warning")
},
)
// Every GuardrailStatus, KnowledgeBaseStatus, DataSourceStatus and
// IngestionJobStatus value except STOPPED, which stays neutral.
it.each(["ACTIVE", "AVAILABLE", "COMPLETE", "READY"])(
"renders success for Bedrock success status '%s'",
(state) => {
render(<StateBadge state={state} />)
expect(screen.getByText(state)).toHaveAttribute("data-tone", "success")
},
)
it.each(["FAILED", "DELETE_UNSUCCESSFUL", "UPDATE_UNSUCCESSFUL"])(
"renders danger for Bedrock error status '%s'",
(state) => {
render(<StateBadge state={state} />)
expect(screen.getByText(state)).toHaveAttribute("data-tone", "danger")
},
)
it.each([
"CREATING",
"DELETING",
"IN_PROGRESS",
"STARTING",
"STOPPING",
"UPDATING",
"VERSIONING",
])("renders warning for Bedrock in-progress status '%s'", (state) => {
render(<StateBadge state={state} />)
expect(screen.getByText(state)).toHaveAttribute("data-tone", "warning")
})
it.each([
["running", "success"],
["available", "success"],
["associated", "success"],
["in-use", "success"],
["Complete", "success"],
["ACTIVE", "success"],
["healthy", "success"],
["pending", "progress"],
["shutting-down", "progress"],
["IN_PROGRESS", "progress"],
["draining", "progress"],
["DEGRADED", "caution"],
["impaired", "caution"],
["stopped", "danger"],
["FAILED", "danger"],
["DELETE_UNSUCCESSFUL", "danger"],
["unhealthy", "danger"],
["unavailable", "danger"],
["initial", "warning"],
["draining", "warning"],
])("renders ELBv2 target health '%s' as %s", (state, tone) => {
["terminated", "muted"],
["no", "muted"],
["something-else", "neutral"],
])("gives '%s' the %s tone", (state, tone) => {
render(<StateBadge state={state} />)
expect(screen.getByText(state)).toHaveAttribute("data-tone", tone)
expect(
screen.getByText(state.toLowerCase().replaceAll("_", "-")),
).toHaveAttribute("data-tone", tone)
})
it("renders muted for terminated state", () => {
render(<StateBadge state="terminated" />)
const badge = screen.getByText("terminated")
expect(badge).toHaveAttribute("data-tone", "muted")
it("shows every state lowercase, whatever case the API used", () => {
render(<StateBadge state="DELETE_UNSUCCESSFUL" />)
expect(screen.getByText("delete-unsuccessful")).toBeInTheDocument()
expect(screen.queryByText("DELETE_UNSUCCESSFUL")).not.toBeInTheDocument()
})
it("renders neutral for unknown state", () => {
render(<StateBadge state="something-else" />)
const badge = screen.getByText("something-else")
expect(badge).toHaveAttribute("data-tone", "neutral")
// A Bedrock job the user stopped finished; EC2's stopped is still red.
it("keeps Bedrock's STOPPED neutral", () => {
render(<StateBadge state="STOPPED" />)
expect(screen.getByText("stopped")).toHaveAttribute("data-tone", "neutral")
})
it("spins while the state is changing, and only then", () => {
const { container, rerender } = render(<StateBadge state="pending" />)
expect(
container.querySelector('[data-icon="progress"]'),
).toBeInTheDocument()
rerender(<StateBadge state="running" />)
expect(container.querySelector('[data-icon="progress"]')).toBeNull()
})
it("renders neutral for undefined state", () => {
const { container } = render(<StateBadge state={undefined} />)
const badge = container.firstElementChild
expect(badge).toHaveAttribute("data-tone", "neutral")
expect(container.firstElementChild).toHaveAttribute("data-tone", "neutral")
})
})
describe("StatusBadge", () => {
it("takes its tone from the caller, for a status that is not an API state", () => {
render(<StatusBadge tone="success">Healthy</StatusBadge>)
expect(screen.getByText("Healthy")).toHaveAttribute("data-tone", "success")
})
})
@@ -1,37 +1,58 @@
import { LoaderCircle } from "lucide-react"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
// "Complete" is the only status an RDS subnet group ever reports, and it is
// capitalised where every other settled state is not.
// States are matched after normalising, so EKS's ACTIVE, RDS's Complete and
// EC2's running read the same way and land in the same tone.
const SUCCESS_STATES = new Set([
"running",
"available",
"completed",
"Complete",
"Active",
// Bedrock guardrails, knowledge bases, data sources and ingestion jobs.
"ACTIVE",
"AVAILABLE",
"COMPLETE",
"READY",
// ELBv2 target health.
"complete",
"active",
"ready",
"healthy",
"associated",
"attached",
"in-use",
"ok",
"main",
"enabled",
"issued",
"succeeded",
"inservice",
])
// "stopped" stays an error state because EC2 reads it that way. RDS treats a
// stopped instance as a normal resting state, so its badge is redder than the
// state warrants; splitting the map by service is its own change.
const ERROR_STATES = new Set([
const DANGER_STATES = new Set([
"stopped",
"error",
"failed",
"FAILED",
"DELETE_UNSUCCESSFUL",
"UPDATE_UNSUCCESSFUL",
"create-failed",
"delete-failed",
"delete-unsuccessful",
"update-unsuccessful",
"update-failed",
"blackhole",
"unhealthy",
"unavailable",
])
const WARNING_STATES = new Set([
// Orange: the resource works but needs attention.
const CAUTION_STATES = new Set([
"degraded",
"impaired",
"warning",
"insufficient-data",
"unused",
"expired",
])
// Yellow: the resource is changing state and will settle on its own.
const PROGRESS_STATES = new Set([
"pending",
"shutting-down",
"stopping",
@@ -42,59 +63,109 @@ const WARNING_STATES = new Set([
"starting",
"recovering",
"deleting",
"CREATING",
"DELETING",
"IN_PROGRESS",
"STARTING",
"STOPPING",
"UPDATING",
"VERSIONING",
"in-progress",
"updating",
"versioning",
"initial",
"draining",
"associating",
"disassociating",
"attaching",
"detaching",
"provisioning",
"registering",
"deregistering",
"pending-validation",
"optimizing",
])
type StateTone = "success" | "danger" | "warning" | "muted" | "neutral"
const MUTED_STATES = new Set(["terminated", "deleted", "inactive", "no"])
export type StateTone =
| "success"
| "progress"
| "caution"
| "danger"
| "muted"
| "neutral"
const TONE_CLASSES: Record<StateTone, string> = {
success: "bg-success-subtle text-success-foreground",
progress: "bg-warning-subtle text-warning-foreground",
caution: "bg-caution-subtle text-caution-foreground",
danger: "bg-danger-subtle text-danger-foreground",
warning: "bg-warning-subtle text-warning-foreground",
muted: "bg-neutral-subtle text-muted-foreground",
neutral: "bg-neutral-subtle text-foreground",
}
function getStateTone(state: string | undefined): StateTone {
if (state && SUCCESS_STATES.has(state)) {
// Lowercase with hyphens, as EC2 writes its states; the API value is untouched.
export function displayState(state: string): string {
return state.toLowerCase().replaceAll("_", "-")
}
export function getStateTone(state: string | undefined): StateTone {
// A Bedrock job a user stopped is finished, not failed.
if (state === undefined || state === "STOPPED") {
return "neutral"
}
const key = displayState(state)
if (SUCCESS_STATES.has(key)) {
return "success"
}
if (state && ERROR_STATES.has(state)) {
if (DANGER_STATES.has(key)) {
return "danger"
}
if (state && WARNING_STATES.has(state)) {
return "warning"
if (CAUTION_STATES.has(key)) {
return "caution"
}
if (state === "terminated") {
return "muted"
if (PROGRESS_STATES.has(key)) {
return "progress"
}
return "neutral"
return MUTED_STATES.has(key) ? "muted" : "neutral"
}
export function StateBadge({ state }: { state: string | undefined }) {
const tone = getStateTone(state)
// For a status that is not an API state, such as a cluster's health.
export function StatusBadge({
tone,
children,
}: {
tone: StateTone
children: ReactNode
}) {
return (
<div
className={cn(
"inline-flex w-fit rounded-full px-2 py-1 text-xs",
"inline-flex w-fit items-center gap-1 rounded-full px-2 py-1 text-xs",
TONE_CLASSES[tone],
)}
data-tone={tone}
>
{state}
{children}
</div>
)
}
// A changing state spins the submit button's loader, so a launch reads as work
// still under way rather than a label waiting to change.
export function StateBadge({ state }: { state: string | undefined }) {
const tone = getStateTone(state)
return (
<StatusBadge tone={tone}>
{tone === "progress" && (
<LoaderCircle
aria-hidden
className="size-3 animate-spin motion-reduce:animate-none"
data-icon="progress"
/>
)}
{state === undefined ? undefined : displayState(state)}
</StatusBadge>
)
}
@@ -0,0 +1,51 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { SecurityGroupPicker } from "./security-group-picker"
import { SwitchList } from "./switch-list"
const ITEMS = [
{ id: "a", label: "a" },
{ id: "b", label: "b" },
]
describe("SwitchList", () => {
it("grows with its rows unless asked to scroll", () => {
render(
<SwitchList
emptyText="None"
itemLabel="Item"
items={ITEMS}
onChange={vi.fn()}
selected={[]}
/>,
)
expect(screen.getByRole("list")).not.toHaveClass("overflow-y-auto")
})
it("caps a scrolling list and scrolls the rest", () => {
render(
<SwitchList
emptyText="None"
itemLabel="Item"
items={ITEMS}
onChange={vi.fn()}
scroll
selected={[]}
/>,
)
expect(screen.getByRole("list")).toHaveClass("max-h-64", "overflow-y-auto")
})
it("scrolls every security group picker", () => {
render(
<SecurityGroupPicker
emptyText="None"
groups={[{ GroupId: "sg-1", GroupName: "default" }]}
onChange={vi.fn()}
selected={[]}
/>,
)
expect(screen.getByRole("list")).toHaveClass("overflow-y-auto")
})
})
@@ -2,6 +2,7 @@ import { useId, type ReactNode } from "react"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
export interface SwitchListItem {
id: string
@@ -18,6 +19,9 @@ interface SwitchListProps {
// Prefixes each row's accessible name, as in "Subnet subnet-0abc".
itemLabel: string
mono?: boolean
// Caps the box at about five rows and scrolls the rest, for lists that grow
// with the account, such as security groups.
scroll?: boolean
}
// A boxed multi-select, one switch per row, for picking subnets, security
@@ -29,6 +33,7 @@ export function SwitchList({
emptyText,
itemLabel,
mono = true,
scroll = false,
}: SwitchListProps) {
const baseId = useId()
@@ -39,7 +44,13 @@ export function SwitchList({
const chosen = new Set(selected)
return (
<ul className="divide-y rounded-md border bg-background/40">
<ul
className={cn(
"divide-y rounded-md border bg-background/40",
scroll && "max-h-64 overflow-y-auto",
)}
data-scroll={scroll ? "" : undefined}
>
{items.map((item, index) => {
const id = `${baseId}-${index}`
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it } from "vitest"
import { TagPills, TruncatedText } from "./table-cells"
const TAGS = [
{ Key: "Name", Value: "web-1" },
{ Key: "spinifex:eks-nodegroup", Value: "node-gap" },
{ Key: "spinifex:eks-cluster", Value: "test-cluster" },
]
describe("TagPills", () => {
it("shows one pill and counts the rest, leaving out omitted keys", () => {
render(<TagPills omit={["Name"]} tags={TAGS} />)
expect(screen.getByText("spinifex:eks-cluster")).toBeInTheDocument()
expect(screen.getByText("+1")).toBeInTheDocument()
expect(screen.queryByText("Name")).not.toBeInTheDocument()
})
it("lists every tag with its value on hover", async () => {
render(<TagPills omit={["Name"]} tags={TAGS} />)
await userEvent.hover(screen.getByText("spinifex:eks-cluster"))
await expect(screen.findByText("test-cluster")).resolves.toBeInTheDocument()
expect(screen.getByText("node-gap")).toBeInTheDocument()
})
it("shows a dash when nothing is left to show", () => {
render(<TagPills omit={["Name"]} tags={[{ Key: "Name", Value: "x" }]} />)
expect(screen.getByText("—")).toBeInTheDocument()
})
})
describe("TruncatedText", () => {
it("shows the full text on hover", async () => {
const long = "a-very-long-instance-name-that-would-widen-its-column"
render(<TruncatedText text={long} />)
await userEvent.hover(screen.getByText(long))
await expect(screen.findAllByText(long)).resolves.toHaveLength(2)
})
})
@@ -0,0 +1,116 @@
import type { ReactNode } from "react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { relativeTime } from "@/lib/relative-time"
import { cn, formatDateTime } from "@/lib/utils"
interface Tag {
Key?: string
Value?: string
}
// A short time in the cell and the full one on hover, so a time column takes
// the width of "8 months ago" rather than a whole timestamp.
export function RelativeTime({ date }: { date: Date | string | undefined }) {
if (!date) {
return <span className="text-muted-foreground">—</span>
}
const full = formatDateTime(date)
return (
<Tooltip>
<TooltipTrigger
render={
<time
className="whitespace-nowrap"
dateTime={new Date(date).toISOString()}
/>
}
>
{relativeTime(date)}
</TooltipTrigger>
<TooltipContent>{full}</TooltipContent>
</Tooltip>
)
}
// Ends in an ellipsis rather than widening its column; hover shows it all.
export function TruncatedText({
text,
className,
}: {
text: string | undefined
className?: string
}) {
if (!text) {
return <span className="text-muted-foreground">—</span>
}
return (
<Tooltip>
<TooltipTrigger render={<span className="block min-w-0" />}>
<span className={cn("block max-w-48 truncate", className)}>{text}</span>
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
)
}
function TagPill({ children }: { children: ReactNode }) {
return (
<span className="inline-flex max-w-40 items-center truncate rounded-full border bg-muted px-2 py-0.5 font-mono text-2xs text-foreground">
{children}
</span>
)
}
const NO_KEYS: readonly string[] = []
// One pill and a count of the rest; hover lists every tag as key and value.
export function TagPills({
tags,
omit = NO_KEYS,
}: {
tags: readonly Tag[] | undefined
omit?: readonly string[]
}) {
const shown = (tags ?? [])
.filter((tag) => tag.Key && !omit.includes(tag.Key))
.toSorted((a, b) => (a.Key ?? "").localeCompare(b.Key ?? ""))
const [first, ...rest] = shown
if (!first) {
return <span className="text-muted-foreground">—</span>
}
return (
<Tooltip>
<TooltipTrigger render={<span />}>
<span className="inline-flex items-center gap-1">
<TagPill>{first.Key}</TagPill>
{rest.length > 0 && (
<span className="text-2xs text-muted-foreground">
+{rest.length}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-left font-mono text-2xs">
{shown.map((tag) => (
<div className="contents" key={tag.Key}>
<dt className="text-muted-foreground">{tag.Key}</dt>
<dd className="break-all">{tag.Value || "—"}</dd>
</div>
))}
</dl>
</TooltipContent>
</Tooltip>
)
}
@@ -1,155 +0,0 @@
import type { Tag } from "@aws-sdk/client-elastic-load-balancing-v2"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import { TagsEditor } from "./tags-editor"
function at<T>(arr: T[], i: number): T {
const v = arr[i]
if (v === undefined) {
throw new Error(`expected an element at index ${i}`)
}
return v
}
const INITIAL: Tag[] = [
{ Key: "env", Value: "prod" },
{ Key: "team", Value: "platform" },
]
describe("TagsEditor", () => {
it("seeds rows from tags and disables Save with no changes", () => {
render(<TagsEditor isPending={false} onSubmit={() => {}} tags={INITIAL} />)
const keys = screen.getAllByLabelText("Tag key")
expect(keys[0]).toHaveValue("env")
expect(keys[1]).toHaveValue("team")
expect(screen.getByText("No changes")).toBeInTheDocument()
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled()
})
it("submits the full desired tag set after an edit", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<TagsEditor isPending={false} onSubmit={onSubmit} tags={INITIAL} />)
const value = at(screen.getAllByLabelText("Tag value"), 0)
await user.clear(value)
await user.type(value, "staging")
await user.click(screen.getByRole("button", { name: /save changes/i }))
expect(onSubmit).toHaveBeenCalledWith([
{ key: "env", value: "staging" },
{ key: "team", value: "platform" },
])
})
it("adds and removes tag rows", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<TagsEditor isPending={false} onSubmit={onSubmit} tags={INITIAL} />)
await user.click(screen.getByRole("button", { name: /add tag/i }))
await user.type(at(screen.getAllByLabelText("Tag key"), 2), "owner")
await user.type(at(screen.getAllByLabelText("Tag value"), 2), "alice")
await user.click(
at(screen.getAllByRole("button", { name: /remove tag/i }), 1),
)
await user.click(screen.getByRole("button", { name: /save changes/i }))
expect(onSubmit).toHaveBeenCalledWith([
{ key: "env", value: "prod" },
{ key: "owner", value: "alice" },
])
})
it("blocks submit on a duplicate key", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<TagsEditor isPending={false} onSubmit={onSubmit} tags={INITIAL} />)
const key = at(screen.getAllByLabelText("Tag key"), 1)
await user.clear(key)
await user.type(key, "env")
expect(screen.getByText(/keys must be unique/i)).toBeInTheDocument()
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled()
expect(onSubmit).not.toHaveBeenCalled()
})
it("blocks submit on a value without a key", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<TagsEditor isPending={false} onSubmit={onSubmit} tags={[]} />)
await user.click(screen.getByRole("button", { name: /add tag/i }))
await user.type(at(screen.getAllByLabelText("Tag value"), 0), "orphan")
expect(screen.getByText(/needs a key/i)).toBeInTheDocument()
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled()
expect(onSubmit).not.toHaveBeenCalled()
})
it("blocks submit on a key over the AWS length limit", async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<TagsEditor isPending={false} onSubmit={onSubmit} tags={INITIAL} />)
await user.click(screen.getByRole("button", { name: /add tag/i }))
await user.click(at(screen.getAllByLabelText("Tag key"), 2))
await user.paste("k".repeat(129))
expect(
screen.getByText("Tag key must be 128 characters or less"),
).toBeInTheDocument()
expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled()
})
it("ignores an added row left blank", async () => {
const user = userEvent.setup()
render(<TagsEditor isPending={false} onSubmit={() => {}} tags={INITIAL} />)
await user.click(screen.getByRole("button", { name: /add tag/i }))
expect(screen.getByText("No changes")).toBeInTheDocument()
expect(screen.queryByText(/tag key is required/i)).not.toBeInTheDocument()
})
it("Reset reverts edits back to initial", async () => {
const user = userEvent.setup()
render(<TagsEditor isPending={false} onSubmit={() => {}} tags={INITIAL} />)
const value = at(screen.getAllByLabelText("Tag value"), 0)
await user.clear(value)
await user.type(value, "dev")
expect(screen.getByText("Unsaved changes")).toBeInTheDocument()
await user.click(screen.getByRole("button", { name: /reset/i }))
expect(at(screen.getAllByLabelText("Tag value"), 0)).toHaveValue("prod")
expect(screen.getByText("No changes")).toBeInTheDocument()
})
it("renders the Save button in pending state", () => {
render(<TagsEditor isPending={true} onSubmit={() => {}} tags={INITIAL} />)
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled()
})
it("renders an error banner when the mutation fails", () => {
render(
<TagsEditor
error={new Error("boom")}
isPending={false}
onSubmit={() => {}}
tags={INITIAL}
/>,
)
expect(screen.getByRole("alert")).toHaveTextContent("boom")
})
})
@@ -1,105 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm, useWatch } from "react-hook-form"
import { z } from "zod"
import { EditorShell } from "@/components/editor-shell"
import { TagsFieldArray } from "@/components/tags-field-array"
import { type TagFormData, tagSchema } from "@/types/tags"
// Structural rather than one SDK's Tag: ELBv2 and RDS declare the same shape in
// their own packages, and the editor works against either.
interface Tag {
Key?: string
Value?: string
}
// A blank row is dropped on save rather than refused, and a value without a key
// is reported across rows, so a row's own key may be empty here.
const tagsEditorSchema = z.object({
tags: z.array(
tagSchema.extend({ key: z.literal("").or(tagSchema.shape.key) }),
),
})
type TagsEditorFormData = z.infer<typeof tagsEditorSchema>
interface TagsEditorProps {
tags: Tag[]
onSubmit: (tags: TagFormData[]) => void
isPending: boolean
error?: Error | null
isSuccess?: boolean
}
function toRows(tags: Tag[]): TagFormData[] {
return tags.map((t) => ({ key: t.Key ?? "", value: t.Value ?? "" }))
}
function normalize(rows: TagFormData[]): TagFormData[] {
return rows.flatMap((r) => {
const key = r.key.trim()
return key.length > 0 ? [{ key, value: r.value }] : []
})
}
export function TagsEditor({
tags,
onSubmit,
isPending,
error,
isSuccess,
}: TagsEditorProps) {
const initialRows = toRows(tags)
const { control, formState, reset } = useForm<TagsEditorFormData>({
resolver: zodResolver(tagsEditorSchema),
mode: "onChange",
defaultValues: { tags: initialRows },
})
const rows = useWatch({ control, name: "tags" })
// A row with a value but no key is incomplete; duplicate keys are ambiguous.
const hasBlankKey = rows.some(
(r) => r.key.trim().length === 0 && r.value.length > 0,
)
const normalized = normalize(rows)
const keys = normalized.map((r) => r.key)
const hasDuplicate = new Set(keys).size !== keys.length
const hasError = hasBlankKey || hasDuplicate || !formState.isValid
const dirty =
JSON.stringify(normalized) !== JSON.stringify(normalize(initialRows))
return (
<EditorShell
dirty={dirty}
error={error}
errorMsg="Failed to save tags"
invalid={hasError}
isPending={isPending}
isSuccess={isSuccess}
onReset={() => {
reset({ tags: initialRows })
}}
onSave={() => {
onSubmit(normalized)
}}
successMsg="Tags saved."
>
<div className="flex flex-col gap-2 p-4">
<TagsFieldArray control={control} name="tags" />
{hasBlankKey && (
<p className="text-xs text-destructive">
Every tag with a value needs a key.
</p>
)}
{hasDuplicate && (
<p className="text-xs text-destructive">Tag keys must be unique.</p>
)}
</div>
</EditorShell>
)
}
@@ -48,6 +48,9 @@ export const collapsibleContentVariants = cva("", {
panel: "space-y-4 rounded-md border border-border p-4",
indented: "space-y-4 border-l-2 border-muted pl-4",
section: "px-4 pb-4",
// A sidebar service group: grows open and folds shut. Clipped while it
// moves, so the padding leaves its items' focus rings room.
nav: "-m-0.5 h-(--collapsible-panel-height) overflow-hidden p-0.5 transition-all duration-200 ease-out data-ending-style:h-0 data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-in data-starting-style:h-0 data-starting-style:opacity-0 motion-reduce:transition-none",
},
},
defaultVariants: {
@@ -51,14 +51,13 @@ export function TooltipContent({
>
<TooltipPrimitive.Popup
className={cn(
"z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background **:data-[slot=kbd]:rounded-md",
"z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10 **:data-[slot=kbd]:rounded-md",
className,
)}
data-slot="tooltip-content"
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
@@ -1,6 +1,6 @@
import { ChevronDown, ChevronRight } from "lucide-react"
import { useState } from "react"
import type { UseFormReturn } from "react-hook-form"
import { type UseFormReturn, useFormState } from "react-hook-form"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
@@ -25,7 +25,8 @@ export function SubnetCidrInputs({
const fieldName =
type === "public" ? "publicSubnetCidrs" : "privateSubnetCidrs"
const fieldErrors = form.formState.errors[fieldName]
const { errors } = useFormState({ control: form.control })
const fieldErrors = errors[fieldName]
// A subnet error must be visible, or Create appears to do nothing.
const isOpen = expanded || fieldErrors !== undefined
@@ -97,7 +97,7 @@ function NavSection({
<span className="flex-1 text-left">{group.label}</span>
<ChevronRight className="transition-transform group-data-panel-open/nav-trigger:rotate-90 motion-reduce:transition-none" />
</SidebarGroupLabel>
<CollapsibleContent>
<CollapsibleContent variant="nav">
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.to}>
@@ -25,11 +25,11 @@ const DZ = (s: string): Int32Array => {
}
return out
}
const GROUP_COUNT = 191
const GROUP_COUNT = 190
const customValidatorNames: string[] = []
const edgeStart = PS(U("D04000042001300002140000021200020004200000212002120000213000022002100820100200000003020007000000022010301400001200520200200000301020202001000"))
const labelStart = PS(U(":111141112111111191146581131;315664411111252347723331321111221443414111152226313111111171161634111111111327451111112341136535224431215255117"))
const labelText = "@containerabcdeasefghinlmoprstunderlinewzlignnimatespectuto-rowsaglurockdrop-blursis-coniclinearradialrderttom-blrtolursor-spantartivide-yurationillexontromlterapr-xyidow-colsrowssetvert-ringxyeiadingftnest-clampax-bin-lrtxyhw-screenhwpacityrutlinederiginblrstxyioghtng-offsettateundedw-blrt-spantartchizepace-ytrokealeroll-mypyadowrinkextorapckingnsitionlate-xy-offset"
const edgeStart = PS(U("D040000420013000021400000212000200040000212002120000213000022002100820100200000003020007000000022010301400001200520200200000301020202001000"))
const labelStart = PS(U(":111141112111111191146581131;3156644111112523477533321111221443414111152226313111111171161634111111111327451111112341136535224431215255117"))
const labelText = "@containerabcdeasefghinlmoprstunderlinewzlignnimatespectuto-rowsaglurockdrop-blursis-coniclinearradialrderttom-blrtolursor-spantartivide-yurationilterlexontromapr-xyidow-colsrowssetvert-ringxyeiadingftnest-clampax-bin-lrtxyhw-screenhwpacityrutlinederiginblrstxyioghtng-offsettateundedw-blrt-spantartchizepace-ytrokealeroll-mypyadowrinkextorapckingnsitionlate-xy-offset"
// pre-order tree: targets derived from edge counts via subtree sizes
const edgeTarget = (() => {
const N = edgeStart.length - 1
@@ -48,17 +48,17 @@ const edgeTarget = (() => {
}
return out
})()
const nodeGroup = U("0200000007000909;0<0>?AB00000000H00000L™00000000™000V0000Y00]000000000000d00000000000000p00000000000z000~0€‚00000000000ˆ‰0000000000’000«000", 1)
const nodeGroup = U("0200000007000909;0<0>?AB00000000H000K˜00000000˜000U0000X00\\000000000000c00000000000000o00000000000y000}0~€00000000000‡ˆ0000000000‘000ª000", 1)
// vlists = op-pattern pool + per-list refs; the engine indexes these directly
const vlistPat = PS(U("12333553634333535136"))
const vlistOps = U("93242332583248325F@0326325B:0325B:4325532032542H<C=153;8432588GA8B:032")
const vlistRef = U("01112345671489898:7719;1<1=><33377;::8::;3:?13:@A3:@33333;71933333333:8<;4444477;33B;:3<C<:11::;@7")
const vlistGroup = DZ("0202020200200200200002020010000400200200200200200200200200200020020020200200202002020020200200200200200200200200200020002002002000200020020020002002020020002000002002000200002002002002002002002002020020020020020020020020020020002002002002002002002002002002002002002002002002002000200200200200200200020202000200020020000200")
const vlistPat = PS(U("12333553634335335136"))
const vlistOps = U("93242332583248325F@0326325B:0325B:4325532542H<C=103253;8432588GA8B:032")
const vlistRef = U("01112345671489898:7719;11<=>33377;::8::;3:?13:@A3:@33333;71933333333:8>;4444477;33B;:3>C>:11::;@7")
const vlistGroup = DZ("0202020200200200200002020010000400200200200200200200200200200020020020200200202020020200200200200200200200200200020002002002000200020020020002002020020002000002002000200002002002002002002002002020020020020020020020020020020002002002002002002002002002002002002002002002002002000200200200200200200020202000200020020000200")
// nodeVlist rebuilt sparse: (anchor deltas, vlist ids)
const nodeVlist = (() => {
const out = new Int32Array(141).fill(-1)
const A = DZ("02422262242224422224422422622224428222442226262242224222222442222222222622424222242642422224224442422")
const V = DZ("02222222220222222222022222222222222222222222222222222222222222222222222222222222202222222222222222222")
const out = new Int32Array(139).fill(-1)
const A = DZ("0242226224222442222442242242224428222442226262242224222222442222222222622424222242642422224224442422")
const V = DZ("0222222222022222222202222222222222222222222222222222222222222222222222222222222202222222222222222222")
for (let i = 0; i < A.length; i++) out[A[i]!] = V[i]!
return out
})()
@@ -70,12 +70,12 @@ const SETS = "container | block contents flow-root hidden inline inline-block in
}
return tails
})
const AA = DZ("0000000000000000000000000000000242226220644<4422626220026428244628242200242442222228222222222268242222426424422400044442422")
const AG = DZ("þ222222222222222222222222222222ķ222222Įī04Īė20224222Ċ2ĉ262222426242ÈÅ¢tã2222222222242ÊÅ2222222282222220222224222OR‹2222222")
const AS = DZ("022222222222222222222222222222222222222227:7:21422202222200202Q00TSVAD222223245000082_F0000000MDV0000_21::0N2O9\\222ux220qts")
const litAnchor = new Int32Array(534)
const litGroup = new Int32Array(534)
const litPool = new Int32Array(534)
const AA = DZ("0000000000000000000000000000000242226220644<442262420026428244628242200242442222228222222222268242222426424422400044442422")
const AG = DZ("ü222222222222222222222222222222ĵ222222Ĭĩ04Ĩĕ2022422Ċ2ĉ262222426242ÈÅ¢tã2222222222242ÊÅ2222222282222220222224222OR‹2222222")
const AS = DZ("022222222222222222222222222222222222222227:7:2142222222200202Q00TSVAD222223245000082_F0000000MDV0000_21::0N2O9\\222ux220qts")
const litAnchor = new Int32Array(533)
const litGroup = new Int32Array(533)
const litPool = new Int32Array(533)
let poolText = ''
const poolOffsets = new Int32Array(620)
{
@@ -100,11 +100,11 @@ const poolOffsets = new Int32Array(620)
}
}
// conflict adjacency (engine builds claim bitmask CSR at init)
const adjGid = DZ("0F2P8>6284B2:<2<22228266P6DF")
const adjGid = DZ("0F2N8>6284B2:<2<22228266P6DF")
const adjStart = PS(U("1::32:442:44:44>222244212213"))
const adjTgt = DZ("2ź22222ţ152Ŭ22222ŭŰ2ŷ?l–2@2ú222—]™fø2Åcô2“ÇÚ8“2è222ñ176ð2ï1ô2ñ9T25Ð22Ñ3324ÐÑ1Ö2Ñ7æ2Ç132Î222222212563232R2222222Û­>°2p2Hr22")
const patGid = U("Œ")
const patTgt = U("]")
const adjTgt = DZ("2Ÿ22222š152Ū22222ūŮ2ŵ?j–2@2ú222—]—dø2Åcô2“ÅØ8“2è222ñ176ð2ï1ô2ñ9T25Ð22Ñ3324ÐÑ1Ö2Ñ7æ2Ç132Î222222212563232R2222222Û­>°2p2Hr22")
const patGid = U("‹")
const patTgt = U("\\")
const postfixLookupGroups = U("1")
const orderSensitiveModifiers = "* ** after backdrop before details-content file first-letter first-line marker placeholder selection"
export default {
@@ -0,0 +1,24 @@
import type { InstanceTypeInfo } from "@aws-sdk/client-ec2"
import { describe, expect, it } from "vitest"
import { describeClassSize } from "./instance-type-size"
const TYPES: InstanceTypeInfo[] = [
{
InstanceType: "m5.large",
VCpuInfo: { DefaultVCpus: 2 },
MemoryInfo: { SizeInMiB: 8192 },
},
]
describe("describeClassSize", () => {
it("sizes a db. class by the EC2 type it runs on", () => {
expect(describeClassSize("db.m5.large", TYPES)).toBe(
"2 vCPU · 8 GiB memory",
)
})
it("says nothing for a class the cluster does not offer", () => {
expect(describeClassSize("db.r6g.large", TYPES)).toBeUndefined()
})
})
@@ -0,0 +1,29 @@
import type { InstanceTypeInfo } from "@aws-sdk/client-ec2"
// "2 vCPU · 8 GiB memory", read from DescribeInstanceTypes, which reports
// the size of every type the cluster offers.
export function describeTypeSize(type: InstanceTypeInfo): string {
const vcpus = type.VCpuInfo?.DefaultVCpus
const mib = type.MemoryInfo?.SizeInMiB
return [
vcpus === undefined ? undefined : `${vcpus} vCPU`,
mib === undefined
? undefined
: `${Number((mib / 1024).toFixed(2))} GiB memory`,
]
.filter(Boolean)
.join(" · ")
}
// An RDS class is a db.-prefixed name for the EC2 type it runs on, so its size
// is that type's. Undefined when the cluster does not offer the type.
export function describeClassSize(
dbInstanceClass: string,
types: readonly InstanceTypeInfo[],
): string | undefined {
const name = dbInstanceClass.replace(/^db\./, "")
const type = types.find((t) => t.InstanceType === name)
return type ? describeTypeSize(type) || undefined : undefined
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest"
import {
formatDailyWindow,
formatDuration,
formatWeeklyWindow,
parseDailyWindow,
parseWeeklyWindow,
windowsOverlap,
withCurrent,
} from "./rds-windows"
describe("daily backup window", () => {
it("builds AWS's string from a start and a length", () => {
expect(formatDailyWindow({ start: 3 * 60, duration: 30 })).toBe(
"03:00-03:30",
)
})
it("ends after midnight when it runs past it", () => {
expect(formatDailyWindow({ start: 23 * 60 + 30, duration: 60 })).toBe(
"23:30-00:30",
)
})
it("reads back what it wrote, including a wrapping window", () => {
expect(parseDailyWindow("23:30-00:30")).toStrictEqual({
start: 23 * 60 + 30,
duration: 60,
})
})
it("keeps an off-grid window set elsewhere", () => {
expect(parseDailyWindow("03:17-04:02")).toStrictEqual({
start: 3 * 60 + 17,
duration: 45,
})
})
it.each(["", "3:00-3:30", "24:00-00:30", "03:00", "03:00-03:30-04:00"])(
"refuses %j",
(value) => {
expect(parseDailyWindow(value)).toBeUndefined()
},
)
})
describe("weekly maintenance window", () => {
it("builds AWS's string from a day, a start and a length", () => {
expect(formatWeeklyWindow({ day: 1, start: 4 * 60, duration: 30 })).toBe(
"mon:04:00-mon:04:30",
)
})
it("moves to the next day when it runs past midnight", () => {
expect(
formatWeeklyWindow({ day: 2, start: 23 * 60 + 30, duration: 60 }),
).toBe("tue:23:30-wed:00:30")
})
it("wraps from Saturday into Sunday", () => {
expect(formatWeeklyWindow({ day: 6, start: 23 * 60, duration: 120 })).toBe(
"sat:23:00-sun:01:00",
)
})
it("reads back a window that wraps the week", () => {
expect(parseWeeklyWindow("sat:23:00-sun:01:00")).toStrictEqual({
day: 6,
start: 23 * 60,
duration: 120,
})
})
it.each(["", "mon:04:00", "xyz:04:00-xyz:04:30", "mon:4:00-mon:4:30"])(
"refuses %j",
(value) => {
expect(parseWeeklyWindow(value)).toBeUndefined()
},
)
})
describe("windowsOverlap", () => {
it("is false for windows at different times of day", () => {
expect(windowsOverlap("03:00-03:30", "mon:04:00-mon:04:30")).toBeFalsy()
})
it("is false for windows that only touch", () => {
expect(windowsOverlap("03:00-03:30", "mon:03:30-mon:04:00")).toBeFalsy()
})
// The backend projects the maintenance window onto one day, so the day
// it falls on does not matter.
it("is true for the same time of day on any weekday", () => {
expect(windowsOverlap("03:00-04:00", "thu:03:30-thu:04:00")).toBeTruthy()
})
it("catches a backup window that wraps midnight", () => {
expect(windowsOverlap("23:30-00:30", "sun:00:00-sun:00:30")).toBeTruthy()
})
it("catches a maintenance window that wraps into the next day", () => {
expect(windowsOverlap("00:00-00:30", "tue:23:30-wed:00:30")).toBeTruthy()
})
it("is false while either window is unset", () => {
expect(windowsOverlap("", "mon:03:00-mon:03:30")).toBeFalsy()
expect(windowsOverlap("03:00-03:30", "")).toBeFalsy()
})
})
describe("formatDuration", () => {
it.each([
[30, "30 min"],
[60, "1 hour"],
[90, "1 hour 30 min"],
[480, "8 hours"],
])("%i minutes reads %s", (minutes, text) => {
expect(formatDuration(minutes)).toBe(text)
})
})
describe("withCurrent", () => {
it("adds an off-grid current value in order", () => {
expect(withCurrent([30, 60, 90], 45)).toStrictEqual([30, 45, 60, 90])
})
it("leaves the choices alone when the value is already one", () => {
expect(withCurrent([30, 60], 60)).toStrictEqual([30, 60])
})
})
@@ -0,0 +1,194 @@
// AWS's backup and maintenance windows, both in UTC: "hh24:mi-hh24:mi" for the
// daily backup window and "ddd:hh24:mi-ddd:hh24:mi" for the weekly maintenance
// window. The form edits a start and a length; these turn one into the other.
export const MINUTES_PER_DAY = 24 * 60
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY
export const WEEKDAYS = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
] as const
export const WEEKDAY_LABELS: Record<(typeof WEEKDAYS)[number], string> = {
sun: "Sunday",
mon: "Monday",
tue: "Tuesday",
wed: "Wednesday",
thu: "Thursday",
fri: "Friday",
sat: "Saturday",
}
// Every half hour, which is AWS's granularity for an assigned window.
export const START_TIMES: readonly number[] = Array.from(
{ length: MINUTES_PER_DAY / 30 },
(_, index) => index * 30,
)
export const DURATIONS: readonly number[] = [
30, 60, 90, 120, 180, 240, 360, 480,
]
export interface DailyWindow {
start: number
duration: number
}
export interface WeeklyWindow extends DailyWindow {
day: number
}
const CLOCK = /^(?<h>[01]\d|2[0-3]):(?<m>[0-5]\d)$/u
function parseClock(value: string): number | undefined {
const match = CLOCK.exec(value)
if (!match?.groups) {
return undefined
}
return Number(match.groups.h) * 60 + Number(match.groups.m)
}
export function formatClock(minutes: number): string {
const inDay =
((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
const hours = String(Math.floor(inDay / 60)).padStart(2, "0")
const mins = String(inDay % 60).padStart(2, "0")
return `${hours}:${mins}`
}
function forward(start: number, end: number, period: number): number {
return (((end - start) % period) + period) % period
}
export function parseDailyWindow(value: string): DailyWindow | undefined {
const [from = "", to = "", extra] = value.split("-")
const start = parseClock(from)
const end = parseClock(to)
if (extra !== undefined || start === undefined || end === undefined) {
return undefined
}
return { start, duration: forward(start, end, MINUTES_PER_DAY) }
}
export function formatDailyWindow({ start, duration }: DailyWindow): string {
return `${formatClock(start)}-${formatClock(start + duration)}`
}
function parseWeekdayClock(value: string): number | undefined {
const day = WEEKDAYS.findIndex((name) => value.startsWith(`${name}:`))
const clock = parseClock(value.slice(4))
if (day === -1 || clock === undefined) {
return undefined
}
return day * MINUTES_PER_DAY + clock
}
export function parseWeeklyWindow(value: string): WeeklyWindow | undefined {
const [from = "", to = "", extra] = value.split("-")
const start = parseWeekdayClock(from)
const end = parseWeekdayClock(to)
if (extra !== undefined || start === undefined || end === undefined) {
return undefined
}
return {
day: Math.floor(start / MINUTES_PER_DAY),
start: start % MINUTES_PER_DAY,
duration: forward(start, end, MINUTES_PER_WEEK),
}
}
// A window running past midnight ends on the next day, and past Saturday on
// Sunday, as AWS writes it.
export function formatWeeklyWindow({
day,
start,
duration,
}: WeeklyWindow): string {
const from = day * MINUTES_PER_DAY + start
const to = (from + duration) % MINUTES_PER_WEEK
const stamp = (minutes: number) =>
`${WEEKDAYS[Math.floor(minutes / MINUTES_PER_DAY)]}:${formatClock(minutes)}`
return `${stamp(from)}-${stamp(to)}`
}
// A window as the non-wrapping spans of one period it covers.
function segments(
start: number,
duration: number,
period: number,
): [number, number][] {
if (duration >= period) {
return [[0, period]]
}
const end = start + duration
return end <= period
? [[start, end]]
: [
[start, period],
[0, end - period],
]
}
// The backend's rule: the maintenance window is projected onto a single day,
// so the pair is refused whatever day the maintenance falls on.
export function windowsOverlap(backup: string, maintenance: string): boolean {
const daily = parseDailyWindow(backup)
const weekly = parseWeeklyWindow(maintenance)
if (!daily || !weekly) {
return false
}
const a = segments(daily.start, daily.duration, MINUTES_PER_DAY)
const b = segments(weekly.start, weekly.duration, MINUTES_PER_DAY)
return a.some(([aStart, aEnd]) =>
b.some(([bStart, bEnd]) => aStart < bEnd && bStart < aEnd),
)
}
export function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (hours === 0) {
return `${rest} min`
}
const hourText = `${hours} hour${hours === 1 ? "" : "s"}`
return rest === 0 ? hourText : `${hourText} ${rest} min`
}
// The standard choices plus the current one, so a window set elsewhere at an
// odd time or length is shown as it is rather than silently moved.
export function withCurrent(
choices: readonly number[],
current: number | undefined,
): number[] {
if (current === undefined || choices.includes(current)) {
return [...choices]
}
return [...choices, current].toSorted((a, b) => a - b)
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest"
import { relativeTime } from "./relative-time"
const NOW = new Date("2026-11-01T12:00:00Z")
function ago(seconds: number): Date {
return new Date(NOW.getTime() - seconds * 1000)
}
describe("relativeTime", () => {
it.each([
[30, "just now"],
[33 * 60, "33 min ago"],
[60 * 60, "1 hour ago"],
[2 * 24 * 60 * 60 + 5, "2 days ago"],
[7 * 24 * 60 * 60, "1 week ago"],
[240 * 24 * 60 * 60, "8 months ago"],
[400 * 24 * 60 * 60, "1 year ago"],
])("%i seconds back reads %s", (seconds, expected) => {
expect(relativeTime(ago(seconds), NOW)).toBe(expected)
})
it("rounds down, so 59 minutes is never an hour", () => {
expect(relativeTime(ago(59 * 60 + 59), NOW)).toBe("59 min ago")
})
it("reads an absent or unparseable time as unknown", () => {
expect(relativeTime(undefined, NOW)).toBe("Unknown")
expect(relativeTime("not a date", NOW)).toBe("Unknown")
})
})
@@ -0,0 +1,37 @@
const UNITS = [
["year", 365 * 24 * 60 * 60],
["month", 30 * 24 * 60 * 60],
["week", 7 * 24 * 60 * 60],
["day", 24 * 60 * 60],
["hour", 60 * 60],
["min", 60],
] as const
// "33 min ago", "2 days ago", "8 months ago": the largest whole unit, rounded
// down, so a time never reads as later than it was.
export function relativeTime(
date: Date | string | undefined,
now: Date = new Date(),
): string {
if (!date) {
return "Unknown"
}
const seconds = Math.floor((now.getTime() - new Date(date).getTime()) / 1000)
if (Number.isNaN(seconds)) {
return "Unknown"
}
for (const [unit, size] of UNITS) {
const count = Math.floor(seconds / size)
if (count >= 1) {
const plural = count === 1 || unit === "min" ? unit : `${unit}s`
return `${count} ${plural} ago`
}
}
return "just now"
}
@@ -1,6 +1,7 @@
import {
type _InstanceType,
type EC2Client,
type IpPermission,
type RequestLaunchTemplateData,
type Tag,
type TagSpecification,
@@ -206,6 +207,11 @@ async function runInstances(
? { GroupName: params.placementGroupName }
: undefined,
BlockDeviceMappings: buildBlockDeviceMappings(params),
TagSpecifications: buildTagSpec(
ResourceType.instance,
params.name?.trim(),
[],
),
// When a template is chosen the backend expands it, then the direct
// params above overlay it (direct params win, matching AWS).
LaunchTemplate: params.launchTemplateId
@@ -240,36 +246,67 @@ async function createLaunchWizardGroup(
return sg.GroupId
}
// authorizeLaunchWizardIngress opens the ticked inbound ports on the new group.
async function authorizeLaunchWizardIngress(
client: ReturnType<typeof getEc2Client>,
groupId: string,
params: CreateInstanceParams,
): Promise<void> {
interface LaunchWizardRuleInput {
allowPing?: boolean
allowSsh?: boolean
allowHttp?: boolean
allowHttps?: boolean
ruleSource?: "anywhere" | "custom"
customCidr?: string
}
// The ticked inbound rules: ping is ICMP echo request (type 8, any code), the
// rest are single TCP ports. Shared with the CLI preview so the two agree.
export function launchWizardPermissions(
params: LaunchWizardRuleInput,
): IpPermission[] {
const cidrIp =
params.ruleSource === "custom" && params.customCidr
? params.customCidr
: "0.0.0.0/0"
const ports: number[] = [
...(params.allowSsh ? [22] : []),
...(params.allowHttp ? [80] : []),
...(params.allowHttps ? [443] : []),
]
if (ports.length === 0) {
return [
...(params.allowPing
? [
{
IpProtocol: "icmp",
FromPort: 8,
ToPort: -1,
IpRanges: [{ CidrIp: cidrIp }],
},
]
: []),
...ports.map((port) => ({
IpProtocol: "tcp",
FromPort: port,
ToPort: port,
IpRanges: [{ CidrIp: cidrIp }],
})),
]
}
// authorizeLaunchWizardIngress opens the ticked inbound rules on the new group.
async function authorizeLaunchWizardIngress(
client: ReturnType<typeof getEc2Client>,
groupId: string,
params: CreateInstanceParams,
): Promise<void> {
const permissions = launchWizardPermissions(params)
if (permissions.length === 0) {
return
}
const cidrIp =
params.ruleSource === "custom" && params.customCidr
? params.customCidr
: "0.0.0.0/0"
await client.send(
new AuthorizeSecurityGroupIngressCommand({
GroupId: groupId,
IpPermissions: ports.map((port) => ({
IpProtocol: "tcp",
FromPort: port,
ToPort: port,
IpRanges: [{ CidrIp: cidrIp }],
})),
IpPermissions: permissions,
}),
)
}
@@ -3,7 +3,6 @@ import {
type CreateTargetGroupCommandInput,
type RuleCondition,
type TargetDescription,
AddTagsCommand,
CreateListenerCommand,
CreateLoadBalancerCommand,
CreateRuleCommand,
@@ -18,7 +17,6 @@ import {
ModifyRuleCommand,
ModifyTargetGroupAttributesCommand,
RegisterTargetsCommand,
RemoveTagsCommand,
SetSecurityGroupsCommand,
SetSubnetsCommand,
} from "@aws-sdk/client-elastic-load-balancing-v2"
@@ -31,7 +29,7 @@ import {
type ListenerProtocolValue,
isSecureProtocol,
} from "@/types/elbv2"
import { type TagFormData, diffTags, toSdkTags } from "@/types/tags"
import { toSdkTags } from "@/types/tags"
export function useDeleteLoadBalancer() {
const queryClient = useQueryClient()
@@ -85,51 +83,6 @@ export function useModifyLoadBalancerAttributes() {
})
}
export interface UpdateTagsParams {
// ARN of any taggable ELBv2 resource (load balancer, target group, listener,
// listener rule).
resourceArn: string
// Desired final tag set after the edit.
tags: TagFormData[]
// Tag keys present before the edit, used to compute removals.
initialKeys: string[]
}
// useUpdateTags reconciles a resource's tags to the desired set by diffing
// against the prior keys: it overwrites the final tags via AddTags and deletes
// any dropped keys via RemoveTags. Either call is skipped when it has no work.
export function useUpdateTags() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (params: UpdateTagsParams) => {
const { toSet, toRemove } = diffTags(params.tags, params.initialKeys)
const client = getElbv2Client()
if (toSet) {
await client.send(
new AddTagsCommand({
ResourceArns: [params.resourceArn],
Tags: toSet,
}),
)
}
if (toRemove.length > 0) {
await client.send(
new RemoveTagsCommand({
ResourceArns: [params.resourceArn],
TagKeys: toRemove,
}),
)
}
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["elbv2", "tags"] })
},
})
}
export interface SetSecurityGroupsParams {
loadBalancerArn: string
securityGroupIds: string[]
@@ -38,7 +38,6 @@ import {
useRestoreDBInstanceFromDBSnapshot,
useStartDBInstance,
useStopDBInstance,
useUpdateRdsTags,
} from "./rds"
const CREATE_FORM: CreateDBInstanceFormData = {
@@ -303,45 +302,6 @@ describe("rds lifecycle mutations", () => {
})
})
describe("useUpdateRdsTags", () => {
it("adds the final set and removes the keys that went away", async () => {
const { result } = renderMutationHook(() => useUpdateRdsTags())
result.current.mutate({
resourceName: "arn:db",
tags: [{ key: "env", value: "prod" }],
initialKeys: ["env", "owner"],
})
await waitFor(() => {
expect(result.current.isSuccess).toBeTruthy()
})
expect(mockSend.mock.calls[0]?.[0].input).toStrictEqual({
ResourceName: "arn:db",
Tags: [{ Key: "env", Value: "prod" }],
})
expect(mockSend.mock.calls[1]?.[0].input).toStrictEqual({
ResourceName: "arn:db",
TagKeys: ["owner"],
})
})
it("skips both calls when there is nothing to do", async () => {
const { result } = renderMutationHook(() => useUpdateRdsTags())
result.current.mutate({
resourceName: "arn:db",
tags: [],
initialKeys: [],
})
await waitFor(() => {
expect(result.current.isSuccess).toBeTruthy()
})
expect(mockSend).not.toHaveBeenCalled()
})
})
describe("useCreateDBSubnetGroup", () => {
it("sends the name, description and subnets", async () => {
const { result } = renderMutationHook(() => useCreateDBSubnetGroup())
@@ -1,5 +1,4 @@
import {
AddTagsToResourceCommand,
CreateDBInstanceCommand,
CreateDBParameterGroupCommand,
CreateDBSnapshotCommand,
@@ -11,7 +10,6 @@ import {
ModifyDBInstanceCommand,
ModifyDBParameterGroupCommand,
RebootDBInstanceCommand,
RemoveTagsFromResourceCommand,
RestoreDBInstanceFromDBSnapshotCommand,
StartDBInstanceCommand,
StopDBInstanceCommand,
@@ -28,7 +26,7 @@ import type {
ParameterUpdate,
RestoreDBInstanceFormData,
} from "@/types/rds"
import { type TagFormData, diffTags, toSdkTags } from "@/types/tags"
import { toSdkTags } from "@/types/tags"
const DB_INSTANCES_KEY = ["rds", "dbInstances"]
const DB_SNAPSHOTS_KEY = ["rds", "dbSnapshots"]
@@ -385,44 +383,3 @@ export function useDeleteDBParameterGroup() {
},
})
}
export interface UpdateRdsTagsParams {
resourceName: string
tags: TagFormData[]
initialKeys: string[]
}
// Reconciles a DB instance's tags to the desired set: AddTagsToResource
// overwrites the final tags and RemoveTagsFromResource drops the keys that went
// away. Either call is skipped when it has no work.
export function useUpdateRdsTags() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (params: UpdateRdsTagsParams) => {
const { toSet, toRemove } = diffTags(params.tags, params.initialKeys)
const client = getRdsClient()
if (toSet) {
await client.send(
new AddTagsToResourceCommand({
ResourceName: params.resourceName,
Tags: toSet,
}),
)
}
if (toRemove.length > 0) {
await client.send(
new RemoveTagsFromResourceCommand({
ResourceName: params.resourceName,
TagKeys: toRemove,
}),
)
}
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["rds", "tags"] })
},
})
}
@@ -0,0 +1,238 @@
import {
CreateTagsCommand as Ec2CreateTagsCommand,
DeleteTagsCommand as Ec2DeleteTagsCommand,
} from "@aws-sdk/client-ec2"
import {
TagResourceCommand as EcsTagResourceCommand,
UntagResourceCommand as EcsUntagResourceCommand,
} from "@aws-sdk/client-ecs"
import {
TagResourceCommand as EksTagResourceCommand,
UntagResourceCommand as EksUntagResourceCommand,
} from "@aws-sdk/client-eks"
import {
AddTagsCommand as Elbv2AddTagsCommand,
RemoveTagsCommand as Elbv2RemoveTagsCommand,
} from "@aws-sdk/client-elastic-load-balancing-v2"
import {
TagInstanceProfileCommand,
TagPolicyCommand,
TagRoleCommand,
TagUserCommand,
UntagInstanceProfileCommand,
UntagPolicyCommand,
UntagRoleCommand,
UntagUserCommand,
} from "@aws-sdk/client-iam"
import {
AddTagsToResourceCommand,
RemoveTagsFromResourceCommand,
} from "@aws-sdk/client-rds"
import { type QueryKey, useQueryClient } from "@tanstack/react-query"
import {
getEc2Client,
getEcsClient,
getEksClient,
getElbv2Client,
getIamClient,
getRdsClient,
} from "@/lib/awsClient"
export interface TagWriter {
onPut: (key: string, value: string) => Promise<void>
onRemove: (key: string) => Promise<void>
}
// The calls' outputs carry nothing the editor reads.
type Send = Promise<object>
// Each service tags through its own pair of calls; the editor sees one shape,
// and every write refreshes the queries that carry the resource's tags.
function useTagWriter(
invalidate: QueryKey,
put: (key: string, value: string) => Send,
remove: (key: string) => Send,
): TagWriter {
const queryClient = useQueryClient()
async function then(work: Send) {
await work
await queryClient.invalidateQueries({ queryKey: invalidate })
}
return {
onPut: async (key, value) => {
await then(put(key, value))
},
onRemove: async (key) => {
await then(remove(key))
},
}
}
// `invalidate` is the resource's list key, which prefixes its detail key too.
export function useEc2TagWriter(
resourceId: string,
invalidate: QueryKey,
): TagWriter {
return useTagWriter(
invalidate,
async (key, value) =>
await getEc2Client().send(
new Ec2CreateTagsCommand({
Resources: [resourceId],
Tags: [{ Key: key, Value: value }],
}),
),
async (key) =>
await getEc2Client().send(
new Ec2DeleteTagsCommand({
Resources: [resourceId],
Tags: [{ Key: key }],
}),
),
)
}
export function useElbv2TagWriter(arn: string): TagWriter {
return useTagWriter(
["elbv2", "tags"],
async (key, value) =>
await getElbv2Client().send(
new Elbv2AddTagsCommand({
ResourceArns: [arn],
Tags: [{ Key: key, Value: value }],
}),
),
async (key) =>
await getElbv2Client().send(
new Elbv2RemoveTagsCommand({ ResourceArns: [arn], TagKeys: [key] }),
),
)
}
export function useRdsTagWriter(arn: string): TagWriter {
return useTagWriter(
["rds", "tags"],
async (key, value) =>
await getRdsClient().send(
new AddTagsToResourceCommand({
ResourceName: arn,
Tags: [{ Key: key, Value: value }],
}),
),
async (key) =>
await getRdsClient().send(
new RemoveTagsFromResourceCommand({
ResourceName: arn,
TagKeys: [key],
}),
),
)
}
export function useEksTagWriter(arn: string): TagWriter {
return useTagWriter(
["eks", "clusters"],
async (key, value) =>
await getEksClient().send(
new EksTagResourceCommand({ resourceArn: arn, tags: { [key]: value } }),
),
async (key) =>
await getEksClient().send(
new EksUntagResourceCommand({ resourceArn: arn, tagKeys: [key] }),
),
)
}
export function useEcsTagWriter(arn: string): TagWriter {
return useTagWriter(
["ecs", "clusters"],
async (key, value) =>
await getEcsClient().send(
new EcsTagResourceCommand({
resourceArn: arn,
tags: [{ key, value }],
}),
),
async (key) =>
await getEcsClient().send(
new EcsUntagResourceCommand({ resourceArn: arn, tagKeys: [key] }),
),
)
}
export type IamTaggable = "user" | "role" | "policy" | "instance-profile"
const IAM_LIST_KEYS: Record<IamTaggable, QueryKey> = {
user: ["iam", "users"],
role: ["iam", "roles"],
policy: ["iam", "policies"],
"instance-profile": ["iam", "instance-profiles"],
}
interface IamCalls {
put: (id: string, key: string, value: string) => Send
remove: (id: string, key: string) => Send
}
const IAM_CALLS: Record<IamTaggable, IamCalls> = {
user: {
put: async (UserName, Key, Value) =>
await getIamClient().send(
new TagUserCommand({ UserName, Tags: [{ Key, Value }] }),
),
remove: async (UserName, key) =>
await getIamClient().send(
new UntagUserCommand({ UserName, TagKeys: [key] }),
),
},
role: {
put: async (RoleName, Key, Value) =>
await getIamClient().send(
new TagRoleCommand({ RoleName, Tags: [{ Key, Value }] }),
),
remove: async (RoleName, key) =>
await getIamClient().send(
new UntagRoleCommand({ RoleName, TagKeys: [key] }),
),
},
policy: {
put: async (PolicyArn, Key, Value) =>
await getIamClient().send(
new TagPolicyCommand({ PolicyArn, Tags: [{ Key, Value }] }),
),
remove: async (PolicyArn, key) =>
await getIamClient().send(
new UntagPolicyCommand({ PolicyArn, TagKeys: [key] }),
),
},
"instance-profile": {
put: async (InstanceProfileName, Key, Value) =>
await getIamClient().send(
new TagInstanceProfileCommand({
InstanceProfileName,
Tags: [{ Key, Value }],
}),
),
remove: async (InstanceProfileName, key) =>
await getIamClient().send(
new UntagInstanceProfileCommand({
InstanceProfileName,
TagKeys: [key],
}),
),
},
}
// `id` is the name, or the ARN for a managed policy.
export function useIamTagWriter(kind: IamTaggable, id: string): TagWriter {
const calls = IAM_CALLS[kind]
return useTagWriter(
IAM_LIST_KEYS[kind],
async (key, value) => await calls.put(id, key, value),
async (key) => await calls.remove(id, key),
)
}
@@ -1,5 +1,10 @@
import { Plus, Trash2 } from "lucide-react"
import { useState } from "react"
import mariadbMark from "@/assets/engines/mariadb.svg?no-inline"
import postgresMark from "@/assets/engines/postgresql.svg?no-inline"
import { ChoiceTile, ChoiceTiles } from "@/components/choice-tile"
import { FilterBar } from "@/components/filter-bar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -150,6 +155,73 @@ export function ControlsSection() {
<Slider defaultValue={[20, 80]} />
</div>
</Specimen>
<EngineTilesSpecimen />
<FilterBarSpecimen />
</CatalogueSection>
)
}
function EngineTilesSpecimen() {
const [engine, setEngine] = useState("mariadb")
return (
<Specimen label="Choice tiles: a few named products, each with its own mark; one that cannot be used stays visible, disabled, saying why">
<div className="w-full max-w-md">
<ChoiceTiles columns={3} label="Engine">
<ChoiceTile
checked={engine === "mariadb"}
icon={<img alt="" className="size-8" src={mariadbMark} />}
label="MariaDB"
onSelect={() => {
setEngine("mariadb")
}}
/>
<ChoiceTile
checked={engine === "postgres"}
icon={<img alt="" className="size-8" src={postgresMark} />}
label="PostgreSQL"
onSelect={() => {
setEngine("postgres")
}}
/>
<ChoiceTile
checked={false}
description="Image not imported"
icon={
<img alt="" className="size-8 opacity-40" src={postgresMark} />
}
label="Unavailable"
/>
</ChoiceTiles>
</div>
</Specimen>
)
}
const FILTER_CHOICES = [
{ value: "all", label: "All", count: 12 },
{ value: "running", label: "Running", count: 9 },
{ value: "stopped", label: "Stopped", count: 3 },
] as const
type FilterChoiceValue = (typeof FILTER_CHOICES)[number]["value"]
function FilterBarSpecimen() {
const [query, setQuery] = useState("")
const [choice, setChoice] = useState<FilterChoiceValue>("all")
return (
<Specimen label="Filter bar: every list searches and filters with it; Clear filters shows only while something is filtered">
<FilterBar
choice={choice}
choices={FILTER_CHOICES}
choicesLabel="Instance state"
onChoiceChange={setChoice}
onQueryChange={setQuery}
placeholder="Filter by name, ID, type or IP"
query={query}
searchLabel="Filter instances"
/>
</Specimen>
)
}
@@ -1,6 +1,7 @@
import { useState } from "react"
import { StateBadge } from "@/components/state-badge"
import { RelativeTime, TagPills, TruncatedText } from "@/components/table-cells"
import {
Breadcrumb,
BreadcrumbItem,
@@ -139,23 +140,25 @@ function TableSpecimen() {
return (
<Specimen label="Table states">
<div className="flex w-full flex-col gap-3">
<ToggleGroup
onValueChange={(values) => {
const next = values.at(-1)
<div className="overflow-x-auto">
<ToggleGroup
onValueChange={(values) => {
const next = values.at(-1)
if (next) {
setState(next)
}
}}
value={[state]}
variant="outline"
>
{TABLE_STATES.map((option) => (
<ToggleGroupItem key={option} value={option}>
{option}
</ToggleGroupItem>
))}
</ToggleGroup>
if (next) {
setState(next)
}
}}
value={[state]}
variant="outline"
>
{TABLE_STATES.map((option) => (
<ToggleGroupItem key={option} value={option}>
{option}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="rounded-md border">
<ScrollArea className="h-72">
<Table>
@@ -179,10 +182,24 @@ function TableSpecimen() {
)
}
// Relative to the moment the page renders, so the specimen always reads the same.
const THIRTY_THREE_MINUTES_AGO = new Date(Date.now() - 33 * 60 * 1000)
const SAMPLE_TAGS = [
{ Key: "Name", Value: "web-1" },
{ Key: "spinifex:eks-cluster", Value: "test-cluster" },
{ Key: "spinifex:eks-nodegroup", Value: "node-gap" },
]
export function DataSection() {
return (
<CatalogueSection title="Data and layout">
<TableSpecimen />
<Specimen label="Table cells: relative time, truncated text, tag pills; hover for the full value">
<RelativeTime date={THIRTY_THREE_MINUTES_AGO} />
<TruncatedText text="a-very-long-instance-name-that-would-otherwise-widen-its-column" />
<TagPills omit={["Name"]} tags={SAMPLE_TAGS} />
</Specimen>
<Specimen label="Breadcrumb">
<Breadcrumb>
<BreadcrumbList>
@@ -29,7 +29,7 @@ const NOT_SHOWN = {
"sandbox-welcome-dialog": "shown once to a new sandbox account",
"security-group-picker": "a SwitchList over live security groups",
"system-image-required": "rechecks the account's images",
"tags-editor": "drives a real tagging mutation",
"resource-tags": "InlineTags wired to each service's tagging calls",
"theme-provider": "part of the shell",
"theme-switch": "part of the shell, in the header",
"upload-progress-dialog": "tracks real S3 uploads",
@@ -114,6 +114,7 @@ function SampleCreateForm({ onClose }: { onClose: () => void }) {
<SelectField
control={control}
id="sample-scheme"
info="Internet-facing takes traffic from outside the VPC; internal only from inside it."
label="Scheme"
name="scheme"
options={selectOptions(["internet-facing", "internal"])}
@@ -13,6 +13,7 @@ import {
} from "@/components/detail-panel"
import { ErrorBanner } from "@/components/error-banner"
import { InfoList, InfoTip } from "@/components/info-tip"
import { InlineTags } from "@/components/inline-tags"
import { PageHeading } from "@/components/page-heading"
import { PickerNoticeText } from "@/components/picker-notice"
import { SelectionBar } from "@/components/selection-bar"
@@ -22,6 +23,15 @@ import { WizardFailureBanner } from "@/components/wizard-failure-banner"
import { CatalogueSection, Specimen, unwired } from "./catalogue-section"
const SAMPLE_TAGS = [
{ Key: "env", Value: "prod" },
{ Key: "team", Value: "web" },
]
async function resolveUnwired() {
// A specimen shows the control, not its effect.
}
interface SampleVolume {
id: string
size: number
@@ -170,6 +180,16 @@ export function PatternsSection() {
</PanelSection>
</div>
</Specimen>
<Specimen label="Tags: edit a row with the pencil, save with the tick, remove with the bin, add with +; read-only where tags cannot change">
<div className="grid w-full gap-4 md:grid-cols-2">
<InlineTags
onPut={resolveUnwired}
onRemove={resolveUnwired}
tags={SAMPLE_TAGS}
/>
<InlineTags tags={SAMPLE_TAGS} />
</div>
</Specimen>
<Specimen label="Info tip">
<span className="flex items-center gap-1">
Tenancy
@@ -1,4 +1,4 @@
import { StateBadge } from "@/components/state-badge"
import { StateBadge, StatusBadge } from "@/components/state-badge"
import { CatalogueSection, Specimen } from "./catalogue-section"
@@ -26,6 +26,11 @@ const STATUSES = [
solid: "bg-warning",
tint: "border-warning-border bg-warning-subtle text-warning-foreground",
},
{
name: "caution",
solid: "bg-caution",
tint: "border-caution-border bg-caution-subtle text-caution-foreground",
},
{
name: "danger",
solid: "bg-danger",
@@ -66,9 +71,12 @@ const TYPE_SCALE = [
const SAMPLE_STATES = [
"running",
"available",
"associated",
"ACTIVE",
"pending",
"stopping",
"DEGRADED",
"impaired",
"stopped",
"failed",
"terminated",
@@ -107,11 +115,16 @@ export function TokensSection() {
</div>
))}
</Specimen>
<Specimen label="StateBadge: AWS states mapped to tones">
<Specimen label="StateBadge: green settled, yellow changing, orange warning, red failed or stopped, grey gone; always lowercase">
{SAMPLE_STATES.map((state) => (
<StateBadge key={state} state={state} />
))}
</Specimen>
<Specimen label="StatusBadge: a status that is not an API state">
<StatusBadge tone="success">Healthy</StatusBadge>
<StatusBadge tone="caution">Degraded</StatusBadge>
<StatusBadge tone="muted">no</StatusBadge>
</Specimen>
<Specimen label="Chart palette">
{CHARTS.map((chart) => (
<Swatch key={chart.name} {...chart} />
@@ -7,6 +7,7 @@ import {
type SortState,
} from "@/components/data-table"
import { PageHeading } from "@/components/page-heading"
import { type StateTone, StatusBadge } from "@/components/state-badge"
import { Badge } from "@/components/ui/badge"
import { formatVRAMMiB } from "@/lib/utils"
import {
@@ -71,18 +72,17 @@ const AVAILABILITY_HINT = {
"no-credential": "Add a provider credential to enable this model",
} satisfies Record<CatalogAvailability, string | undefined>
function availabilityVariant(
availability: CatalogAvailability,
): "default" | "secondary" | "destructive" {
// Ungranted is a choice; the other two need an operator to act.
function availabilityTone(availability: CatalogAvailability): StateTone {
if (availability === "available") {
return "default"
return "success"
}
if (availability === "ungranted") {
return "secondary"
return "neutral"
}
return "destructive"
return "caution"
}
function AvailabilityCell({
@@ -94,9 +94,9 @@ function AvailabilityCell({
return (
<div>
<Badge variant={availabilityVariant(availability)}>
<StatusBadge tone={availabilityTone(availability)}>
{AVAILABILITY_LABEL[availability]}
</Badge>
</StatusBadge>
{hint && <p className="mt-1 text-2xs text-muted-foreground">{hint}</p>}
</div>
)
@@ -328,6 +328,7 @@ export function GuardrailDetails({
aria-label="Open guardrail page"
className={buttonVariants({ size: "icon-sm", variant: "ghost" })}
params={{ guardrailId }}
search={{ tab }}
to="/bedrock/list-guardrails/$guardrailId"
>
<Maximize2 />
@@ -107,7 +107,7 @@ describe("GuardrailsPage", () => {
it("shows the guardrail id and status", () => {
renderSeeded([GUARDRAIL])
expect(screen.getByText("gr-1")).toBeInTheDocument()
expect(screen.getByText("READY")).toBeInTheDocument()
expect(screen.getByText("ready")).toBeInTheDocument()
})
it("deletes each selected guardrail with its own call", async () => {
@@ -86,7 +86,7 @@ describe("DataSourceCard", () => {
})
renderWithClient()
await expect(screen.findByText("job-1")).resolves.toBeInTheDocument()
expect(screen.getByText("COMPLETE")).toBeInTheDocument()
expect(screen.getByText("complete")).toBeInTheDocument()
expect(
screen.getByText("scanned 10 · indexed 9 · failed 1"),
).toBeInTheDocument()
@@ -194,6 +194,7 @@ export function KnowledgeBaseDetails({
aria-label="Open knowledge base page"
className={buttonVariants({ size: "icon-sm", variant: "ghost" })}
params={{ knowledgeBaseId }}
search={{ tab }}
to="/bedrock/list-knowledge-bases/$knowledgeBaseId"
>
<Maximize2 />
@@ -87,7 +87,7 @@ describe("KnowledgeBasesPage", () => {
it("shows the knowledge base id and status", () => {
renderSeeded([KB])
expect(screen.getByText("kb-1")).toBeInTheDocument()
expect(screen.getByText("ACTIVE")).toBeInTheDocument()
expect(screen.getByText("active")).toBeInTheDocument()
})
it("renders a create knowledge base link", () => {
@@ -8,7 +8,7 @@ import {
} from "@/components/data-table"
import { PageHeading } from "@/components/page-heading"
import { ResourceDeleteDialog } from "@/components/resource-delete-dialog"
import { Badge } from "@/components/ui/badge"
import { StatusBadge } from "@/components/state-badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Field, FieldTitle } from "@/components/ui/field"
@@ -136,9 +136,9 @@ function modelAccessColumns(
const granted = grantedModelIds.has(entry.modelId)
return (
<Badge variant={granted ? "default" : "secondary"}>
<StatusBadge tone={granted ? "success" : "neutral"}>
{granted ? "Granted" : "Ungranted"}
</Badge>
</StatusBadge>
)
},
},
@@ -8,9 +8,9 @@ import {
PanelField,
PanelFields,
PanelSection,
PanelTags,
} from "@/components/detail-panel"
import { ResourceDeleteDialog } from "@/components/resource-delete-dialog"
import { Ec2Tags } from "@/components/resource-tags"
import { StateBadge } from "@/components/state-badge"
import { Button, buttonVariants } from "@/components/ui/button"
import type { PanelTab } from "@/lib/panel-search"
@@ -88,7 +88,11 @@ function Settings({
return (
<>
<PanelTags tags={address.Tags} />
<Ec2Tags
id={address.AllocationId}
invalidate={["ec2", "addresses"]}
tags={address.Tags}
/>
<PanelSection title="Manage">
<div className="flex flex-col items-start gap-3">
{associated && (
@@ -155,6 +159,7 @@ export function ElasticIpDetails({
aria-label="Open Elastic IP page"
className={buttonVariants({ size: "icon-sm", variant: "ghost" })}
params={{ id }}
search={{ tab }}
to="/ec2/describe-addresses/$id"
>
<Maximize2 />
@@ -2,6 +2,7 @@ import { screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import { callerIdentityQueryOptions } from "@/queries/sts"
import { createTestQueryClient, renderWithClient } from "@/test/render"
const { sdk } = vi.hoisted(() => {
@@ -42,16 +43,27 @@ const IMAGE = {
],
}
function renderDetails(tab: PanelTab, onClose = vi.fn()) {
function renderDetails(
tab: PanelTab,
onClose = vi.fn(),
image: typeof IMAGE = IMAGE,
) {
const queryClient = createTestQueryClient()
queryClient.setQueryData(callerIdentityQueryOptions.queryKey, {
Account: "000000000001",
$metadata: {},
})
renderWithClient(
<ImageDetails
image={IMAGE}
image={image}
layout="page"
onClose={onClose}
onTabChange={vi.fn()}
tab={tab}
/>,
createTestQueryClient(),
queryClient,
)
}
@@ -65,6 +77,14 @@ describe("ImageDetails", () => {
)
})
it("hides the snapshot of an image another account owns", () => {
renderDetails("overview", vi.fn(), { ...IMAGE, OwnerId: "000000000000" })
expect(screen.getByText("/dev/vda", { selector: "*" })).toBeInTheDocument()
expect(screen.queryByText("snap-1")).not.toBeInTheDocument()
expect(screen.queryByText("Snapshot ID")).not.toBeInTheDocument()
})
it("deregisters only after it is confirmed, then closes", async () => {
const user = userEvent.setup()
const onClose = vi.fn()
@@ -9,9 +9,10 @@ import {
} from "lucide-react"
import { useState } from "react"
import { DetailPanel, PanelSection, PanelTags } from "@/components/detail-panel"
import { DetailPanel, PanelSection } from "@/components/detail-panel"
import { ErrorBanner } from "@/components/error-banner"
import { ResourceDeleteDialog } from "@/components/resource-delete-dialog"
import { Ec2Tags } from "@/components/resource-tags"
import { StateBadge } from "@/components/state-badge"
import {
AlertDialog,
@@ -59,7 +60,12 @@ function Settings({
return (
<>
<PanelTags tags={image.Tags} />
<Ec2Tags
id={image.ImageId}
invalidate={["ec2", "images"]}
readOnly={isSystemImage}
tags={image.Tags}
/>
<PanelSection title="Manage">
<div className="flex flex-col items-start gap-3">
{promote.error && (
@@ -154,6 +160,7 @@ export function ImageDetails({
aria-label="Open image page"
className={buttonVariants({ size: "icon-sm", variant: "ghost" })}
params={{ id }}
search={{ tab }}
to="/ec2/describe-images/$id"
>
<Maximize2 />
@@ -1,5 +1,6 @@
import type { Image } from "@aws-sdk/client-ec2"
import { ChoiceTile, ChoiceTiles } from "@/components/choice-tile"
import { Field, FieldDescription, FieldTitle } from "@/components/ui/field"
import {
Select,
@@ -15,7 +16,6 @@ import {
parseImageName,
} from "@/lib/os-images"
import { ChoiceTile, ChoiceTiles } from "./choice-tile"
import { OsMark } from "./os-mark"
const OTHER = "other"
@@ -10,8 +10,9 @@ import {
} from "lucide-react"
import { ConsoleOutput } from "@/components/console-output"
import { DetailPanel, PanelSection, PanelTags } from "@/components/detail-panel"
import { DetailPanel, PanelSection } from "@/components/detail-panel"
import { ErrorBanner } from "@/components/error-banner"
import { Ec2Tags } from "@/components/resource-tags"
import { StateBadge } from "@/components/state-badge"
import { Button, buttonVariants } from "@/components/ui/button"
import type { PanelTab } from "@/lib/panel-search"
@@ -139,7 +140,11 @@ export function InstanceDetails({
icon: SlidersHorizontal,
content: (
<>
<PanelTags tags={instance.Tags} />
<Ec2Tags
id={instance.InstanceId}
invalidate={["ec2", "instances"]}
tags={instance.Tags}
/>
<ConsoleSection instance={instance} />
</>
),
@@ -0,0 +1,93 @@
import type { Instance } from "@aws-sdk/client-ec2"
import { screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import { ec2InstanceTypesQueryOptions } from "@/queries/ec2"
import { createTestQueryClient, renderWithClient } from "@/test/render"
const { sdk } = vi.hoisted(() => {
interface SdkCommand {
readonly input: object
}
return {
sdk: {
send: vi.fn<(command: SdkCommand) => Promise<unknown>>(async () => ({})),
},
}
})
vi.mock("@/lib/awsClient", async () => {
const { mockAwsClients } = await import("@/test/aws-clients")
return mockAwsClients(sdk.send)
})
vi.mock("@tanstack/react-router", async (importOriginal) => {
const { mockRouter } = await import("@/test/router")
return await mockRouter(importOriginal)
})
import { InstanceOverview } from "./instance-overview"
const INSTANCE: Instance = {
InstanceId: "i-0a1b2c3d4e5f60001",
InstanceType: "t3.large",
State: { Name: "stopped" },
Tags: [
{ Key: "env", Value: "dev" },
{ Key: "Name", Value: "web-01" },
],
}
function renderOverview(instance: Instance = INSTANCE) {
const queryClient = createTestQueryClient()
queryClient.setQueryData(ec2InstanceTypesQueryOptions.queryKey, {
InstanceTypes: [
{
InstanceType: "t3.large",
VCpuInfo: { DefaultVCpus: 2 },
MemoryInfo: { SizeInMiB: 8192 },
},
],
$metadata: {},
})
renderWithClient(<InstanceOverview instance={instance} />, queryClient)
}
describe("InstanceOverview", () => {
it("shows the Name tag under the instance ID", () => {
renderOverview()
const labels = screen
.getAllByText(/^(?:Instance ID|Name|Type)$/)
.map((node) => node.textContent)
expect(labels.slice(0, 3)).toStrictEqual(["Instance ID", "Name", "Type"])
expect(screen.getByText("web-01")).toBeInTheDocument()
})
it("explains the type's size from DescribeInstanceTypes", async () => {
const user = userEvent.setup()
renderOverview()
await user.hover(screen.getByRole("button", { name: "About Type" }))
await expect(
screen.findByText("2 vCPU · 8 GiB memory"),
).resolves.toBeInTheDocument()
})
it("offers no size for a type the cluster does not list", () => {
renderOverview({ ...INSTANCE, InstanceType: "m5.24xlarge" })
expect(screen.getByText("m5.24xlarge")).toBeInTheDocument()
expect(
screen.queryByRole("button", { name: "About Type" }),
).not.toBeInTheDocument()
})
})
@@ -20,12 +20,14 @@ import { adminVMsQueryOptions } from "@/queries/admin"
import {
ec2AddressesQueryOptions,
ec2ImageQueryOptions,
ec2InstanceTypesQueryOptions,
ec2SecurityGroupsQueryOptions,
} from "@/queries/ec2"
import { GpuSection } from "../../-components/gpu-section"
import { IamRolePanel } from "../../-components/iam-role-panel"
import { SSHAccess } from "../../-components/ssh-access"
import { describeTypeSize } from "./run-instances-helpers"
const LINK = "text-primary hover:underline"
@@ -225,6 +227,20 @@ function ImageField({
)
}
function TypeField({ instance }: { instance: Instance }) {
const { data } = useQuery(ec2InstanceTypesQueryOptions)
const type = data?.InstanceTypes?.find(
(candidate) => candidate.InstanceType === instance.InstanceType,
)
return (
<PanelField info={type && describeTypeSize(type)} label="Type" mono>
{instance.InstanceType}
</PanelField>
)
}
function InstanceInformation({
image,
instance,
@@ -232,15 +248,16 @@ function InstanceInformation({
image: Image | undefined
instance: Instance
}) {
const name = instance.Tags?.find((tag) => tag.Key === "Name")?.Value
return (
<PanelSection title="Instance information">
<PanelFields>
<PanelField copy={instance.InstanceId} label="Instance ID" mono>
{instance.InstanceId}
</PanelField>
<PanelField label="Type" mono>
{instance.InstanceType}
</PanelField>
<PanelField label="Name">{name}</PanelField>
<TypeField instance={instance} />
<PanelField label="Zone">
{instance.Placement?.AvailabilityZone}
</PanelField>
@@ -1,26 +1,21 @@
import type { Instance } from "@aws-sdk/client-ec2"
import { Link } from "@tanstack/react-router"
import { Pause, Play, Plus, RotateCw, Search, Trash2 } from "lucide-react"
import { Pause, Play, Plus, RotateCw, Trash2 } from "lucide-react"
import { type ReactNode, useState } from "react"
import { CreatePanel } from "@/components/create-panel"
import { type DataColumn, DataTable } from "@/components/data-table"
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
import { ErrorBanner } from "@/components/error-banner"
import { FilterBar } from "@/components/filter-bar"
import { PageHeading } from "@/components/page-heading"
import { ResourceView } from "@/components/resource-view"
import { SelectionBar } from "@/components/selection-bar"
import { StateBadge } from "@/components/state-badge"
import { RelativeTime, TagPills, TruncatedText } from "@/components/table-cells"
import { Button, buttonVariants } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { useRowSelection } from "@/hooks/use-row-selection"
import { CLOSED_PANEL, type PanelSearch } from "@/lib/panel-search"
import { formatDateTime } from "@/lib/utils"
import {
useRebootInstance,
useStartInstance,
@@ -98,18 +93,21 @@ function Mono({ children }: { children: string | undefined }) {
)
}
const NAME_TAG = ["Name"]
// Instance ID, Name and State stay on a phone; the rest drop out as the table
// narrows, as they do beside an open panel.
const COLUMNS: DataColumn<Instance>[] = [
{
id: "instance",
header: "Instance",
sortValue: (instance) => instanceName(instance) ?? instance.InstanceId,
header: "Instance ID",
sortValue: (instance) => instance.InstanceId,
cell: (instance) => {
const name = instanceName(instance)
const id = instance.InstanceId ?? ""
return (
<Link
className="group block max-w-72 min-w-0 rounded-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
className="rounded-sm font-mono text-xs font-medium whitespace-nowrap text-primary hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
from="/ec2/describe-instances/"
search={(previous) => ({
...previous,
@@ -118,18 +116,17 @@ const COLUMNS: DataColumn<Instance>[] = [
})}
to="."
>
<span className="block truncate font-medium text-primary group-hover:underline">
{name ?? id}
</span>
{name && (
<span className="block truncate font-mono text-xs text-muted-foreground">
{id}
</span>
)}
{id}
</Link>
)
},
},
{
id: "name",
header: "Name",
sortValue: instanceName,
cell: (instance) => <TruncatedText text={instanceName(instance)} />,
},
{
id: "state",
header: "State",
@@ -140,16 +137,9 @@ const COLUMNS: DataColumn<Instance>[] = [
id: "type",
header: "Type",
sortValue: (instance) => instance.InstanceType,
hideBelow: "sm",
hideBelow: "md",
cell: (instance) => <Mono>{instance.InstanceType}</Mono>,
},
{
id: "az",
header: "Zone",
sortValue: (instance) => instance.Placement?.AvailabilityZone,
hideBelow: "lg",
cell: (instance) => instance.Placement?.AvailabilityZone ?? "—",
},
{
id: "private-ip",
header: "Private IP",
@@ -169,13 +159,19 @@ const COLUMNS: DataColumn<Instance>[] = [
header: "Launched",
sortValue: (instance) =>
instance.LaunchTime ? new Date(instance.LaunchTime).getTime() : undefined,
hideBelow: "xl",
hideBelow: "lg",
cell: (instance) => (
<span className="text-muted-foreground">
{formatDateTime(instance.LaunchTime)}
<RelativeTime date={instance.LaunchTime} />
</span>
),
},
{
id: "tags",
header: "Tags",
hideBelow: "lg",
cell: (instance) => <TagPills omit={NAME_TAG} tags={instance.Tags} />,
},
]
// "-launched" sorts launched descending; an unknown column falls back to none.
@@ -410,56 +406,27 @@ export function InstancesPage({
/>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="w-full sm:w-72">
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput
aria-label="Filter instances"
onChange={(event) => {
onSearchChange({ q: event.target.value })
}}
placeholder="Filter by name, ID, type or IP"
type="search"
value={search.q}
/>
</InputGroup>
<ToggleGroup
aria-label="Instance state"
onValueChange={(values) => {
const next = STATE_FILTERS.find(
(filter) => filter === values.at(-1),
)
if (next) {
onSearchChange({ state: next })
}
}}
value={[search.state]}
variant="outline"
>
{STATE_FILTERS.map((filter) => (
<ToggleGroupItem key={filter} value={filter}>
<span className="capitalize">{filter}</span>
<span className="text-muted-foreground tabular-nums">
{counts[filter]}
</span>
</ToggleGroupItem>
))}
</ToggleGroup>
{filtered && (
<Button
onClick={() => {
onSearchChange({ q: "", state: "all" })
}}
size="sm"
variant="ghost"
>
Clear filters
</Button>
)}
</div>
<FilterBar
choice={search.state}
choices={STATE_FILTERS.map((filter) => ({
value: filter,
label: filter.charAt(0).toUpperCase() + filter.slice(1),
count: counts[filter],
}))}
choicesLabel="Instance state"
onChoiceChange={(state) => {
onSearchChange({ state })
}}
onClear={() => {
onSearchChange({ q: "", state: "all" })
}}
onQueryChange={(q) => {
onSearchChange({ q })
}}
placeholder="Filter by name, ID, type or IP"
query={search.q}
searchLabel="Filter instances"
/>
<div>
{selectedVisible.length > 0 && (
@@ -1,6 +1,6 @@
import { ChoiceTile, ChoiceTiles } from "@/components/choice-tile"
import { regionLocation } from "@/lib/aws-regions"
import { ChoiceTile, ChoiceTiles } from "./choice-tile"
import { RegionFlag } from "./region-flag"
interface LocationTileProps {
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest"
import { partsToText } from "@/components/cli-command-panel"
import { buildRunInstancesCommands } from "./run-instances-cli"
const BASE = {
imageId: "ami-1",
instanceType: "t3.nano",
keyName: "default",
securityGroupMode: "create" as const,
newSgName: "launch-wizard-1",
}
function text(values: Parameters<typeof buildRunInstancesCommands>[0]) {
return partsToText(buildRunInstancesCommands(values, "vpc-1"))
}
describe("buildRunInstancesCommands", () => {
it("tags the instance with its name, quoting a name with a quote in it", () => {
const out = text({ ...BASE, name: " it's web " })
expect(out).toContain("--tag-specifications")
expect(out).toContain(String.raw`"Value":"it'\''s web"`)
})
it("leaves out tag specifications when the name is blank", () => {
expect(text({ ...BASE, name: " " })).not.toContain("--tag-specifications")
})
it("authorizes ping as ICMP echo request ahead of SSH", () => {
const out = text({ ...BASE, allowPing: true, allowSsh: true })
expect(out).toContain(
'{"IpProtocol":"icmp","FromPort":8,"ToPort":-1,"IpRanges":[{"CidrIp":"0.0.0.0/0"}]},{"IpProtocol":"tcp","FromPort":22',
)
})
it("names the instance on a launch-template launch as well", () => {
const out = text({ launchTemplateId: "lt-1", name: "web-1" })
expect(out).toContain("--launch-template")
expect(out).toContain('"Value":"web-1"')
})
})
@@ -8,6 +8,7 @@ import {
optionalFlag,
type CommandPart,
} from "@/components/cli-command-panel"
import { launchWizardPermissions } from "@/mutations/ec2"
import type { CreateInstanceFormData } from "@/types/ec2"
import { DEFAULT_ROOT_DEVICE_NAME } from "./run-instances-helpers"
@@ -25,6 +26,28 @@ function countParts(values: RunInstancesValues): CommandPart[] {
return commandFlag("\\\n --count", (values.count ?? 0) || 1)
}
// Single-quoted for the shell, so a quote in the name closes and reopens.
function shellQuote(text: string): string {
return `'${text.replaceAll("'", String.raw`'\''`)}'`
}
function nameTagParts(values: RunInstancesValues): CommandPart[] {
const name = values.name?.trim()
if (!name) {
return []
}
const spec = [
{ ResourceType: "instance", Tags: [{ Key: "Name", Value: name }] },
]
return commandFlag(
"\\\n --tag-specifications",
shellQuote(JSON.stringify(spec)),
)
}
function launchTemplateCommand(values: RunInstancesValues): CliCommand {
const templateVersion = values.launchTemplateVersion || "$Default"
@@ -37,6 +60,7 @@ function launchTemplateCommand(values: RunInstancesValues): CliCommand {
`LaunchTemplateId=${values.launchTemplateId},Version=${templateVersion}`,
),
...countParts(values),
...nameTagParts(values),
],
}
}
@@ -62,6 +86,7 @@ function directLaunchParts(values: RunInstancesValues): CommandPart[] {
values.placementGroupName && `GroupName=${values.placementGroupName}`,
),
...countParts(values),
...nameTagParts(values),
]
}
@@ -93,29 +118,13 @@ function createSecurityGroupCommand(
function authorizeIngressCommand(
values: RunInstancesValues,
): CliCommand | undefined {
const cidr =
values.ruleSource === "custom" && values.customCidr
? values.customCidr
: "0.0.0.0/0"
const permissions = launchWizardPermissions(values)
const ports = [
...(values.allowSsh === true ? [22] : []),
...(values.allowHttp === true ? [80] : []),
...(values.allowHttps === true ? [443] : []),
]
if (ports.length === 0) {
if (permissions.length === 0) {
return undefined
}
const ipPerms = JSON.stringify(
ports.map((port) => ({
IpProtocol: "tcp",
FromPort: port,
ToPort: port,
IpRanges: [{ CidrIp: cidr }],
})),
)
const ipPerms = JSON.stringify(permissions)
return {
label: "Authorize inbound rules",
@@ -23,21 +23,7 @@ export type RunInstancesFormInput = z.input<typeof createInstanceSchema>
export const DEFAULT_ROOT_DEVICE_NAME = "/dev/sda1"
// "2 vCPU · 8 GiB memory", read from DescribeInstanceTypes, which reports
// the size of every type the cluster offers.
export function describeTypeSize(type: InstanceTypeInfo): string {
const vcpus = type.VCpuInfo?.DefaultVCpus
const mib = type.MemoryInfo?.SizeInMiB
return [
vcpus === undefined ? undefined : `${vcpus} vCPU`,
mib === undefined
? undefined
: `${Number((mib / 1024).toFixed(2))} GiB memory`,
]
.filter(Boolean)
.join(" · ")
}
export { describeTypeSize } from "@/lib/instance-type-size"
export function typesForArchitecture(
types: readonly InstanceTypeInfo[],
@@ -182,6 +168,7 @@ export function runInstancesDefaults({
return {
count: 1,
name: "",
imageId: defaultImageId ?? "",
keyName: keyPairs[0]?.KeyName ?? "",
instanceType: defaultTypeName(archTypeNames) ?? "",
@@ -194,6 +181,7 @@ export function runInstancesDefaults({
securityGroups.map((sg) => sg.GroupName ?? ""),
),
newSgDescription: "Created by launch wizard",
allowPing: true,
allowSsh: true,
allowHttp: false,
allowHttps: false,
@@ -1,7 +1,9 @@
import type {
AuthorizeSecurityGroupIngressCommandInput,
Image,
InstanceTypeInfo,
LaunchTemplate,
PlacementGroup,
RunInstancesCommandInput,
Subnet,
} from "@aws-sdk/client-ec2"
@@ -120,6 +122,7 @@ interface SetupOptions {
capacity?: Record<string, number>
subnets?: Subnet[]
launchTemplates?: LaunchTemplate[]
placementGroups?: PlacementGroup[]
onClose?: (instanceId?: string) => void
}
@@ -129,6 +132,7 @@ function setup({
capacity = CAPACITY,
subnets = [DEFAULT_SUBNET],
launchTemplates = [],
placementGroups = [],
onClose = vi.fn(),
}: SetupOptions = {}) {
sdk.reset()
@@ -155,7 +159,7 @@ function setup({
})
queryClient.setQueryData(ec2PlacementGroupsQueryOptions.queryKey, {
$metadata: {},
PlacementGroups: [],
PlacementGroups: placementGroups,
})
queryClient.setQueryData(ec2VpcsQueryOptions.queryKey, {
$metadata: {},
@@ -434,6 +438,54 @@ describe("run-instances launch template", () => {
})
})
describe("run-instances placement group", () => {
it("is hidden when no group can take a launch", async () => {
setup({
placementGroups: [
{
GroupId: "pg-1",
GroupName: "gone",
Strategy: "spread",
State: "deleted",
},
],
})
await screen.findByLabelText("Version")
expect(screen.queryByLabelText("Placement Group")).not.toBeInTheDocument()
})
it("offers only the available groups", async () => {
const user = userEvent.setup()
setup({
placementGroups: [
{
GroupId: "pg-1",
GroupName: "web",
Strategy: "spread",
State: "available",
},
{
GroupId: "pg-2",
GroupName: "old",
Strategy: "cluster",
State: "deleting",
},
],
})
await user.click(await screen.findByLabelText("Placement Group"))
await expect(
screen.findByRole("option", { name: "web (spread)" }),
).resolves.toBeInTheDocument()
expect(
screen.queryByRole("option", { name: "old (cluster)" }),
).not.toBeInTheDocument()
})
})
describe("run-instances network", () => {
it("shows the region with its flag and the default subnet's zone", async () => {
setup()
@@ -475,16 +527,23 @@ describe("run-instances network", () => {
})
describe("run-instances security group mode", () => {
it("defaults to creating a group with SSH allowed and switches to existing groups", async () => {
it("defaults to creating a group with ping and SSH allowed and switches to existing groups", async () => {
const user = userEvent.setup()
setup()
await expect(
screen.findByRole("radio", { name: "Create new security group" }),
).resolves.toBeChecked()
expect(screen.getByLabelText("Allow ping")).toBeChecked()
expect(screen.getByLabelText("Allow SSH")).toBeChecked()
expect(screen.getByLabelText("Allow HTTP")).not.toBeChecked()
const rules = screen
.getAllByRole("switch")
.map((el) => el.getAttribute("aria-label"))
expect(rules.indexOf("Allow ping")).toBeLessThan(rules.indexOf("Allow SSH"))
await user.click(
screen.getByRole("radio", { name: "Select existing security group" }),
)
@@ -528,6 +587,88 @@ describe("run-instances submit", () => {
expect(input?.BlockDeviceMappings).toBeUndefined()
})
it("sends no tags when the name is left blank", async () => {
const user = userEvent.setup()
const runInstances = vi.fn((_input: RunInstancesCommandInput) => ({
Instances: [{ InstanceId: "i-1" }],
}))
setup()
sdk.setHandler("CreateSecurityGroupCommand", () => ({ GroupId: "sg-new" }))
sdk.setHandler("AuthorizeSecurityGroupIngressCommand", () => ({}))
sdk.setHandler("RunInstancesCommand", runInstances)
await user.click(await screen.findByRole("radio", { name: "Oracle Linux" }))
await user.click(screen.getByRole("button", { name: "Create Instance" }))
await waitFor(() => {
expect(runInstances).toHaveBeenCalled()
})
expect(runInstances.mock.calls[0]?.[0].TagSpecifications).toBeUndefined()
})
it("tags the instance with its name, trimmed", async () => {
const user = userEvent.setup()
const runInstances = vi.fn((_input: RunInstancesCommandInput) => ({
Instances: [{ InstanceId: "i-1" }],
}))
setup()
sdk.setHandler("CreateSecurityGroupCommand", () => ({ GroupId: "sg-new" }))
sdk.setHandler("AuthorizeSecurityGroupIngressCommand", () => ({}))
sdk.setHandler("RunInstancesCommand", runInstances)
await user.click(await screen.findByRole("radio", { name: "Oracle Linux" }))
await user.type(screen.getByLabelText("Name (optional)"), " web-1 ")
await user.click(screen.getByRole("button", { name: "Create Instance" }))
await waitFor(() => {
expect(runInstances).toHaveBeenCalled()
})
expect(runInstances.mock.calls[0]?.[0].TagSpecifications).toStrictEqual([
{ ResourceType: "instance", Tags: [{ Key: "Name", Value: "web-1" }] },
])
})
it("opens ping and SSH on the new group by default, ping as echo request only", async () => {
const user = userEvent.setup()
const authorize = vi.fn(
(_input: AuthorizeSecurityGroupIngressCommandInput) => ({}),
)
setup()
sdk.setHandler("CreateSecurityGroupCommand", () => ({ GroupId: "sg-new" }))
sdk.setHandler("AuthorizeSecurityGroupIngressCommand", authorize)
sdk.setHandler("RunInstancesCommand", () => ({
Instances: [{ InstanceId: "i-1" }],
}))
await user.click(await screen.findByRole("radio", { name: "Oracle Linux" }))
await user.click(screen.getByRole("button", { name: "Create Instance" }))
await waitFor(() => {
expect(authorize).toHaveBeenCalled()
})
expect(authorize.mock.calls[0]?.[0].IpPermissions).toStrictEqual([
{
IpProtocol: "icmp",
FromPort: 8,
ToPort: -1,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
{
IpProtocol: "tcp",
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
])
})
it("shows a refused launch beside the button and stays open", async () => {
const user = userEvent.setup()
const onClose = vi.fn()

Some files were not shown because too many files have changed in this diff Show More