ui: confirming an alert dialog should close it #6

Open
jules wants to merge 7 commits from fix/alert-dialog-action-closes into main
6 changed files with 129 additions and 15 deletions
Showing only changes of commit 3fcdddefda - Show all commits

View File

@@ -156,3 +156,21 @@
} }
} }
/* Accessibility: honour a reduced-motion request. Skyrise leans on ambient
* drift (the aurora field), spring transitions, and looping keyframes; for
* anyone who asks for less motion, near-instant everything and freeze the
* decorative loops. This is the global guard the app previously lacked. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
[data-slot="aurora-field"] {
animation: none !important;
}
}

View File

@@ -0,0 +1,51 @@
import { useState } from "react"
import { Check, Copy } from "lucide-react"
/**
* A monospace id (UUID, slug, token name) that copies to the clipboard on
* click, with a brief checkmark. Raw ids are common in an admin console and
* useless if you can't get them into a support ticket or a CLI.
*/
export function CopyId({
value,
label,
className = "",
dataAction,
}: {
value: string
/** What's being copied, for the aria-label. Defaults to "id". */
label?: string
className?: string
dataAction?: string
}) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(value)
setCopied(true)
setTimeout(() => setCopied(false), 1200)
} catch {
// Clipboard blocked (insecure context / permissions) — no-op; the value
// is still selectable by hand.
}
}
return (
<button
type="button"
onClick={copy}
data-action={dataAction}
aria-label={`Copy ${label ?? "id"}`}
title="Copy"
className={`group inline-flex max-w-full items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-xs transition-colors hover:bg-accent ${className}`}
>
<span className="truncate">{value}</span>
{copied ? (
<Check className="size-3 shrink-0 text-emerald-500" />
) : (
<Copy className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
)}
</button>
)
}

View File

@@ -360,7 +360,7 @@ export function AppShell({
data-slot="sidebar" data-slot="sidebar"
data-expanded={expanded ? "true" : "false"} data-expanded={expanded ? "true" : "false"}
className={[ className={[
"sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard md:flex", "sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard motion-reduce:transition-none md:flex",
expanded ? "w-60" : "w-16", expanded ? "w-60" : "w-16",
].join(" ")} ].join(" ")}
> >
@@ -656,7 +656,10 @@ export function AppShell({
one floating card in a sea of black. The floating actions one floating card in a sea of black. The floating actions
pill is fixed to the viewport edge and lives outside this pill is fixed to the viewport edge and lives outside this
column, so it stays clear regardless of cap width. */} column, so it stays clear regardless of cap width. */}
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 p-6 [&>*:first-child]:lg:pr-72"> {/* pt-16 on mobile reserves a row for the floating hamburger (left)
and actions pill (right) so they never overlap the page H1; the
desktop rail/pill sit outside this column, so pt drops to p-6. */}
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 px-4 pb-6 pt-16 md:p-6 [&>*:first-child]:lg:pr-72">
<RouteGuard>{children}</RouteGuard> <RouteGuard>{children}</RouteGuard>
</div> </div>
</div> </div>
@@ -693,6 +696,9 @@ function NavRow({
to={item.to} to={item.to}
end={item.end} end={item.end}
title={expanded ? undefined : item.label} title={expanded ? undefined : item.label}
// When collapsed there's no visible label text, only an icon — give
// screen readers the name explicitly (title alone isn't reliably read).
aria-label={expanded ? undefined : item.label}
onClick={onNavigate} onClick={onNavigate}
data-action={`${prefix}${item.label.toLowerCase()}`} data-action={`${prefix}${item.label.toLowerCase()}`}
className={({ isActive }) => className={({ isActive }) =>

View File

@@ -11,7 +11,7 @@ import {
Users as UsersIcon, Users as UsersIcon,
} from "lucide-react" } from "lucide-react"
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client" import { useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner } from "@crema/feedback-ui" import { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
@@ -37,6 +37,7 @@ import { listTenants, type Tenant } from "~/lib/arcadia/tenants"
import { listUsers, type User } from "~/lib/arcadia/users" import { listUsers, type User } from "~/lib/arcadia/users"
import { useRegisterContext } from "@crema/aifirst-ui/context" import { useRegisterContext } from "@crema/aifirst-ui/context"
import { pageTitle } from "~/lib/page-meta" import { pageTitle } from "~/lib/page-meta"
import { errorMessage } from "~/lib/errors"
import { useSession } from "~/lib/session" import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Overview") export const meta = () => pageTitle("Overview")
@@ -56,7 +57,7 @@ export default function HomeRoute() {
const [data, setData] = useState<DashboardData>(EMPTY) const [data, setData] = useState<DashboardData>(EMPTY)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<unknown>(null)
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null) const [refreshedAt, setRefreshedAt] = useState<Date | null>(null)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@@ -70,7 +71,7 @@ export default function HomeRoute() {
listAuditLogs(arcadia, { limit: 10 }), listAuditLogs(arcadia, { limit: 10 }),
getHealth(arcadia).catch(() => null), getHealth(arcadia).catch(() => null),
]).catch((err) => { ]).catch((err) => {
setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.") setError(err)
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null] return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
}) })
setData({ tenants, users, audit, health }) setData({ tenants, users, audit, health })
@@ -92,7 +93,7 @@ export default function HomeRoute() {
tenants: { total: data.tenants.length, active: activeTenants }, tenants: { total: data.tenants.length, active: activeTenants },
users: { total: data.users.length, active: activeUsers }, users: { total: data.users.length, active: activeUsers },
audit: { recent: data.audit.length, errors: errorEvents }, audit: { recent: data.audit.length, errors: errorEvents },
health: data.health?.status ?? "unconfigured", health: deriveOverallStatus(data.health),
} }
}, [data]) }, [data])
@@ -129,7 +130,7 @@ export default function HomeRoute() {
{error ? ( {error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}> <AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error} {errorMessage(error, "load the overview")}
</AlertBanner> </AlertBanner>
) : null} ) : null}
@@ -408,6 +409,25 @@ function statusLabel(status: HealthStatus | string): string {
return "Unknown" return "Unknown"
} }
/**
* The overall health hero must never say "Unknown" while its own Subsystems
* card reports everything Healthy. When the backend gives no meaningful overall
* status (unknown/unconfigured/missing), derive it from the subsystem probes
* the same card shows: any error → Down, any degraded → Degraded, otherwise
* Healthy if at least one subsystem reported ok.
*/
function deriveOverallStatus(health: OverallHealth | null): HealthStatus | string {
if (!health) return "unconfigured"
if (health.status === "ok" || health.status === "degraded" || health.status === "error") {
return health.status
}
const statuses = Object.values(health.subsystems ?? {}).map((s) => s?.status)
if (statuses.some((s) => s === "error")) return "error"
if (statuses.some((s) => s === "degraded")) return "degraded"
if (statuses.some((s) => s === "ok")) return "ok"
return health.status ?? "unconfigured"
}
function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" { function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" {
if (status === "ok") return "ok" if (status === "ok") return "ok"
if (status === "degraded") return "warning" if (status === "degraded") return "warning"

View File

@@ -334,7 +334,15 @@ export default function MonitoringRoute() {
/> />
<KpiTile <KpiTile
label="Disk (busiest mount)" label="Disk (busiest mount)"
value={busiestDiskLabel(data.host.disks)} // The percentage alone is the metric — keep it in `value` so it
// never clips. The mount path (which can be long) goes in the
// caption, truncated with the full path on hover.
value={busiestDiskValue(data.host.disks)}
caption={
<span className="block truncate" title={busiestDiskMount(data.host.disks)}>
{busiestDiskMount(data.host.disks)}
</span>
}
icon={<Database className="size-4" />} icon={<Database className="size-4" />}
tone={ tone={
busiestDiskPct(data.host.disks) > 90 busiestDiskPct(data.host.disks) > 90
@@ -691,10 +699,18 @@ function busiestDiskPct(disks: HostStats["disks"]): number {
return disks.reduce((m, d) => Math.max(m, d.used_pct), 0) return disks.reduce((m, d) => Math.max(m, d.used_pct), 0)
} }
function busiestDiskLabel(disks: HostStats["disks"]): string { function busiestDisk(disks: HostStats["disks"]) {
if (disks.length === 0) return "—" if (disks.length === 0) return null
const busiest = disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a)) return disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a))
return `${busiest.used_pct}% (${busiest.mount})` }
function busiestDiskValue(disks: HostStats["disks"]): string {
const d = busiestDisk(disks)
return d ? `${d.used_pct}%` : "—"
}
function busiestDiskMount(disks: HostStats["disks"]): string {
return busiestDisk(disks)?.mount ?? "no mounts"
} }
function barColor(pct: number): string { function barColor(pct: number): string {

View File

@@ -5,6 +5,7 @@ import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner } from "@crema/feedback-ui" import { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { CopyId } from "~/components/copy-id"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { Badge } from "~/components/ui/badge" import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
@@ -357,9 +358,11 @@ export default function ProfileRoute() {
</span> </span>
{account ? ( {account ? (
<> <>
<span className="text-xs text-muted-foreground"> <span className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
Tenant <code className="font-mono">{account.tenant_id}</code> · Tenant
ID <code className="font-mono">{account.id}</code> <CopyId value={account.tenant_id} label="tenant id" dataAction="profile-copy-tenant-id" />
· ID
<CopyId value={account.id} label="account id" dataAction="profile-copy-account-id" />
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Last sign-in{" "} Last sign-in{" "}