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).
143 lines
3.8 KiB
TypeScript
143 lines
3.8 KiB
TypeScript
import { Badge } from "@/components/ui/badge";
|
|
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 { BackupJob, BackupRun } from "../types/backups";
|
|
|
|
interface Props {
|
|
jobs: BackupJob[];
|
|
latestRuns: Map<string, BackupRun>;
|
|
}
|
|
|
|
function formatInterval(seconds: number | null): string {
|
|
if (!seconds) return "N/A";
|
|
if (seconds < 60) return `${seconds}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
return `${Math.floor(seconds / 86400)}d`;
|
|
}
|
|
|
|
function formatTimestamp(ts: number | null): string {
|
|
if (!ts) return "Never";
|
|
return new Date(ts * 1000).toLocaleString();
|
|
}
|
|
|
|
type StatusVariant = "success" | "destructive" | "warning" | "secondary";
|
|
|
|
/**
|
|
* Map a job/run status onto a Badge variant per design §2.3:
|
|
* `success` → success (chart-2); `failure` → destructive (chart-4);
|
|
* `in_progress` → warning (chart-3); unknown → secondary (neutral accent).
|
|
*/
|
|
function statusVariant(status: string): StatusVariant {
|
|
if (status === "success") return "success";
|
|
if (status === "failure") return "destructive";
|
|
if (status === "in_progress") return "warning";
|
|
return "secondary";
|
|
}
|
|
|
|
// Mobile card fields (spec R3.2): job name is primary; source/schedule/status
|
|
// give at-a-glance context. See OpenSpec change `mobile-responsive-parity`.
|
|
interface JobCardRow {
|
|
job: BackupJob;
|
|
status: string;
|
|
run_started: number | null;
|
|
}
|
|
|
|
const jobCardFields: MobileCardField<JobCardRow>[] = [
|
|
{ key: "name", label: "Name", render: (r) => r.job.name, primary: true },
|
|
{
|
|
key: "source",
|
|
label: "Source",
|
|
render: (r) => r.job.source ?? "—",
|
|
},
|
|
{
|
|
key: "schedule",
|
|
label: "Schedule",
|
|
render: (r) => formatInterval(r.job.schedule_interval_seconds),
|
|
},
|
|
{
|
|
key: "status",
|
|
label: "Last status",
|
|
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
|
|
},
|
|
];
|
|
|
|
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
|
const isMobile = useIsMobile();
|
|
|
|
if (isMobile) {
|
|
const cardRows: JobCardRow[] = jobs.map((job) => {
|
|
const run = latestRuns.get(job.id);
|
|
return {
|
|
job,
|
|
status: run?.status ?? "unknown",
|
|
run_started: run?.started_at ?? null,
|
|
};
|
|
});
|
|
return (
|
|
<MobileCardRow
|
|
rows={cardRows}
|
|
fields={jobCardFields}
|
|
getRowId={(r) => r.job.id}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-lg border border-border">
|
|
<Table aria-label="Backup jobs">
|
|
<TableHeader>
|
|
<TableRow className="bg-card hover:bg-card">
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Source</TableHead>
|
|
<TableHead>Target</TableHead>
|
|
<TableHead>Schedule</TableHead>
|
|
<TableHead>Last Status</TableHead>
|
|
<TableHead>Last Run</TableHead>
|
|
<TableHead>Next Expected</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{jobs.map((job) => {
|
|
const run = latestRuns.get(job.id);
|
|
const status = run?.status ?? "unknown";
|
|
const nextExpected =
|
|
run && job.schedule_interval_seconds
|
|
? run.started_at + job.schedule_interval_seconds
|
|
: null;
|
|
|
|
return (
|
|
<TableRow key={job.id}>
|
|
<TableCell>{job.name}</TableCell>
|
|
<TableCell>{job.source ?? "—"}</TableCell>
|
|
<TableCell>{job.target ?? "—"}</TableCell>
|
|
<TableCell>
|
|
{formatInterval(job.schedule_interval_seconds)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={statusVariant(status)}>{status}</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{formatTimestamp(run?.started_at ?? null)}
|
|
</TableCell>
|
|
<TableCell>{formatTimestamp(nextExpected)}</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
);
|
|
}
|