Skip to content
Closed
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ chart's `appVersion`; the image and chart are released together under it.
- Dependabot for Go modules, npm, GitHub Actions and the Dockerfile.
- English install guide for the chart; the Chinese one is kept as `helm/console/README.zh-CN.md`.
- Contributor guide, issue and PR templates, security policy (#12).
- Inference services (`/inferences`): swissd's releases in Rise Global's model service layout -- a resource table with a name/model cell, a state dot and a row menu, and a detail page with a header card over tabs (overview, instances, cluster resources, health check, SLO, versions, activity, plan). `/swiss` is unchanged; deploy, upgrade and roll back still open its wizards.

### Changed
- Shell, access control, Playground and router pages use `@modelsphere/ui` in Rise Global's current look: page banner, resource tables with search and pagination, deletes confirmed by typing the name. The swiss module's components follow.
Expand All @@ -28,6 +29,7 @@ chart's `appVersion`; the image and chart are released together under it.

### Fixed
- An empty Playground chat names the selected model (#13).
- The sidebar keeps a module's group open on its pages without a menu entry, such as a deployment's detail page.

### Security
- `golang.org/x/crypto` 0.47.0 → 0.57.0 and `golang.org/x/net` 0.49.0 → 0.59.0, for the advisories Dependabot reported against them. Console uses only `bcrypt` from `x/crypto`; most of the advisories are in `ssh`.
Expand Down
46 changes: 44 additions & 2 deletions docs/console-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ unchanged. Moving either one moves both.
## Modules

The console is a shell plus compile-time modules. A module is a feature area
(iam, swiss, playground, router, later container management); the shell owns everything
(iam, swiss, inferences, playground, router, later container management); the shell owns everything
around it.

| Owned by | What |
Expand All @@ -122,10 +122,11 @@ export const swissModule: ConsoleModule = {

Rules:

- A module imports from `@/shell` only, never from a file under it or from another module.
- A module imports from `@/shell` only, never from a file under it or from another module. One exception: `inferences` builds on swiss (see "The inferences module").
- `permission` is a UI permission (a Role's `uiPermissions`); it guards the route and hides the menu entry.
- Links go through `useModulePath()`: `p("catalog")` → `/swiss/catalog`. A leading `/` is still relative to the module.
- API calls go through `apiFetch` (drop-in `fetch` with the session) or `request<T>` (JSON helper).
- The sidebar opens the group of the module the current path is mounted under (`activeGroupTitle`), so a page with no menu entry -- a detail page -- still keeps its module's group open.
- `validateModules` rejects duplicate ids/basePaths, shell-reserved paths and ids (`common`, `shell`: they are i18n namespaces) and pages declared twice at startup.
- Text goes through `useT()` (see "i18n"); the declaration's `title` and menu labels stay Chinese.

Expand Down Expand Up @@ -222,6 +223,47 @@ chrome, and is there for the case where a Rise component reads wrong on a deploy
page -- the tables are the likely one, since Rise's `TableHead`/`TableCell` are
`h-12 px-4` against swiss's `h-10 px-3`.

### The inferences module

`/inferences` is a second view on swissd beside the swiss module, laid out the
way Rise Global's model service pages are: a resource table of releases
(`/inferences`), and a detail page with a header card over tabs
(`/inferences/:release/details?namespace=…&tab=…`) instead of every panel
stacked down the page. `/swiss` is unchanged; both read the same swissd.

| Part | Where it comes from |
|---|---|
| list, header card, tabs, overview, instances, versions, activity | `web/src/modules/inferences/`, on `@modelsphere/ui` |
| state, tabs, install track, revisions joined with runs | `modules/inferences/lib.ts`, pure and tested |
| API client and types | `@swiss/lib/api` |
| cluster resources, health check, SLO, plan panels | swiss's own components (`ReleaseObjects`, `Endpoint`, `SLOCard`, `Provenance`) |
| deploy, upgrade, roll back | links to swiss's wizards under `/swiss` |
| uninstall | `ConfirmDialog`, typed to confirm |

It is the one module that imports another. The API client and the panels are
swiss's domain, and a copy would be a second implementation of the same thing
drifting from the first; the layout is what this module owns. The cost:
`inferences` cannot be installed without the swiss module.

swiss's components resolve their `/…` links against the module they render in,
so they render in swiss's context: `SwissScope` wraps every page in swiss's
`Gate` under swiss's `ModuleProvider` (its redirect to setup is swiss's page) and
puts this module's context back for the page; `InSwiss` does the same around
each embedded panel. The shell exports `ModuleProvider` for this.

Not there yet:

- **Logs and events tabs.** swissd has no endpoint for either; adding them is a
swissd change and starts with a design proposal (`AGENTS.md`).
- **Search on the list.** swissd pages its list (at most 100 a page) and takes no
query; filtering one page on the client would miss the others. It needs `?q=`
on swissd's `/api/deployments`.
- **swiss's panels are English.** They are swiss's, and swiss's pages have no
Chinese yet.
- **Cluster resources needs a recent swissd.** An older one (0.5.6, for one) has no
`/objects`: the tab says so, and the install
track reports the route and scaler as unreadable rather than absent.

## Backends

Each module's backend is a `backends` entry; console proxies it after login.
Expand Down
3 changes: 2 additions & 1 deletion web/src/modules/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// The installed modules, in sidebar order. Add or remove a line to plug one in or out.
import { registerI18n, type ConsoleModule } from "@/shell";
import { iamModule } from "@/modules/iam";
import { inferencesModule } from "@/modules/inferences";
import { playgroundModule } from "@/modules/playground";
import { routerModule } from "@/modules/router";
import { swissModule } from "@/modules/swiss";
Expand All @@ -10,4 +11,4 @@ import swissNavEn from "@/modules/swiss-nav.en-US.json";
// English lives beside it; its pages stay Chinese.
registerI18n("swiss", { "en-US": swissNavEn });

export const modules: ConsoleModule[] = [swissModule, playgroundModule, routerModule, iamModule];
export const modules: ConsoleModule[] = [swissModule, inferencesModule, playgroundModule, routerModule, iamModule];
223 changes: 223 additions & 0 deletions web/src/modules/inferences/InferenceDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription, Badge, Button, Card, Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@modelsphere/ui";
import { ArrowLeft, TriangleAlert } from "lucide-react";
import { useModulePath } from "@/shell";
import { api } from "@swiss/lib/api";
import { useReleaseObjects } from "@swiss/components/ReleaseObjects";
import { ReleaseObjects } from "@swiss/components/ReleaseObjects";
import { Endpoint } from "@swiss/components/Endpoint";
import { SLOCard } from "@swiss/components/SLOCard";
import { Provenance } from "@swiss/components/Provenance";
import { useT } from "@/modules/inferences/i18n";
import { modelLine, parseTab, releaseState, showSLO, swissLinks, visibleTabs, type Tab } from "@/modules/inferences/lib";
import { StatusDot } from "@/modules/inferences/components/StatusDot";
import { Section } from "@/modules/inferences/components/Section";
import { InSwiss, swissBase } from "@/modules/inferences/components/SwissScope";
import { UninstallDialog } from "@/modules/inferences/components/UninstallDialog";
import { Overview } from "@/modules/inferences/tabs/Overview";
import { Instances } from "@/modules/inferences/tabs/Instances";
import { Versions } from "@/modules/inferences/tabs/Versions";
import { Runs } from "@/modules/inferences/tabs/Runs";

// One release: a header card with what it is and what can be done to it, then
// one tab per concern instead of every panel stacked down the page.
export function InferenceDetail() {
const t = useT();
const p = useModulePath();
const navigate = useNavigate();
const { release = "" } = useParams();
const [search, setSearch] = useSearchParams();
const namespace = search.get("namespace") ?? "";
const swiss = swissLinks(swissBase);
const [uninstalling, setUninstalling] = useState(false);

// Query keys match swiss's pages, so the two views share one cache.
const status = useQuery({
queryKey: ["status", namespace, release],
queryFn: () => api.status(namespace, release),
refetchInterval: 15_000,
enabled: !!namespace,
});
// A release swiss did not deploy has no plan beside it: untracked, not an error.
const plan = useQuery({
queryKey: ["releasePlan", namespace, release],
queryFn: () => api.releasePlan(namespace, release),
retry: false,
enabled: !!namespace,
});
const cluster = useQuery({ queryKey: ["cluster"], queryFn: api.cluster });
const objects = useReleaseObjects(namespace, release);
const revisions = useQuery({
queryKey: ["revisions", namespace, release],
queryFn: () => api.revisions(namespace, release),
enabled: !!namespace,
});

const s = status.data;
const visible = visibleTabs({ slo: !!s && showSLO(s, plan.data), plan: !!plan.data });
const tab = parseTab(search.get("tab"), visible);
const setTab = (next: Tab) =>
setSearch(
(q) => {
if (next === "overview") q.delete("tab");
else q.set("tab", next);
return q;
},
{ replace: true },
);

const back = (
<Link to={p("")} className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" /> {t("detail.back")}
</Link>
);

if (!namespace || status.isPending) {
return (
<Page>
{back}
<Skeleton className="h-32 w-full" />
</Page>
);
}
if (status.error || !s) {
return (
<Page>
{back}
<Alert variant="destructive">
<TriangleAlert />
<AlertDescription>{String(status.error?.message ?? status.error)}</AlertDescription>
</Alert>
</Page>
);
}

const readOnly = cluster.data ? !cluster.data.allowDeploy : false;
const source = plan.data?.source;
const model = modelLine(source ?? {}) || s.model;
const counts: Partial<Record<Tab, number>> = { instances: s.pods.length, versions: revisions.data?.revisions.length };

return (
<Page>
<Card className="gap-0 border-l-[3px] border-l-primary/70 py-0">
<div className="flex flex-wrap items-start justify-between gap-3 px-6 pt-4">
<div className="min-w-0 space-y-3">
{back}
<div className="flex flex-wrap items-center gap-3">
<h1 className="truncate text-lg font-semibold">{release}</h1>
<StatusDot state={releaseState(s)} suffix={s.total > 0 ? `${s.ready}/${s.total}` : undefined} />
{!plan.data && plan.isError && <Badge variant="warning">{t("detail.untracked")}</Badge>}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setTab("check")}>
{t("actions.check")}
</Button>
<Button
variant="outline"
size="sm"
disabled={readOnly || !plan.data}
title={readOnly ? t("disabled.readOnly") : !plan.data ? t("disabled.untracked") : undefined}
onClick={() => navigate(swiss.upgrade(namespace, release))}
>
{t("actions.upgrade")}
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive enabled:hover:text-destructive"
disabled={readOnly || !s.exists}
title={readOnly ? t("disabled.readOnly") : undefined}
onClick={() => setUninstalling(true)}
>
{t("actions.uninstall")}
</Button>
</div>
</div>
<dl className="mx-6 mt-4 grid grid-cols-2 gap-4 border-t py-4 md:grid-cols-4">
<HeaderField label={t("detail.fields.namespace")} value={namespace} />
<HeaderField label={t("detail.fields.model")} value={model} />
<HeaderField label={t("detail.fields.chart")} value={plan.data ? `${plan.data.chart.name}-${plan.data.chart.version}` : undefined} />
<HeaderField label={t("detail.fields.instances")} value={`${s.ready}/${s.total}`} />
</dl>
</Card>

<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="gap-4">
<TabsList variant="line" className="h-9 border-b w-full justify-start rounded-none">
{visible.map((key) => (
<TabsTrigger key={key} value={key} className="flex-none px-3">
{t(`tabs.${key}`)}
{counts[key] !== undefined && <span className="text-xs text-muted-foreground tabular-nums">{counts[key]}</span>}
</TabsTrigger>
))}
</TabsList>

<TabsContent value="overview">
<Overview namespace={namespace} release={release} status={s} plan={plan.data} objects={objects.data?.objects} objectsUnreadable={objects.isError} onAllRuns={() => setTab("runs")} />
</TabsContent>
<TabsContent value="instances">
<Instances status={s} />
</TabsContent>
<TabsContent value="resources">
<InSwiss>
<ReleaseObjects namespace={namespace} release={release} />
</InSwiss>
</TabsContent>
<TabsContent value="check">
<InSwiss>
<Endpoint namespace={namespace} release={release} status={s} access={false} />
</InSwiss>
</TabsContent>
{visible.includes("slo") && (
<TabsContent value="slo">
<InSwiss>
<SLOCard namespace={namespace} release={release} canEdit={!readOnly} />
</InSwiss>
</TabsContent>
)}
<TabsContent value="versions">
<Versions namespace={namespace} release={release} canRollBack={!readOnly && !!plan.data} />
</TabsContent>
<TabsContent value="runs">
<Runs namespace={namespace} release={release} />
</TabsContent>
{plan.data && (
<TabsContent value="plan">
<Section title={t("plan.title")} hint={t("plan.description")}>
<InSwiss>
<Provenance plan={plan.data} />
</InSwiss>
</Section>
</TabsContent>
)}
</Tabs>

<UninstallDialog
target={uninstalling ? { namespace, release } : null}
onClose={() => setUninstalling(false)}
onDone={() => navigate(p(""))}
/>
</Page>
);
}

function Page({ children }: { children: React.ReactNode }) {
return (
<div className="h-full overflow-auto">
<div className="space-y-4 p-4">{children}</div>
</div>
);
}

function HeaderField({ label, value }: { label: string; value?: string }) {
return (
<div className="min-w-0">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-1 truncate text-sm" title={value}>
{value || "-"}
</dd>
</div>
);
}
Loading
Loading