Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,5 @@
"lint:fix": "eslint . --fix",
"format:check": "prettier --check ."
},
"version": "1.7.0"
"version": "1.7.1"
}
6 changes: 4 additions & 2 deletions apps/web/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ export function Sidebar({ forceExpanded = false }: { forceExpanded?: boolean })
const toggle = useUiStore((s) => s.toggleSidebar);
const collapsed = forceExpanded ? false : storeCollapsed;

const ds = useDataset();
const { data: ds } = useDataset();
const { unread } = useNotifications();
const base = sidebarCounts(ds);

const base = ds ? sidebarCounts(ds) : ({} as ReturnType<typeof sidebarCounts>);

const counts: Record<CountKey, number> = {
...base,
artifactsReclaimable: 0,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/data/generate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cvssToSeverity } from "@/lib/severity";
import { cvssToSeverity } from "../lib/severity";
import type { Severity } from "@cleat/contracts";
import { Rng } from "./rng";
import {
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/features/access/AccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,42 @@ import { AuditLogTab } from "./AuditLogTab";
type Tab = "members" | "apps" | "webhooks" | "keys" | "tokens" | "audit";

export function AccessPage() {
const ds = useDataset();
const { data: ds, error, loading, retry } = useDataset();
const [tab, setTab] = useState<Tab>("members");

if (loading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div
className="size-8 animate-spin rounded-full border-2 border-surface-3 border-t-primary"
aria-label="loading"
/>
</div>
);
}

if (error) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 text-sm text-ink-subtle">
<p> Failed to load access data.</p>
<button
onClick={retry}
className="rounded-md bg-surface-2 px-3 py-2 text-ink hover:bg-surface-3"
>
Retry
</button>
</div>
);
}
if (!ds) {
return (
<div className="flex h-[60vh] items-center justify-center px-4 text-center">
<div>
<p className="text-sm font-medium text-ink">No access data available</p>
</div>
</div>
);
}
const without2fa = membersWithout2fa(ds).length;
const outside = ds.members.filter((m) => m.outsideCollaborator).length;

Expand Down
51 changes: 45 additions & 6 deletions apps/web/src/features/artifacts/ArtifactsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,58 @@ function isReclaimable(a: Artifact) {
}

export function ArtifactsPage() {
const ds = useDataset();
const { data: ds, error, loading, retry } = useDataset();
const addToast = useUiStore((s) => s.addToast);
const [tab, setTab] = useState<Tab>("artifacts");
const [deleted, setDeleted] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());

const artifacts = useMemo(
() => ds.artifacts.filter((a) => !deleted.has(a.id)),
[ds.artifacts, deleted],
() => ds?.artifacts.filter((a) => !deleted.has(a.id)) ?? [],
[ds, deleted],
);
const caches = useMemo(() => ds.caches.filter((c) => !deleted.has(c.id)), [ds.caches, deleted]);
const packages = ds.packages;

const caches = useMemo(() => ds?.caches.filter((c) => !deleted.has(c.id)) ?? [], [ds, deleted]);

const packages = ds?.packages ?? [];
if (loading) {
return (
<div className="space-y-5">
<PageHeader eyebrow="Maintenance" title="Artifacts & cost" description="Loading..." />

<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-24 animate-pulse rounded-xl border border-hairline bg-surface-2"
/>
))}
</div>

<div className="h-96 animate-pulse rounded-xl border border-hairline bg-surface-2" />
</div>
);
}

if (error) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 text-sm text-ink-subtle">
<p> Failed to load artifacts data.</p>
<button
onClick={retry}
className="rounded-md bg-surface-2 px-3 py-2 text-ink hover:bg-surface-3"
>
Retry
</button>
</div>
);
}
if (!ds) {
return (
<div className="flex h-[60vh] items-center justify-center text-sm text-ink-subtle">
No dataset available.
</div>
);
}
const totalStorageMb =
artifacts.reduce((s, a) => s + a.sizeMb, 0) +
caches.reduce((s, c) => s + c.sizeMb, 0) +
Expand Down
51 changes: 43 additions & 8 deletions apps/web/src/features/dependencies/DependenciesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,11 @@ import { cn } from "@/lib/cn";
const TABLE = "dependencies";

export function DependenciesPage() {
const ds = useDataset();
const { data: ds, error, loading, retry } = useDataset();
const addToast = useUiStore((s) => s.addToast);
const [format, setFormat] = useState("spdx");

const deps = useMemo(() => buildDependencies(ds), [ds]);
const dist = useMemo(() => licenseDistribution(deps), [deps]);
const vulnerable = deps.filter((d) => d.vulnerable).length;
const outdated = deps.filter((d) => d.outdated).length;
const copyleft = deps.filter((d) => COPYLEFT.has(d.license)).length;
const deps = useMemo(() => (ds ? buildDependencies(ds) : []), [ds]);
const dist = useMemo(() => (deps.length ? licenseDistribution(deps) : []), [deps]);

const facets: FacetDef<Dependency>[] = [
{
Expand Down Expand Up @@ -71,19 +67,58 @@ export function DependenciesPage() {
facets,
});

if (loading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div
className="size-8 animate-spin rounded-full border-2 border-surface-3 border-t-primary"
aria-label="loading"
/>
</div>
);
}

if (error) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 text-sm text-ink-subtle">
<p> Failed to load dependencies.</p>
<button
onClick={retry}
className="rounded-md bg-surface-2 px-3 py-2 text-ink hover:bg-surface-3"
>
Retry
</button>
</div>
);
}
if (!ds) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-2 text-sm text-ink-subtle">
<Package className="size-6" />
<span>No dependency data found.</span>
</div>
);
}
const vulnerable = deps.filter((d) => d.vulnerable).length;
const outdated = deps.filter((d) => d.outdated).length;
const copyleft = deps.filter((d) => COPYLEFT.has(d.license)).length;

function exportSbom() {
if (!ds) return;

const content =
format === "spdx"
? buildSpdx(ds.account.login, deps)
: buildCycloneDx(ds.account.login, deps);

downloadFile(`${ds.account.login}-sbom.${format}.json`, content);

addToast({
title: `${format === "spdx" ? "SPDX" : "CycloneDX"} SBOM exported`,
description: `${deps.length} components`,
variant: "success",
});
}

const columns: Column<Dependency>[] = [
{
id: "name",
Expand Down
36 changes: 35 additions & 1 deletion apps/web/src/features/overview/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,41 @@ import { eventIcon } from "@/features/notifications/eventMeta";
import { cn } from "@/lib/cn";

export function OverviewPage() {
const ds = useDataset();
const { data: ds, error, loading, retry } = useDataset();

if (loading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div
className="size-8 animate-spin rounded-full border-2 border-surface-3 border-t-primary"
aria-label="loading"
/>
</div>
);
}

if (error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error branch is exactly right and is what I want users to see on failure. In my repro it never renders, the page is blank before it gets here. Once you find why the tree blanks on a failed fetch, verify the fix by confirming this 'Failed to load overview data.' message actually appears on a 500, on at least this page and one more. A quick win regardless: give it a retry affordance (a button that re-triggers the fetch) so a transient backend blip is recoverable without a full reload.

return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 text-sm text-ink-subtle">
<p> Failed to load overview data.</p>
<button
onClick={() => {
retry();
}}
className="rounded-md bg-surface-2 px-3 py-2 text-ink hover:bg-surface-3"
>
Retry
</button>
</div>
);
}
if (!ds) {
return (
<div className="flex h-[60vh] items-center justify-center text-sm text-ink-subtle">
No account selected.
</div>
);
}
const sev = severityBreakdown(ds);
const findings = totalOpenFindings(ds);
const grade = scoreToGrade(ds.account.postureScore);
Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/features/repositories/RepoDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,50 @@ function scoreHex(score: number) {

export function RepoDetailPage() {
const { repoId } = useParams();
const ds = useDataset();
const { data: ds, error, loading, retry } = useDataset();
if (loading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div
className="size-8 animate-spin rounded-full border-2 border-surface-3 border-t-primary"
aria-label="loading"
/>
</div>
);
}

if (error) {
return (
<div className="flex h-[300px] items-center justify-center px-4 text-center text-sm text-ink-subtle">
Failed to load repository data.
</div>
);
}
if (error) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 text-sm text-ink-subtle">
<p> Failed to load repository data.</p>
<button
onClick={retry}
className="rounded-md bg-surface-2 px-3 py-2 text-ink hover:bg-surface-3"
>
Retry
</button>
</div>
);
}
if (!ds) {
return (
<div className="flex h-[300px] items-center justify-center px-4 text-center">
<div>
<p className="text-sm font-medium text-ink">No repository data available</p>
<p className="mt-1 text-sm text-ink-subtle">
Select an account to view repository details.
</p>
</div>
</div>
);
}
const repo = ds.repos.find((r) => r.id === repoId);

if (!repo) {
Expand Down
49 changes: 40 additions & 9 deletions apps/web/src/features/repositories/RepositoriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,9 @@ const VIS_ICON: Record<Visibility, typeof Lock> = {
};

export function RepositoriesPage() {
const ds = useDataset();
const { data: ds, error, loading, retry } = useDataset();
const navigate = useNavigate();

const protectedCount = ds.repos.filter((r) => r.branchProtected).length;
const archived = ds.repos.filter((r) => r.archived).length;
const avgHygiene = Math.round(
ds.repos.reduce((s, r) => s + r.hygieneScore, 0) / Math.max(1, ds.repos.length),
);

const facets: FacetDef<Repo>[] = [
{
key: "visibility",
Expand All @@ -55,7 +49,7 @@ export function RepositoriesPage() {
key: "language",
label: "Language",
accessor: (r) => r.language,
options: [...new Set(ds.repos.map((r) => r.language))]
options: [...new Set(ds?.repos?.map((r) => r.language) ?? [])]
.sort()
.map((l) => ({ value: l, label: l })),
},
Expand All @@ -76,10 +70,47 @@ export function RepositoriesPage() {
},
];

const rows = useFilteredRows(TABLE, ds.repos, {
const rows = useFilteredRows(TABLE, ds?.repos ?? [], {
search: (r) => `${r.name} ${r.language} ${r.topics.join(" ")}`,
facets,
});
if (loading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div
className="size-8 animate-spin rounded-full border-2 border-surface-3 border-t-primary"
aria-label="loading"
/>
</div>
);
}

if (error) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 text-sm text-ink-subtle">
<p> Failed to load repositories data.</p>
<button
onClick={retry}
className="rounded-md bg-surface-2 px-3 py-2 text-ink hover:bg-surface-3"
>
Retry
</button>
</div>
);
}
if (!ds) {
return (
<div className="flex h-[60vh] items-center justify-center text-sm text-ink-subtle">
No data available.
</div>
);
}

const protectedCount = ds.repos.filter((r) => r.branchProtected).length;
const archived = ds.repos.filter((r) => r.archived).length;
const avgHygiene = Math.round(
ds.repos.reduce((s, r) => s + r.hygieneScore, 0) / Math.max(1, ds.repos.length),
);

const columns: Column<Repo>[] = [
{
Expand Down
Loading
Loading