Files
floci-ui/packages/frontend/src/components/ServerlessInvokePanel.tsx
T
2026-09-30 17:43:42 -04:00

248 lines
7.3 KiB
TypeScript

import {useEffect, useState} from "react";
import {ChevronDown, ChevronUp, Copy, Loader2, Play, Wand2} from "lucide-react";
import {useMutation} from "@tanstack/react-query";
import {invokeCloudResource} from "@/api/cloudProxyClient";
import type {ServerlessInvokeResult} from "@/api/cloudProxyClient";
import type {CloudProvider} from "@/types/cloud";
import type {CloudResource} from "@/types/resource";
import {ServiceIcon} from "@/components/serviceIcons";
import {LogsQueryPanel} from "./LogsQueryPanel";
interface ServerlessInvokePanelProps {
cloud: CloudProvider;
resource?: CloudResource;
runtimeReachable: boolean;
}
export function ServerlessInvokePanel({
cloud,
resource,
runtimeReachable,
}: ServerlessInvokePanelProps) {
const [payload, setPayload] = useState("{\n \n}");
const [invokeResult, setInvokeResult] = useState<ServerlessInvokeResult | null>(null);
const [showLog, setShowLog] = useState(false);
const [copied, setCopied] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [logsRefreshKey, setLogsRefreshKey] = useState(0);
useEffect(() => {
setPayload("{\n \n}");
setInvokeResult(null);
setShowLog(false);
setCopied(false);
setValidationError(null);
setLogsRefreshKey(0);
}, [resource?.id]);
const isSupportedResource =
resource?.service === "serverless" &&
(resource.type === "lambda" ||
resource.type === "azure-function" ||
resource.type === "gcp-function");
const canInvoke = Boolean(resource && isSupportedResource && runtimeReachable);
const canSubmit = canInvoke && !validationError;
const providerLabel =
cloud === "aws"
? "Lambda function"
: cloud === "azure"
? "Azure Function"
: "Google Cloud Function";
const invokeMutation = useMutation({
mutationFn: () =>
invokeCloudResource(cloud, "serverless", resource!.id, payload),
onSuccess: (result) => {
setInvokeResult(result);
setShowLog(false);
setCopied(false);
setLogsRefreshKey((value) => value + 1);
},
});
const isInvokeError = Boolean(
invokeResult?.functionError || (invokeResult && invokeResult.statusCode >= 400),
);
const formatPayload = () => {
try {
const formatted = JSON.stringify(JSON.parse(payload), null, 2);
setPayload(formatted);
setValidationError(null);
} catch {
setValidationError("Payload must be valid JSON before formatting.");
}
};
const clearPayload = () => {
setPayload("{}");
setValidationError(null);
};
const copyResponse = async () => {
if (!invokeResult) return;
await navigator.clipboard.writeText(invokeResult.payload || "");
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
if (!resource || resource.service !== "serverless") {
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select a serverless function</h3>
<p>
Select a Lambda function, Azure Function, or Google Cloud Function to
invoke it from Cloud Explorer.
</p>
</div>
</section>
);
}
return (
<section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Serverless Actions</p>
<h3>
<ServiceIcon cloud={cloud} iconKey="serverless" size={18} />
Invoke {providerLabel}
</h3>
<p className="muted compact-text">
Send a JSON event payload to the selected serverless function.
</p>
</div>
<span className={`runtime-state ${canInvoke ? "ready" : "pending"}`}>
{canInvoke ? "Ready" : "Runtime unavailable"}
</span>
</div>
<div className="resource-create-inline">
<div className="inspector-section-header">
<label className="metric-label" htmlFor="serverless-invoke-payload">
Event payload JSON
</label>
<button className="button" type="button" onClick={formatPayload}>
<Wand2 size={13} />
Format JSON
</button>
<button className="button" type="button" onClick={clearPayload}>
Clear
</button>
</div>
<textarea
id="serverless-invoke-payload"
className="json-editor serverless-payload-editor"
value={payload}
onChange={(event) => {
const value = event.target.value;
setPayload(value);
try {
JSON.parse(value);
setValidationError(null);
} catch {
setValidationError("Payload must be valid JSON.");
}
}}
spellCheck={false}
placeholder="{}"
style={{minHeight: 140}}
/>
{validationError && (
<p className="error-text compact-text">
{validationError}
</p>
)}
<button
className="button primary"
type="button"
disabled={!canSubmit || invokeMutation.isPending}
onClick={() => invokeMutation.mutate()}
>
{invokeMutation.isPending ? <Loader2 size={13} /> : <Play size={13} />}
{invokeMutation.isPending ? "Invoking" : "Invoke"}
</button>
{invokeMutation.isError && (
<p className="error-text compact-text">
{invokeMutation.error instanceof Error
? invokeMutation.error.message
: "Invocation failed"}
</p>
)}
{invokeResult && (
<div className="inspector-section">
<div className="inspector-section-header">
<p className="metric-label">Invocation Result</p>
<span className={`runtime-state ${isInvokeError ? "unavailable" : "ready"}`}>
HTTP {invokeResult.statusCode}
</span>
</div>
{invokeResult.executionDuration !== undefined && (
<p className="muted compact-text">
Completed in {invokeResult.executionDuration}ms
</p>
)}
{invokeResult.functionError && (
<p className="error-text compact-text">
{invokeResult.functionError}
</p>
)}
<button className="button" type="button" onClick={copyResponse}>
<Copy size={13} />
{copied ? "Copied" : "Copy response"}
</button>
<pre className={`invoke-result ${isInvokeError ? "error" : "success"}`}>
{tryFormatJson(invokeResult.payload) || "(empty)"}
</pre>
{invokeResult.logResult && (
<div>
<button
className="button"
type="button"
onClick={() => setShowLog((open) => !open)}
>
Log tail
{showLog ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
</button>
{showLog && <div className="log-tail">{invokeResult.logResult}</div>}
</div>
)}
</div>
)}
</div>
{cloud === "aws" && invokeResult && (
<LogsQueryPanel
cloud={cloud}
logGroupName={`/aws/lambda/${resource.name}`}
runtimeReachable={runtimeReachable}
autoRunKey={logsRefreshKey}
/>
)}
</section>
);
}
function tryFormatJson(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}