feat: manage function dependencies from UI
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getFunctionRuntime,
|
||||
isDependencyFilename,
|
||||
} from "../lib/FunctionDependencies";
|
||||
|
||||
describe("FunctionDependencies", () => {
|
||||
it("recognizes the supported runtime families", () => {
|
||||
expect(getFunctionRuntime("python:3.12")).toBe("python");
|
||||
expect(getFunctionRuntime("golang:1.23")).toBe("golang");
|
||||
expect(getFunctionRuntime("mcr.microsoft.com/dotnet/sdk:8.0")).toBe("dotnet");
|
||||
expect(getFunctionRuntime("node:22")).toBe("unsupported");
|
||||
});
|
||||
|
||||
it("recognizes dependency manifests for all supported runtimes", () => {
|
||||
expect(isDependencyFilename("requirements.txt")).toBe(true);
|
||||
expect(isDependencyFilename("go.mod")).toBe(true);
|
||||
expect(isDependencyFilename("go.sum")).toBe(true);
|
||||
expect(isDependencyFilename("src/MyFunction.csproj")).toBe(true);
|
||||
expect(isDependencyFilename("main.py")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export type FunctionRuntime = "python" | "golang" | "dotnet" | "unsupported";
|
||||
|
||||
export function getFunctionRuntime(image: string): FunctionRuntime {
|
||||
if (image.startsWith("python")) return "python";
|
||||
if (image.startsWith("golang")) return "golang";
|
||||
if (image.startsWith("mcr.microsoft.com/dotnet/sdk:")) return "dotnet";
|
||||
return "unsupported";
|
||||
}
|
||||
export function isDependencyFilename(filename: string): boolean {
|
||||
const normalized = filename.trim().replaceAll("\\", "/").toLowerCase();
|
||||
const basename = normalized.split("/").pop() ?? normalized;
|
||||
|
||||
return (
|
||||
basename === "requirements.txt" ||
|
||||
basename === "go.mod" ||
|
||||
basename === "go.sum" ||
|
||||
basename.endsWith(".csproj")
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { OpenAPITags } from "../../lib/openapi";
|
||||
import { getFunctionAppDir } from "../../lib/StoragePaths";
|
||||
import { createLogger } from "../../lib/logger";
|
||||
import { listGitAppFiles } from "../../lib/GitOps";
|
||||
import { isDependencyFilename } from "../../lib/FunctionDependencies";
|
||||
|
||||
const log = createLogger("files");
|
||||
|
||||
@@ -40,8 +41,9 @@ async function updateContainerDependencies(
|
||||
filename: string,
|
||||
_content: string,
|
||||
) {
|
||||
// Only process dependency files
|
||||
if (filename !== "requirements.txt" && filename !== "package.json") {
|
||||
// Restart the runtime when a manifest changes. Python and Go apply their
|
||||
// manifests during init; .NET picks up the updated project on the next build.
|
||||
if (!isDependencyFilename(filename) && filename !== "package.json") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -212,10 +214,7 @@ export = new fileRouter.Path("/")
|
||||
encoding: "utf-8",
|
||||
});
|
||||
// If this is a dependency file, ensure container dependencies are updated using the replaced content
|
||||
if (
|
||||
data.filename === "requirements.txt" ||
|
||||
data.filename === "package.json"
|
||||
) {
|
||||
if (isDependencyFilename(data.filename) || data.filename === "package.json") {
|
||||
await updateContainerDependencies(
|
||||
functionId,
|
||||
data.filename,
|
||||
@@ -559,29 +558,41 @@ export = new fileRouter.Path("/")
|
||||
// Handle renames of dependency files, which requires updating files on disk too
|
||||
if (
|
||||
oldFile &&
|
||||
(oldFile.name === "requirements.txt" ||
|
||||
(isDependencyFilename(oldFile.name) ||
|
||||
oldFile.name === "package.json" ||
|
||||
data.newFilename === "requirements.txt" ||
|
||||
isDependencyFilename(data.newFilename) ||
|
||||
data.newFilename === "package.json")
|
||||
) {
|
||||
const funcAppDir = getFunctionAppDir(functionId);
|
||||
try {
|
||||
// If renaming away from a dependency file, create an empty one
|
||||
if (
|
||||
oldFile.name === "requirements.txt" ||
|
||||
oldFile.name === "package.json"
|
||||
) {
|
||||
const oldPath = path.join(funcAppDir, oldFile.name);
|
||||
const newPath = path.join(funcAppDir, data.newFilename);
|
||||
const isLegacyDependency =
|
||||
oldFile.name === "requirements.txt" || oldFile.name === "package.json";
|
||||
|
||||
// Remove the old path before writing the renamed file. Keep the
|
||||
// historical placeholders only for the legacy Python/Node manifests.
|
||||
if (oldFile.name !== data.newFilename) {
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch {
|
||||
// The file may not exist yet for a newly-created database record.
|
||||
}
|
||||
}
|
||||
if (isLegacyDependency) {
|
||||
await fs.writeFile(
|
||||
path.join(funcAppDir, oldFile.name),
|
||||
oldPath,
|
||||
"# File was renamed\n",
|
||||
);
|
||||
log.info({ fileName: oldFile.name }, "Created empty file after rename to prevent broken deployments");
|
||||
}
|
||||
|
||||
// If renaming to a dependency file, write the content and update dependencies
|
||||
// Keep the host app directory synchronized for dependency and
|
||||
// dependency-adjacent renames.
|
||||
if (
|
||||
data.newFilename === "requirements.txt" ||
|
||||
data.newFilename === "package.json"
|
||||
isDependencyFilename(data.newFilename) ||
|
||||
isDependencyFilename(oldFile.name) ||
|
||||
isLegacyDependency
|
||||
) {
|
||||
try {
|
||||
const funcInfo = await getFunctionExecInfo(functionId);
|
||||
@@ -593,15 +604,15 @@ export = new fileRouter.Path("/")
|
||||
funcInfo.executionId,
|
||||
);
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(funcAppDir, data.newFilename),
|
||||
contentToWrite,
|
||||
);
|
||||
await updateContainerDependencies(
|
||||
functionId,
|
||||
data.newFilename,
|
||||
contentToWrite as string,
|
||||
);
|
||||
await fs.mkdir(path.dirname(newPath), { recursive: true });
|
||||
await fs.writeFile(newPath, contentToWrite);
|
||||
if (isDependencyFilename(data.newFilename) || data.newFilename === "package.json") {
|
||||
await updateContainerDependencies(
|
||||
functionId,
|
||||
data.newFilename,
|
||||
contentToWrite as string,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err, fileName: data.newFilename }, "Error writing renamed dependency file");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- [x] Shift Enter Submits on modals (any modal) (add as a agent rule for the future)
|
||||
- [ ] Fix SHSF Global & Redo it
|
||||
- [x] Built-in MCP Server & ready to copy Agentic commands ("claude mcp xxxx", "openclaw mcp add xxxxx", and codex ofc) // Seperate Agents Page & usecases for agents using shsf
|
||||
- [ ] Add cron and more mcp tools
|
||||
|
||||
## P1 - Priority 1 (High)
|
||||
- [x] Replace data transport layer between backend and functions with a more robust and safe solution.
|
||||
@@ -32,7 +33,7 @@
|
||||
|
||||
## P3 - Priority 3 (Low)
|
||||
- [x] Account Wide Environment Variables
|
||||
- [ ] Add a way to manage function dependencies (eg. requirements.txt) from the UI
|
||||
- [x] Add a way to manage function dependencies (eg. requirements.txt) from the UI
|
||||
- [x] Runner & Backend: Implement a Block for interactions on Functions while “Container ready.” not reached (pretty much wait for “[SHSF] Container ready.”). Message would be something like “Function is not ready yet.”
|
||||
- [ ] Function Logs Update
|
||||
- Investigate (Shows only Errors)
|
||||
@@ -47,4 +48,4 @@
|
||||
## AI Slop
|
||||
- [ ] AI Performance Analytics (Code Check)
|
||||
- [ ] Improve AI's capeability
|
||||
- [ ] Support OpenAI API Keys
|
||||
- [ ] Support OpenAI API Keys
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import DependencyManagerModal from "./DependencyManagerModal";
|
||||
|
||||
const dotnetProject = {
|
||||
id: 1,
|
||||
name: "app.csproj",
|
||||
content: "<Project />",
|
||||
functionId: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe("DependencyManagerModal", () => {
|
||||
it.each([
|
||||
["python:3.12", "Python Dependencies", "requirements.txt"],
|
||||
["golang:1.23", "Go Dependencies", "go.mod"],
|
||||
["mcr.microsoft.com/dotnet/sdk:8.0", ".NET Dependencies", "app.csproj"],
|
||||
])("supports %s dependency manifests", async (image, title, filename) => {
|
||||
const onSave = jest.fn().mockResolvedValue(true);
|
||||
const files = image.startsWith("mcr.microsoft.com") ? [dotnetProject] : [];
|
||||
|
||||
render(React.createElement(DependencyManagerModal, {
|
||||
isOpen: true,
|
||||
onClose: jest.fn(),
|
||||
functionId: 1,
|
||||
image,
|
||||
files,
|
||||
onSave,
|
||||
}));
|
||||
|
||||
expect(screen.getByRole("heading", { name: title })).toBeTruthy();
|
||||
const selector = screen.getByRole("combobox");
|
||||
expect(selector.value).toBe(filename);
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: `${filename} content` }), {
|
||||
target: { value: "updated dependency content" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Dependencies" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(filename, "updated dependency content");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Modal from "../Modal";
|
||||
import {
|
||||
cancelBtnClass,
|
||||
inputClass,
|
||||
labelClass,
|
||||
ModalError,
|
||||
ModalFooter,
|
||||
primaryBtnClass,
|
||||
textareaClass,
|
||||
} from "../Modal";
|
||||
import { useShiftEnterSubmit } from "../../../hooks/useShiftEnterSubmit";
|
||||
import { FunctionFile, isDotnetImage } from "../../../types/Prisma";
|
||||
|
||||
interface DependencyManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
functionId: number;
|
||||
image: string;
|
||||
files: FunctionFile[];
|
||||
onSave: (filename: string, content: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
type DependencyRuntime = "python" | "golang" | "dotnet";
|
||||
|
||||
function getRuntime(image: string): DependencyRuntime | null {
|
||||
if (isDotnetImage(image)) return "dotnet";
|
||||
if (image.startsWith("python")) return "python";
|
||||
if (image.startsWith("golang")) return "golang";
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDefaultContent(
|
||||
runtime: DependencyRuntime,
|
||||
filename: string,
|
||||
functionId: number,
|
||||
image: string,
|
||||
): string {
|
||||
if (filename === "requirements.txt") {
|
||||
return "# Add one Python package per line\n";
|
||||
}
|
||||
if (filename === "go.mod") {
|
||||
return `module shsf_function_${functionId}\n\ngo 1.23\n`;
|
||||
}
|
||||
if (filename === "go.sum") {
|
||||
return "";
|
||||
}
|
||||
if (runtime === "dotnet" && filename.endsWith(".csproj")) {
|
||||
const targetFramework = image.split(":").pop() || "8.0";
|
||||
return `<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net${targetFramework}</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getManifestDescription(runtime: DependencyRuntime, filename: string): string {
|
||||
if (filename === "requirements.txt") {
|
||||
return "One Python package or pinned version per line. SHSF installs it during runtime setup.";
|
||||
}
|
||||
if (filename === "go.mod") {
|
||||
return "The Go module definition. SHSF downloads modules and rebuilds the function when it runs.";
|
||||
}
|
||||
if (filename === "go.sum") {
|
||||
return "Go module checksums. It is normally generated and maintained by the Go toolchain.";
|
||||
}
|
||||
if (runtime === "dotnet") {
|
||||
return "The .NET project file. Add NuGet PackageReference entries here, then run .NET Build.";
|
||||
}
|
||||
return "Runtime dependency manifest.";
|
||||
}
|
||||
|
||||
function DependencyManagerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
functionId,
|
||||
image,
|
||||
files,
|
||||
onSave,
|
||||
}: DependencyManagerModalProps) {
|
||||
const runtime = getRuntime(image);
|
||||
const dependencyFiles = useMemo(() => {
|
||||
if (!runtime) return [];
|
||||
|
||||
if (runtime === "python") {
|
||||
return ["requirements.txt"];
|
||||
}
|
||||
if (runtime === "golang") {
|
||||
return ["go.mod", "go.sum"];
|
||||
}
|
||||
|
||||
const projectFiles = files
|
||||
.map((file) => file.name)
|
||||
.filter((filename) => filename.toLowerCase().endsWith(".csproj"));
|
||||
return projectFiles.length > 0 ? projectFiles : ["project.csproj"];
|
||||
}, [files, runtime]);
|
||||
|
||||
const [selectedFilename, setSelectedFilename] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const selectedFile = files.find((file) => file.name === selectedFilename);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || dependencyFiles.length === 0) return;
|
||||
setSelectedFilename((current) =>
|
||||
dependencyFiles.includes(current) ? current : dependencyFiles[0],
|
||||
);
|
||||
}, [dependencyFiles, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !runtime || !selectedFilename) return;
|
||||
setDraft(
|
||||
selectedFile?.content ??
|
||||
getDefaultContent(runtime, selectedFilename, functionId, image),
|
||||
);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
}, [functionId, image, isOpen, runtime, selectedFile, selectedFilename]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedFilename) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
if (await onSave(selectedFilename, draft)) {
|
||||
setSaved(true);
|
||||
} else {
|
||||
setError("The dependency file could not be saved.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useShiftEnterSubmit(() => handleSave(), isOpen && !loading);
|
||||
|
||||
if (!runtime) return null;
|
||||
|
||||
const runtimeLabel = runtime === "golang" ? "Go" : runtime === "dotnet" ? ".NET" : "Python";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`${runtimeLabel} Dependencies`}
|
||||
maxWidth="xl"
|
||||
isLoading={loading}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="text-sm text-text/80">
|
||||
Manage the dependency manifest used by this {runtimeLabel} function.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
{runtime === "python"
|
||||
? "Use requirements.txt for pip packages."
|
||||
: runtime === "golang"
|
||||
? "Use go.mod for modules; go.sum is kept for checksums."
|
||||
: "Use the .csproj file for NuGet PackageReference entries."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModalError message={error} />
|
||||
{saved && (
|
||||
<div className="rounded-lg border border-emerald-400/20 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">
|
||||
{selectedFilename} saved. {runtime === "python" ? "Use Install requirements.txt to apply it now." : runtime === "dotnet" ? "Run .NET Build to apply project changes." : "The next function run will resolve the module changes."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Dependency file</label>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={selectedFilename}
|
||||
onChange={(event) => setSelectedFilename(event.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{dependencyFiles.map((filename) => (
|
||||
<option key={filename} value={filename}>
|
||||
{filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>File content</label>
|
||||
<textarea
|
||||
className={`${textareaClass} min-h-[360px] font-mono text-xs`}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setSaved(false);
|
||||
}}
|
||||
spellCheck={false}
|
||||
disabled={loading}
|
||||
aria-label={`${selectedFilename} content`}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted/70">
|
||||
{getManifestDescription(runtime, selectedFilename)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<button className={cancelBtnClass} onClick={onClose} disabled={loading}>
|
||||
Close
|
||||
</button>
|
||||
<button className={primaryBtnClass} onClick={handleSave} disabled={loading || !selectedFilename}>
|
||||
Save Dependencies
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DependencyManagerModal;
|
||||
@@ -18,6 +18,7 @@ import LoadDefaultModal from "../../components/modals/functionFiles/LoadDefaultM
|
||||
import AIGenerateModal from "../../components/modals/AIGenerateModal";
|
||||
import GitVersionControlModal from "../../components/modals/functions/GitVersionControlModal";
|
||||
import DependencyModal from "../../components/modals/functionDetail/DependencyModal";
|
||||
import DependencyManagerModal from "../../components/modals/functionDetail/DependencyManagerModal";
|
||||
import ResultModal from "../../components/modals/functionDetail/ResultModal";
|
||||
import HtmlResultModal from "../../components/modals/functionDetail/HtmlResultModal";
|
||||
import ImageResultModal from "../../components/modals/functionDetail/ImageResultModal";
|
||||
@@ -144,6 +145,7 @@ function FunctionDetail() {
|
||||
const [, setShowAllImageHeaders] = useState<boolean>(false);
|
||||
const [serveHtmlOnly, setServeHtmlOnly] = useState<boolean>(false);
|
||||
const [showDepModal, setShowDepModal] = useState(false);
|
||||
const [showDependencyManager, setShowDependencyManager] = useState(false);
|
||||
const [depModalContent, setDepModalContent] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -439,7 +441,17 @@ function FunctionDetail() {
|
||||
});
|
||||
|
||||
if (data.status === "OK") {
|
||||
setFiles((prev) => [...prev, { ...data.data, content }]);
|
||||
setFiles((prev) => {
|
||||
const alreadyExists = prev.some((file) => file.id === data.data.id);
|
||||
return alreadyExists
|
||||
? prev.map((file) =>
|
||||
file.id === data.data.id ? { ...file, content } : file,
|
||||
)
|
||||
: [...prev, { ...data.data, content }];
|
||||
});
|
||||
if (activeFile?.id === data.data.id) {
|
||||
setCode(content);
|
||||
}
|
||||
return { success: true, name: filename };
|
||||
}
|
||||
|
||||
@@ -1818,6 +1830,16 @@ function FunctionDetail() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-primary/10 pt-4">
|
||||
{!serveHtmlOnly && (isDotnetRuntime || functionData.image.startsWith("python") || functionData.image.startsWith("golang")) && (
|
||||
<button
|
||||
className="h-9 px-3 text-sm rounded-lg bg-background/45 border border-primary/20 text-primary hover:border-primary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
|
||||
onClick={() => setShowDependencyManager(true)}
|
||||
disabled={running || saving || Boolean(functionData.git_url)}
|
||||
title={functionData.git_url ? "Dependency files are managed by the linked git repository" : "Edit runtime dependency manifests"}
|
||||
>
|
||||
Manage Dependencies
|
||||
</button>
|
||||
)}
|
||||
{/* Show Pip Install button if requirements.txt exists or if it's a git-based function (since we don't know the files) */}
|
||||
{(files.find((file) => file.name === "requirements.txt") || Boolean(functionData.git_url)) && (
|
||||
<button
|
||||
@@ -1980,6 +2002,21 @@ function FunctionDetail() {
|
||||
|
||||
{/* Modals */}
|
||||
<div>
|
||||
<DependencyManagerModal
|
||||
isOpen={showDependencyManager}
|
||||
onClose={() => setShowDependencyManager(false)}
|
||||
functionId={functionData?.id ?? 0}
|
||||
image={functionData?.image ?? ""}
|
||||
files={files}
|
||||
onSave={async (filename, content) => {
|
||||
const result = await persistFile(filename, content);
|
||||
if (!result.success) {
|
||||
toast.error(`Error saving ${filename}: ${result.message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
<CreateFileModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
|
||||
Reference in New Issue
Block a user