ui: confirming an alert dialog should close it #6
18
app/app.css
18
app/app.css
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
51
app/components/copy-id.tsx
Normal file
51
app/components/copy-id.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{" "}
|
||||||
|
|||||||
Reference in New Issue
Block a user