2eb649eceb
Below md, the Users directory and the three Backups tables render as MobileCardRow cards: - UsersPage: display name primary; username/activity/email fields. Each card carries a selection checkbox (44px via mobile-touch-target) in the actions slot with stopPropagation so toggling selection does not open the drawer; card-body tap still opens the drawer. - BackupAlertsTable: alert message primary; severity/type/created fields; Acknowledge action preserved in actions slot. - BackupJobsTable: job name primary; source/schedule/last-status fields (joins latestRuns into a JobCardRow). - BackupRunsTable: run job_id primary; status/duration/size/started fields; status-filter Select renders above both layouts (preserved on mobile). Desktop (md+) is byte-for-byte identical for all four components -- the UsersPage diff is dominated by re-indenting the existing Table into the isMobile ternary else branch. Fix from Slice 5 review: MobileCardRow now renders the clickable card as <div role=button tabIndex=0> with Enter/Space keyboard handling instead of <button>, so nesting a Radix Checkbox (which renders a <button>) in the actions slot produces valid HTML. The desktop-parity argument for <button>-in-<button> did not hold (desktop rows are <tr>, not buttons). Cross-cutting: useIsMobile hardened with typeof window.matchMedia guard (safe in real browsers; only changes jsdom crash -> false). The file-local 900px compose hook was renamed useComposeViewport to avoid collision with the shared 768px useIsMobile. Tests: BackupJobsTable test file added (was untested), UsersPage mobile selection round-trip + stopPropagation, mobile card render across all four components. 105 tests pass; lint/build green. Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 5).
151 lines
4.2 KiB
TypeScript
151 lines
4.2 KiB
TypeScript
import { useState } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
MobileCardRow,
|
|
type MobileCardField,
|
|
} from "@/components/ui/mobile-card";
|
|
import { useIsMobile } from "../hooks/useIsMobile";
|
|
import type { BackupRun } from "../types/backups";
|
|
|
|
interface Props {
|
|
runs: BackupRun[];
|
|
}
|
|
|
|
function formatBytes(bytes: number | null): string {
|
|
if (bytes === null || bytes === undefined) return "—";
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
if (bytes < 1024 * 1024 * 1024)
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
}
|
|
|
|
function formatDuration(ms: number | null): string {
|
|
if (ms === null || ms === undefined) return "—";
|
|
if (ms < 1000) return `${ms}ms`;
|
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
|
|
return `${(ms / 3600_000).toFixed(1)}h`;
|
|
}
|
|
|
|
function formatTimestamp(ts: number): string {
|
|
return new Date(ts * 1000).toLocaleString();
|
|
}
|
|
|
|
type StatusVariant = "success" | "destructive" | "warning";
|
|
|
|
/**
|
|
* Map a run status onto a Badge variant per design §2.3:
|
|
* `success` → success (chart-2); `failure` → destructive (chart-4);
|
|
* `in_progress` → warning (chart-3).
|
|
*/
|
|
function statusVariant(status: string): StatusVariant {
|
|
if (status === "success") return "success";
|
|
if (status === "failure") return "destructive";
|
|
return "warning";
|
|
}
|
|
|
|
// Mobile card fields (spec R3.2): job_id is primary; status/duration/size/
|
|
// started give the at-a-glance info. See OpenSpec change `mobile-responsive-parity`.
|
|
const runCardFields: MobileCardField<BackupRun>[] = [
|
|
{ key: "job", label: "Job", render: (r) => r.job_id, primary: true },
|
|
{
|
|
key: "status",
|
|
label: "Status",
|
|
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
|
|
},
|
|
{
|
|
key: "duration",
|
|
label: "Duration",
|
|
render: (r) => formatDuration(r.duration_ms),
|
|
},
|
|
{
|
|
key: "size",
|
|
label: "Size",
|
|
render: (r) => formatBytes(r.bytes_transferred),
|
|
},
|
|
{
|
|
key: "started",
|
|
label: "Started",
|
|
render: (r) => formatTimestamp(r.started_at),
|
|
},
|
|
];
|
|
|
|
export default function BackupRunsTable({ runs }: Props) {
|
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
const isMobile = useIsMobile();
|
|
|
|
const filteredRuns =
|
|
statusFilter === "all"
|
|
? runs
|
|
: runs.filter((r) => r.status === statusFilter);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="w-[160px]" aria-label="Status filter">
|
|
<SelectValue placeholder="Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All</SelectItem>
|
|
<SelectItem value="success">Success</SelectItem>
|
|
<SelectItem value="failure">Failure</SelectItem>
|
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{isMobile ? (
|
|
<MobileCardRow
|
|
rows={filteredRuns}
|
|
fields={runCardFields}
|
|
getRowId={(r) => r.id}
|
|
/>
|
|
) : (
|
|
<div className="overflow-hidden rounded-lg border border-border">
|
|
<Table aria-label="Backup runs">
|
|
<TableHeader>
|
|
<TableRow className="bg-card hover:bg-card">
|
|
<TableHead>Job</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Duration</TableHead>
|
|
<TableHead>Size</TableHead>
|
|
<TableHead>Started</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredRuns.map((run) => (
|
|
<TableRow key={run.id}>
|
|
<TableCell>{run.job_id}</TableCell>
|
|
<TableCell>
|
|
<Badge variant={statusVariant(run.status)}>
|
|
{run.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
|
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
|
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|