Files
punktfunk/web/src/sections/Store/Runner.tsx
T
enricobuehlerandClaude Fable 5 833f3348a0 feat(store): console plugin store, index repo, and the fixes on-glass found
Console: a Plugins section (Browse / Installed / Sources) on a static nav
entry, with install friction proportional to trust — a plain confirm for a
verified entry, a warning naming the curator for an external one, and a
danger dialog that makes you retype the spec for a raw package. Tier badges
are permanent and follow the plugin onto its own UI page.

Index: unom/punktfunk-plugin-index published and served from Gitea's raw
endpoint (real HTTPS, byte-exact, no vhost to stand up) — merge to main is
publish, which resolves the design's open hosting question.

Four things only running it could find:
- runner discovery matched @punktfunk/plugin-* only, so a third-party
  scoped plugin (which D8 requires) would install and never run
- ...and that convention also matches @punktfunk/plugin-kit, a plugin's
  own framework: it listed as installed and would have been imported as a
  unit. Both now key off the plugins dir's top-level dependencies, with an
  emptied dependency list meaning 'nothing installed' rather than falling
  back to the naming convention
- the store must not pass new flags to the runner: the scripting package
  ships separately and an older one reads an unknown flag's value as a
  package name. The host writes the bunfig scope mapping itself
- ureq reports only >= 400 as Err, so a conditional request's 304 arrives
  as Ok with an empty body — handled as an error it made every refresh
  after the first verify a signature over zero bytes and sit stale

Also: the console's first Tabs use exposed an @unom/ui theme gap that
rendered inactive tabs invisible (caught in a browser pass, not by types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:48:02 +02:00

126 lines
4.3 KiB
TypeScript

import { toast } from "@unom/ui/toast";
import { Play, Power, PowerOff } from "lucide-react";
import type { FC } from "react";
import {
type RuntimeStatus,
useSetRuntime,
useStoreRuntime,
} from "@/api/store";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
// The plugin/script runner is the service every plugin actually executes inside. Installing a plugin
// while it's switched off silently gets you nothing running, so Browse carries a banner and the
// Installed tab carries the full switch.
/** Small helper both surfaces share: fire the toggle, surface a failure as a toast. */
function useRunnerToggle() {
const set = useSetRuntime();
const toggle = (enabled: boolean) => {
set.mutate(enabled, {
onError: () => toast.error(m.store_runner_failed()),
});
};
return { toggle, isPending: set.isPending };
}
/**
* Browse-tab banner: the runner is installed but off, so nothing an operator installs here would
* start. Renders nothing in every other state (including "not installed" — the Installed tab's card
* explains that case properly).
*/
export const RunnerBanner: FC = () => {
const runtime = useStoreRuntime();
const { toggle, isPending } = useRunnerToggle();
const s = runtime.data;
if (!s?.installed || s.enabled) return null;
return (
<div className="flex flex-col gap-3 rounded-lg border border-amber-600/40 bg-amber-500/10 p-4 text-sm text-amber-600 sm:flex-row sm:items-center dark:border-amber-500/40 dark:text-amber-500">
<PowerOff className="size-5 shrink-0" />
<p className="flex-1">{m.store_runner_banner()}</p>
<Button size="sm" disabled={isPending} onClick={() => toggle(true)}>
<Play className="size-4" />
{m.store_runner_enable()}
</Button>
</div>
);
};
/**
* Container: the runner switch at the top of the Installed tab. Like the library's source toggles
* this is a secondary control — it stays out of the way until the query resolves rather than
* stacking a second error banner on top of the installed list's own.
*/
export const RunnerCardSection: FC = () => {
const runtime = useStoreRuntime();
const { toggle, isPending } = useRunnerToggle();
if (!runtime.data) return null;
return (
<RunnerCard status={runtime.data} busy={isPending} onToggle={toggle} />
);
};
/** The runner card: what the service is, whether it's up, and the one switch that changes it. */
export const RunnerCard: FC<{
status: RuntimeStatus;
busy: boolean;
onToggle: (enabled: boolean) => void;
}> = ({ status, busy, onToggle }) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between gap-3 text-base">
<span>{m.store_runner_title()}</span>
{!status.installed ? (
<Badge variant="outline">{m.store_runner_state_missing()}</Badge>
) : status.running ? (
<Badge variant="success">{m.store_runner_state_running()}</Badge>
) : status.enabled ? (
<Badge variant="outline">{m.store_runner_state_stopped()}</Badge>
) : (
<Badge variant="secondary">{m.store_runner_state_disabled()}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{status.installed
? m.store_runner_help()
: m.store_runner_not_installed()}
</p>
<dl className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
<div className="flex gap-2">
<dt>{m.store_runner_unit()}</dt>
<dd className="font-mono text-foreground">{status.unit}</dd>
</div>
{status.principal && (
<div className="flex gap-2">
<dt>{m.store_runner_principal()}</dt>
<dd className="font-mono text-foreground">{status.principal}</dd>
</div>
)}
</dl>
{status.detail && (
<p className="text-xs text-muted-foreground">{status.detail}</p>
)}
{status.installed && (
<Button
size="sm"
variant={status.enabled ? "outline" : "default"}
disabled={busy}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? (
<PowerOff className="size-4" />
) : (
<Power className="size-4" />
)}
{status.enabled ? m.store_runner_disable() : m.store_runner_enable()}
</Button>
)}
</CardContent>
</Card>
);