Phase 7: polish — overview health, reduced-motion, mobile, a11y, copy-ids

- Overview health hero derives from subsystem probes when the backend gives no
  overall status, so it never says "Unknown" next to an all-Healthy Subsystems
  card (the audit's most jarring contradiction). Home error copy now uses the
  Phase-2 describeError instead of raw backend strings.
- Global prefers-reduced-motion guard (transitions, loops, aurora field) +
  motion-reduce on the sidebar width transition.
- Mobile: content reserves a top row (pt-16 md:p-6) so the floating hamburger
  never overlaps the page H1.
- A11y: collapsed rail items get an explicit aria-label.
- Monitoring: disk "busiest mount" tile shows the % as the value and the long
  mount path as a truncated caption, so it no longer clips mid-word.
- New CopyId component (click-to-copy UUIDs), wired into Profile.

Typecheck 36→36, 24-route sweep clean. Remaining items (lib a11y labels,
table aria-sort, dev-only monitoring data) noted in the spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-14 14:40:58 +10:00
parent a03fbd9e9b
commit 3fcdddefda
6 changed files with 129 additions and 15 deletions

View File

@@ -11,7 +11,7 @@ import {
Users as UsersIcon,
} 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 { 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 { useRegisterContext } from "@crema/aifirst-ui/context"
import { pageTitle } from "~/lib/page-meta"
import { errorMessage } from "~/lib/errors"
import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Overview")
@@ -56,7 +57,7 @@ export default function HomeRoute() {
const [data, setData] = useState<DashboardData>(EMPTY)
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 refresh = useCallback(async () => {
@@ -70,7 +71,7 @@ export default function HomeRoute() {
listAuditLogs(arcadia, { limit: 10 }),
getHealth(arcadia).catch(() => null),
]).catch((err) => {
setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.")
setError(err)
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
})
setData({ tenants, users, audit, health })
@@ -92,7 +93,7 @@ export default function HomeRoute() {
tenants: { total: data.tenants.length, active: activeTenants },
users: { total: data.users.length, active: activeUsers },
audit: { recent: data.audit.length, errors: errorEvents },
health: data.health?.status ?? "unconfigured",
health: deriveOverallStatus(data.health),
}
}, [data])
@@ -129,7 +130,7 @@ export default function HomeRoute() {
{error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error}
{errorMessage(error, "load the overview")}
</AlertBanner>
) : null}
@@ -408,6 +409,25 @@ function statusLabel(status: HealthStatus | string): string {
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" {
if (status === "ok") return "ok"
if (status === "degraded") return "warning"

View File

@@ -334,7 +334,15 @@ export default function MonitoringRoute() {
/>
<KpiTile
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" />}
tone={
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)
}
function busiestDiskLabel(disks: HostStats["disks"]): string {
if (disks.length === 0) return "—"
const busiest = disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a))
return `${busiest.used_pct}% (${busiest.mount})`
function busiestDisk(disks: HostStats["disks"]) {
if (disks.length === 0) return null
return disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a))
}
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 {

View File

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