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 frontend/src/app/(learn)/(catalog)/labs/guided/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default function GuidedLabsPage() {
</div>
</AreaSection>

<ComingSoonPanel title="Guided labs are being built" action={{ href: "/labs/visual", label: "Try a visual lab now" }}>
<ComingSoonPanel title="Guided labs are being built" action={{ href: "/labs?kind=visual", label: "Try a visual lab now" }}>
They run on the same machines as shell labs, so they arrive with the desktop app. Until then, the visual labs check your designs the same way.
</ComingSoonPanel>
</AreaPage>
Expand Down
178 changes: 90 additions & 88 deletions frontend/src/app/(learn)/(catalog)/labs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,118 +1,120 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SITE_SHARE_IMAGE } from "@/lib/site";
import { LAB_KINDS, areaById } from "@/lib/learn-areas";
import { visualLabGroups } from "@/lib/visual-labs";
import { LAB_KIND_ICON } from "@/components/areas/area-icon";
import { AreaHero, AreaPage, AreaSection, CardGrid, InfoCard, StepList } from "@/components/areas/area-page";
import { buttonVariants } from "@/components/ui/button";
import { AreaHero, AreaPage, CardGrid, InfoCard, StepList } from "@/components/areas/area-page";
import { FilterChips, pickFilter } from "@/components/areas/filter-chips";

export const metadata: Metadata = {
title: "Labs",
description: "Build it, break it, and see the result. Visual labs are open now; guided and shell labs are on the way.",
description: "Build it, break it, and see the result. Filter by kind of lab and by topic: System Design, Cloud and DevOps, Networking.",
alternates: { canonical: "/labs" },
openGraph: { title: "Labs on Edorax", url: "/labs", images: [SITE_SHARE_IMAGE] },
};

export default function LabsPage() {
type SearchParams = Promise<Record<string, string | string[] | undefined>>;

export default async function LabsPage({ searchParams }: { searchParams: SearchParams }) {
const area = areaById("labs");
const groups = visualLabGroups();
const challenges = groups.find((group) => group.id === "system-design-challenges")?.labs ?? [];
const cloud = groups.find((group) => group.id === "cloud-devops")?.labs ?? [];
const query = await searchParams;
const kind = pickFilter(query.kind, LAB_KINDS.map((k) => k.id));
const topic = kind === "guided" || kind === "shell" ? null : pickFilter(query.topic, groups.map((g) => g.id));
const params = { kind, topic };
const total = groups.reduce((sum, group) => sum + group.labs.length, 0);

const labs = groups
.filter((group) => topic === null || group.id === topic)
.flatMap((group) => group.labs.map((lab) => ({ ...lab, topic: group.title })));
const soonKinds = LAB_KINDS.filter((k) => k.status !== "live" && (kind === null ? topic === null : k.id === kind));

return (
<AreaPage>
<AreaHero
eyebrow="Labs"
title="Build it, break it, see the result"
status={area.status}
mood="determined"
intro="Reading tells you how a system should behave. A lab lets you find out. There are three kinds, from no code at all to a real terminal."
>
<Link href="/labs/visual" className={buttonVariants({ size: "lg" })}>
Open the visual labs
</Link>
<Link href="/labs/shell#desktop" className={buttonVariants({ size: "lg", variant: "outline" })}>
Desktop app docs
</Link>
</AreaHero>
intro="Reading tells you how a system should behave. A lab lets you find out. Pick a kind and a topic."
/>

<div className="mt-8 space-y-3" data-testid="lab-filters">
<FilterChips
label="Kind"
param="kind"
current={kind}
basePath="/labs"
params={params}
options={[
{ value: null, label: "All" },
...LAB_KINDS.map((k) => ({ value: k.id, label: k.name.replace(/ labs$/, ""), count: k.status === "live" ? total : undefined, soon: k.status !== "live" })),
]}
/>
{kind === null || kind === "visual" ? (
<FilterChips
label="Topic"
param="topic"
current={topic}
basePath="/labs"
params={params}
options={[{ value: null, label: "All" }, ...groups.map((g) => ({ value: g.id, label: g.title, count: g.labs.length }))]}
/>
) : null}
</div>

<AreaSection id="kinds" title="Three kinds of lab">
<CardGrid>
{LAB_KINDS.map((kind) => {
const Icon = LAB_KIND_ICON[kind.id];
return (
{kind === null || kind === "visual" ? (
<section aria-label="Visual labs" className="mt-8">
<CardGrid>
{labs.map((lab) => (
<InfoCard
key={kind.id}
title={kind.name}
body={kind.summary}
meta={kind.tagline}
href={kind.href}
status={kind.status}
icon={<Icon className="size-6" />}
cta={kind.status === "live" ? "Open visual labs" : "See how it will work"}
key={lab.slug}
title={lab.title}
body={lab.purpose}
href={`/labs/visual/${lab.slug}`}
cta="Open lab"
meta={lab.challenge ? `${lab.topic} · ${lab.checks} checks` : lab.topic}
/>
);
})}
</CardGrid>
</AreaSection>
))}
</CardGrid>
</section>
) : null}

<AreaSection
id="visual"
title="Open now: System Design challenges"
intro="Each one is a product to design from parts. Run it through peak traffic and failures, and keep it under budget."
>
<CardGrid>
{challenges.map((lab) => (
<InfoCard
key={lab.slug}
title={lab.title}
body={lab.purpose}
href={`/labs/visual/${lab.slug}`}
cta="Open lab"
meta={lab.checks > 0 ? `${lab.checks} checks` : undefined}
/>
))}
</CardGrid>
<p className="mt-5 text-base">
<Link href="/labs/visual" className="font-bold text-primary underline-offset-4 hover:underline">
See every visual lab, including networking and cloud
</Link>
</p>
</AreaSection>
{soonKinds.length > 0 ? (
<section aria-labelledby="labs-next" className="mt-10">
<h2 id="labs-next" className="text-xl tracking-[-0.02em]">
{kind ? "Not open yet" : "Coming next"}
</h2>
<div className="mt-4">
<CardGrid columns={2}>
{soonKinds.map((k) => {
const Icon = LAB_KIND_ICON[k.id];
return (
<InfoCard key={k.id} title={k.name} body={k.summary} meta={k.tagline} href={k.href} status={k.status} icon={<Icon className="size-6" />} cta="See how it will work" />
);
})}
</CardGrid>
</div>
</section>
) : null}

<AreaSection
id="cloud-devops"
title="Open now: Cloud and DevOps"
intro="Containers, clusters and cloud services, on the same kind of board as the System Design labs."
>
<CardGrid columns={2}>
{cloud.map((lab) => (
<InfoCard
key={lab.slug}
title={lab.title}
body={lab.purpose}
href={`/labs/visual/${lab.slug}`}
cta="Open lab"
{kind === null || kind === "visual" ? (
<details className="group mt-10 border-t border-border pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-lg font-bold outline-none focus-visible:outline-3 focus-visible:outline-brand-yellow [&::-webkit-details-marker]:hidden">
How a visual lab works
</summary>
<div className="mt-4">
<StepList
steps={[
{ title: "Build", body: "Add parts from the list, link them and set their size. Switch between plain names and real AWS names at any time." },
{ title: "Choose what to break", body: "Open a part and press Break it. That only arms the failure. Nothing changes until you run." },
{ title: "Press Run", body: "The design plays through every scenario: normal traffic, the peak, and each failure." },
{ title: "Read the checks", body: "Every test case passes or fails on the board, with the number it measured and why. Fix one thing and run again." },
]}
/>
))}
</CardGrid>
<p className="mt-5 text-base">
<Link href="/catalog?category=cloud-devops" className="font-bold text-primary underline-offset-4 hover:underline">
Cloud and DevOps courses
</Link>
</p>
</AreaSection>

<AreaSection id="how" title="How a visual lab works">
<StepList
steps={[
{ title: "Build", body: "Add parts from the list, link them and set their size. Switch between plain names and real AWS names at any time." },
{ title: "Choose what to break", body: "Open a part and press Break it. That only arms the failure. Nothing changes until you run." },
{ title: "Press Run", body: "The design plays through every scenario: normal traffic, the peak, and each failure. Nothing runs until you ask." },
{ title: "Read the checks", body: "Every test case passes or fails on the board, with the number it measured and why. Fix one thing and run again." },
]}
/>
</AreaSection>
</div>
</details>
) : null}
</AreaPage>
);
}
2 changes: 1 addition & 1 deletion frontend/src/app/(learn)/(catalog)/labs/shell/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default function ShellLabsPage() {
</p>
</AreaSection>

<ComingSoonPanel title="Shell labs arrive with the desktop app" action={{ href: "/labs/visual", label: "Try a visual lab now" }}>
<ComingSoonPanel title="Shell labs arrive with the desktop app" action={{ href: "/labs?kind=visual", label: "Try a visual lab now" }}>
Visual labs are open today in the browser, with no install.
</ComingSoonPanel>
</AreaPage>
Expand Down
42 changes: 3 additions & 39 deletions frontend/src/app/(learn)/(catalog)/labs/visual/page.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,6 @@
import type { Metadata } from "next";
import { SITE_SHARE_IMAGE } from "@/lib/site";
import { visualLabGroups } from "@/lib/visual-labs";
import { AreaHero, AreaPage, AreaSection, CardGrid, InfoCard } from "@/components/areas/area-page";

export const metadata: Metadata = {
title: "Visual labs",
description: "No code. Move parts, press Run, and watch every test case pass or fail on the board.",
alternates: { canonical: "/labs/visual" },
openGraph: { title: "Visual labs on Edorax", url: "/labs/visual", images: [SITE_SHARE_IMAGE] },
};
import { redirect } from "next/navigation";

// Visual labs are a filter on /labs now. Old links (and group anchors such as #networking) land on it.
export default function VisualLabsPage() {
const groups = visualLabGroups();
return (
<AreaPage>
<AreaHero
eyebrow="Labs"
title="Visual labs"
status="live"
mood="happy"
intro="Design on a board instead of in code. Add parts, link them, pick what to break, then press Run. Every check passes or fails in front of you, with the reason."
/>
{groups.map((group) => (
<AreaSection key={group.id} id={group.id} title={group.title} intro={group.description}>
<CardGrid>
{group.labs.map((lab) => (
<InfoCard
key={lab.slug}
title={lab.title}
body={lab.purpose}
href={`/labs/visual/${lab.slug}`}
cta="Open lab"
meta={lab.challenge ? `Challenge, ${lab.checks} checks` : undefined}
/>
))}
</CardGrid>
</AreaSection>
))}
</AreaPage>
);
redirect("/labs?kind=visual");
}
Loading
Loading