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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 0 additions & 69 deletions app/hooks/use-is-overflow.ts

This file was deleted.

1 change: 1 addition & 0 deletions app/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default function LoginPage() {
name="password"
type="password"
placeholder="Password"
autoComplete="current-password"
required
control={form.control}
/>
Expand Down
9 changes: 7 additions & 2 deletions app/stores/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Copyright Oxide Computer Company
*/
import { type ReactElement } from 'react'
import { v4 as uuid } from 'uuid'
import { create } from 'zustand'

import type { ToastProps } from '~/ui/lib/Toast'
Expand All @@ -18,6 +17,10 @@ type Toast = {

export const useToastStore = create<{ toasts: Toast[] }>(() => ({ toasts: [] }))

// A counter and not crypto.randomUUID(), which only exists in secure contexts.
// The console can be served over plain HTTP, e.g., from Nexus on a dev machine.
let nextId = 0

/**
* If argument is `ReactElement | string`, use it directly as `{ content }`.
* Otherwise it's a config object.
Expand All @@ -27,7 +30,9 @@ export function addToast(optionsOrContent: Toast['options'] | ReactElement | str
typeof optionsOrContent === 'object' && 'content' in optionsOrContent
? optionsOrContent
: { content: optionsOrContent }
useToastStore.setState(({ toasts }) => ({ toasts: [...toasts, { id: uuid(), options }] }))
useToastStore.setState(({ toasts }) => ({
toasts: [...toasts, { id: String(nextId++), options }],
}))
}

export function removeToast(id: Toast['id']) {
Expand Down
16 changes: 2 additions & 14 deletions app/ui/lib/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,15 @@
* Copyright Oxide Computer Company
*/
import cn from 'classnames'
import React, { useRef, type JSX, type ReactElement } from 'react'
import React, { type JSX, type ReactElement } from 'react'
import SimpleBar from 'simplebar-react'

import { useIsOverflow } from '~/hooks/use-is-overflow'
import { classed } from '~/util/classed'

export type TableProps = JSX.IntrinsicElements['table']
export function Table({ className, ...props }: TableProps) {
const overflowRef = useRef<HTMLDivElement>(null)
const { isOverflow, scrollStart, scrollEnd } = useIsOverflow(overflowRef, 'horizontal')

return (
<SimpleBar
scrollableNodeProps={{ ref: overflowRef }}
className={cn(
'overflow-x-auto rounded-md pb-4',
!scrollStart && 'scrolled',
isOverflow && !scrollEnd && 'overflowing'
)}
autoHide={false}
>
<SimpleBar className="overflow-x-auto rounded-md pb-4" autoHide={false}>
<table
className={cn(className, 'ox-table text-sans-md w-full border-separate')}
{...props}
Expand Down
79 changes: 44 additions & 35 deletions app/ui/styles/components/table.css
Original file line number Diff line number Diff line change
Expand Up @@ -314,45 +314,54 @@ table.ox-table {
}

/*
Drop shadow appears if content is below a sticky
cell and the table is overflowing
Drop shadow on a sticky column when content is scrolled under it, driven by
the table's horizontal scroll position. When the table fits, the timeline is
inactive and neither shadow shows. The clip-path stops the shadow spilling
above and below the cell; it's only on while there's a shadow so it doesn't
clip the selected-row border.
*/
td:first-of-type,
th:first-of-type,
td:last-of-type.action-col,
th:last-of-type.action-col {
transition-property: box-shadow;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 250ms;
.ox-table {
--sticky-col-shadow: light-dark(oklch(0 0 0 / 0.1), oklch(0 0 0 / 0.8));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benjaminleonard you might want to tone down the light mode one. See the screenshot in the PR description. It may have been fine before. The dark mode one was invisible though.

}

.scrolled td:first-of-type,
.scrolled th:first-of-type {
box-shadow: 6px 0 8px -2px oklch(0 0 0 / 0.2);
}
.overflowing td:last-of-type.action-col,
.overflowing th:last-of-type.action-col {
box-shadow: -6px 0 8px -2px oklch(0 0 0 / 0.2);
}
[data-theme='light'] .scrolled td:first-of-type,
[data-theme='light'] .scrolled th:first-of-type {
box-shadow: 6px 0 8px -2px oklch(0 0 0 / 0.05);
}
[data-theme='light'] .overflowing td:last-of-type.action-col,
[data-theme='light'] .overflowing th:last-of-type.action-col {
box-shadow: -6px 0 8px -2px oklch(0 0 0 / 0.05);
/* Without the guard, browsers lacking scroll timelines would run the animation
with a 0s duration and hold the start shadow's end state on every table */
@supports (animation-timeline: scroll()) {
.ox-table :is(td, th):first-of-type {
animation: sticky-start-shadow linear both;
animation-timeline: scroll(nearest inline);
animation-range: 0 2rem;
}
.ox-table :is(td, th):last-of-type.action-col {
animation: sticky-end-shadow linear both;
animation-timeline: scroll(nearest inline);
animation-range: calc(100% - 2rem) 100%;
}
}

/*
Stops shadow from overflowing below
*/
.scrolled td:first-of-type,
.scrolled th:first-of-type {
clip-path: inset(0px -40px 0px 0px);
@keyframes sticky-start-shadow {
from {
clip-path: none;
}
1% {
clip-path: inset(0 -40px 0 0);
}
to {
clip-path: inset(0 -40px 0 0);
box-shadow: 6px 0 8px -2px var(--sticky-col-shadow);
}
}
.overflowing td:last-of-type.action-col,
.overflowing th:last-of-type.action-col {
clip-path: inset(0px 0px 0px -40px);
@keyframes sticky-end-shadow {
from {
clip-path: inset(0 0 0 -40px);
box-shadow: -6px 0 8px -2px var(--sticky-col-shadow);
}
99% {
clip-path: inset(0 0 0 -40px);
}
to {
clip-path: none;
}
}

.simplebar-scrollbar {
Expand All @@ -361,15 +370,15 @@ th:last-of-type.action-col {
}

.simplebar-scrollbar:before {
background-color: rgba(255, 255, 255, 0.28);
background-color: var(--content-quaternary);
left: 0 !important;
top: 0 !important;
right: 0 !important;
border-radius: 8px !important;
}

.simplebar-hover.simplebar-scrollbar:before {
background-color: rgba(255, 255, 255, 0.4);
background-color: var(--content-tertiary);
}

.simplebar-track.simplebar-horizontal {
Expand Down
9 changes: 9 additions & 0 deletions app/ui/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@
@source '../../../node_modules/@oxide/design-system/dist';
@custom-variant light (&:where([data-theme=light], [data-theme=light] *));

/* The color-scheme meta tag follows the OS. These make browser-drawn UI
(scrollbars, date pickers, autofill) follow the chosen theme instead. */
[data-theme='dark'] {
color-scheme: dark;
}
[data-theme='light'] {
color-scheme: light;
}

@theme {
--animate-spin-slow: spin 5s linear infinite;
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
Expand Down
16 changes: 7 additions & 9 deletions mock-api/audit-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
* Copyright Oxide Computer Company
*/

import { v4 as uuid } from 'uuid'

import type { AuditLogEntry } from '@oxide/api'

import { Rando } from '~/util/rando'
Expand Down Expand Up @@ -78,7 +76,7 @@ const mockSourceIps = [
'198.51.100.42',
]

const mockRequestIds = Array.from({ length: 20 }, () => uuid())
const mockRequestIds = Array.from({ length: 20 }, () => crypto.randomUUID())

// Use seeded random for consistent states across runs
const rando = new Rando(0)
Expand All @@ -94,7 +92,7 @@ function generateAuditLogEntry(index: number): Json<AuditLogEntry> {
completedTime.setMilliseconds(rando.next() * 300 + completedTime.getMilliseconds()) // deterministic random durations

return {
id: uuid(),
id: crypto.randomUUID(),
auth_method: mockAuthMethod[index % mockAuthMethod.length],
actor: {
kind: 'silo_user',
Expand All @@ -121,7 +119,7 @@ function generateAuditLogEntry(index: number): Json<AuditLogEntry> {
export const auditLog: Json<AuditLogEntry[]> = [
// Recent successful operations
{
id: uuid(),
id: crypto.randomUUID(),
auth_method: 'session_cookie',
actor: {
kind: 'silo_user',
Expand All @@ -137,7 +135,7 @@ export const auditLog: Json<AuditLogEntry[]> = [
source_ip: '192.168.1.100',
},
{
id: uuid(),
id: crypto.randomUUID(),
auth_method: 'access_token',
actor: {
kind: 'silo_user',
Expand All @@ -155,7 +153,7 @@ export const auditLog: Json<AuditLogEntry[]> = [
},
// Failed operations
{
id: uuid(),
id: crypto.randomUUID(),
auth_method: 'session_cookie',
actor: {
kind: 'silo_user',
Expand All @@ -177,7 +175,7 @@ export const auditLog: Json<AuditLogEntry[]> = [
source_ip: '172.16.0.25',
},
{
id: uuid(),
id: crypto.randomUUID(),
auth_method: null,
actor: { kind: 'unauthenticated' },
result: {
Expand All @@ -195,7 +193,7 @@ export const auditLog: Json<AuditLogEntry[]> = [
},
// More historical entries
{
id: uuid(),
id: crypto.randomUUID(),
auth_method: 'session_cookie',
actor: {
kind: 'silo_user',
Expand Down
4 changes: 1 addition & 3 deletions mock-api/msw/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@
*
* Copyright Oxide Computer Company
*/
// note that isUuid checks for any kind of UUID. strictly speaking, we should
// only be checking for v4
import * as R from 'remeda'
import { validate as isUuid } from 'uuid'

import type { ApiTypes as Api, IpPoolType, IpVersion } from '@oxide/api'
import * as mock from '@oxide/api-mocks'
Expand All @@ -20,6 +17,7 @@ import { commaSeries } from '~/util/str'
import type { Json } from '../json-type'
import { projects, type DbProject } from '../project'
import { defaultSilo, siloSettings } from '../silo'
import { isUuid } from '../util'
import { internalError, invalidRequest } from './util'

export const notFoundErr = (msg: string) => {
Expand Down
Loading
Loading