├── .eslintrc.cjs ├── .git-blame-ignore-revs ├── .github ├── renovate.json5 └── workflows │ ├── license-check.yml │ ├── lintBuildTest.yml │ └── upload-assets.yaml ├── .gitignore ├── .husky └── pre-commit ├── .licenserc.yaml ├── .oxlintrc.json ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── css.json ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── OMICRON_VERSION ├── README.md ├── app ├── api │ ├── __generated__ │ │ ├── Api.ts │ │ ├── OMICRON_VERSION │ │ ├── http-client.ts │ │ ├── msw-handlers.ts │ │ ├── util.ts │ │ └── validate.ts │ ├── __tests__ │ │ ├── errors.spec.ts │ │ ├── hooks.spec.tsx │ │ ├── nav-to-login.spec.ts │ │ └── safety.spec.ts │ ├── client.ts │ ├── errors.ts │ ├── hooks.ts │ ├── index.ts │ ├── nav-to-login.ts │ ├── roles.spec.ts │ ├── roles.ts │ ├── selectors.ts │ ├── util.spec.ts │ ├── util.ts │ └── window.ts ├── assets │ ├── favicon.png │ ├── favicon.svg │ └── oxide-hero-rack.webp ├── components │ ├── AccordionItem.tsx │ ├── AffinityDocsPopover.tsx │ ├── AttachAddon.ts │ ├── AttachEphemeralIpModal.tsx │ ├── AttachFloatingIpModal.tsx │ ├── CapacityBar.tsx │ ├── CapacityBars.tsx │ ├── ConfirmActionModal.tsx │ ├── CopyCode.tsx │ ├── CopyIdItem.tsx │ ├── DocsPopover.tsx │ ├── ErrorBoundary.tsx │ ├── ErrorPage.tsx │ ├── ExternalIps.tsx │ ├── HL.tsx │ ├── InstanceAutoRestartPopover.tsx │ ├── InstanceDocsPopover.tsx │ ├── IpPoolUtilization.tsx │ ├── ListPlusCell.tsx │ ├── MoreActionsMenu.tsx │ ├── MswBanner.tsx │ ├── OxideLogo.tsx │ ├── PageActions.tsx │ ├── PageSkeleton.tsx │ ├── Pagination.tsx │ ├── QueryParamTabs.tsx │ ├── RefetchIntervalPicker.tsx │ ├── RefreshButton.tsx │ ├── RoundedSector.tsx │ ├── RouteTabs.tsx │ ├── Sidebar.tsx │ ├── StateBadge.tsx │ ├── SystemMetric.tsx │ ├── Terminal.tsx │ ├── TimeAgo.tsx │ ├── TimeSeriesChart.tsx │ ├── ToastStack.tsx │ ├── TopBar.tsx │ ├── form │ │ ├── Form.tsx │ │ ├── FullPageForm.tsx │ │ ├── ModalForm.tsx │ │ ├── SideModalForm.tsx │ │ └── fields │ │ │ ├── CheckboxField.tsx │ │ │ ├── ComboboxField.tsx │ │ │ ├── DateTimeRangePicker.spec.tsx │ │ │ ├── DateTimeRangePicker.tsx │ │ │ ├── DescriptionField.tsx │ │ │ ├── DiskSizeField.tsx │ │ │ ├── DisksTableField.tsx │ │ │ ├── ErrorMessage.tsx │ │ │ ├── FileField.tsx │ │ │ ├── ImageSelectField.tsx │ │ │ ├── ListboxField.tsx │ │ │ ├── NameField.spec.tsx │ │ │ ├── NameField.tsx │ │ │ ├── NetworkInterfaceField.tsx │ │ │ ├── NumberField.tsx │ │ │ ├── RadioField.tsx │ │ │ ├── SshKeysField.tsx │ │ │ ├── SubnetListbox.tsx │ │ │ ├── TextField.tsx │ │ │ ├── TlsCertsField.tsx │ │ │ ├── ip-pool-item.tsx │ │ │ └── useItemsList.ts │ └── oxql-metrics │ │ ├── HighlightedOxqlQuery.spec.tsx │ │ ├── HighlightedOxqlQuery.tsx │ │ ├── OxqlMetric.tsx │ │ ├── util.spec.ts │ │ └── util.ts ├── forms │ ├── access-util.tsx │ ├── affinity-util.tsx │ ├── anti-affinity-group-create.tsx │ ├── anti-affinity-group-edit.tsx │ ├── anti-affinity-group-member-add.tsx │ ├── disk-attach.tsx │ ├── disk-create.tsx │ ├── firewall-rules-common.tsx │ ├── firewall-rules-create.tsx │ ├── firewall-rules-edit.tsx │ ├── firewall-rules-util.ts │ ├── floating-ip-create.tsx │ ├── floating-ip-edit.tsx │ ├── idp │ │ ├── create.tsx │ │ ├── edit.tsx │ │ ├── shared.tsx │ │ ├── util.spec.ts │ │ └── util.ts │ ├── image-edit.tsx │ ├── image-from-snapshot.tsx │ ├── image-upload.tsx │ ├── instance-create.tsx │ ├── ip-pool-create.tsx │ ├── ip-pool-edit.tsx │ ├── ip-pool-range-add.tsx │ ├── network-interface-create.tsx │ ├── network-interface-edit.tsx │ ├── project-access.tsx │ ├── project-create.tsx │ ├── project-edit.tsx │ ├── silo-access.tsx │ ├── silo-create.tsx │ ├── snapshot-create.tsx │ ├── ssh-key-create.tsx │ ├── ssh-key-edit.tsx │ ├── subnet-create.tsx │ ├── subnet-edit.tsx │ ├── vpc-create.tsx │ ├── vpc-edit.tsx │ ├── vpc-router-create.tsx │ ├── vpc-router-edit.tsx │ ├── vpc-router-route-common.tsx │ ├── vpc-router-route-create.tsx │ └── vpc-router-route-edit.tsx ├── hooks │ ├── use-crumbs.ts │ ├── use-current-user.ts │ ├── use-is-active-path.ts │ ├── use-is-overflow.ts │ ├── use-key.ts │ ├── use-pagination.spec.ts │ ├── use-pagination.ts │ ├── use-params.ts │ ├── use-quick-actions.tsx │ └── use-scroll-restoration.ts ├── layouts │ ├── AuthLayout.tsx │ ├── AuthenticatedLayout.tsx │ ├── LoginLayout.tsx │ ├── ProjectLayout.tsx │ ├── ProjectLayoutBase.tsx │ ├── RootLayout.tsx │ ├── SerialConsoleLayout.tsx │ ├── SettingsLayout.tsx │ ├── SiloLayout.tsx │ ├── SystemLayout.tsx │ └── helpers.tsx ├── main.tsx ├── msw-mock-api.ts ├── pages │ ├── DeviceAuthSuccessPage.tsx │ ├── DeviceAuthVerifyPage.tsx │ ├── InstanceLookup.tsx │ ├── LoginPage.tsx │ ├── LoginPageSaml.tsx │ ├── ProjectsPage.tsx │ ├── SiloAccessPage.tsx │ ├── SiloImageEdit.tsx │ ├── SiloImagesPage.tsx │ ├── SiloUtilizationPage.tsx │ ├── project │ │ ├── access │ │ │ └── ProjectAccessPage.tsx │ │ ├── affinity │ │ │ ├── AffinityPage.tsx │ │ │ └── AntiAffinityGroupPage.tsx │ │ ├── disks │ │ │ ├── DiskCreate.tsx │ │ │ └── DisksPage.tsx │ │ ├── floating-ips │ │ │ └── FloatingIpsPage.tsx │ │ ├── images │ │ │ ├── ImagesPage.tsx │ │ │ └── ProjectImageEdit.tsx │ │ ├── instances │ │ │ ├── AntiAffinityCard.tsx │ │ │ ├── AutoRestartCard.tsx │ │ │ ├── ConnectTab.tsx │ │ │ ├── CpuMetricsTab.tsx │ │ │ ├── DiskMetricsTab.tsx │ │ │ ├── InstancePage.tsx │ │ │ ├── InstancesPage.tsx │ │ │ ├── MetricsTab.tsx │ │ │ ├── NetworkMetricsTab.tsx │ │ │ ├── NetworkingTab.tsx │ │ │ ├── SerialConsolePage.tsx │ │ │ ├── SettingsTab.tsx │ │ │ ├── StorageTab.tsx │ │ │ ├── actions.tsx │ │ │ └── common.tsx │ │ ├── snapshots │ │ │ └── SnapshotsPage.tsx │ │ └── vpcs │ │ │ ├── RouterPage.tsx │ │ │ ├── VpcFirewallRulesTab.tsx │ │ │ ├── VpcGatewaysTab.tsx │ │ │ ├── VpcPage.tsx │ │ │ ├── VpcRoutersTab.tsx │ │ │ ├── VpcSubnetsTab.tsx │ │ │ ├── VpcsPage.tsx │ │ │ ├── gateway-data.ts │ │ │ └── internet-gateway-edit.tsx │ ├── settings │ │ ├── AccessTokensPage.tsx │ │ ├── ProfilePage.tsx │ │ ├── SSHKeysPage.tsx │ │ └── ssh-key-create.tsx │ └── system │ │ ├── UtilizationPage.tsx │ │ ├── inventory │ │ ├── DisksTab.tsx │ │ ├── InventoryPage.tsx │ │ ├── SledsTab.tsx │ │ └── sled │ │ │ ├── SledInstancesTab.tsx │ │ │ └── SledPage.tsx │ │ ├── networking │ │ ├── IpPoolPage.tsx │ │ └── IpPoolsPage.tsx │ │ └── silos │ │ ├── SiloIdpsTab.tsx │ │ ├── SiloIpPoolsTab.tsx │ │ ├── SiloPage.tsx │ │ ├── SiloQuotasTab.tsx │ │ └── SilosPage.tsx ├── routes.tsx ├── stores │ ├── confirm-action.ts │ ├── confirm-delete.tsx │ └── toast.ts ├── table │ ├── QueryTable.tsx │ ├── Table.tsx │ ├── cells │ │ ├── BooleanCell.tsx │ │ ├── DefaultPoolCell.tsx │ │ ├── DescriptionCell.tsx │ │ ├── EmptyCell.tsx │ │ ├── EnabledCell.tsx │ │ ├── InstanceLinkCell.tsx │ │ ├── InstanceResourceCell.tsx │ │ ├── InstanceStateCell.tsx │ │ ├── IpPoolCell.tsx │ │ ├── LinkCell.tsx │ │ ├── RouterLinkCell.tsx │ │ ├── TwoLineCell.tsx │ │ └── TypeValueCell.tsx │ └── columns │ │ ├── action-col.tsx │ │ ├── common.tsx │ │ └── select-col.tsx ├── ui │ ├── README.md │ ├── assets │ │ └── fonts │ │ │ ├── GT-America-Mono-Medium.woff │ │ │ ├── GT-America-Mono-Medium.woff2 │ │ │ ├── GT-America-Mono-Regular-OCC.woff │ │ │ ├── GT-America-Mono-Regular-OCC.woff2 │ │ │ ├── SuisseIntl-Light-WebS.woff │ │ │ ├── SuisseIntl-Light-WebS.woff2 │ │ │ ├── SuisseIntl-Medium-WebS.woff │ │ │ ├── SuisseIntl-Medium-WebS.woff2 │ │ │ ├── SuisseIntl-Regular-WebS.woff │ │ │ ├── SuisseIntl-Regular-WebS.woff2 │ │ │ ├── SuisseIntl-RegularItalic-WebS.woff │ │ │ └── SuisseIntl-RegularItalic-WebS.woff2 │ ├── lib │ │ ├── ActionMenu.tsx │ │ ├── AuthCodeInput.tsx │ │ ├── Badge.tsx │ │ ├── BigNum.tsx │ │ ├── BulkActionMenu.tsx │ │ ├── Button.tsx │ │ ├── Calendar.tsx │ │ ├── CalendarCell.tsx │ │ ├── CalendarGrid.tsx │ │ ├── CardBlock.tsx │ │ ├── Checkbox.tsx │ │ ├── Combobox.tsx │ │ ├── CopyToClipboard.tsx │ │ ├── CopyableIp.tsx │ │ ├── CreateButton.tsx │ │ ├── DateField.tsx │ │ ├── DatePicker.tsx │ │ ├── DateRangePicker.tsx │ │ ├── DateTime.tsx │ │ ├── Dialog.tsx │ │ ├── DialogOverlay.tsx │ │ ├── Divider.tsx │ │ ├── DropdownMenu.tsx │ │ ├── EmptyMessage.tsx │ │ ├── FieldLabel.tsx │ │ ├── FileInput.spec.tsx │ │ ├── FileInput.tsx │ │ ├── Identicon.tsx │ │ ├── InlineCode.tsx │ │ ├── Listbox.tsx │ │ ├── Message.tsx │ │ ├── MiniTable.tsx │ │ ├── Modal.tsx │ │ ├── ModalLinks.tsx │ │ ├── NumberInput.tsx │ │ ├── PageHeader.tsx │ │ ├── Pagination.tsx │ │ ├── Popover.tsx │ │ ├── Progress.tsx │ │ ├── PropertiesTable.tsx │ │ ├── Radio.tsx │ │ ├── RadioGroup.tsx │ │ ├── RangeCalendar.tsx │ │ ├── ResourceMeter.tsx │ │ ├── SideModal.tsx │ │ ├── SkipLink.tsx │ │ ├── Slash.tsx │ │ ├── Spinner.tsx │ │ ├── Table.tsx │ │ ├── Tabs.tsx │ │ ├── Tag.tsx │ │ ├── TextInput.tsx │ │ ├── TimeoutIndicator.tsx │ │ ├── TipIcon.tsx │ │ ├── Toast.tsx │ │ ├── Tooltip.tsx │ │ ├── Truncate.tsx │ │ ├── modal-context.ts │ │ ├── use-interval.ts │ │ ├── use-stepped-scroll.ts │ │ └── use-timeout.ts │ ├── styles │ │ ├── .gitignore │ │ ├── components │ │ │ ├── Tabs.css │ │ │ ├── button.css │ │ │ ├── form.css │ │ │ ├── loading-bar.css │ │ │ ├── login-page.css │ │ │ ├── menu-button.css │ │ │ ├── menu-list.css │ │ │ ├── mini-table.css │ │ │ ├── side-modal.css │ │ │ ├── spinner.css │ │ │ ├── table.css │ │ │ └── tooltip.css │ │ ├── fonts.css │ │ ├── index.css │ │ └── themes │ │ │ └── selection.css │ └── util │ │ ├── aria.ts │ │ ├── keys.ts │ │ ├── story-section.tsx │ │ └── wrap.tsx └── util │ ├── __snapshots__ │ └── path-builder.spec.ts.snap │ ├── abort.ts │ ├── access.ts │ ├── all-zeros.spec.ts │ ├── array.spec.tsx │ ├── array.ts │ ├── children.spec.tsx │ ├── children.tsx │ ├── classed.ts │ ├── consts.ts │ ├── date.spec.ts │ ├── date.ts │ ├── file.spec.ts │ ├── file.ts │ ├── invariant.ts │ ├── ip.spec.ts │ ├── ip.ts │ ├── links.ts │ ├── math.spec.ts │ ├── math.ts │ ├── motion-features.ts │ ├── path-builder.spec.ts │ ├── path-builder.ts │ ├── path-params.ts │ ├── str.spec.tsx │ ├── str.ts │ └── units.ts ├── docs ├── architecture-browser-only.svg ├── csp-headers.md ├── mock-api-differences.md ├── readme-screenshot.png ├── serve-from-nexus.md └── update-pinned-api.md ├── index.html ├── mock-api ├── affinity-group.ts ├── disk.ts ├── external-ip.ts ├── floating-ip.ts ├── image.ts ├── index.ts ├── instance.ts ├── internet-gateway.ts ├── ip-pool.ts ├── json-type.ts ├── json-type.type-spec.ts ├── metrics.ts ├── msw │ ├── db.ts │ ├── handlers.ts │ ├── util.spec.ts │ └── util.ts ├── network-interface.ts ├── oxql-metrics.ts ├── physical-disk.ts ├── project.ts ├── rack.ts ├── role-assignment.ts ├── silo.ts ├── sled.ts ├── snapshot.ts ├── sshKeys.ts ├── switch.ts ├── token.ts ├── user-group.ts ├── user.ts ├── util.ts └── vpc.ts ├── mockServiceWorker.js ├── package-lock.json ├── package.json ├── patches ├── @radix-ui+react-use-escape-keydown+1.1.0.patch ├── react-remove-scroll+2.6.3.patch └── vite-plugin-html+3.2.2.patch ├── playwright.config.ts ├── postcss.config.mjs ├── public └── assets │ └── og-preview-image.webp ├── tailwind.config.ts ├── test ├── e2e │ ├── access-tokens.e2e.ts │ ├── action-menu.e2e.ts │ ├── anti-affinity.e2e.ts │ ├── authz.e2e.ts │ ├── breadcrumbs.e2e.ts │ ├── click-everything.e2e.ts │ ├── dates.e2e.ts │ ├── disks.e2e.ts │ ├── docs-popover.e2e.ts │ ├── error-pages.e2e.ts │ ├── firewall-rules.e2e.ts │ ├── floating-ip-create.e2e.ts │ ├── floating-ip-update.e2e.ts │ ├── image-upload.e2e.ts │ ├── images.e2e.ts │ ├── instance-auto-restart.e2e.ts │ ├── instance-create.e2e.ts │ ├── instance-disks.e2e.ts │ ├── instance-metrics.e2e.ts │ ├── instance-networking.e2e.ts │ ├── instance-serial.e2e.ts │ ├── instance.e2e.ts │ ├── inventory.e2e.ts │ ├── ip-pools.e2e.ts │ ├── login-saml.e2e.ts │ ├── login.e2e.ts │ ├── lookup-routes.e2e.ts │ ├── meta.e2e.ts │ ├── nav-guard-modal.e2e.ts │ ├── network-interface-create.e2e.ts │ ├── networking.e2e.ts │ ├── pagination.e2e.ts │ ├── profile.e2e.ts │ ├── project-access.e2e.ts │ ├── project-create.e2e.ts │ ├── row-select.e2e.ts │ ├── scroll-restore.e2e.ts │ ├── silo-access.e2e.ts │ ├── silos.e2e.ts │ ├── snapshots.e2e.ts │ ├── ssh-keys.e2e.ts │ ├── utilization.e2e.ts │ ├── utils.ts │ ├── vpcs.e2e.ts │ └── z-index.e2e.ts └── unit │ ├── server.ts │ └── setup.ts ├── tools ├── debug-ci-e2e-fail.sh ├── deno │ ├── api-diff.ts │ ├── bump-omicron.ts │ ├── deno.jsonc │ └── deploy-dogfood.ts ├── dogfood │ ├── find-zone.sh │ └── scp-assets.sh ├── generate_api_client.sh ├── populate_omicron_data.sh ├── start_api.sh └── start_mock_api.ts ├── tsconfig.json ├── types ├── react-table.d.ts └── util.d.ts ├── vercel.json └── vite.config.ts /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # get rid of hooks barrel file 2 | a0c29a535d17b0f57fa5fa9bee7cca05631885fc 3 | 4 | # get rid of useForm wrapper 5 | a8fcdab9e906f8ff37bb106c20e8364f371c9997 6 | 7 | # standardize on ~/ instead of app/ 8 | 269c2c82018d09d442a932474d84366a54837068 9 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | extends: ['config:base'], 4 | enabledManagers: ['npm'], 5 | packageRules: [ 6 | // Disable all npm dependency updates by default 7 | { 8 | managers: ['npm'], 9 | depTypeList: ['dependencies', 'devDependencies', 'peerDependencies'], 10 | packagePatterns: ['*'], 11 | enabled: false, 12 | }, 13 | // Disable all engine updates by default 14 | { 15 | managers: ['npm'], 16 | depTypeList: ['engines'], 17 | packagePatterns: ['*'], 18 | enabled: false, 19 | }, 20 | // Packages to auto update and auto merge if CI passes 21 | { 22 | managers: ['npm'], 23 | packageNames: ['typescript'], 24 | enabled: true, 25 | }, 26 | // Open PRs for any oxide packages 27 | { 28 | managers: ['npm'], 29 | packagePatterns: ['@oxide/*'], 30 | enabled: true, 31 | }, 32 | ], 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/license-check.yml: -------------------------------------------------------------------------------- 1 | # To run this check locally, install SkyWalking Eyes somehow 2 | # (https://github.com/apache/skywalking-eyes). On macOS you can `brew install 3 | # license-eye` and run `license-eye header check` or `license-eye header fix`. 4 | 5 | name: license-check 6 | 7 | on: 8 | push: 9 | branches: [main] 10 | pull_request: 11 | 12 | jobs: 13 | license: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Check License Header 18 | uses: apache/skywalking-eyes/header@5dfa68f93380a5e57259faaf95088b7f133b5778 19 | -------------------------------------------------------------------------------- /.github/workflows/upload-assets.yaml: -------------------------------------------------------------------------------- 1 | name: Upload assets to dl.oxide.computer 2 | # Files are at dl.oxide.computer/console/releases/.tar.gz 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | build-and-upload: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | cache: 'npm' 17 | - name: 'Authenticate to Google Cloud' 18 | uses: 'google-github-actions/auth@v2' 19 | with: 20 | credentials_json: '${{ secrets.GOOGLE_CLOUD_DL_SA }}' 21 | - name: Set up Cloud SDK 22 | uses: google-github-actions/setup-gcloud@v2 23 | with: 24 | project_id: oxide-downloads 25 | - run: npm install 26 | - name: Build for Nexus 27 | run: SHA=${{ github.sha }} npm run build 28 | - name: Gzip individual files (keep originals) 29 | run: ls dist/assets/*.{js,css,map} | xargs gzip --keep 30 | - name: add console_version file 31 | run: echo ${{ github.sha }} > dist/VERSION 32 | - run: mkdir -p releases/console 33 | - name: Make .tar.gz 34 | run: tar czf releases/console/${{ github.sha }}.tar.gz --directory=dist . 35 | - name: Write sha256sum to file 36 | run: sha256sum releases/console/${{ github.sha }}.tar.gz | awk '{print $1}' > releases/console/${{ github.sha }}.sha256.txt 37 | - name: Upload files to GCP bucket 38 | id: upload-files 39 | uses: google-github-actions/upload-cloud-storage@v2 40 | with: 41 | # weird combo: the path tells it where to find the files, and for some 42 | # reason it takes only the immediate containing folder name and sticks 43 | # it in the destination, so we end up with dl.oxide.computer/releases/console, 44 | # as desired 45 | path: 'releases/console' 46 | destination: 'dl.oxide.computer/releases' 47 | process_gcloudignore: false # we don't have one 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | deno.lock 4 | 5 | # IDEs and editors 6 | /.idea 7 | .project 8 | .classpath 9 | .c9/ 10 | *.launch 11 | .settings/ 12 | *.sublime-workspace 13 | .helix 14 | .react-router/ 15 | 16 | # IDE - VSCode 17 | .vscode/* 18 | !.vscode/settings.json 19 | !.vscode/tasks.json 20 | !.vscode/launch.json 21 | !.vscode/extensions.json 22 | !.vscode/css.json 23 | 24 | .env 25 | .eslintcache 26 | 27 | # System Files 28 | .DS_Store 29 | Thumbs.db 30 | test-results/ 31 | ci-e2e-traces/ 32 | playwright-report/ 33 | .vercel 34 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | # use path directly instead of npx or npm run because it's way faster 2 | node_modules/.bin/oxlint --deny-warnings 3 | -------------------------------------------------------------------------------- /.licenserc.yaml: -------------------------------------------------------------------------------- 1 | header: 2 | # default is 80, need to make it slightly longer for a long shebang 3 | license-location-threshold: 120 4 | license: 5 | spdx-id: MPL-2.0 6 | content: | 7 | This Source Code Form is subject to the terms of the Mozilla Public 8 | License, v. 2.0. If a copy of the MPL was not distributed with this 9 | file, you can obtain one at https://mozilla.org/MPL/2.0/. 10 | 11 | Copyright Oxide Computer Company 12 | 13 | paths: 14 | - '**/*.{ts,tsx,css,html,js,sh}' 15 | 16 | paths-ignore: 17 | - 'dist' 18 | - '**/*.md' 19 | - 'LICENSE' 20 | - 'OMICRON_VERSION' 21 | - '.husky' 22 | - 'mockServiceWorker.js' 23 | 24 | comment: never 25 | -------------------------------------------------------------------------------- /.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/oxlint/configuration_schema.json", 3 | "plugins": [ 4 | "import", 5 | // defaults 6 | "react", 7 | "unicorn", 8 | "typescript", 9 | "oxc" 10 | ], 11 | "categories": { 12 | "correctness": "error" 13 | }, 14 | "rules": { 15 | // only worry about console.log 16 | "no-console": ["error", { "allow": ["warn", "error", "info", "table"] }], 17 | "no-unused-vars": [ 18 | "error", 19 | { 20 | "argsIgnorePattern": "^_", 21 | "varsIgnorePattern": "^_", 22 | "caughtErrorsIgnorePattern": "^_" 23 | } 24 | ], 25 | 26 | "react/button-has-type": "error", 27 | "react/jsx-boolean-value": "error", 28 | 29 | "react-hooks/exhaustive-deps": "error", 30 | "react-hooks/rules-of-hooks": "error", 31 | "import/no-default-export": "error", 32 | "consistent-type-imports": "error" 33 | }, 34 | "overrides": [ 35 | { 36 | // default exports are needed in the route modules and the config files, 37 | // but we want to avoid them anywhere else 38 | "files": [ 39 | "app/pages/**/*", 40 | "app/layouts/**/*", 41 | "app/forms/**/*", 42 | "*.config.ts", 43 | "*.config.mjs" 44 | ], 45 | "rules": { 46 | "import/no-default-export": "off" 47 | } 48 | } 49 | ], 50 | "ignorePatterns": ["dist/", "node_modules/"] 51 | } 52 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | /templates 6 | 7 | mockServiceWorker.js -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | export default { 10 | // note: it seems like tailwind has to be last for it to work 11 | plugins: ['@ianvs/prettier-plugin-sort-imports', 'prettier-plugin-tailwindcss'], 12 | printWidth: 92, 13 | singleQuote: true, 14 | semi: false, 15 | trailingComma: 'es5', // default changed to all in prettier 3, wanted to minimize diff 16 | importOrder: [ 17 | '', 18 | '', 19 | '^@oxide/(.*)$', 20 | '', 21 | '^~/(.*)$', 22 | '^app/(.*)$', 23 | '', 24 | '^[./]', 25 | ], 26 | importOrderTypeScriptVersion: '5.2.2', 27 | // ts and jsx are the default, last is needed for `await using` in bump-omicron.ts 28 | importOrderParserPlugins: ['typescript', 'jsx', 'explicitResourceManagement'], 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/css.json: -------------------------------------------------------------------------------- 1 | { 2 | "//": "https://gist.github.com/gigawatson/5b87e972da3362f8a42c587b3a5957dc", 3 | "version": 1.1, 4 | "atDirectives": [ 5 | { 6 | "name": "@tailwind", 7 | "description": "Use the @tailwind directive to insert Tailwind’s `base`, `components`, `utilities`, and `screens` styles into your CSS.", 8 | "references": [ 9 | { 10 | "name": "See Documentation", 11 | "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" 12 | } 13 | ] 14 | }, 15 | { 16 | "name": "@layer", 17 | "description": "Use the @layer directive to tell Tailwind which 'bucket' a set of custom styles belong to. Valid layers are a base, components, and utilities.", 18 | "references": [ 19 | { 20 | "name": "See Documentation", 21 | "url": "https://tailwindcss.com/docs/functions-and-directives#layer" 22 | } 23 | ] 24 | }, 25 | { 26 | "name": "@variants", 27 | "description": "You can generate responsive, hover, focus, active, and other variants of your own utilities by wrapping their definitions in the @variants directive.", 28 | "references": [ 29 | { 30 | "name": "See Documentation", 31 | "url": "https://tailwindcss.com/docs/functions-and-directives#variants" 32 | } 33 | ] 34 | }, 35 | { 36 | "name": "@apply", 37 | "description": "Use @apply to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", 38 | "references": [ 39 | { 40 | "name": "See Documentation", 41 | "url": "https://tailwindcss.com/docs/functions-and-directives#apply" 42 | } 43 | ] 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, 3 | "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, 4 | "[javascript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, 5 | "[javascriptreact]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, 6 | "[typescript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, 7 | "[typescriptreact]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "editor.formatOnSave": true, 10 | "eslint.format.enable": true, 11 | "tailwindCSS.experimental.classRegex": ["classed.[a-z]+`([^`]*)`"], 12 | "css.customData": ["./.vscode/css.json"], 13 | "explorer.fileNesting.enabled": true, 14 | "explorer.fileNesting.expand": false, 15 | "explorer.fileNesting.patterns": { 16 | ".gitignore": ".gitattributes, .gitmodules, .gitmessage, .mailmap, .git-blame*", 17 | "package.json": "package-lock.json, .babelrc, .editorconfig, .eslint*, .figma*, .github*, .huskyrc*, plopfile*, .prettier*, .vscode*, playwright.config.*, prettier*, tsconfig.*, vitest.config.*, yarn*, postcss.config.*, tailwind.config.*, vite.config.ts, mockServiceWorker.js, vercel.json, .licenserc.yaml, LICENSE" 18 | }, 19 | "vite.browserType": "system", 20 | "vite.buildCommand": "npm run build", 21 | "vite.devCommand": "npm run start:msw", 22 | "deno.enablePaths": ["./tools/deno"] 23 | } 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Web console project status and open source 2 | 3 | We've made the console repo public because we prefer to work in the open, but we 4 | are a small team at a small company narrowly focused on our customers. You're 5 | welcome to send PRs, and if we have time, or if the PRs are very small or fix 6 | bugs, we may be able to review and merge them. But be aware that we might not 7 | get to it for a while, by which time it might no longer be relevant. If you want 8 | to ask about whether a PR is consistent with our plans _before_ you put in the 9 | work — and you should! — feel free to ask on the relevant issue. 10 | -------------------------------------------------------------------------------- /OMICRON_VERSION: -------------------------------------------------------------------------------- 1 | 99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b 2 | -------------------------------------------------------------------------------- /app/api/__generated__/OMICRON_VERSION: -------------------------------------------------------------------------------- 1 | # generated file. do not update manually. see docs/update-pinned-api.md 2 | 99ffcbe2b1f4bddc4be85e45d9d1a0d920e2201b 3 | -------------------------------------------------------------------------------- /app/api/__tests__/nav-to-login.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { describe, expect, it } from 'vitest' 9 | 10 | import { loginUrl } from '../nav-to-login' 11 | 12 | describe('loginUrl', () => { 13 | describe('no options', () => { 14 | it('leaves off state param', () => { 15 | expect(loginUrl()).toEqual('/login') 16 | }) 17 | }) 18 | 19 | describe('includeCurrent = false', () => { 20 | it('leaves off state param', () => { 21 | expect(loginUrl({ includeCurrent: false })).toEqual('/login') 22 | }) 23 | }) 24 | 25 | describe('includeCurrent = true', () => { 26 | it('includes state param', () => { 27 | window.history.pushState({}, '', '/abc/def') 28 | expect(loginUrl({ includeCurrent: true })).toEqual('/login?redirect_uri=%2Fabc%2Fdef') 29 | }) 30 | 31 | it('includes query params from redirect url', () => { 32 | window.history.pushState({}, '', '/abc/def?query=hi&x=y') 33 | expect(loginUrl({ includeCurrent: true })).toEqual( 34 | '/login?redirect_uri=%2Fabc%2Fdef%3Fquery%3Dhi%26x%3Dy' 35 | ) 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /app/api/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | // for convenience so we can do `import type { ApiTypes } from '@oxide/api'` 10 | import type * as ApiTypes from './__generated__/Api' 11 | 12 | import './window.ts' 13 | 14 | export * from './client' 15 | export * from './roles' 16 | export * from './util' 17 | export * from './__generated__/Api' 18 | // export * as ZVal from './__generated__/validate' 19 | 20 | export type { ApiTypes } 21 | 22 | export { ensurePrefetched, type PaginatedQuery, type ResultsPage } from './hooks' 23 | export type { ApiError } from './errors' 24 | export { navToLogin } from './nav-to-login' 25 | -------------------------------------------------------------------------------- /app/api/nav-to-login.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | // this is only a separate module so we can easily mock it in tests. jsdom 10 | // doesn't support navigation 11 | 12 | export function loginUrl(opts: { includeCurrent: boolean } = { includeCurrent: false }) { 13 | const { pathname, search } = window.location 14 | return opts.includeCurrent 15 | ? // TODO: include query args too? 16 | `/login?redirect_uri=${encodeURIComponent(pathname + search)}` 17 | : '/login' 18 | } 19 | 20 | export function navToLogin(opts: { includeCurrent: boolean }) { 21 | window.location.assign(loginUrl(opts)) 22 | } 23 | -------------------------------------------------------------------------------- /app/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/assets/favicon.png -------------------------------------------------------------------------------- /app/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/assets/oxide-hero-rack.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/assets/oxide-hero-rack.webp -------------------------------------------------------------------------------- /app/components/AccordionItem.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import * as Accordion from '@radix-ui/react-accordion' 9 | import cn from 'classnames' 10 | import { useEffect, useRef } from 'react' 11 | 12 | import { DirectionRightIcon } from '@oxide/design-system/icons/react' 13 | 14 | type AccordionItemProps = { 15 | children: React.ReactNode 16 | isOpen: boolean 17 | label: string 18 | value: string 19 | } 20 | 21 | export const AccordionItem = ({ children, isOpen, label, value }: AccordionItemProps) => { 22 | const contentRef = useRef(null) 23 | useEffect(() => { 24 | if (isOpen && contentRef.current) { 25 | contentRef.current.scrollIntoView({ behavior: 'smooth' }) 26 | } 27 | }, [isOpen]) 28 | 29 | return ( 30 | 31 | 32 | 33 |
{label}
34 | 35 |
36 |
37 | 42 | {children} 43 | 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /app/components/AffinityDocsPopover.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { Affinity16Icon } from '@oxide/design-system/icons/react' 9 | 10 | import { policyHelpText } from '~/forms/affinity-util' 11 | import { TipIcon } from '~/ui/lib/TipIcon' 12 | import { docLinks } from '~/util/links' 13 | 14 | import { DocsPopover } from './DocsPopover' 15 | 16 | export const AffinityDocsPopover = () => ( 17 | } 20 | summary="Instances in an anti-affinity group will be placed on different sleds when they start. The policy attribute determines whether instances can still start when a unique sled is not available." 21 | links={[docLinks.affinity]} 22 | /> 23 | ) 24 | 25 | export const AffinityPolicyHeader = () => ( 26 | <> 27 | Policy{policyHelpText} 28 | 29 | ) 30 | -------------------------------------------------------------------------------- /app/components/CapacityBars.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { VirtualResourceCounts } from '@oxide/api' 10 | import { Cpu16Icon, Ram16Icon, Ssd16Icon } from '@oxide/design-system/icons/react' 11 | 12 | import { bytesToGiB, bytesToTiB } from '~/util/units' 13 | 14 | import { CapacityBar } from './CapacityBar' 15 | 16 | export const CapacityBars = ({ 17 | allocated, 18 | provisioned, 19 | allocatedLabel, 20 | }: { 21 | allocated: VirtualResourceCounts 22 | provisioned: VirtualResourceCounts 23 | allocatedLabel: string 24 | }) => { 25 | return ( 26 |
27 | } 29 | title="CPU" 30 | unit="vCPUs" 31 | provisioned={provisioned.cpus} 32 | capacity={allocated.cpus} 33 | includeUnit={false} 34 | capacityLabel={allocatedLabel} 35 | /> 36 | } 38 | title="MEMORY" 39 | unit="GiB" 40 | provisioned={bytesToGiB(provisioned.memory)} 41 | capacity={bytesToGiB(allocated.memory)} 42 | capacityLabel={allocatedLabel} 43 | /> 44 | } 46 | title="STORAGE" 47 | unit="TiB" 48 | provisioned={bytesToTiB(provisioned.storage)} 49 | capacity={bytesToTiB(allocated.storage)} 50 | capacityLabel={allocatedLabel} 51 | /> 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /app/components/CopyIdItem.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import * as DropdownMenu from '~/ui/lib/DropdownMenu' 10 | 11 | export function CopyIdItem({ id, label = 'Copy ID' }: { id: string; label?: string }) { 12 | return ( 13 | window.navigator.clipboard.writeText(id)} 15 | label={label} 16 | /> 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/components/HL.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { classed } from '~/util/classed' 9 | 10 | // note parent with secondary text color must have 'group' on it for 11 | // this to work. see Toast for an example 12 | export const HL = classed.span` 13 | text-semi-md text-raise 14 | group-[.text-accent-secondary]:text-accent 15 | group-[.text-error-secondary]:text-error 16 | group-[.text-info-secondary]:text-info 17 | ` 18 | -------------------------------------------------------------------------------- /app/components/InstanceDocsPopover.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Instances16Icon } from '@oxide/design-system/icons/react' 10 | 11 | import { docLinks } from '~/util/links' 12 | 13 | import { DocsPopover } from './DocsPopover' 14 | 15 | export const InstanceDocsPopover = () => ( 16 | } 19 | summary="Instances are virtual machines that run on the Oxide platform." 20 | links={[docLinks.instances, docLinks.remoteAccess, docLinks.instanceActions]} 21 | /> 22 | ) 23 | -------------------------------------------------------------------------------- /app/components/IpPoolUtilization.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { parseIpUtilization, type IpPoolUtilization } from '~/api' 10 | import { Badge } from '~/ui/lib/Badge' 11 | import { BigNum } from '~/ui/lib/BigNum' 12 | 13 | const IpUtilFrac = (props: { allocated: number | bigint; capacity: number | bigint }) => ( 14 | <> 15 | /{' '} 16 | 17 | 18 | ) 19 | 20 | export function IpUtilCell(util: IpPoolUtilization) { 21 | const { ipv4, ipv6 } = parseIpUtilization(util) 22 | 23 | if (ipv6.capacity === 0n) { 24 | return ( 25 |
26 | 27 |
28 | ) 29 | } 30 | 31 | // the API doesn't let you add IPv6 ranges, but there's a remote possibility 32 | // a pool already exists with IPv6 ranges, so we might as well show that. also 33 | // this is nice for e2e testing the utilization logic 34 | return ( 35 |
36 |
37 | 38 | v4 39 | 40 | 41 |
42 |
43 | 44 | v6 45 | 46 | 47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/components/ListPlusCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import React from 'react' 10 | 11 | import { EmptyCell } from '~/table/cells/EmptyCell' 12 | import { Tooltip } from '~/ui/lib/Tooltip' 13 | 14 | type ListPlusCellProps = { 15 | tooltipTitle: string 16 | children: React.ReactNode 17 | /** The number of items to show in the cell vs. in the popup */ 18 | numInCell?: number 19 | } 20 | 21 | /** 22 | * Gives a count with a tooltip that expands to show details when the user hovers over it. 23 | * The ReactNode children are split into two groups: the first `numInCell` are shown in the cell, 24 | * and the rest are shown in the tooltip. If the number of children is less than or equal to 25 | * `numInCell`, no tooltip (or `+N` target) is shown. 26 | */ 27 | export const ListPlusCell = ({ 28 | tooltipTitle, 29 | children, 30 | numInCell = 1, 31 | }: ListPlusCellProps) => { 32 | const array = React.Children.toArray(children) 33 | if (array.length === 0) { 34 | return 35 | } 36 | const inCell = array.slice(0, numInCell) 37 | const rest = array.slice(numInCell) 38 | const content = ( 39 |
40 |
{tooltipTitle}
41 |
{...rest}
42 |
43 | ) 44 | return ( 45 |
46 | {inCell} 47 | {rest.length > 0 && ( 48 | 49 |
+{rest.length}
50 |
51 | )} 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /app/components/MoreActionsMenu.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | import { type ReactNode } from 'react' 10 | 11 | import { More12Icon } from '@oxide/design-system/icons/react' 12 | 13 | import * as DropdownMenu from '~/ui/lib/DropdownMenu' 14 | 15 | interface MoreActionsMenuProps { 16 | /** The accessible name for the menu button */ 17 | label: string 18 | isSmall?: boolean 19 | /** Dropdown items only */ 20 | children?: ReactNode 21 | } 22 | 23 | export const MoreActionsMenu = ({ 24 | label, 25 | isSmall = false, 26 | children, 27 | }: MoreActionsMenuProps) => { 28 | return ( 29 | 30 | 31 |
37 | 38 |
39 |
40 | {children} 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/components/PageActions.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import tunnel from 'tunnel-rat' 9 | 10 | const Tunnel = tunnel() 11 | 12 | export const PageActions = Tunnel.In 13 | export const PageActionsTarget = Tunnel.Out 14 | -------------------------------------------------------------------------------- /app/components/Pagination.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import tunnel from 'tunnel-rat' 9 | 10 | import { 11 | Pagination as UIPagination, 12 | type PaginationProps as UIPaginationProps, 13 | } from '~/ui/lib/Pagination' 14 | 15 | const Tunnel = tunnel() 16 | 17 | export function Pagination(props: UIPaginationProps) { 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | 25 | Pagination.Target = Tunnel.Out 26 | -------------------------------------------------------------------------------- /app/components/QueryParamTabs.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useSearchParams } from 'react-router' 9 | 10 | import { Tabs, type TabsRootProps } from '~/ui/lib/Tabs' 11 | 12 | /** 13 | * Use instead of `Tabs.Root` to sync current tab with arg in URL query string. 14 | * 15 | * If you don't want the query arg functionality, e.g., if you have multiple 16 | * sets of tabs on the same page, use `Tabs.Root` directly. 17 | */ 18 | export function QueryParamTabs(props: TabsRootProps) { 19 | const [searchParams, setSearchParams] = useSearchParams() 20 | const value = searchParams.get('tab') || props.defaultValue 21 | 22 | function onValueChange(newValue: string) { 23 | if (newValue === props.defaultValue) { 24 | searchParams.delete('tab') 25 | } else { 26 | searchParams.set('tab', newValue) 27 | } 28 | setSearchParams(searchParams, { replace: true }) 29 | } 30 | 31 | return 32 | } 33 | -------------------------------------------------------------------------------- /app/components/RefreshButton.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useState } from 'react' 10 | 11 | import { Refresh16Icon } from '@oxide/design-system/icons/react' 12 | 13 | import { Button } from '~/ui/lib/Button' 14 | import { SpinnerLoader } from '~/ui/lib/Spinner' 15 | 16 | export function RefreshButton({ onClick }: { onClick: () => Promise }) { 17 | const [refreshing, setRefreshing] = useState(false) 18 | 19 | async function refresh() { 20 | setRefreshing(true) 21 | await onClick() 22 | setRefreshing(false) 23 | } 24 | 25 | return ( 26 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/components/TimeAgo.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { Placement } from '@floating-ui/react' 9 | import type { JSX } from 'react' 10 | 11 | import { Tooltip } from '~/ui/lib/Tooltip' 12 | import { timeAgoAbbr, toLocaleDateTimeString } from '~/util/date' 13 | 14 | export const TimeAgo = ({ 15 | datetime, 16 | tooltipText, 17 | placement = 'top', 18 | }: { 19 | datetime: Date 20 | tooltipText?: string 21 | placement?: Placement 22 | }): JSX.Element => { 23 | const content = ( 24 |
25 | {tooltipText} 26 | {toLocaleDateTimeString(datetime)} 27 |
28 | ) 29 | return ( 30 | 31 | {timeAgoAbbr(datetime)} 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/components/ToastStack.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { AnimatePresence } from 'motion/react' 9 | import * as m from 'motion/react-m' 10 | 11 | import { removeToast, useToastStore } from '~/stores/toast' 12 | import { Toast } from '~/ui/lib/Toast' 13 | 14 | export function ToastStack() { 15 | const toasts = useToastStore((state) => state.toasts) 16 | 17 | return ( 18 |
22 | 23 | {toasts.map((toast) => ( 24 | 31 | { 34 | removeToast(toast.id) 35 | toast.options.onClose?.() 36 | }} 37 | /> 38 | 39 | ))} 40 | 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/components/form/fields/DescriptionField.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { FieldPath, FieldValues } from 'react-hook-form' 9 | 10 | import { TextField, type TextFieldProps } from './TextField' 11 | 12 | // TODO: Pull this from generated types 13 | const MAX_LEN = 512 14 | 15 | export function DescriptionField< 16 | TFieldValues extends FieldValues, 17 | TName extends FieldPath, 18 | >(props: Omit, 'validate'>) { 19 | return 20 | } 21 | 22 | // TODO Update JSON schema to match this, add fuzz testing between this and name pattern 23 | export function validateDescription(name: string) { 24 | if (name.length > MAX_LEN) { 25 | return `A description must be no longer than ${MAX_LEN} characters` 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/components/form/fields/DiskSizeField.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { 9 | FieldPath, 10 | FieldPathByValue, 11 | FieldValues, 12 | ValidateResult, 13 | } from 'react-hook-form' 14 | 15 | import { MAX_DISK_SIZE_GiB } from '@oxide/api' 16 | 17 | import { NumberField } from './NumberField' 18 | import type { TextFieldProps } from './TextField' 19 | 20 | interface DiskSizeProps< 21 | TFieldValues extends FieldValues, 22 | TName extends FieldPath, 23 | > extends TextFieldProps { 24 | minSize?: number 25 | validate?(diskSizeGiB: number): ValidateResult 26 | } 27 | 28 | export function DiskSizeField< 29 | TFieldValues extends FieldValues, 30 | TName extends FieldPathByValue, 31 | >({ 32 | required = true, 33 | name, 34 | minSize = 1, 35 | validate, 36 | ...props 37 | }: DiskSizeProps) { 38 | return ( 39 | { 46 | // Run a number of default validators 47 | if (Number.isNaN(diskSizeGiB)) { 48 | return 'Disk size is required' 49 | } 50 | if (diskSizeGiB < minSize) { 51 | return `Must be at least ${minSize} GiB` 52 | } 53 | if (diskSizeGiB > MAX_DISK_SIZE_GiB) { 54 | return `Can be at most ${MAX_DISK_SIZE_GiB} GiB` 55 | } 56 | // Run any additional validators passed in from the callsite 57 | return validate?.(diskSizeGiB) 58 | }} 59 | {...props} 60 | /> 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /app/components/form/fields/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { FieldError } from 'react-hook-form' 9 | 10 | import { TextInputError } from '~/ui/lib/TextInput' 11 | 12 | type ErrorMessageProps = { 13 | error: FieldError | undefined 14 | label: string 15 | } 16 | 17 | export function ErrorMessage({ error, label }: ErrorMessageProps) { 18 | if (!error) return null 19 | 20 | const message = error.type === 'required' ? `${label} is required` : error.message 21 | if (!message) return null 22 | 23 | return {message} 24 | } 25 | -------------------------------------------------------------------------------- /app/components/form/fields/FileField.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { 9 | useController, 10 | type Control, 11 | type FieldPath, 12 | type FieldValues, 13 | } from 'react-hook-form' 14 | 15 | import { FieldLabel } from '~/ui/lib/FieldLabel' 16 | import { FileInput } from '~/ui/lib/FileInput' 17 | import { TextInputHint } from '~/ui/lib/TextInput' 18 | 19 | import { ErrorMessage } from './ErrorMessage' 20 | 21 | export function FileField< 22 | TFieldValues extends FieldValues, 23 | TName extends FieldPath, 24 | >({ 25 | id, 26 | name, 27 | label, 28 | control, 29 | required = false, 30 | accept, 31 | description, 32 | disabled, 33 | }: { 34 | id: string 35 | name: TName 36 | label: string 37 | tooltipText?: string 38 | control: Control 39 | required?: boolean 40 | accept?: string 41 | description?: string | React.ReactNode 42 | disabled?: boolean 43 | }) { 44 | const { 45 | field: { value: _, ...rest }, 46 | fieldState: { error }, 47 | } = useController({ name, control, rules: { required } }) 48 | return ( 49 |
50 |
51 | 52 | {label} 53 | 54 | {description && {description}} 55 |
56 | 64 | 65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /app/components/form/fields/NameField.spec.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { describe, expect, it } from 'vitest' 9 | 10 | import { validateName } from './NameField' 11 | 12 | describe('validateName', () => { 13 | const validate = (name: string) => validateName(name, 'Name', true) 14 | 15 | it('returns undefined for valid names', () => { 16 | expect(validate('abc')).toBeUndefined() 17 | expect(validate('abc-def')).toBeUndefined() 18 | expect(validate('abc9-d0ef-6')).toBeUndefined() 19 | }) 20 | 21 | it('detects names starting with something other than lower-case letter', () => { 22 | expect(validate('9bc')).toEqual('Must start with a lower-case letter') 23 | }) 24 | 25 | // this fails if we check last letter before we check all chars 26 | it('gives correct error on ending with capital letter', () => { 27 | expect(validate('freeBSD')).toEqual( 28 | 'Can only contain lower-case letters, numbers, and dashes' 29 | ) 30 | }) 31 | 32 | it('requires names to end with letter or number', () => { 33 | expect(validate('abc-')).toEqual('Must end with a letter or number') 34 | expect(validate('abc---')).toEqual('Must end with a letter or number') 35 | }) 36 | 37 | it('rejects invalid characters', () => { 38 | const err = 'Can only contain lower-case letters, numbers, and dashes' 39 | expect(validate('aBc')).toEqual(err) 40 | expect(validate('asldk:c')).toEqual(err) 41 | expect(validate('Abc-')).toEqual(err) 42 | expect(validate('Abc')).toEqual(err) 43 | }) 44 | 45 | it('rejects names that are too long', () => { 46 | expect(validate('a'.repeat(64))).toEqual('Must be 63 characters or fewer') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /app/components/form/fields/ip-pool-item.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { SiloIpPool } from '~/api' 9 | import { Badge } from '~/ui/lib/Badge' 10 | 11 | export function toIpPoolItem(p: SiloIpPool) { 12 | const value = p.name 13 | const selectedLabel = p.name 14 | const label = ( 15 |
16 |
17 | {p.name} 18 | {p.isDefault && ( 19 | 20 | default 21 | 22 | )} 23 |
24 | {!!p.description && ( 25 |
{p.description}
26 | )} 27 |
28 | ) 29 | return { value, selectedLabel, label } 30 | } 31 | -------------------------------------------------------------------------------- /app/components/form/fields/useItemsList.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useMemo } from 'react' 10 | 11 | import { useApiQuery } from '~/api' 12 | import { useVpcSelector } from '~/hooks/use-params' 13 | 14 | /** 15 | * Special value indicating no router. Must use helper functions to convert 16 | * `undefined` to this when populating form, and this back to `undefined` in 17 | * onSubmit. 18 | */ 19 | const NO_ROUTER = '||no router||' 20 | 21 | /** Convert form value to value for PUT body */ 22 | export function customRouterFormToData(value: string): string | undefined { 23 | return value === NO_ROUTER ? undefined : value 24 | } 25 | 26 | /** Convert value from response body to form value */ 27 | export function customRouterDataToForm(value: string | undefined | null): string { 28 | return value || NO_ROUTER 29 | } 30 | 31 | export const useCustomRouterItems = () => { 32 | const vpcSelector = useVpcSelector() 33 | const { data, isLoading } = useApiQuery('vpcRouterList', { query: vpcSelector }) 34 | 35 | const routerItems = useMemo(() => { 36 | const items = (data?.items || []) 37 | .filter((item) => item.kind === 'custom') 38 | .map((router) => ({ value: router.id, label: router.name })) 39 | 40 | return [{ value: NO_ROUTER, label: 'None' }, ...items] 41 | }, [data]) 42 | 43 | return { isLoading, items: routerItems } 44 | } 45 | -------------------------------------------------------------------------------- /app/forms/access-util.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { 9 | allRoles, 10 | type Actor, 11 | type IdentityType, 12 | type Policy, 13 | type RoleKey, 14 | } from '@oxide/api' 15 | 16 | import { Badge } from '~/ui/lib/Badge' 17 | import { type ListboxItem } from '~/ui/lib/Listbox' 18 | import { capitalize } from '~/util/str' 19 | 20 | type AddUserValues = { 21 | identityId: string 22 | roleName: RoleKey | '' 23 | } 24 | 25 | export const defaultValues: AddUserValues = { 26 | identityId: '', 27 | roleName: '', 28 | } 29 | 30 | export const roleItems = allRoles.map((role) => ({ value: role, label: capitalize(role) })) 31 | 32 | export const actorToItem = (actor: Actor): ListboxItem => ({ 33 | value: actor.id, 34 | label: ( 35 | <> 36 | {actor.displayName} 37 | {actor.identityType === 'silo_group' && ( 38 | 39 | Group 40 | 41 | )} 42 | 43 | ), 44 | selectedLabel: actor.displayName, 45 | }) 46 | 47 | export type AddRoleModalProps = { 48 | onDismiss: () => void 49 | policy: Policy 50 | } 51 | 52 | export type EditRoleModalProps = AddRoleModalProps & { 53 | name?: string 54 | identityId: string 55 | identityType: IdentityType 56 | defaultValues: { roleName: RoleKey } 57 | } 58 | -------------------------------------------------------------------------------- /app/forms/affinity-util.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { apiq } from '~/api' 10 | import { ALL_ISH } from '~/util/consts' 11 | import type * as PP from '~/util/path-params' 12 | 13 | export const instanceList = ({ project }: PP.Project) => 14 | apiq('instanceList', { query: { project, limit: ALL_ISH } }) 15 | 16 | export const antiAffinityGroupList = ({ project }: PP.Project) => 17 | apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) 18 | 19 | export const antiAffinityGroupView = ({ 20 | project, 21 | antiAffinityGroup, 22 | }: PP.AntiAffinityGroup) => 23 | apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } }) 24 | 25 | export const antiAffinityGroupMemberList = ({ 26 | antiAffinityGroup, 27 | project, 28 | }: PP.AntiAffinityGroup) => 29 | apiq('antiAffinityGroupMemberList', { 30 | path: { antiAffinityGroup }, 31 | // member limit in DB is currently 32, so pagination isn't needed 32 | query: { project, limit: ALL_ISH }, 33 | }) 34 | 35 | export const policyHelpText = 36 | "Determines whether member instances are allowed to start when the anti-affinity rule can't be satisfied" 37 | -------------------------------------------------------------------------------- /app/forms/firewall-rules-util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { VpcFirewallRule, VpcFirewallRuleTarget, VpcFirewallRuleUpdate } from '~/api' 9 | 10 | // this file is separate from firewall-rules-common because of rules around fast refresh: 11 | // you can only export components from a file that exports components 12 | 13 | export type FirewallRuleValues = { 14 | enabled: boolean 15 | priority: number 16 | name: string 17 | description: string 18 | action: VpcFirewallRule['action'] 19 | direction: VpcFirewallRule['direction'] 20 | 21 | protocols: NonNullable 22 | 23 | ports: NonNullable 24 | hosts: NonNullable 25 | targets: VpcFirewallRuleTarget[] 26 | } 27 | 28 | export const valuesToRuleUpdate = (values: FirewallRuleValues): VpcFirewallRuleUpdate => ({ 29 | name: values.name, 30 | status: values.enabled ? 'enabled' : 'disabled', 31 | action: values.action, 32 | description: values.description, 33 | direction: values.direction, 34 | filters: { 35 | hosts: values.hosts, 36 | ports: values.ports, 37 | protocols: values.protocols, 38 | }, 39 | priority: values.priority, 40 | targets: values.targets, 41 | }) 42 | -------------------------------------------------------------------------------- /app/forms/idp/util.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { describe, expect, it } from 'vitest' 9 | 10 | import { getDelegatedDomain } from './util' 11 | 12 | describe('getDomainSuffix', () => { 13 | it('handles arbitrary URLs by falling back to placeholder', () => { 14 | expect(getDelegatedDomain({ hostname: 'localhost' })).toBe('placeholder') 15 | expect(getDelegatedDomain({ hostname: 'console-preview.oxide.computer' })).toBe( 16 | 'placeholder' 17 | ) 18 | }) 19 | 20 | it('handles 1 subdomain after sys', () => { 21 | const location = { hostname: 'oxide.sys.r3.oxide-preview.com' } 22 | expect(getDelegatedDomain(location)).toBe('r3.oxide-preview.com') 23 | }) 24 | 25 | it('handles 2 subdomains after sys', () => { 26 | const location = { hostname: 'oxide.sys.rack2.eng.oxide.computer' } 27 | expect(getDelegatedDomain(location)).toBe('rack2.eng.oxide.computer') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /app/forms/idp/util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | // note: this lives in its own file for fast refresh reasons 10 | 11 | /** 12 | * When given a full URL hostname for an Oxide silo, return the domain 13 | * (everything after `.sys.`). Placeholder logic should only apply 14 | * in local dev or Vercel previews. 15 | */ 16 | export const getDelegatedDomain = (location: { hostname: string }) => 17 | location.hostname.split('.sys.')[1] || 'placeholder' 18 | -------------------------------------------------------------------------------- /app/hooks/use-current-user.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useApiQueryErrorsAllowed, usePrefetchedApiQuery } from '~/api/client' 10 | import { invariant } from '~/util/invariant' 11 | 12 | /** 13 | * Access all the data fetched by the loader. Because of the `shouldRevalidate` 14 | * trick, that loader runs on every authenticated page, which means callers do 15 | * not have to worry about hitting these endpoints themselves in their own 16 | * loaders. 17 | */ 18 | export function useCurrentUser() { 19 | const { data: me } = usePrefetchedApiQuery('currentUserView', {}) 20 | const { data: myGroups } = usePrefetchedApiQuery('currentUserGroups', {}) 21 | 22 | // User can only get to system routes if they have viewer perms (at least) on 23 | // the fleet. The natural place to find out whether they have such perms is 24 | // the fleet (system) policy, but if the user doesn't have fleet read, we'll 25 | // get a 403 from that endpoint. So we simply check whether that endpoint 200s 26 | // or not to determine whether the user is a fleet viewer. 27 | const { data: systemPolicy } = useApiQueryErrorsAllowed('systemPolicyView', {}) 28 | // don't use usePrefetchedApiQuery because it's not worth making an errors 29 | // allowed version of that 30 | invariant(systemPolicy, 'System policy must be prefetched') 31 | const isFleetViewer = systemPolicy.type === 'success' 32 | 33 | return { me, myGroups, isFleetViewer } 34 | } 35 | -------------------------------------------------------------------------------- /app/hooks/use-is-active-path.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useLocation, useResolvedPath } from 'react-router' 9 | 10 | interface ActivePathOptions { 11 | to: string 12 | end?: boolean 13 | } 14 | /** 15 | * Returns true if the provided path is currently active. 16 | * 17 | * This implementation is based on logic from React Router's NavLink component. 18 | * 19 | * @see https://github.com/remix-run/react-router/blob/67f16e73603765158c63a27afb70d3a4b3e823d3/packages/react-router/index.tsx#L448-L467 20 | * 21 | * @param to The path to check 22 | * @param options.end Ensure this path isn't matched as "active" when its descendant paths are matched. 23 | */ 24 | export const useIsActivePath = ({ to, end }: ActivePathOptions) => { 25 | const path = useResolvedPath(to) 26 | const location = useLocation() 27 | 28 | const toPathname = path.pathname 29 | const locationPathname = location.pathname 30 | 31 | return ( 32 | locationPathname === toPathname || 33 | (!end && 34 | locationPathname.startsWith(toPathname) && 35 | locationPathname.charAt(toPathname.length) === '/') 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /app/hooks/use-key.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import Mousetrap from 'mousetrap' 9 | 10 | import 'mousetrap/plugins/global-bind/mousetrap-global-bind' 11 | 12 | import { useEffect, useRef } from 'react' 13 | 14 | type Key = Parameters[0] 15 | type Callback = Parameters[1] 16 | 17 | /** 18 | * Bind a keyboard shortcut with [Mousetrap](https://craig.is/killing/mice). 19 | * Neither `fn` nor `key` needs to be memoized. If `global`, use 20 | * `mousetrap-global-bind` to capture key presses from anywhere, including 21 | * inside textarea/input fields. 22 | 23 | * The `fnRef` trick is to avoid having to memoize the callback in the caller. 24 | * The keybind effect only runs when the key itself changes, i.e., never. See 25 | * Dan Abramov's post: 26 | * https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 27 | */ 28 | export const useKey = (key: Key, fn: Callback, { global = false } = {}) => { 29 | const fnRef = useRef(fn) 30 | 31 | useEffect(() => { 32 | fnRef.current = fn 33 | }, [fn]) 34 | 35 | useEffect(() => { 36 | const bind = global ? Mousetrap.bindGlobal : Mousetrap.bind 37 | bind(key, (e, combo) => fnRef.current(e, combo)) 38 | return () => { 39 | Mousetrap.unbind(key) 40 | } 41 | // JSON.stringify lets us avoid having to memoize the keys at the call site. 42 | /* eslint-disable-next-line react-hooks/exhaustive-deps */ 43 | }, [JSON.stringify(key), global]) 44 | } 45 | -------------------------------------------------------------------------------- /app/hooks/use-pagination.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useCallback, useState } from 'react' 9 | 10 | type PageToken = string | undefined 11 | 12 | export function usePagination() { 13 | const [prevPages, setPrevPages] = useState([]) 14 | const [currentPage, setCurrentPage] = useState() 15 | 16 | const goToPrevPage = useCallback(() => { 17 | const prevPage = prevPages.pop() 18 | setCurrentPage(prevPage) 19 | setPrevPages(prevPages) 20 | }, [prevPages]) 21 | 22 | const goToNextPage = useCallback( 23 | (nextPageToken: string) => { 24 | setPrevPages((prevPages) => [...prevPages, currentPage]) 25 | setCurrentPage(nextPageToken) 26 | }, 27 | [currentPage] 28 | ) 29 | 30 | return { 31 | currentPage, 32 | goToNextPage, 33 | goToPrevPage, 34 | hasPrev: prevPages.length > 0, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/hooks/use-scroll-restoration.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useEffect } from 'react' 9 | import { useLocation, useNavigation } from 'react-router' 10 | 11 | function getScrollPosition(key: string) { 12 | const pos = window.sessionStorage.getItem(key) 13 | return Number(pos) || 0 14 | } 15 | 16 | function setScrollPosition(key: string, pos: number) { 17 | window.sessionStorage.setItem(key, pos.toString()) 18 | } 19 | 20 | /** 21 | * Given a ref to a scrolling container element, keep track of its scroll 22 | * position before navigation and restore it on return (e.g., back/forward nav). 23 | * Note that `location.key` is used in the cache key, not `location.pathname`, 24 | * so the same path navigated to at different points in the history stack will 25 | * not share the same scroll position. 26 | */ 27 | export function useScrollRestoration(container: React.RefObject) { 28 | const key = `scroll-position-${useLocation().key}` 29 | const { state } = useNavigation() 30 | useEffect(() => { 31 | if (state === 'loading') { 32 | setScrollPosition(key, container.current?.scrollTop ?? 0) 33 | } else if (state === 'idle') { 34 | container.current?.scrollTo(0, getScrollPosition(key)) 35 | } 36 | }, [key, state, container]) 37 | } 38 | -------------------------------------------------------------------------------- /app/layouts/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { Outlet } from 'react-router' 9 | 10 | import { OxideLogo } from '~/components/OxideLogo' 11 | 12 | export default function AuthLayout() { 13 | return ( 14 |
21 | 22 |
23 | 24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /app/layouts/AuthenticatedLayout.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { Outlet } from 'react-router' 9 | 10 | import { apiQueryClient } from '@oxide/api' 11 | 12 | import { RouterDataErrorBoundary } from '~/components/ErrorBoundary' 13 | import { QuickActions } from '~/hooks/use-quick-actions' 14 | 15 | /** very important. see `currentUserLoader` and `useCurrentUser` */ 16 | export const shouldRevalidate = () => true 17 | 18 | export function ErrorBoundary() { 19 | return 20 | } 21 | 22 | /** 23 | * We use `shouldRevalidate={() => true}` to force this to re-run on every nav, 24 | * but the longer-than-default `staleTime` avoids fetching too much. 25 | */ 26 | export async function clientLoader() { 27 | const staleTime = 60000 28 | await Promise.all([ 29 | apiQueryClient.prefetchQuery('currentUserView', {}, { staleTime }), 30 | apiQueryClient.prefetchQuery('currentUserGroups', {}, { staleTime }), 31 | // Need to prefetch this because every layout hits it when deciding whether 32 | // to show the silo/system picker. It's also fetched by the SystemLayout 33 | // loader to figure out whether to 404, but RQ dedupes the request. 34 | apiQueryClient.prefetchQueryErrorsAllowed( 35 | 'systemPolicyView', 36 | {}, 37 | { 38 | explanation: '/v1/system/policy 403 is expected if user is not a fleet viewer.', 39 | expectedStatusCode: 403, 40 | staleTime, 41 | } 42 | ), 43 | ]) 44 | return null 45 | } 46 | 47 | /** Wraps all authenticated routes. */ 48 | export default function AuthenticatedLayout() { 49 | return ( 50 | <> 51 | 52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/layouts/LoginLayout.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { Outlet } from 'react-router' 9 | 10 | import heroRackImg from '~/assets/oxide-hero-rack.webp' 11 | import { OxideLogo } from '~/components/OxideLogo' 12 | 13 | export default function LoginLayout() { 14 | return ( 15 |
16 |
17 |
18 | A populated Oxide rack 19 |
20 |
21 |
22 |
23 |
24 | 25 |
26 |
27 |
28 | 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/layouts/ProjectLayout.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { 10 | ProjectLayoutBase, 11 | projectLayoutHandle, 12 | projectLayoutLoader, 13 | } from './ProjectLayoutBase.tsx' 14 | 15 | export const clientLoader = projectLayoutLoader 16 | 17 | export const handle = projectLayoutHandle 18 | 19 | export default function ProjectLayout() { 20 | return 21 | } 22 | -------------------------------------------------------------------------------- /app/layouts/SerialConsoleLayout.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { SerialConsoleContentPane } from './helpers.tsx' 9 | import { 10 | ProjectLayoutBase, 11 | projectLayoutHandle, 12 | projectLayoutLoader, 13 | } from './ProjectLayoutBase.tsx' 14 | 15 | export const clientLoader = projectLayoutLoader 16 | 17 | export const handle = projectLayoutHandle 18 | 19 | export default function SerialConsoleLayout() { 20 | return } /> 21 | } 22 | -------------------------------------------------------------------------------- /app/pages/DeviceAuthSuccessPage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { Success12Icon } from '@oxide/design-system/icons/react' 9 | 10 | /** 11 | * Device authorization success page 12 | */ 13 | export default function DeviceAuthSuccessPage() { 14 | return ( 15 |
16 |
17 |
18 | 19 |
20 |

Device logged in

21 |

You can close this window

22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/pages/InstanceLookup.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { redirect, type LoaderFunctionArgs } from 'react-router' 9 | 10 | import { apiq, queryClient } from '@oxide/api' 11 | 12 | import { trigger404 } from '~/components/ErrorBoundary' 13 | import { pb } from '~/util/path-builder' 14 | 15 | export async function clientLoader({ params }: LoaderFunctionArgs) { 16 | try { 17 | const instance = await queryClient.fetchQuery( 18 | apiq('instanceView', { path: { instance: params.instance! } }) 19 | ) 20 | const project = await queryClient.fetchQuery( 21 | apiq('projectView', { path: { project: instance.projectId } }) 22 | ) 23 | return redirect(pb.instance({ project: project.name, instance: instance.name })) 24 | } catch (_e) { 25 | throw trigger404 26 | } 27 | } 28 | 29 | /** This should never render because the loader always redirects or 404s */ 30 | export default function InstanceLookup() { 31 | return null 32 | } 33 | -------------------------------------------------------------------------------- /app/pages/LoginPageSaml.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | import { useSearchParams } from 'react-router' 10 | 11 | import { useIdpSelector } from '~/hooks/use-params' 12 | import { buttonStyle } from '~/ui/lib/Button' 13 | import { Identicon } from '~/ui/lib/Identicon' 14 | 15 | /** SAML "login page" that just links to the actual IdP */ 16 | export default function LoginPageSaml() { 17 | const [searchParams] = useSearchParams() 18 | const { silo, provider } = useIdpSelector() 19 | 20 | const redirect_uri = searchParams.get('redirect_uri')?.trim() 21 | const query = redirect_uri ? `?redirect_uri=${redirect_uri}` : '' 22 | 23 | return ( 24 | <> 25 |
26 | 30 |
{silo}
31 |
32 | 33 |
34 | 35 | 39 | Sign in with {provider} 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/pages/SiloImageEdit.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { type LoaderFunctionArgs } from 'react-router' 9 | 10 | import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' 11 | 12 | import { EditImageSideModalForm } from '~/forms/image-edit' 13 | import { titleCrumb } from '~/hooks/use-crumbs' 14 | import { getSiloImageSelector, useSiloImageSelector } from '~/hooks/use-params' 15 | import { pb } from '~/util/path-builder' 16 | 17 | export async function clientLoader({ params }: LoaderFunctionArgs) { 18 | const { image } = getSiloImageSelector(params) 19 | await apiQueryClient.prefetchQuery('imageView', { path: { image } }) 20 | return null 21 | } 22 | 23 | export const handle = titleCrumb('Edit Image') 24 | 25 | export default function SiloImageEdit() { 26 | const { image } = useSiloImageSelector() 27 | const { data } = usePrefetchedApiQuery('imageView', { path: { image } }) 28 | 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /app/pages/project/disks/DiskCreate.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useNavigate } from 'react-router' 9 | 10 | import { CreateDiskSideModalForm } from '~/forms/disk-create' 11 | import { titleCrumb } from '~/hooks/use-crumbs' 12 | import { useProjectSelector } from '~/hooks/use-params' 13 | import { pb } from '~/util/path-builder' 14 | 15 | export const handle = titleCrumb('New disk') 16 | 17 | export default function DiskCreate() { 18 | const navigate = useNavigate() 19 | const { project } = useProjectSelector() 20 | const onDismiss = () => navigate(pb.disks({ project })) 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /app/pages/project/images/ProjectImageEdit.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { type LoaderFunctionArgs } from 'react-router' 9 | 10 | import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api' 11 | 12 | import { EditImageSideModalForm } from '~/forms/image-edit' 13 | import { titleCrumb } from '~/hooks/use-crumbs' 14 | import { getProjectImageSelector, useProjectImageSelector } from '~/hooks/use-params' 15 | import { pb } from '~/util/path-builder' 16 | 17 | export async function clientLoader({ params }: LoaderFunctionArgs) { 18 | const { project, image } = getProjectImageSelector(params) 19 | await apiQueryClient.prefetchQuery('imageView', { path: { image }, query: { project } }) 20 | return null 21 | } 22 | 23 | export const handle = titleCrumb('Edit Image') 24 | 25 | export default function ProjectImageEdit() { 26 | const { project, image } = useProjectImageSelector() 27 | const { data } = usePrefetchedApiQuery('imageView', { 28 | path: { image }, 29 | query: { project }, 30 | }) 31 | 32 | const dismissLink = pb.projectImages({ project }) 33 | return 34 | } 35 | -------------------------------------------------------------------------------- /app/pages/project/instances/SettingsTab.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { type LoaderFunctionArgs } from 'react-router' 10 | 11 | import { queryClient } from '@oxide/api' 12 | 13 | import { antiAffinityGroupList } from '~/forms/affinity-util' 14 | import { getInstanceSelector } from '~/hooks/use-params' 15 | 16 | import { AntiAffinityCard, instanceAntiAffinityGroups } from './AntiAffinityCard' 17 | import { AutoRestartCard } from './AutoRestartCard' 18 | 19 | export const handle = { crumb: 'Settings' } 20 | 21 | export async function clientLoader({ params }: LoaderFunctionArgs) { 22 | const { project, instance } = getInstanceSelector(params) 23 | await Promise.all([ 24 | queryClient.prefetchQuery(instanceAntiAffinityGroups({ project, instance })), 25 | queryClient.prefetchQuery(antiAffinityGroupList({ project })), 26 | ]) 27 | return null 28 | } 29 | 30 | export default function SettingsTab() { 31 | return ( 32 |
33 | 34 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /app/pages/project/instances/common.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { createContext, useContext, type ReactNode } from 'react' 9 | 10 | import { intersperse } from '~/util/array' 11 | import { invariant } from '~/util/invariant' 12 | 13 | const white = (s: string) => ( 14 | 15 | {s} 16 | 17 | ) 18 | 19 | export const fancifyStates = (states: string[]) => 20 | intersperse(states.map(white), <>, , <> or ) 21 | 22 | type MetricsContextValue = { 23 | startTime: Date 24 | endTime: Date 25 | dateTimeRangePicker: ReactNode 26 | intervalPicker: ReactNode 27 | setIsIntervalPickerEnabled: (enabled: boolean) => void 28 | } 29 | 30 | /** 31 | * Using context lets the selected time window persist across route tab navs. 32 | */ 33 | export const MetricsContext = createContext(null) 34 | 35 | // this lets us init with a null value but rule it out in the consumers 36 | export function useMetricsContext() { 37 | const value = useContext(MetricsContext) 38 | invariant(value, 'useMetricsContext can only be called inside a MetricsContext') 39 | return value 40 | } 41 | -------------------------------------------------------------------------------- /app/pages/settings/ssh-key-create.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { SSHKeyCreate } from '~/forms/ssh-key-create' 9 | import { titleCrumb } from '~/hooks/use-crumbs' 10 | 11 | export const handle = titleCrumb('New SSH key') 12 | 13 | export default function SSHKeyCreatePage() { 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /app/pages/system/inventory/InventoryPage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Servers16Icon, Servers24Icon } from '@oxide/design-system/icons/react' 10 | 11 | import { DocsPopover } from '~/components/DocsPopover' 12 | import { RouteTabs, Tab } from '~/components/RouteTabs' 13 | import { makeCrumb } from '~/hooks/use-crumbs' 14 | import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' 15 | import { docLinks } from '~/util/links' 16 | import { pb } from '~/util/path-builder' 17 | 18 | export const handle = makeCrumb('Inventory', pb.sledInventory()) 19 | 20 | export default function InventoryPage() { 21 | return ( 22 | <> 23 | 24 | }>Inventory 25 | } 28 | summary="Information about the physical sleds and disks in the Oxide rack." 29 | links={[docLinks.sleds, docLinks.storage]} 30 | /> 31 | 32 | 33 | 34 | Sleds 35 | Disks 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /app/stores/confirm-action.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { ReactNode } from 'react' 9 | import { create } from 'zustand' 10 | 11 | type ActionConfig = { 12 | /** Must be `mutateAsync`, otherwise we can't catch the error generically */ 13 | doAction: () => Promise 14 | /** e.g., Confirm delete, Confirm unlink */ 15 | modalTitle: string 16 | modalContent: ReactNode 17 | /** Title of error toast */ 18 | errorTitle: string 19 | actionType: 'primary' | 'danger' 20 | } 21 | 22 | type ConfirmActionStore = { 23 | actionConfig: ActionConfig | null 24 | } 25 | 26 | export const useConfirmAction = create(() => ({ 27 | actionConfig: null, 28 | })) 29 | 30 | // zustand docs say this pattern is equivalent to putting the actions on the 31 | // store and has no downsides, despite all the readme examples doing it the 32 | // other way. We do it this way so can modify the store in callbacks without 33 | // calling the useStore hook. Only components that need to subscribe to changes 34 | // in the store need to the hook. 35 | // https://github.com/pmndrs/zustand/blob/a5343354/docs/guides/practice-with-no-store-actions.md 36 | 37 | export function confirmAction(actionConfig: ActionConfig) { 38 | useConfirmAction.setState({ actionConfig }) 39 | } 40 | 41 | export function clearConfirmAction() { 42 | useConfirmAction.setState({ actionConfig: null }) 43 | } 44 | -------------------------------------------------------------------------------- /app/stores/confirm-delete.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { HL } from '~/components/HL' 10 | 11 | import { useConfirmAction } from './confirm-action' 12 | 13 | // confirmAction was originally abstracted from confirmDelete. this preserves 14 | // the existing confirmDelete API by constructing a confirmAction from it 15 | 16 | type DeleteConfig = { 17 | /** Must be `mutateAsync`, otherwise we can't catch the error generically */ 18 | doDelete: () => Promise 19 | /** 20 | * Label identifying the resource. Could be a name or something more elaborate 21 | * "the Admin role for user Harry Styles". If a string, the modal will 22 | * automatically give it a highlighted style. Otherwise it will be rendered 23 | * directly. 24 | */ 25 | label: React.ReactNode 26 | resourceKind?: string 27 | extraContent?: React.ReactNode 28 | } 29 | 30 | export const confirmDelete = 31 | ({ doDelete, label, resourceKind, extraContent }: DeleteConfig) => 32 | () => { 33 | const displayLabel = typeof label === 'string' ? {label} : label 34 | const modalTitle = resourceKind ? `Confirm delete ${resourceKind}` : 'Confirm delete' 35 | useConfirmAction.setState({ 36 | actionConfig: { 37 | doAction: doDelete, 38 | modalContent: ( 39 |
40 |

Are you sure you want to delete {displayLabel}?

41 | {extraContent ?

{extraContent}

: null} 42 |
43 | ), 44 | errorTitle: 'Could not delete resource', 45 | modalTitle, 46 | actionType: 'danger', 47 | }, 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /app/stores/toast.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { type ReactElement } from 'react' 9 | import { v4 as uuid } from 'uuid' 10 | import { create } from 'zustand' 11 | 12 | import type { ToastProps } from '~/ui/lib/Toast' 13 | 14 | type Toast = { 15 | id: string 16 | options: Optional 17 | } 18 | 19 | export const useToastStore = create<{ toasts: Toast[] }>(() => ({ toasts: [] })) 20 | 21 | /** 22 | * If argument is `ReactElement | string`, use it directly as `{ content }`. 23 | * Otherwise it's a config object. 24 | */ 25 | export function addToast(optionsOrContent: Toast['options'] | ReactElement | string) { 26 | const options = 27 | typeof optionsOrContent === 'object' && 'content' in optionsOrContent 28 | ? optionsOrContent 29 | : { content: optionsOrContent } 30 | useToastStore.setState(({ toasts }) => ({ toasts: [...toasts, { id: uuid(), options }] })) 31 | } 32 | 33 | export function removeToast(id: Toast['id']) { 34 | useToastStore.setState(({ toasts }) => ({ toasts: toasts.filter((t) => t.id !== id) })) 35 | } 36 | -------------------------------------------------------------------------------- /app/table/cells/BooleanCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /* eslint-disable jsx-a11y/aria-proptypes */ 10 | // there seems to be a bug in the linter. it doesn't want you to use the string 11 | // "true" because it insists it's a boolean 12 | import { Disabled12Icon, Success12Icon } from '@oxide/design-system/icons/react' 13 | 14 | export const BooleanCell = ({ isTrue }: { isTrue: boolean }) => 15 | isTrue ? ( 16 | 17 | ) : ( 18 | 19 | ) 20 | -------------------------------------------------------------------------------- /app/table/cells/DefaultPoolCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { Success12Icon } from '@oxide/design-system/icons/react' 9 | 10 | import { Badge } from '~/ui/lib/Badge' 11 | 12 | export const DefaultPoolCell = ({ isDefault }: { isDefault: boolean }) => 13 | isDefault ? ( 14 | <> 15 | 16 | default 17 | 18 | ) : null 19 | -------------------------------------------------------------------------------- /app/table/cells/DescriptionCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { EmptyCell } from '~/table/cells/EmptyCell' 10 | import { Truncate } from '~/ui/lib/Truncate' 11 | 12 | export type Props = { text?: string; maxLength?: number } 13 | 14 | export const DescriptionCell = ({ text, maxLength = 48 }: Props) => 15 | text ? : 16 | -------------------------------------------------------------------------------- /app/table/cells/EmptyCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { classed } from '~/util/classed' 10 | 11 | export const EmptyCell = () => 12 | 13 | export const SkeletonCell = classed.div`h-4 w-12 rounded bg-tertiary motion-safe:animate-pulse` 14 | -------------------------------------------------------------------------------- /app/table/cells/EnabledCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { VpcFirewallRuleStatus } from '@oxide/api' 9 | import { Disabled12Icon, Success12Icon } from '@oxide/design-system/icons/react' 10 | 11 | import { Badge } from '~/ui/lib/Badge' 12 | 13 | export const EnabledCell = ({ value }: { value: VpcFirewallRuleStatus }) => 14 | value === 'enabled' ? ( 15 | <> 16 | 17 | Enabled 18 | 19 | ) : ( 20 | <> 21 | 22 | Disabled 23 | 24 | ) 25 | -------------------------------------------------------------------------------- /app/table/cells/InstanceLinkCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useApiQuery } from '@oxide/api' 10 | 11 | import { useProjectSelector } from '~/hooks/use-params' 12 | import { pb } from '~/util/path-builder' 13 | 14 | import { EmptyCell, SkeletonCell } from './EmptyCell' 15 | import { LinkCell } from './LinkCell' 16 | 17 | export const InstanceLinkCell = ({ instanceId }: { instanceId?: string | null }) => { 18 | const { project } = useProjectSelector() 19 | const { data: instance } = useApiQuery( 20 | 'instanceView', 21 | { path: { instance: instanceId! } }, 22 | { enabled: !!instanceId } 23 | ) 24 | 25 | // has to be after the hooks because hooks can't be executed conditionally 26 | if (!instanceId) return 27 | if (!instance) return 28 | 29 | return ( 30 | 31 | {instance.name} 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /app/table/cells/InstanceResourceCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { filesize } from 'filesize' 9 | 10 | import type { Instance } from '@oxide/api' 11 | 12 | type Props = { value: Pick } 13 | 14 | export const InstanceResourceCell = ({ value }: Props) => { 15 | const memory = filesize(value.memory, { output: 'object', base: 2 }) 16 | return ( 17 |
18 |
19 | {value.ncpus} vCPU 20 |
21 |
22 | {memory.value} {memory.unit} 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/table/cells/InstanceStateCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { Instance } from '@oxide/api' 9 | 10 | import { InstanceStateBadge } from '~/components/StateBadge' 11 | import { TimeAgo } from '~/components/TimeAgo' 12 | 13 | type Props = { value: Pick } 14 | 15 | export const InstanceStateCell = ({ value }: Props) => { 16 | return ( 17 |
18 | 19 | 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /app/table/cells/IpPoolCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useApiQuery } from '~/api' 9 | import { Tooltip } from '~/ui/lib/Tooltip' 10 | 11 | import { EmptyCell } from './EmptyCell' 12 | 13 | export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { 14 | const pool = useApiQuery('projectIpPoolView', { path: { pool: ipPoolId } }).data 15 | if (!pool) return 16 | return ( 17 | 18 | {pool.name} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/table/cells/LinkCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { CellContext } from '@tanstack/react-table' 9 | import { Link } from 'react-router' 10 | 11 | import { classed } from '~/util/classed' 12 | 13 | const linkClass = 'link-with-underline group flex h-full w-full items-center text-sans-md' 14 | 15 | /** Pushes out the link area to the entire cell for improved clickability™ */ 16 | const Pusher = classed.div`absolute inset-0 right-px group-hover:bg-raise` 17 | 18 | /** 19 | * Because this returns a component, it should only be used in a static context 20 | * or memoized with useCallback. It should not be used unmemoized inside the 21 | * render loop. It's probably better to inline the contents directly at the call 22 | * site if it needs to be called inside render. 23 | */ 24 | export const makeLinkCell = 25 | (makeHref: (value: string) => string) => 26 | (props: CellContext) => { 27 | const value = props.getValue() 28 | return {value} 29 | } 30 | 31 | export function LinkCell({ to, children }: { to: string; children: React.ReactNode }) { 32 | return ( 33 | 34 | 35 |
{children}
36 | 37 | ) 38 | } 39 | 40 | export const ButtonCell = ({ children, ...props }: React.ComponentProps<'button'>) => { 41 | return ( 42 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /app/table/cells/RouterLinkCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useApiQuery } from '~/api' 10 | import { useVpcSelector } from '~/hooks/use-params' 11 | import { Badge } from '~/ui/lib/Badge' 12 | import { pb } from '~/util/path-builder' 13 | 14 | import { EmptyCell, SkeletonCell } from './EmptyCell' 15 | import { LinkCell } from './LinkCell' 16 | 17 | export const RouterLinkCell = ({ routerId }: { routerId?: string | null }) => { 18 | const { project, vpc } = useVpcSelector() 19 | const { data: router, isError } = useApiQuery( 20 | 'vpcRouterView', 21 | { path: { router: routerId! } }, // it's an ID, so no parent selector 22 | { enabled: !!routerId } 23 | ) 24 | if (!routerId) return 25 | // probably not possible but let’s be safe 26 | if (isError) return Deleted 27 | if (!router) return // loading 28 | return ( 29 | 30 | {router.name} 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/table/cells/TwoLineCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | 10 | interface TwoLineCellProps { 11 | value: [React.ReactNode, React.ReactNode] 12 | detailsClass?: string 13 | } 14 | 15 | export const TwoLineCell = ({ value, detailsClass }: TwoLineCellProps) => ( 16 |
17 |
{value[0]}
18 |
{value[1]}
19 |
20 | ) 21 | -------------------------------------------------------------------------------- /app/table/cells/TypeValueCell.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Badge } from '~/ui/lib/Badge' 10 | 11 | export type TypeValue = { 12 | type: string 13 | value: string 14 | } 15 | 16 | export const TypeValueCell = ({ type, value }: TypeValue) => ( 17 |
18 | {type} 19 | 20 | {value} 21 | 22 |
23 | ) 24 | -------------------------------------------------------------------------------- /app/table/columns/select-col.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { Row, Table } from '@tanstack/react-table' 9 | 10 | import { Checkbox } from '~/ui/lib/Checkbox' 11 | import { Radio } from '~/ui/lib/Radio' 12 | 13 | // only needs to be a function because of the generic params 14 | export const getSelectCol = () => ({ 15 | id: 'select', 16 | meta: { thClassName: 'w-10' }, 17 | header: '', 18 | cell: ({ row }: { row: Row }) => { 19 | // `onChange` is empty to suppress react warning. Actual trigger happens in `Table.tsx` 20 | return {}} /> 21 | }, 22 | }) 23 | 24 | export const getMultiSelectCol = () => ({ 25 | id: 'select', 26 | meta: { thClassName: 'w-10' }, 27 | header: ({ table }: { table: Table }) => ( 28 |
29 | 34 |
35 | ), 36 | cell: ({ row }: { row: Row }) => ( 37 | // `onChange` is empty to suppress react warning. Actual trigger happens in `Table.tsx` 38 | {}} /> 39 | ), 40 | }) 41 | -------------------------------------------------------------------------------- /app/ui/README.md: -------------------------------------------------------------------------------- 1 | # UI components 2 | 3 | What distinguishes these components from those in `app/components` is that these are ones you could imagine being in a generic Oxide UI library. They are (mostly, aspirationally, ideally) not aware of React Router (except for a couple `Link`s), React Hook Form, or React Query. 4 | -------------------------------------------------------------------------------- /app/ui/assets/fonts/GT-America-Mono-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/GT-America-Mono-Medium.woff -------------------------------------------------------------------------------- /app/ui/assets/fonts/GT-America-Mono-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/GT-America-Mono-Medium.woff2 -------------------------------------------------------------------------------- /app/ui/assets/fonts/GT-America-Mono-Regular-OCC.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/GT-America-Mono-Regular-OCC.woff -------------------------------------------------------------------------------- /app/ui/assets/fonts/GT-America-Mono-Regular-OCC.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/GT-America-Mono-Regular-OCC.woff2 -------------------------------------------------------------------------------- /app/ui/assets/fonts/SuisseIntl-Light-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/SuisseIntl-Light-WebS.woff -------------------------------------------------------------------------------- /app/ui/assets/fonts/SuisseIntl-Light-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/SuisseIntl-Light-WebS.woff2 -------------------------------------------------------------------------------- /app/ui/assets/fonts/SuisseIntl-Medium-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/SuisseIntl-Medium-WebS.woff -------------------------------------------------------------------------------- /app/ui/assets/fonts/SuisseIntl-Medium-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/SuisseIntl-Medium-WebS.woff2 -------------------------------------------------------------------------------- /app/ui/assets/fonts/SuisseIntl-Regular-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/SuisseIntl-Regular-WebS.woff -------------------------------------------------------------------------------- /app/ui/assets/fonts/SuisseIntl-Regular-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/SuisseIntl-Regular-WebS.woff2 -------------------------------------------------------------------------------- /app/ui/assets/fonts/SuisseIntl-RegularItalic-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/SuisseIntl-RegularItalic-WebS.woff -------------------------------------------------------------------------------- /app/ui/assets/fonts/SuisseIntl-RegularItalic-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/console/e891f96818fd024420a48d8c37f70df121d18220/app/ui/assets/fonts/SuisseIntl-RegularItalic-WebS.woff2 -------------------------------------------------------------------------------- /app/ui/lib/BigNum.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { displayBigNum } from '~/util/math' 10 | 11 | import { Tooltip } from './Tooltip' 12 | 13 | /** 14 | * Possibly abbreviate number if it's big enough, and if it is, wrap it in a 15 | * tooltip showing the unabbreviated value. 16 | */ 17 | export function BigNum({ num, className }: { num: number | bigint; className?: string }) { 18 | const [display, abbreviated] = displayBigNum(num) 19 | 20 | const inner = {display} 21 | 22 | if (!abbreviated) return inner 23 | 24 | return {inner} 25 | } 26 | -------------------------------------------------------------------------------- /app/ui/lib/BulkActionMenu.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { flattenChildren } from '~/util/children' 9 | 10 | import { Button, type ButtonProps } from './Button' 11 | 12 | export interface BulkActionMenuProps { 13 | selectedCount: number 14 | children: React.ReactNode 15 | onSelectAll: () => void 16 | } 17 | 18 | export function BulkActionMenu({ children, selectedCount }: BulkActionMenuProps) { 19 | const actionButtons = flattenChildren(children) 20 | return ( 21 |
22 |
{actionButtons}
23 |
24 | {selectedCount} selected 25 |
26 |
27 | ) 28 | } 29 | 30 | BulkActionMenu.Button = (props: Omit) => ( 31 | 25 | ) 26 | 27 | export const CreateLink = ({ children, ...rest }: LinkProps) => ( 28 | 29 | 30 | {children} 31 | 32 | ) 33 | -------------------------------------------------------------------------------- /app/ui/lib/DateTime.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { toLocaleDateString, toLocaleTimeString } from '~/util/date' 10 | 11 | export const DateTime = ({ date, locale }: { date: Date; locale?: string }) => ( 12 | 16 | ) 17 | -------------------------------------------------------------------------------- /app/ui/lib/Dialog.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useRef, type ReactNode } from 'react' 9 | import { useDialog, type AriaDialogProps } from 'react-aria' 10 | 11 | interface DialogProps extends AriaDialogProps { 12 | children: ReactNode 13 | } 14 | 15 | export function Dialog({ children, ...props }: DialogProps) { 16 | const ref = useRef(null) 17 | const { dialogProps } = useDialog(props, ref) 18 | 19 | return ( 20 |
21 | {children} 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/ui/lib/DialogOverlay.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import * as m from 'motion/react-m' 10 | import { type Ref } from 'react' 11 | 12 | type Props = { 13 | ref?: Ref 14 | } 15 | 16 | export const DialogOverlay = ({ ref }: Props) => ( 17 | 26 | ) 27 | -------------------------------------------------------------------------------- /app/ui/lib/Divider.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { classed } from '~/util/classed' 9 | 10 | /** Gets special styling from being inside `.ox-form` */ 11 | export const FormDivider = classed.hr`ox-divider w-full border-t border-secondary` 12 | 13 | /** Needs !important styles to override :gutter thing on `
` */ 14 | export const Divider = classed.hr`!mx-0 !w-full border-t border-secondary` 15 | -------------------------------------------------------------------------------- /app/ui/lib/EmptyMessage.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | import type { ReactElement, ReactNode } from 'react' 10 | import { Link } from 'react-router' 11 | 12 | import { classed } from '~/util/classed' 13 | 14 | import { Button, buttonStyle } from './Button' 15 | 16 | const buttonStyleProps = { variant: 'ghost', size: 'sm', color: 'secondary' } as const 17 | 18 | type Props = { 19 | icon?: ReactElement 20 | title: string 21 | body?: ReactNode 22 | } & ( // only require buttonTo or onClick if buttonText is present 23 | | { buttonText: string; buttonTo: string } 24 | | { buttonText: string; onClick: () => void } 25 | | { buttonText?: never } 26 | ) 27 | 28 | export function EmptyMessage(props: Props) { 29 | let button: ReactElement | null = null 30 | if (props.buttonText && 'buttonTo' in props) { 31 | button = ( 32 | 33 | {props.buttonText} 34 | 35 | ) 36 | } else if (props.buttonText && 'onClick' in props) { 37 | button = ( 38 | 41 | ) 42 | } 43 | return ( 44 |
45 | {props.icon && ( 46 |
47 | {props.icon} 48 |
49 | )} 50 |

{props.title}

51 | {typeof props.body === 'string' ? {props.body} : props.body} 52 | {button} 53 |
54 | ) 55 | } 56 | 57 | export const EMBody = classed.p`mt-0.5 text-balance text-sans-md text-default` 58 | -------------------------------------------------------------------------------- /app/ui/lib/FieldLabel.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | import type { ElementType, PropsWithChildren } from 'react' 10 | 11 | interface FieldLabelProps { 12 | id: string 13 | as?: ElementType 14 | htmlFor?: string 15 | optional?: boolean 16 | className?: string 17 | } 18 | 19 | export const FieldLabel = ({ 20 | id, 21 | children, 22 | htmlFor, 23 | optional, 24 | as, 25 | className, 26 | }: PropsWithChildren) => { 27 | const Component = as || 'label' 28 | return ( 29 |
30 | 35 | {children} 36 | {optional && ( 37 | // Announcing this optional text is unnecessary as the required attribute on the 38 | // form will be used 39 | 42 | )} 43 | 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /app/ui/lib/FileInput.spec.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' 9 | import { describe, expect, it, vi } from 'vitest' 10 | 11 | import { FileInput } from './FileInput' 12 | 13 | const file = new File(['hello'], 'stuff.png', { type: 'image/png' }) 14 | 15 | describe('FileInput', () => { 16 | it('calls onChange on file choose and reset click', async () => { 17 | const onChange = vi.fn() 18 | render() 19 | 20 | const input = screen.getByTestId('input') 21 | expect(screen.queryByText('stuff.png')).toBeNull() 22 | 23 | await waitFor(() => fireEvent.change(input, { target: { files: [file] } })) 24 | 25 | expect(onChange).toHaveBeenCalledWith(file) 26 | screen.getByText('stuff.png') // file is there 27 | 28 | // clear file 29 | act(() => { 30 | screen.getByRole('button', { name: 'Clear file' }).click() 31 | }) 32 | 33 | expect(onChange).toHaveBeenCalledWith(null) 34 | expect(screen.queryByText('stuff.png')).toBeNull() 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /app/ui/lib/Identicon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import md5 from 'md5' 9 | import { useMemo } from 'react' 10 | 11 | type Rectangle = { x: number; y: number; isOn: boolean } 12 | 13 | const getPixels = (s: string) => { 14 | const hash = md5(s) 15 | const buffer: Rectangle[] = [] 16 | 17 | for (let i = 0; i < 18; i++) { 18 | const isOn = hash.charCodeAt(i) % 2 === 0 19 | 20 | if (i < 3) { 21 | // Start with the two central columns 22 | buffer.push({ x: 2, y: i, isOn }) 23 | buffer.push({ x: 3, y: i, isOn }) 24 | } else if (i < 6) { 25 | // Move out to the columns one from the edge 26 | buffer.push({ x: 1, y: i - 3, isOn }) 27 | buffer.push({ x: 4, y: i - 3, isOn }) 28 | } else if (i < 9) { 29 | // Fill the outside columns 30 | buffer.push({ x: 0, y: i - 6, isOn }) 31 | buffer.push({ x: 5, y: i - 6, isOn }) 32 | } 33 | } 34 | 35 | return buffer 36 | } 37 | 38 | type IdenticonProps = { 39 | /** string used to generate the graphic */ 40 | name: string 41 | className?: string 42 | } 43 | 44 | export function Identicon({ name, className }: IdenticonProps) { 45 | const pixels = useMemo(() => getPixels(name), [name]) 46 | return ( 47 |
48 | 49 | 50 | {pixels.map((pixel) => { 51 | if (!pixel.isOn) return null 52 | const x = pixel.x * 3 + 2 * pixel.x 53 | const y = pixel.y * 8 + 2 * pixel.y 54 | return 55 | })} 56 | 57 | 58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/ui/lib/InlineCode.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { classed } from '~/util/classed' 10 | 11 | export const InlineCode = classed.code`whitespace-nowrap rounded-sm px-[3px] py-[1px] text-mono-sm !normal-case bg-raise border border-secondary mx-px` 12 | -------------------------------------------------------------------------------- /app/ui/lib/ModalLinks.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { ReactNode } from 'react' 9 | 10 | import { OpenLink12Icon } from '@oxide/design-system/icons/react' 11 | 12 | export const ModalLinks = ({ 13 | heading, 14 | children, 15 | }: { 16 | heading: string 17 | children: ReactNode 18 | }) => ( 19 |
20 |

{heading}

21 |
    {children}
22 |
23 | ) 24 | 25 | export const ModalLink = ({ to, label }: { to: string; label: string }) => ( 26 |
  • 27 | 34 | 35 | {label} 36 | 37 |
  • 38 | ) 39 | -------------------------------------------------------------------------------- /app/ui/lib/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type { ReactElement } from 'react' 9 | 10 | import { classed } from '~/util/classed' 11 | 12 | export const PageHeader = classed.header`mb-16 mt-12 flex items-center justify-between` 13 | 14 | interface PageTitleProps { 15 | icon?: ReactElement 16 | children: React.ReactNode 17 | } 18 | export const PageTitle = ({ children: title, icon }: PageTitleProps) => { 19 | return ( 20 |

    21 | {icon} 22 | {title} 23 |

    24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/ui/lib/Popover.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { 9 | DismissButton, 10 | Overlay, 11 | usePopover, 12 | type AriaPopoverProps, 13 | } from '@react-aria/overlays' 14 | import { useRef, type ReactNode } from 'react' 15 | import type { OverlayTriggerState } from 'react-stately' 16 | 17 | interface PopoverProps extends Omit { 18 | state: OverlayTriggerState 19 | children: ReactNode 20 | } 21 | 22 | export function Popover(props: PopoverProps) { 23 | const ref = useRef(null) 24 | const { state, children } = props 25 | 26 | const { popoverProps, underlayProps } = usePopover( 27 | { 28 | ...props, 29 | popoverRef: ref, 30 | }, 31 | state 32 | ) 33 | 34 | // Add a hidden component at the end of the popover 35 | // to allow screen reader users to dismiss the popup easily. 36 | return ( 37 | 38 |
    39 |
    44 | 45 | {children} 46 | 47 |
    48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/ui/lib/Progress.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | 10 | import { ariaLabel, type AriaLabel } from '../util/aria' 11 | 12 | export type ProgressProps = { 13 | value: number // always out of 100 14 | className?: string 15 | transitionTime?: number // time in ms 16 | } & AriaLabel 17 | 18 | // https://w3c.github.io/aria-practices/#range_related_properties_progressbar_role 19 | 20 | export const Progress = (props: ProgressProps) => ( 21 |
    27 |
    37 |
    38 | ) 39 | -------------------------------------------------------------------------------- /app/ui/lib/ResourceMeter.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import cn from 'classnames' 10 | 11 | import { Tooltip } from './Tooltip' 12 | 13 | type ResourceMeterProps = { 14 | value: number 15 | warningThreshold?: number 16 | errorThreshold?: number 17 | } 18 | 19 | /** 20 | * Show "percent used" relative to "total available" 21 | * 22 | * This is a vertical indicator showing how a resource's usage compares with 23 | * the total amount of the resource available. Note that the current 24 | * configuration of this component has "low usage" showing as "positive" (green) 25 | * with warning (yellow) and error (red) thresholds at "high usage" levels. 26 | */ 27 | export const ResourceMeter = ({ 28 | value, 29 | warningThreshold = 66, 30 | errorThreshold = 75, 31 | }: ResourceMeterProps) => { 32 | const usagePercent = `${value.toFixed(2)}%` 33 | const label = `${usagePercent} used` 34 | // prettier-ignore 35 | const fg = 36 | value > errorThreshold ? 'bg-destructive' 37 | : value > warningThreshold ? 'bg-notice' 38 | : 'bg-accent' 39 | 40 | const bg = 41 | value > errorThreshold 42 | ? 'bg-destructive-secondary' 43 | : value > warningThreshold 44 | ? 'bg-notice-secondary' 45 | : 'bg-accent-secondary' 46 | return ( 47 | 48 |
    52 |
    56 |
    57 |
    58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /app/ui/lib/SkipLink.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | import type { PropsWithChildren } from 'react' 10 | 11 | const skipLinkStyles = ` 12 | absolute left-1/2 -translate-x-1/2 -top-10 z-10 px-3 py-2 13 | uppercase text-mono-lg rounded 14 | inline-flex items-center justify-center 15 | focus:ring-2 focus:ring-accent-secondary 16 | bg-accent-secondary border-transparent text-accent text-mono-sm 17 | transition-all motion-reduce:transform-none 18 | ` 19 | 20 | export type SkipLinkProps = PropsWithChildren<{ 21 | id: string 22 | target?: string 23 | className?: string 24 | }> 25 | export const SkipLink = ({ 26 | id, 27 | target = 'content', 28 | children = 'Skip to content', 29 | className, 30 | }: SkipLinkProps) => { 31 | return ( 32 | 33 | {children} 34 | 35 | ) 36 | } 37 | 38 | export const SkipLinkTarget = ({ id = 'content' }) => { 39 | return
    40 | } 41 | -------------------------------------------------------------------------------- /app/ui/lib/Slash.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | 10 | export const Slash = ({ className }: { className?: string }) => ( 11 | 12 | / 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /app/ui/lib/Tabs.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { 9 | Content, 10 | List, 11 | Root, 12 | Trigger, 13 | type TabsContentProps, 14 | type TabsListProps, 15 | type TabsProps, 16 | type TabsTriggerProps, 17 | } from '@radix-ui/react-tabs' 18 | import cn from 'classnames' 19 | import type { SetRequired } from 'type-fest' 20 | 21 | // They don't require a default value, but without it there is no tab selected 22 | // by default. 23 | export type TabsRootProps = SetRequired 24 | 25 | export const Tabs = { 26 | Root: ({ className, ...props }: TabsRootProps) => ( 27 | 28 | ), 29 | Trigger: ({ children, className, ...props }: TabsTriggerProps) => ( 30 | 31 | {/* this div needs to be here for the background on `ox-tab:hover > *` */} 32 |
    {children}
    33 |
    34 | ), 35 | List: ({ className, ...props }: TabsListProps) => ( 36 | 37 | ), 38 | Content: ({ className, ...props }: TabsContentProps) => ( 39 | 40 | ), 41 | } 42 | -------------------------------------------------------------------------------- /app/ui/lib/TimeoutIndicator.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useTimeout } from './use-timeout' 10 | 11 | export interface TimeoutIndicatorProps { 12 | timeout: number 13 | onTimeoutEnd: () => void 14 | } 15 | 16 | export const TimeoutIndicator = ({ timeout, onTimeoutEnd }: TimeoutIndicatorProps) => { 17 | useTimeout(onTimeoutEnd, timeout) 18 | 19 | return null 20 | } 21 | -------------------------------------------------------------------------------- /app/ui/lib/TipIcon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | 10 | import { Question12Icon } from '@oxide/design-system/icons/react' 11 | 12 | import { Tooltip } from './Tooltip' 13 | 14 | type TipIconProps = { 15 | children: React.ReactNode 16 | className?: string 17 | } 18 | export function TipIcon({ children, className }: TipIconProps) { 19 | return ( 20 | 21 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /app/ui/lib/modal-context.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { createContext, useContext } from 'react' 10 | 11 | export const ModalContext = createContext(false) 12 | export const useIsInModal = () => useContext(ModalContext) 13 | 14 | export const SideModalContext = createContext(false) 15 | export const useIsInSideModal = () => useContext(SideModalContext) 16 | -------------------------------------------------------------------------------- /app/ui/lib/use-interval.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useEffect, useRef } from 'react' 9 | 10 | interface UseIntervalProps { 11 | fn: () => void 12 | delay: number | null | undefined 13 | /** Use to force a render because changes to the callback won't */ 14 | key?: string 15 | } 16 | 17 | /** 18 | * Fire `fn` on an interval. Does not fire immediately, only after `delay`. 19 | * 20 | * Use null `delay` to prevent the interval from firing at all. Change `key` to 21 | * force a render, which cleans up the currently set interval and possibly sets 22 | * a new one. 23 | */ 24 | export function useInterval({ fn, delay, key }: UseIntervalProps) { 25 | const callbackRef = useRef<() => void>(undefined) 26 | 27 | useEffect(() => { 28 | callbackRef.current = fn 29 | }, [fn]) 30 | 31 | useEffect(() => { 32 | if (delay === null || delay === undefined) return 33 | const intervalId = setInterval(() => callbackRef.current?.(), delay) 34 | return () => clearInterval(intervalId) 35 | }, [delay, key]) 36 | } 37 | -------------------------------------------------------------------------------- /app/ui/lib/use-timeout.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useEffect, useRef } from 'react' 9 | 10 | // use null delay to prevent the timeout from firing 11 | export function useTimeout(callback: () => void, delay: number | null) { 12 | const callbackRef = useRef<() => void>(undefined) 13 | 14 | useEffect(() => { 15 | callbackRef.current = callback 16 | }, [callback]) 17 | 18 | useEffect(() => { 19 | if (delay === null) return 20 | const intervalId = setTimeout(() => callbackRef.current?.(), delay) 21 | return () => clearTimeout(intervalId) 22 | }, [delay]) 23 | } 24 | -------------------------------------------------------------------------------- /app/ui/styles/.gitignore: -------------------------------------------------------------------------------- 1 | .tokens 2 | -------------------------------------------------------------------------------- /app/ui/styles/components/button.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .ox-button { 10 | @apply relative; 11 | } 12 | .active-clicked:active:not(.visually-disabled) { 13 | @apply motion-safe:translate-y-px; 14 | } 15 | .ox-button:after { 16 | content: ''; 17 | @apply absolute bottom-0 left-0 right-0 top-0 rounded border border-current opacity-[0.05]; 18 | } 19 | .btn-primary { 20 | @apply text-accent bg-accent-secondary hover:bg-accent-secondary-hover disabled:text-accent-disabled disabled:bg-accent-secondary; 21 | } 22 | .btn-primary:disabled > .spinner, 23 | .btn-primary.visually-disabled > .spinner { 24 | @apply text-accent; 25 | } 26 | 27 | .btn-secondary { 28 | @apply text-default bg-secondary hover:bg-hover disabled:text-disabled disabled:bg-secondary; 29 | } 30 | .btn-secondary:disabled > .spinner, 31 | .btn-secondary.visually-disabled > .spinner { 32 | @apply text-default; 33 | } 34 | 35 | .btn-danger { 36 | @apply text-destructive bg-destructive-secondary hover:bg-destructive-secondary-hover disabled:text-destructive-disabled disabled:bg-destructive-secondary; 37 | } 38 | .btn-danger:disabled > .spinner, 39 | .btn-danger.visually-disabled > .spinner { 40 | @apply text-destructive; 41 | } 42 | 43 | .btn-ghost { 44 | @apply border text-default border-default hover:bg-hover disabled:bg-transparent disabled:text-disabled; 45 | } 46 | .btn-ghost:after { 47 | @apply hidden; 48 | } 49 | 50 | /** 51 | * A class to make it very visually obvious that a button style is missing 52 | */ 53 | .btn-not-implemented { 54 | @apply cursor-not-allowed text-transparent focus:ring-transparent; 55 | background: repeating-linear-gradient(45deg, yellow, yellow 10px, black 10px, black 20px); 56 | } 57 | -------------------------------------------------------------------------------- /app/ui/styles/components/form.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .ox-form .ox-divider { 10 | width: calc(100% + var(--content-gutter) * 2); 11 | margin-left: calc(var(--content-gutter) * -1); 12 | } 13 | 14 | .ox-form .ox-tabs.full-width { 15 | width: calc(100% + var(--content-gutter) * 2) !important; 16 | margin-left: calc(var(--content-gutter) * -1) !important; 17 | } 18 | 19 | .ox-form, 20 | .ox-form .ox-tabs-panel, 21 | .ox-form .ox-accordion-content { 22 | @apply space-y-6; 23 | } 24 | 25 | .ox-form .ox-divider { 26 | @apply !my-10; /* important overrides space-y-* on .ox-form */ 27 | } 28 | -------------------------------------------------------------------------------- /app/ui/styles/components/login-page.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .hero-rack { 10 | @apply absolute left-[calc(50%+40px)] top-[15%] h-[115%] max-h-[1080px] -translate-x-1/2 object-contain md:-mr-10; 11 | } 12 | 13 | .hero-rack-wrapper { 14 | @apply relative h-full w-full max-w-[720px]; 15 | } 16 | 17 | @media (min-height: 1000px), (max-width: 1100px) and (max-aspect-ratio: 12/9) { 18 | .hero-rack { 19 | @apply top-[calc(50%+32px)] -translate-y-1/2; 20 | } 21 | 22 | .hero-rack-wrapper { 23 | @apply max-w-[640px]; 24 | } 25 | } 26 | 27 | .hero-bg { 28 | background: linear-gradient(90deg, rgba(42, 46, 49, 0.7) 12.5%, rgba(8, 15, 17, 1) 100%); 29 | } 30 | 31 | @media (max-width: 767px) { 32 | .layout { 33 | background: radial-gradient( 34 | 200% 100% at 50% 100%, 35 | var(--surface-default) 0%, 36 | #161b1d 100% 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/ui/styles/components/menu-button.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .dropdown-menu-content { 10 | /* we want menu popover to be on top of top bar and pagination bar too */ 11 | @apply z-topBarDropdown min-w-36 rounded border p-0 bg-raise border-secondary; 12 | 13 | & .DropdownMenuItem { 14 | @apply flex w-full cursor-pointer select-none items-center border-b py-2 pl-3 pr-6 text-left text-sans-md text-default border-secondary last:border-b-0; 15 | 16 | &.destructive { 17 | @apply text-destructive; 18 | } 19 | 20 | &[data-disabled] { 21 | @apply cursor-not-allowed text-disabled; 22 | } 23 | 24 | &.destructive[data-disabled] { 25 | @apply text-destructive-disabled; 26 | } 27 | 28 | &[data-focus] { 29 | outline: none; 30 | @apply bg-tertiary; 31 | } 32 | } 33 | } 34 | 35 | @keyframes slide-down { 36 | 0% { 37 | opacity: 0; 38 | transform: translateY(10px); 39 | } 40 | 100% { 41 | opacity: 1; 42 | transform: translateY(0); 43 | } 44 | } 45 | 46 | .dropdown-menu-content, 47 | .popover-panel { 48 | animation: slide-down 0.2s var(--ease-out-quad); 49 | } 50 | 51 | @media (prefers-reduced-motion) { 52 | .dropdown-menu-content, 53 | .popover-panel { 54 | animation-name: none; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/ui/styles/components/menu-list.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .ox-menu { 10 | @apply !max-h-[17.5rem] overflow-y-auto rounded border bg-raise border-secondary elevation-2; 11 | } 12 | 13 | .ox-menu-item { 14 | @apply relative cursor-pointer px-3 py-2 text-sans-md text-raise; 15 | } 16 | 17 | .ox-menu-item.is-highlighted { 18 | @apply bg-hover; 19 | } 20 | 21 | .ox-menu-item.is-highlighted.is-selected { 22 | @apply bg-accent-secondary-hover; 23 | } 24 | 25 | .ox-menu-item.is-selected[data-highlighted] { 26 | @apply bg-accent-secondary-hover; 27 | } 28 | 29 | .ox-menu-item.is-selected { 30 | @apply border-0 text-accent bg-accent-secondary hover:bg-accent-secondary-hover; 31 | .ox-badge { 32 | @apply ring-0 text-inverse bg-accent; 33 | } 34 | } 35 | 36 | /* beautiful ring */ 37 | .ox-menu-item.is-selected:after { 38 | content: ''; 39 | @apply absolute bottom-0 left-0 right-0 top-0 block rounded border border-accent; 40 | } 41 | -------------------------------------------------------------------------------- /app/ui/styles/components/mini-table.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .ox-mini-table { 10 | & { 11 | border-spacing: 0px; 12 | } 13 | 14 | & td { 15 | @apply relative px-0 pt-2; 16 | } 17 | 18 | & tr { 19 | @apply relative; 20 | } 21 | 22 | & td + td:before { 23 | @apply absolute bottom-[2px] top-[calc(0.5rem+1px)] block w-[1px] border-l opacity-40 border-accent-tertiary; 24 | content: ' '; 25 | } 26 | 27 | & tr:last-child td + td:before { 28 | @apply bottom-[calc(0.5rem+2px)]; 29 | } 30 | 31 | & td > div { 32 | @apply flex h-11 items-center border-y py-3 pl-3 pr-6 text-accent bg-accent-secondary border-accent-tertiary; 33 | } 34 | 35 | & td:last-child > div { 36 | @apply w-12 justify-center pl-0 pr-0; 37 | } 38 | & td:last-child > div > button { 39 | @apply -mx-3 -my-3 flex items-center justify-center px-3 py-3; 40 | } 41 | & td:last-child > div:has(button:hover, button:focus) { 42 | @apply bg-accent-secondary-hover; 43 | } 44 | 45 | & tr:last-child td { 46 | @apply pb-2; 47 | } 48 | 49 | & td:first-child > div { 50 | @apply ml-2 rounded-l border-l; 51 | } 52 | 53 | & td:last-child > div { 54 | @apply mr-2 rounded-r border-r; 55 | } 56 | 57 | & thead tr:first-of-type th:first-of-type { 58 | border-top-left-radius: var(--border-radius-lg); 59 | @apply border-l; 60 | } 61 | 62 | & thead tr:first-of-type th:last-of-type { 63 | border-top-right-radius: var(--border-radius-lg); 64 | @apply border-r; 65 | } 66 | 67 | & tbody tr:last-of-type td:first-of-type { 68 | border-bottom-left-radius: var(--border-radius-lg); 69 | } 70 | 71 | & tbody tr:last-of-type td:last-of-type { 72 | border-bottom-right-radius: var(--border-radius-lg); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/ui/styles/components/side-modal.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .ox-side-modal { 10 | --content-gutter: 2rem; 11 | } 12 | 13 | .ox-side-modal > :not(.body, button, footer), 14 | .ox-side-modal > .body > * { 15 | margin-left: var(--content-gutter); 16 | margin-right: var(--content-gutter); 17 | } 18 | 19 | .ox-side-modal > footer { 20 | padding-left: var(--content-gutter); 21 | padding-right: var(--content-gutter); 22 | } 23 | -------------------------------------------------------------------------------- /app/ui/styles/components/tooltip.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .ox-tooltip { 10 | @apply rounded border p-2 text-sans-md text-default bg-raise border-secondary elevation-2; 11 | } 12 | 13 | .ox-tooltip-arrow { 14 | position: absolute; 15 | height: 12px; 16 | width: 12px; 17 | transform: rotate(-135deg); 18 | @apply bg-raise border-secondary; 19 | } 20 | 21 | .ox-tooltip[data-placement^='top'] .ox-tooltip-arrow { 22 | @apply border-l border-t; 23 | margin-top: 2.5px; 24 | } 25 | 26 | .ox-tooltip[data-placement^='bottom'] .ox-tooltip-arrow { 27 | @apply -top-[6.5px] border-b border-r; 28 | margin-top: 0px; 29 | } 30 | 31 | .ox-tooltip[data-placement^='right'] .ox-tooltip-arrow { 32 | @apply -left-[6.5px] border-r border-t; 33 | } 34 | 35 | .ox-tooltip[data-placement^='left'] .ox-tooltip-arrow { 36 | @apply -right-[6.5px] border-b border-l; 37 | } 38 | -------------------------------------------------------------------------------- /app/ui/styles/themes/selection.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /** 10 | * The selection theme overrides certain styles such that the selected state of components can be displayed without 11 | * requiring each individual component to implement styles for it 12 | * 13 | * This file is _not_ automatically generated 14 | */ 15 | .is-selected, 16 | :checked ~ * { 17 | --content-default: var(--theme-accent-800); 18 | --content-secondary: var(--theme-accent-700); 19 | --content-tertiary: var(--theme-accent-700); 20 | --content-quaternary: var(--theme-accent-600); 21 | --content-quinary: var(--theme-accent-500); 22 | --surface-default: var(--theme-accent-300); 23 | --stroke-secondary: var(--stroke-accent-secondary); 24 | --stroke-surface: var(--surface-default); 25 | --surface-raise: var(--theme-accent-4); 26 | 27 | & .ox-badge, 28 | & .ox-tag, 29 | & .ox-button:not(.btn-ghost), 30 | & .ox-radio-card { 31 | --content-accent: var(--content-inverse); 32 | --surface-accent-secondary: var(--theme-accent-1); 33 | --surface-accent-secondary-hover: var(--theme-accent-2); 34 | --content-notice: var(--content-inverse); 35 | --surface-notice-secondary: var(--theme-notice-1); 36 | --surface-notice-secondary-hover: var(--theme-notice-2); 37 | --content-destructive: var(--content-inverse); 38 | --surface-destructive-secondary: var(--theme-destructive-1); 39 | --surface-destructive-secondary-hover: var(--theme-destructive-2); 40 | --content-secondary: var(--content-inverse); 41 | --surface-secondary: var(--surface-inverse-secondary); 42 | --surface-secondary-hover: var(--surface-inverse-tertiary); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/ui/util/aria.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | // neat trick for requiring aria-labelledby XOR aria-label on a component 10 | // 11 | // type Props = { ...otherProps } & AriaLabel 12 | // 13 | // then use {...ariaLabel(props)} to put whichever one is actually there on the 14 | // thing. This is better than { aria-labelledby?: string; aria-label?: string } 15 | // because that lets you get away with neither (unless you check at runtime, ew) 16 | 17 | export type AriaLabel = 18 | | { 19 | 'aria-labelledby': string 20 | 'aria-label'?: never 21 | } 22 | | { 23 | 'aria-labelledby'?: never 24 | 'aria-label': string 25 | } 26 | 27 | export const ariaLabel = (props: AriaLabel) => 28 | 'aria-labelledby' in props 29 | ? { 'aria-labelled-by': props['aria-labelledby'] } 30 | : { 'aria-label': props['aria-label'] } 31 | -------------------------------------------------------------------------------- /app/ui/util/keys.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | // For easy reference in components 10 | // See list of values here: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values 11 | export const KEYS = { 12 | end: 'End', 13 | home: 'Home', 14 | left: 'ArrowLeft', 15 | up: 'ArrowUp', 16 | right: 'ArrowRight', 17 | down: 'ArrowDown', 18 | delete: 'Delete', 19 | backspace: 'Backspace', 20 | enter: 'Enter', 21 | space: ' ', 22 | escape: 'Escape', 23 | } as const 24 | -------------------------------------------------------------------------------- /app/ui/util/story-section.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | import type { ReactNode } from 'react' 10 | 11 | import { capitalize } from '~/util/str' 12 | 13 | /** 14 | * This is a utility component that helps prettify sections of stories when there are a lot of 15 | * component variations to show. 16 | */ 17 | export const Section = ({ 18 | title, 19 | children, 20 | className, 21 | }: { 22 | title: string 23 | children: ReactNode 24 | className?: string 25 | }) => ( 26 |
    27 |

    28 | {capitalize(title)} 29 |

    30 | {children} 31 |
    32 | ) 33 | -------------------------------------------------------------------------------- /app/ui/util/wrap.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import React, { type ReactElement, type ReactNode } from 'react' 9 | 10 | interface WrapProps { 11 | when: unknown 12 | with: ReactElement 13 | children: ReactNode 14 | } 15 | 16 | export const Wrap = (props: WrapProps) => 17 | props.when ? React.cloneElement(props.with, [], props.children) : <>{props.children} 18 | -------------------------------------------------------------------------------- /app/util/abort.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /** 10 | * Combine multiple abort signals into one. 11 | * 12 | * Borrowed from: https://github.com/whatwg/fetch/issues/905#issuecomment-1816547024 13 | * 14 | * Can be replaced by AbortSignal.any once browser support improves. It is in 15 | * all major browsers as of March 2024. 16 | * https://caniuse.com/mdn-api_abortsignal_any_static 17 | */ 18 | export function anySignal(signals: Array): AbortSignal { 19 | const controller = new AbortController() 20 | 21 | for (const signal of signals) { 22 | if (!signal) continue 23 | 24 | // if any are already aborted, abort 25 | if (signal.aborted) { 26 | controller.abort(signal.reason) 27 | return controller.signal 28 | } 29 | 30 | signal.addEventListener('abort', () => controller.abort(signal.reason), { 31 | once: true, 32 | signal: controller.signal, 33 | }) 34 | } 35 | 36 | return controller.signal 37 | } 38 | -------------------------------------------------------------------------------- /app/util/access.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { IdentityType, RoleKey } from '~/api' 10 | import type { BadgeColor } from '~/ui/lib/Badge' 11 | 12 | export const identityTypeLabel: Record = { 13 | silo_group: 'Group', 14 | silo_user: 'User', 15 | } 16 | 17 | export const roleColor: Record = { 18 | admin: 'default', 19 | collaborator: 'purple', 20 | viewer: 'blue', 21 | } 22 | -------------------------------------------------------------------------------- /app/util/all-zeros.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { expect, test } from 'vitest' 9 | 10 | import { isAllZeros } from '~/util/str' 11 | 12 | function numberToUint8Array(num: number) { 13 | const bytes = [] 14 | while (num > 0) { 15 | bytes.unshift(num & 0xff) 16 | // eslint-disable-next-line no-param-reassign 17 | num >>= 4 18 | } 19 | return new Uint8Array(bytes) 20 | } 21 | 22 | test('isAllZeros', () => { 23 | expect(isAllZeros('AAAA')).toBeTruthy() 24 | expect(Array.from(Buffer.from('AAAA', 'base64'))).toEqual([0, 0, 0]) 25 | 26 | expect(isAllZeros('AAAB')).toBeFalsy() 27 | expect(Array.from(Buffer.from('AAAB', 'base64'))).toEqual([0, 0, 1]) 28 | 29 | const allZeros = Buffer.alloc(20).toString('base64') 30 | expect(isAllZeros(allZeros)).toBeTruthy() 31 | 32 | const notAllZeros = Buffer.alloc(20, 1).toString('base64') 33 | expect(isAllZeros(notAllZeros)).toBeFalsy() 34 | 35 | for (let i = 1; i < 100000; i++) { 36 | const s = Buffer.from(numberToUint8Array(i)).toString('base64') 37 | expect(isAllZeros(s)).toBeFalsy() 38 | } 39 | }) 40 | -------------------------------------------------------------------------------- /app/util/array.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { cloneElement, type ReactElement } from 'react' 10 | import * as R from 'remeda' 11 | 12 | type GroupKey = string | number | symbol 13 | 14 | export function groupBy(arr: T[], by: (t: T) => GroupKey) { 15 | return Object.entries(R.groupBy(arr, by)) 16 | } 17 | 18 | /** 19 | * If a conjunction is included, use that instead of `sep` when there are two items. 20 | */ 21 | export function intersperse( 22 | items: ReactElement[], 23 | sep: ReactElement, 24 | conj?: ReactElement 25 | ): ReactElement[] { 26 | if (items.length <= 1) return items 27 | if (conj && items.length === 2) { 28 | const conj0 = cloneElement(conj, { key: `conj` }) 29 | return [items[0], conj0, items[1]] 30 | } 31 | return items.flatMap((item, i) => { 32 | if (i === 0) return [item] 33 | const sep0 = cloneElement(sep, { key: `sep-${i}` }) 34 | if (conj && i === items.length - 1) { 35 | const conj0 = cloneElement(conj, { key: `conj` }) 36 | return [sep0, conj0, item] 37 | } 38 | return [sep0, item] 39 | }) 40 | } 41 | 42 | export function isSetEqual(a: Set, b: Set): boolean { 43 | if (a.size !== b.size) return false 44 | for (const item of a) { 45 | if (!b.has(item)) { 46 | return false 47 | } 48 | } 49 | return true 50 | } 51 | 52 | /** Set `a - b` */ 53 | export function setDiff(a: Set, b: Set): Set { 54 | return new Set([...a].filter((x) => !b.has(x))) 55 | } 56 | -------------------------------------------------------------------------------- /app/util/classed.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import cn from 'classnames' 9 | import React, { type JSX } from 'react' 10 | 11 | // all the cuteness of tw.span`text-green-500 uppercase` with zero magic 12 | 13 | const make = 14 | (tag: T) => 15 | // only one argument here means string interpolations are not allowed 16 | (strings: TemplateStringsArray) => { 17 | const Comp = ({ className, ...rest }: JSX.IntrinsicElements[T]) => 18 | React.createElement(tag, { className: cn(strings[0], className), ...rest }) 19 | Comp.displayName = `classed.${tag}` 20 | return Comp 21 | } 22 | 23 | // JSX.IntrinsicElements[T] ensures same props as the real DOM element. For example, 24 | // classed.span doesn't allow a type attr but classed.input does. 25 | 26 | export const classed = { 27 | button: make('button'), 28 | code: make('code'), 29 | div: make('div'), 30 | footer: make('footer'), 31 | h1: make('h1'), 32 | h2: make('h2'), 33 | h3: make('h3'), 34 | h4: make('h4'), 35 | hr: make('hr'), 36 | header: make('header'), 37 | input: make('input'), 38 | label: make('label'), 39 | li: make('li'), 40 | main: make('main'), 41 | ol: make('ol'), 42 | p: make('p'), 43 | span: make('span'), 44 | table: make('table'), 45 | tbody: make('tbody'), 46 | td: make('td'), 47 | th: make('th'), 48 | tr: make('tr'), 49 | } as const 50 | 51 | // result: classed.button`text-green-500 uppercase` returns a component with those classes 52 | -------------------------------------------------------------------------------- /app/util/consts.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /** Used as a stand-in for "approximately everything" limit value in queries */ 10 | export const ALL_ISH = 1000 11 | -------------------------------------------------------------------------------- /app/util/file.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { describe, expect, test } from 'vitest' 9 | 10 | import { readBlobAsBase64 } from './file' 11 | 12 | describe('readBlobAsBase64', () => { 13 | test('works with zeros', async () => { 14 | const blob = new Blob([Buffer.alloc(10)]) 15 | const text = await readBlobAsBase64(blob) 16 | expect(text).toEqual('AAAAAAAAAAAAAA==') 17 | }) 18 | 19 | test('works with other stuff', async () => { 20 | const original = 'abcdef'.repeat(100) 21 | const blob = new Blob([Buffer.from(original)]) 22 | const text = await readBlobAsBase64(blob) 23 | expect(btoa(original)).toEqual(text) 24 | expect(atob(text)).toEqual(original) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /app/util/file.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /** async wrapper for reading a slice of a file */ 10 | export async function readBlobAsBase64(blob: Blob): Promise { 11 | return new Promise((resolve) => { 12 | const fileReader = new FileReader() 13 | 14 | // split on comma and pop because data URL looks like 15 | // 'data:[][;base64],' and we only want . 16 | // e.target is never null and result is always a string 17 | fileReader.onload = function (e) { 18 | const base64Chunk = (e.target!.result as string).split(',').pop()! 19 | resolve(base64Chunk) 20 | } 21 | 22 | fileReader.readAsDataURL(blob) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /app/util/invariant.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /** 10 | * Throw error if `condition` is falsy. Not using `tiny-invariant` because we 11 | * want the full error in production. 12 | */ 13 | export function invariant(condition: unknown, message: string): asserts condition { 14 | if (!condition) throw new Error(`Invariant failed: ${message}`) 15 | } 16 | -------------------------------------------------------------------------------- /app/util/motion-features.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | // see https://motion.dev/docs/react-reduce-bundle-size#lazy-loading 10 | export { domAnimation } from 'motion/react' 11 | -------------------------------------------------------------------------------- /app/util/path-params.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import type * as Sel from '~/api/selectors' 9 | 10 | // The path versions simply have all params required 11 | 12 | export type Project = Required 13 | export type Instance = Required 14 | export type Vpc = Required 15 | export type Silo = Required 16 | export type IdentityProvider = Required 17 | export type Sled = Required 18 | export type Image = Required 19 | export type Snapshot = Required 20 | export type SiloImage = Required 21 | export type IpPool = Required 22 | export type FloatingIp = Required 23 | export type FirewallRule = Required 24 | export type VpcRouter = Required 25 | export type VpcRouterRoute = Required 26 | export type VpcSubnet = Required 27 | export type VpcInternetGateway = Required 28 | export type SshKey = Required 29 | export type AffinityGroup = Required 30 | export type AntiAffinityGroup = Required 31 | -------------------------------------------------------------------------------- /app/util/units.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { round } from './math' 9 | 10 | export const KiB = 1024 11 | export const MiB = 1024 * KiB 12 | export const GiB = 1024 * MiB 13 | export const TiB = 1024 * GiB 14 | 15 | export const bytesToGiB = (b: number, digits = 2) => round(b / GiB, digits) 16 | export const bytesToTiB = (b: number, digits = 2) => round(b / TiB, digits) 17 | -------------------------------------------------------------------------------- /docs/csp-headers.md: -------------------------------------------------------------------------------- 1 | # CSP headers in local dev and on Vercel 2 | 3 | ## Why 4 | 5 | Production CSP headers are set server-side in Nexus, so why should we set the headers on Vercel and the Vite dev server too? We are not _that_ concerned about security in those environments. The main reason is so we can know as early as possible in the development process whether a given CSP directive breaks something the web console. 6 | 7 | ## What 8 | 9 | The base headers are defined in `vercel.json` and imported into `vite.config.ts` to avoid repeating them. 10 | 11 | The `content-security-policy` is based on the recommendation by the [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/index.html) (click the "Best Practices" tab). The directives: 12 | 13 | - `default-src 'self'`: By default, restrict all resources to same-origin. 14 | - `style-src 'unsafe-inline' 'self'`: Restrict CSS to same-origin and inline use. See #2183 for eventually removing `'unsafe-inline'` 15 | - `frame-src 'none'`: Disallow nested browsing contexts (`` and `