Writing node source, installing modules and any flow save that adds, retypes or repoints a code node (Function, Command), edits a Command node's settings or changes a node's resources.env now answer 403 for everyone but a superuser. MCP tools and the assistant reach the same routes, so they inherit the gate. Node types report runs_code. The editor shows code read-only with a note, disables a Command node's settings, leaves code nodes and reusable nodes out of the palette and paste, and the Modules page offers no install. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
181 lines
6.7 KiB
TypeScript
181 lines
6.7 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { createFileRoute } from "@tanstack/react-router"
|
|
import { Package } from "lucide-react"
|
|
import { useState } from "react"
|
|
|
|
import { type ApiError, ModulesService } from "@/client"
|
|
import { InfoTip } from "@/components/Flow/SidePanel"
|
|
import { Button } from "@/components/ui/button"
|
|
import useAuth from "@/hooks/useAuth"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { handleError } from "@/utils"
|
|
|
|
export const Route = createFileRoute("/_layout/modules")({
|
|
component: Modules,
|
|
head: () => ({
|
|
meta: [
|
|
{
|
|
title: "Modules - Fluksio",
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
const modulesKey = ["modules"]
|
|
|
|
const SECTION =
|
|
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
|
|
|
|
function Modules() {
|
|
const { data } = useQuery({
|
|
queryKey: modulesKey,
|
|
queryFn: () => ModulesService.readModules(),
|
|
})
|
|
const queryClient = useQueryClient()
|
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
|
// A package runs its own code on install, so installing is a superuser's.
|
|
const { user } = useAuth()
|
|
const mayInstall = Boolean(user?.is_superuser)
|
|
// Null until the field is touched, so what is stored shows through until
|
|
// someone actually starts editing it.
|
|
const [draft, setDraft] = useState<string | null>(null)
|
|
const [output, setOutput] = useState("")
|
|
|
|
const apply = useMutation({
|
|
mutationFn: (requirements: string) =>
|
|
ModulesService.applyModules({ requestBody: { requirements } }),
|
|
onSuccess: (result) => {
|
|
showSuccessToast("Modules installed")
|
|
setOutput(result.output ?? "")
|
|
setDraft(null)
|
|
queryClient.invalidateQueries({ queryKey: modulesKey })
|
|
},
|
|
onError: (error: ApiError) => {
|
|
// The 400 detail is uv's own output — several lines of resolver
|
|
// reasoning, which belongs in the pane rather than in a toast.
|
|
const detail = (error.body as { detail?: string } | undefined)?.detail
|
|
if (detail) {
|
|
setOutput(detail)
|
|
showErrorToast("Those requirements could not be installed")
|
|
} else {
|
|
handleError.call(showErrorToast, error)
|
|
}
|
|
},
|
|
})
|
|
|
|
const stored = data?.requirements ?? ""
|
|
const requirements = draft ?? stored
|
|
const packages = data?.packages ?? []
|
|
const dirty = requirements !== stored
|
|
// Fluksio was installed into an environment somebody else made, and node
|
|
// code runs on it. Installing from here would mean `uv pip sync`, which
|
|
// holds a venv to exactly one list — and would uninstall their work.
|
|
const adopted = data?.adopted ?? false
|
|
|
|
return (
|
|
<div className="grid gap-6">
|
|
<div className="grid gap-1">
|
|
<div className="flex items-center gap-1">
|
|
<h1 className="text-2xl">Modules</h1>
|
|
<InfoTip label="Modules">
|
|
{adopted
|
|
? "Fluksio was installed into an environment you already had, so that is the one your nodes run on. Change it with pip or uv rather than from here."
|
|
: "Packages install into an environment of their own, separate from the engine's, so a version pinned here is the one your code gets. The list is kept with your flows, so a rebuilt deployment installs the same set again."}
|
|
</InfoTip>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
The Python packages your function nodes can import.
|
|
</p>
|
|
</div>
|
|
|
|
{adopted ? null : !mayInstall ? (
|
|
<p
|
|
className="text-sm text-muted-foreground"
|
|
data-testid="modules-locked"
|
|
>
|
|
Only a superuser can install packages.
|
|
</p>
|
|
) : (
|
|
<section className="grid gap-3">
|
|
<h2 className={SECTION}>Requirements</h2>
|
|
<textarea
|
|
className="min-h-[160px] w-full rounded-md border border-border bg-card p-3 font-mono text-sm shadow-e1 outline-none focus-visible:border-primary"
|
|
spellCheck={false}
|
|
aria-label="Requirements"
|
|
data-testid="requirements"
|
|
placeholder={"requests==2.32.3\npandas>=2.2"}
|
|
value={requirements}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
/>
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
disabled={apply.isPending}
|
|
data-testid="apply-modules"
|
|
onClick={() => apply.mutate(requirements)}
|
|
>
|
|
<Package />
|
|
{apply.isPending ? "Installing" : "Apply"}
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground">
|
|
{dirty
|
|
? "Not applied yet."
|
|
: data?.applied
|
|
? "Installed and in step."
|
|
: "What is installed does not match this list."}
|
|
</span>
|
|
</div>
|
|
{output ? (
|
|
<pre
|
|
className="max-h-64 overflow-auto rounded-md border border-border bg-card p-3 font-mono text-xs shadow-e1"
|
|
data-testid="modules-output"
|
|
>
|
|
{output}
|
|
</pre>
|
|
) : null}
|
|
</section>
|
|
)}
|
|
|
|
<section className="grid gap-3">
|
|
<h2 className={SECTION}>Installed</h2>
|
|
<p className="text-sm text-muted-foreground" data-testid="modules-venv">
|
|
{data
|
|
? `Python ${data.python_version || "unknown"} at ${data.venv_path}`
|
|
: "Reading the environment…"}
|
|
</p>
|
|
{adopted ? (
|
|
<p
|
|
className="text-sm text-muted-foreground"
|
|
data-testid="modules-adopted"
|
|
>
|
|
Adopted — this is the environment Fluksio itself is installed in.
|
|
Add a package the way you added the rest:{" "}
|
|
<span className="font-mono">pip install …</span>, then sync or
|
|
restart so the workers pick it up. For a venv the engine owns
|
|
instead, set <span className="font-mono">NODE_VENV=managed</span>.
|
|
</p>
|
|
) : null}
|
|
{packages.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Nothing installed yet. Node code has the standard library.
|
|
</p>
|
|
) : (
|
|
<div className="grid gap-2">
|
|
{packages.map((entry) => (
|
|
<div
|
|
key={entry.name}
|
|
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
|
|
data-testid="module-row"
|
|
>
|
|
<span className="font-mono text-sm">{entry.name}</span>
|
|
<span className="font-mono text-sm text-muted-foreground">
|
|
{entry.version}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|