diff --git a/apps/console/app/config/page.tsx b/apps/console/app/config/page.tsx index 26ae4ee..992a7c0 100644 --- a/apps/console/app/config/page.tsx +++ b/apps/console/app/config/page.tsx @@ -1,6 +1,8 @@ +import { CloudOff } from "lucide-react"; import { api } from "@/lib/api"; import { ConfigForm } from "@/components/config-form"; -import { Card, CardTitle } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; import { getActor } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -8,12 +10,21 @@ export const dynamic = "force-dynamic"; export default async function ConfigPage() { const [config, actor] = await Promise.all([api.config(), getActor()]); if (!config) { - return

Config unavailable (the API has no config store).

; + return ( + + ); } return ( -
+
+
+

Configuration

+

RBAC policies and scheduler quotas.

+
- Configuration
diff --git a/apps/console/app/globals.css b/apps/console/app/globals.css index fc3189e..fc48e3b 100644 --- a/apps/console/app/globals.css +++ b/apps/console/app/globals.css @@ -1,59 +1,69 @@ @import "tailwindcss"; /* - * Auriga console design tokens. One design system: a calm, dense, neutral base - * with a single near-black accent; semantic state colors live with the Badge. - * Dark mode follows the OS via prefers-color-scheme (Tailwind v4's default - * `dark:` variant keys on the same media query, so both stay in sync). + * Auriga console design tokens. One design system: a warm, tactile, layered base — + * a soft cream / charcoal neutral ramp (no brand color), depth from layered shadows + * and hairline rings, and a faint paper grain over the page. Semantic state colors + * live with the Badge. Dark mode follows the OS via prefers-color-scheme (Tailwind + * v4's default `dark:` variant keys on the same media query, so both stay in sync). */ :root { - --background: 0 0% 100%; - --foreground: 240 10% 4%; - --card: 0 0% 100%; - --card-foreground: 240 10% 4%; - --popover: 0 0% 100%; - --popover-foreground: 240 10% 4%; - --primary: 240 6% 10%; - --primary-foreground: 0 0% 98%; - --secondary: 240 5% 96%; - --secondary-foreground: 240 6% 10%; - --muted: 240 5% 96%; - --muted-foreground: 240 4% 44%; - --accent: 240 5% 95%; - --accent-foreground: 240 6% 10%; + --background: 40 22% 97%; + --foreground: 28 9% 12%; + --card: 40 40% 99%; + --card-foreground: 28 9% 12%; + --popover: 40 40% 99%; + --popover-foreground: 28 9% 12%; + --primary: 28 11% 15%; + --primary-foreground: 40 30% 98%; + --secondary: 40 16% 93%; + --secondary-foreground: 28 11% 15%; + --muted: 40 16% 93%; + --muted-foreground: 30 6% 40%; + --accent: 38 18% 90%; + --accent-foreground: 28 11% 15%; --destructive: 0 72% 51%; --destructive-foreground: 0 0% 98%; - --border: 240 6% 90%; - --input: 240 6% 88%; - --ring: 240 6% 30%; - --radius: 0.625rem; + --border: 36 16% 87%; + --input: 36 15% 84%; + --ring: 30 9% 32%; + --radius: 0.75rem; + + /* A single faint paper grain, reused by the body and elevated surfaces. */ + --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + --grain-opacity: 0.03; + --grain-opacity-surface: 0.035; } @media (prefers-color-scheme: dark) { :root { - --background: 240 10% 4%; - --foreground: 0 0% 96%; - --card: 240 9% 7%; - --card-foreground: 0 0% 96%; - --popover: 240 9% 7%; - --popover-foreground: 0 0% 96%; - --primary: 0 0% 96%; - --primary-foreground: 240 6% 10%; - --secondary: 240 4% 14%; - --secondary-foreground: 0 0% 96%; - --muted: 240 4% 14%; - --muted-foreground: 240 5% 64%; - --accent: 240 4% 16%; - --accent-foreground: 0 0% 96%; + --background: 30 8% 7%; + --foreground: 40 16% 92%; + --card: 30 7% 11%; + --card-foreground: 40 16% 92%; + --popover: 30 7% 11%; + --popover-foreground: 40 16% 92%; + --primary: 40 18% 92%; + --primary-foreground: 28 11% 12%; + --secondary: 30 6% 16%; + --secondary-foreground: 40 16% 92%; + --muted: 30 6% 16%; + --muted-foreground: 38 9% 62%; + --accent: 30 6% 19%; + --accent-foreground: 40 16% 92%; --destructive: 0 62% 47%; --destructive-foreground: 0 0% 98%; - --border: 240 4% 16%; - --input: 240 4% 18%; - --ring: 240 5% 70%; + --border: 36 7% 20%; + --input: 36 7% 22%; + --ring: 40 12% 70%; + + --grain-opacity: 0.05; + --grain-opacity-surface: 0.05; } } -/* Map the tokens onto Tailwind color utilities (bg-background, text-muted-foreground, …). */ +/* Map the tokens onto Tailwind color utilities (bg-background, text-muted-foreground, …) + * plus the layered shadow + radius scale used by the primitives. */ @theme inline { --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); @@ -74,9 +84,14 @@ --color-border: hsl(var(--border)); --color-input: hsl(var(--input)); --color-ring: hsl(var(--ring)); + --radius-xl: calc(var(--radius) + 4px); --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); + /* Soft, warm-tinted layered shadows. Light-mode tuned; on dark the hairline + * ring + border carry the elevation, so the faint shadow simply recedes. */ + --shadow-card: 0 1px 2px -1px hsl(28 20% 12% / 0.06), 0 4px 12px -4px hsl(28 20% 12% / 0.08); + --shadow-card-lg: 0 2px 6px -2px hsl(28 20% 12% / 0.08), 0 12px 28px -8px hsl(28 20% 12% / 0.12); } @layer base { @@ -87,12 +102,59 @@ } body { background-color: hsl(var(--background)); + /* A soft warm wash from the top gives the page gentle depth. */ + background-image: radial-gradient( + 48rem 32rem at 50% -8%, + hsl(40 36% 88% / 0.55), + transparent 70% + ); color: hsl(var(--foreground)); font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.55; + letter-spacing: -0.005em; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } + @media (prefers-color-scheme: dark) { + body { + background-image: radial-gradient( + 48rem 32rem at 50% -8%, + hsl(40 18% 40% / 0.12), + transparent 70% + ); + } + } + h1, + h2, + h3 { + letter-spacing: -0.013em; + } +} + +/* Faint paper grain over the whole page (behind content). */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: var(--grain); + opacity: var(--grain-opacity); +} + +/* Opt-in grain for elevated surfaces (cards, panels) — gives them a tactile face. */ +.auriga-grain { + position: relative; +} +.auriga-grain::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background-image: var(--grain); + opacity: var(--grain-opacity-surface); } /* A soft pulse for the live "running" indicator dot. */ diff --git a/apps/console/app/jobs/[id]/page.tsx b/apps/console/app/jobs/[id]/page.tsx index 78099ca..a68651e 100644 --- a/apps/console/app/jobs/[id]/page.tsx +++ b/apps/console/app/jobs/[id]/page.tsx @@ -1,3 +1,5 @@ +import { ChevronLeft } from "lucide-react"; +import Link from "next/link"; import { notFound } from "next/navigation"; import { api } from "@/lib/api"; import { ApproveButton } from "@/components/approve-button"; @@ -25,28 +27,41 @@ export default async function JobDetailPage({ params }: { params: Promise<{ id: const trace = live ? null : await api.trace(id); const ws = await api.workspace(id); const tokens = job.usage.input_tokens + job.usage.output_tokens; + const hasActions = a.needsApproval || a.runnable || a.pausable || a.cancellable; return (
+ + + Jobs + + -
+

{job.id}

{job.state} {job.reason && {job.reason}}
-

{job.spec.goal}

-

- model {job.model ?? "—"} · attempts {job.attempts} · steps {job.steps} · tokens{" "} - {tokens.toLocaleString()} -

-
- {a.needsApproval && } - {a.runnable && } - {a.pausable && } - {a.cancellable && } -
+

{job.spec.goal}

+
+ + + + +
+ {hasActions && ( +
+ {a.needsApproval && } + {a.runnable && } + {a.pausable && } + {a.cancellable && } +
+ )} @@ -76,3 +91,14 @@ export default async function JobDetailPage({ params }: { params: Promise<{ id:
); } + +function Meta({ label, value }: { label: string; value: string | number }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} diff --git a/apps/console/app/jobs/new/page.tsx b/apps/console/app/jobs/new/page.tsx index 648d9f5..8c99488 100644 --- a/apps/console/app/jobs/new/page.tsx +++ b/apps/console/app/jobs/new/page.tsx @@ -1,5 +1,7 @@ +import { ChevronLeft } from "lucide-react"; +import Link from "next/link"; import { JobForm } from "@/components/job-form"; -import { Card, CardTitle } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; import { getActor } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -7,9 +9,21 @@ export const dynamic = "force-dynamic"; export default async function NewJobPage() { const actor = await getActor(); return ( -
+
+ + + Jobs + +
+

New job

+

+ Define a job spec and submit it as pending. +

+
- New job
diff --git a/apps/console/app/jobs/page.tsx b/apps/console/app/jobs/page.tsx index c720643..a442cda 100644 --- a/apps/console/app/jobs/page.tsx +++ b/apps/console/app/jobs/page.tsx @@ -1,10 +1,12 @@ +import { CloudOff, Inbox } from "lucide-react"; import Link from "next/link"; import { api } from "@/lib/api"; import { JobRowActions } from "@/components/job-row-actions"; import { JobsFilterBar } from "@/components/jobs-filter-bar"; import { JobsPager } from "@/components/jobs-pager"; import { Badge } from "@/components/ui/badge"; -import { Card, CardTitle } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; export const dynamic = "force-dynamic"; @@ -30,19 +32,38 @@ export default async function JobsPage({ searchParams }: { searchParams: SearchP offset, }); - if (!page) return

API unavailable or unauthorized.

; + if (!page) { + return ( + + ); + } return ( - -
- Jobs +
+
+
+

Jobs

+

+ {page.total.toLocaleString()} total +

+
{page.jobs.length === 0 ? ( -

No jobs match.

+ + + ) : ( - <> + @@ -58,7 +79,7 @@ export default async function JobsPage({ searchParams }: { searchParams: SearchP
{j.id} @@ -78,11 +99,11 @@ export default async function JobsPage({ searchParams }: { searchParams: SearchP ))}
-
+
- + )} - +
); } diff --git a/apps/console/app/layout.tsx b/apps/console/app/layout.tsx index 744e880..831faa2 100644 --- a/apps/console/app/layout.tsx +++ b/apps/console/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; -import type { ReactNode } from "react"; import Link from "next/link"; +import type { ReactNode } from "react"; import { LogoutButton } from "@/components/logout-button"; +import { NavLinks } from "@/components/nav-links"; +import { Button } from "@/components/ui/button"; import { getSession } from "@/lib/session"; import "./globals.css"; @@ -10,47 +12,40 @@ export const metadata: Metadata = { description: "Auriga control-plane console", }; -const NAV = [ - { href: "/", label: "Dashboard" }, - { href: "/jobs", label: "Jobs" }, - { href: "/skills", label: "Skills" }, - { href: "/config", label: "Config" }, -]; - export default async function RootLayout({ children }: { children: ReactNode }) { const session = await getSession(); return ( -
-
- - Auriga +
+
+ + + A + + Auriga - - {session && ( -
- - {session.factio} · {session.role} - - -
- )} +
-
{children}
+
{children}
); diff --git a/apps/console/app/login/page.tsx b/apps/console/app/login/page.tsx index 487b567..2c38317 100644 --- a/apps/console/app/login/page.tsx +++ b/apps/console/app/login/page.tsx @@ -1,18 +1,26 @@ import { LoginForm } from "@/components/login-form"; -import { Card, CardTitle } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; export const dynamic = "force-dynamic"; export default function LoginPage() { return ( -
+
+
+ + A + +
+

Sign in to Auriga

+

Control-plane console

+
+
- Sign in to Auriga -

+

Dev sign-in — sets a session for this browser. Production sits behind an auth proxy.

-
+
); } diff --git a/apps/console/app/page.tsx b/apps/console/app/page.tsx index 16acc3b..498b7a9 100644 --- a/apps/console/app/page.tsx +++ b/apps/console/app/page.tsx @@ -1,33 +1,51 @@ +import { CloudOff, DollarSign, Layers, Users } from "lucide-react"; import Link from "next/link"; +import type { ComponentType } from "react"; import { api } from "@/lib/api"; import { Sparkline } from "@/components/sparkline"; import { Badge } from "@/components/ui/badge"; import { Bar } from "@/components/ui/bar"; import { Card, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; export const dynamic = "force-dynamic"; export default async function DashboardPage() { const dash = await api.dashboard(); - if (!dash) return

API unavailable.

; + if (!dash) { + return ( + + ); + } const totalActive = dash.active.reduce((sum, a) => sum + a.active, 0); const maxModelCost = Math.max(...dash.byModel.map((m) => m.cost_usd), 0.0001); return ( -
+
+
+

Overview

+

+ Control-plane activity across all tenants. +

+
+
- - - + + +
Cost trend p.cost_usd)} ariaLabel="daily cost trend" /> -

+

{dash.costTrend.length > 0 ? `${dash.costTrend[0]?.bucket} → ${dash.costTrend.at(-1)?.bucket}` : "no data"}{" "} @@ -40,7 +58,7 @@ export default async function DashboardPage() { {dash.byModel.length === 0 ? (

no data

) : ( -
+
{dash.byModel.slice(0, 6).map((m) => ( Quota utilization -
+
Recent audit - - - - - - - - - - - {dash.recentAudit.map((e) => ( - - - - - + {dash.recentAudit.length === 0 ? ( +

No recent activity.

+ ) : ( +
timefactioactionjob
- {new Date(e.ts).toLocaleString()} - {e.factio}{e.action} - {e.job_id ? ( - - {e.job_id} - - ) : ( - "" - )} -
+ + + + + + - ))} - -
timefactioactionjob
+ + + {dash.recentAudit.map((e) => ( + + + {new Date(e.ts).toLocaleString()} + + {e.factio} + {e.action} + + {e.job_id ? ( + + {e.job_id} + + ) : ( + "" + )} + + + ))} + + + )}
); } -function Stat({ label, value }: { label: string; value: string }) { +function Stat({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon?: ComponentType<{ className?: string }>; +}) { return ( - {label} -

{value}

+
+ {label} + {Icon && } +
+

{value}

); } diff --git a/apps/console/app/skills/page.tsx b/apps/console/app/skills/page.tsx index 066a2b1..097e7be 100644 --- a/apps/console/app/skills/page.tsx +++ b/apps/console/app/skills/page.tsx @@ -1,6 +1,8 @@ +import { Puzzle } from "lucide-react"; import { api } from "@/lib/api"; import { Badge } from "@/components/ui/badge"; -import { Card, CardTitle } from "@/components/ui/card"; +import { Card } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table"; export const dynamic = "force-dynamic"; @@ -9,40 +11,51 @@ export default async function SkillsPage() { const skills = await api.skills(); return ( - - Skill marketplace - {!skills || skills.length === 0 ? ( -

No skills (set AURIGA_SKILLS on the API).

- ) : ( - - - - - - - - - - - - - - {skills.map((s) => ( - - - - - - - - +
+
+

Skills

+

+ The skill marketplace and usage stats. +

+
+ + {!skills || skills.length === 0 ? ( + + ) : ( +
skilltypeversionusesokcostdescription
{s.name} - {s.type} - {s.version}{s.stats.uses}{s.stats.successes}~${s.stats.total_cost_usd.toFixed(4)}{s.description}
+ + + + + + + + + - ))} - -
skilltypeversionusesokcostdescription
- )} -
+ + + {skills.map((s) => ( + + {s.name} + + {s.type} + + {s.version} + {s.stats.uses} + {s.stats.successes} + ~${s.stats.total_cost_usd.toFixed(4)} + {s.description} + + ))} + + + )} + +
); } diff --git a/apps/console/components/config-form.tsx b/apps/console/components/config-form.tsx index 0895f6d..1d7275b 100644 --- a/apps/console/components/config-form.tsx +++ b/apps/console/components/config-form.tsx @@ -76,7 +76,11 @@ export function ConfigForm({ initial, canEdit = true }: { initial: AurigaConfig; return (
-
+
switchMode("form")}> Form @@ -184,8 +188,8 @@ function ModeTab({ onClick={onClick} className={ active - ? "rounded-md bg-secondary px-2.5 py-1 font-medium text-secondary-foreground" - : "rounded-md px-2.5 py-1 text-muted-foreground hover:text-foreground" + ? "rounded-md bg-card px-2.5 py-1 font-medium text-foreground shadow-sm" + : "rounded-md px-2.5 py-1 text-muted-foreground transition-colors hover:text-foreground" } > {children} @@ -205,7 +209,7 @@ function PolicyRow({ onRemove: () => void; }) { return ( -
+
onChange({ ...policy, factio: e.target.value })} /> diff --git a/apps/console/components/job-form.tsx b/apps/console/components/job-form.tsx index a19dc19..044749f 100644 --- a/apps/console/components/job-form.tsx +++ b/apps/console/components/job-form.tsx @@ -67,7 +67,11 @@ export function JobForm({ factio, createdBy }: { factio: string; createdBy: stri return (
-
+
switchMode("form")}> Form @@ -192,12 +196,12 @@ export function JobForm({ factio, createdBy }: { factio: string; createdBy: stri type="checkbox" checked={form.requireApproval} onChange={(e) => set("requireApproval", e.target.checked)} - className="size-4 rounded border-input" + className="size-4 rounded border-input accent-foreground" /> Require human approval before running (HITL) -
+
Advanced (skills, files, links, dependencies) @@ -251,8 +255,8 @@ function ModeTab({ onClick={onClick} className={ active - ? "rounded-md bg-secondary px-2.5 py-1 font-medium text-secondary-foreground" - : "rounded-md px-2.5 py-1 text-muted-foreground hover:text-foreground" + ? "rounded-md bg-card px-2.5 py-1 font-medium text-foreground shadow-sm" + : "rounded-md px-2.5 py-1 text-muted-foreground transition-colors hover:text-foreground" } > {children} @@ -272,7 +276,7 @@ function CriterionRow({ onRemove?: () => void; }) { return ( -
+
setFactio(e.target.value)} - className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-1.5 text-sm" - /> - - + + {passwordRequired && ( - + )} -
+
- {error && {error}} + {error && {error}}
); diff --git a/apps/console/components/logout-button.tsx b/apps/console/components/logout-button.tsx index 35911d5..feb284b 100644 --- a/apps/console/components/logout-button.tsx +++ b/apps/console/components/logout-button.tsx @@ -15,7 +15,7 @@ export function LogoutButton() { router.push("/login"); })} disabled={pending} - className="text-sm text-neutral-600 hover:underline" + className="text-sm text-muted-foreground transition-colors hover:text-foreground" > {pending ? "…" : "logout"} diff --git a/apps/console/components/nav-links.tsx b/apps/console/components/nav-links.tsx new file mode 100644 index 0000000..6a983b1 --- /dev/null +++ b/apps/console/components/nav-links.tsx @@ -0,0 +1,43 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +const NAV = [ + { href: "/", label: "Dashboard" }, + { href: "/jobs", label: "Jobs" }, + { href: "/skills", label: "Skills" }, + { href: "/config", label: "Config" }, +]; + +function isActive(pathname: string, href: string) { + return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`); +} + +/** Primary nav with active-route highlighting (client-side via usePathname). */ +export function NavLinks() { + const pathname = usePathname() ?? "/"; + return ( + + ); +} diff --git a/apps/console/components/run-timeline-panel.tsx b/apps/console/components/run-timeline-panel.tsx index c643554..4e3ec79 100644 --- a/apps/console/components/run-timeline-panel.tsx +++ b/apps/console/components/run-timeline-panel.tsx @@ -16,7 +16,11 @@ export function RunTimelinePanel({ events }: { events: TraceEvent[] }) { return (
-
+
{TABS.map((t) => (