Fixed Issues with Assemblies Populating into Editor

This commit is contained in:
2025-11-03 02:20:06 -07:00
parent 13f37f39b1
commit 5188250a78
7 changed files with 1142 additions and 432 deletions

View File

@@ -98,10 +98,10 @@
[ ] Add “Source” column to AG Grid with domain filter badges.
[ ] Display yellow “Queued to Write to DB” pill for assemblies whose cache entry is dirty.
[ ] Implement Import/Export dropdown in `Assembly_Editor.jsx`:
[ ] Export: bundle metadata + payload contents into legacy JSON format for download.
[ ] Import: parse JSON, populate editor form, and default to saving into `user_created`.
[ ] Add Dev Mode banner/toggles and domain picker when Dev Mode is active; otherwise show read-only warnings.
[ ] Ensure admin-only controls are hidden for non-authorized users.
[x] Export: bundle metadata + payload contents into legacy JSON format for download.
[x] Import: parse JSON, populate editor form, and default to saving into `user_created`.
[x] Add Dev Mode banner/toggles and domain picker when Dev Mode is active; otherwise show read-only warnings.
[x] Ensure admin-only controls are hidden for non-authorized users.
### Details
```
1. Modify `Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx` (or equivalent) to:
@@ -119,6 +119,11 @@
5. Update i18n/strings files if the UI uses localization.
```
**Stage Notes**
- `Assembly_List.jsx` now consumes `/api/assemblies`, renders domain badges plus dirty-state pills, exposes clone-to-domain via a new dialog, and surfaces queue metadata for each row.
- `Assembly_Editor.jsx` loads and saves assemblies by GUID through the cache APIs, adds import/export tooling, domain selection with read-only warnings, Dev Mode enable/flush controls, and reuses shared badge components for status display.
- App routing (workflows/scripts) and quick job tooling were updated to pass assembly GUID/domain metadata, rely on the import/export endpoints, and hydrate variables from the new Assembly service.
## 5. Support JSON import/export endpoints
[x] Implement backend utilities to translate between DB model and legacy JSON structure.
[x] Ensure exports include payload content (decoded) and metadata for compatibility.

View File

@@ -113,7 +113,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
const [userDisplayName, setUserDisplayName] = useState(null);
const [editingJob, setEditingJob] = useState(null);
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { path, mode, context, nonce }
const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { mode: 'script'|'ansible', row, nonce }
const [sessionResolved, setSessionResolved] = useState(false);
const initialPathRef = useRef(window.location.pathname + window.location.search);
const pendingPathRef = useRef(null);
@@ -858,28 +858,36 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
async (name) => {
const tab = tabs.find((t) => t.id === activeTabId);
if (!tab || !name) return;
const payload = {
path: tab.folderPath ? `${tab.folderPath}/${name}` : name,
workflow: {
tab_name: tab.tab_name,
nodes: tab.nodes,
edges: tab.edges
}
const document = {
tab_name: name,
name,
display_name: name,
nodes: tab.nodes,
edges: tab.edges,
};
try {
const body = {
island: 'workflows',
kind: 'file',
path: payload.path,
content: payload.workflow
};
await fetch("/api/assembly/create", {
const resp = await fetch("/api/assemblies/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
body: JSON.stringify({
document,
domain: tab.domain || "user",
assembly_guid: tab.assemblyGuid || undefined,
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
setTabs((prev) =>
prev.map((t) => (t.id === activeTabId ? { ...t, tab_name: name } : t))
prev.map((t) =>
t.id === activeTabId
? {
...t,
tab_name: name,
assemblyGuid: data?.assembly_guid || t.assemblyGuid || null,
domain: (data?.source || data?.domain || t.domain || "user").toLowerCase(),
}
: t
)
);
} catch (err) {
console.error("Failed to save workflow:", err);
@@ -888,6 +896,97 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
[tabs, activeTabId]
);
const openScriptFromList = useCallback(
(row) => {
if (!row) return;
const normalizedRow = {
...row,
domain: (row?.domain || "user").toLowerCase(),
};
const mode = normalizedRow.typeKey === "ansible" || normalizedRow.mode === "ansible" ? "ansible" : "script";
const nonce = Date.now();
const state = {
mode,
row: normalizedRow,
nonce,
};
setAssemblyEditorState(state);
navigateTo(mode === "ansible" ? "ansible_editor" : "scripts", { assemblyState: state });
},
[navigateTo, setAssemblyEditorState]
);
const openWorkflowFromList = useCallback(
async (row) => {
const newId = "flow_" + Date.now();
const rawDomain = (row?.domain || "user").toLowerCase();
const sourcePath = row?.sourcePath || row?.metadata?.source_path || "";
const folderPath = sourcePath ? sourcePath.split("/").slice(0, -1).join("/") : "";
if (row?.assemblyGuid) {
try {
const resp = await fetch(`/api/assemblies/${encodeURIComponent(row.assemblyGuid)}/export`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
let payload = data?.payload;
if (typeof payload === "string") {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const nodes = Array.isArray(payload?.nodes) ? payload.nodes : [];
const edges = Array.isArray(payload?.edges) ? payload.edges : [];
const tabName = payload?.tab_name || data?.display_name || row?.name || "Workflow";
const domain = (data?.domain || rawDomain).toLowerCase();
setTabs([
{
id: newId,
tab_name: tabName,
nodes,
edges,
folderPath,
assemblyGuid: data?.assembly_guid || row?.assemblyGuid || null,
domain,
sourceRow: row,
exportMetadata: data,
},
]);
} catch (err) {
console.error("Failed to load workflow:", err);
setTabs([
{
id: newId,
tab_name: row?.name || "Workflow",
nodes: [],
edges: [],
folderPath,
assemblyGuid: row?.assemblyGuid || null,
domain: rawDomain,
sourceRow: row,
},
]);
}
} else {
setTabs([
{
id: newId,
tab_name: row?.name || "Workflow",
nodes: [],
edges: [],
folderPath,
assemblyGuid: null,
domain: rawDomain,
sourceRow: row,
},
]);
}
setActiveTabId(newId);
navigateTo("workflow-editor");
},
[navigateTo, setTabs, setActiveTabId]
);
const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
useEffect(() => {
@@ -972,97 +1071,31 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "workflows":
return (
<AssemblyList
onOpenWorkflow={async (workflow, folderPath, name) => {
const newId = "flow_" + Date.now();
if (workflow && workflow.rel_path) {
const folder = workflow.rel_path.split("/").slice(0, -1).join("/");
try {
const resp = await fetch(`/api/assembly/load?island=workflows&path=${encodeURIComponent(workflow.rel_path)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setTabs([{ id: newId, tab_name: data.tab_name || workflow.name || workflow.file_name || "Workflow", nodes: data.nodes || [], edges: data.edges || [], folderPath: folder }]);
} catch (err) {
console.error("Failed to load workflow:", err);
setTabs([{ id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [], folderPath: folder }]);
}
} else {
setTabs([{ id: newId, tab_name: name || "Flow", nodes: [], edges: [], folderPath: folderPath || "" }]);
}
setActiveTabId(newId);
navigateTo("workflow-editor");
}}
onOpenScript={(rel, mode, context) => {
const nonce = Date.now();
setAssemblyEditorState({
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
});
navigateTo(mode === 'ansible' ? 'ansible_editor' : 'scripts', {
assemblyState: {
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
}
});
}}
onOpenWorkflow={openWorkflowFromList}
onOpenScript={openScriptFromList}
userRole={userRole || 'User'}
/>
);
case "assemblies":
return (
<AssemblyList
onOpenWorkflow={async (workflow, folderPath, name) => {
const newId = "flow_" + Date.now();
if (workflow && workflow.rel_path) {
const folder = workflow.rel_path.split("/").slice(0, -1).join("/");
try {
const resp = await fetch(`/api/assembly/load?island=workflows&path=${encodeURIComponent(workflow.rel_path)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
setTabs([{ id: newId, tab_name: data.tab_name || workflow.name || workflow.file_name || "Workflow", nodes: data.nodes || [], edges: data.edges || [], folderPath: folder }]);
} catch (err) {
console.error("Failed to load workflow:", err);
setTabs([{ id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [], folderPath: folder }]);
}
} else {
setTabs([{ id: newId, tab_name: name || "Flow", nodes: [], edges: [], folderPath: folderPath || "" }]);
}
setActiveTabId(newId);
navigateTo("workflow-editor");
}}
onOpenScript={(rel, mode, context) => {
const nonce = Date.now();
setAssemblyEditorState({
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
});
navigateTo(mode === 'ansible' ? 'ansible_editor' : 'scripts', {
assemblyState: {
path: rel || '',
mode,
context: context ? { ...context, nonce } : null,
nonce
}
});
}}
onOpenWorkflow={openWorkflowFromList}
onOpenScript={openScriptFromList}
userRole={userRole || 'User'}
/>
);
case "scripts":
return (
<AssemblyEditor
mode="scripts"
initialPath={assemblyEditorState?.mode === 'scripts' ? (assemblyEditorState?.path || '') : ''}
initialContext={assemblyEditorState?.mode === 'scripts' ? assemblyEditorState?.context : null}
onConsumeInitialData={() =>
setAssemblyEditorState((prev) => (prev && prev.mode === 'scripts' ? null : prev))
}
mode="script"
initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'script' ? assemblyEditorState : null}
onConsumeInitialData={() => {
setAssemblyEditorState((prev) => (prev && prev.mode === 'script' ? null : prev));
}}
onSaved={() => navigateTo('assemblies')}
userRole={userRole || 'User'}
/>
);
@@ -1070,12 +1103,12 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
return (
<AssemblyEditor
mode="ansible"
initialPath={assemblyEditorState?.mode === 'ansible' ? (assemblyEditorState?.path || '') : ''}
initialContext={assemblyEditorState?.mode === 'ansible' ? assemblyEditorState?.context : null}
onConsumeInitialData={() =>
setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev))
}
initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'ansible' ? assemblyEditorState : null}
onConsumeInitialData={() => {
setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev));
}}
onSaved={() => navigateTo('assemblies')}
userRole={userRole || 'User'}
/>
);

View File

@@ -0,0 +1,113 @@
import React from "react";
import { Box, Typography } from "@mui/material";
const DOMAIN_METADATA = {
official: {
label: "Official",
textColor: "#89c2ff",
backgroundColor: "rgba(88, 166, 255, 0.16)",
borderColor: "rgba(88, 166, 255, 0.45)",
},
community: {
label: "Community",
textColor: "#d4b5ff",
backgroundColor: "rgba(180, 137, 255, 0.18)",
borderColor: "rgba(180, 137, 255, 0.38)",
},
user: {
label: "User-Created",
textColor: "#8fdaa2",
backgroundColor: "rgba(56, 161, 105, 0.16)",
borderColor: "rgba(56, 161, 105, 0.4)",
},
};
export function resolveDomainMeta(domain) {
const key = (domain || "").toLowerCase();
return DOMAIN_METADATA[key] || {
label: domain ? String(domain).charAt(0).toUpperCase() + String(domain).slice(1) : "Unknown",
textColor: "#96a3b6",
backgroundColor: "rgba(150, 163, 182, 0.14)",
borderColor: "rgba(150, 163, 182, 0.32)",
};
}
export function DomainBadge({ domain, size = "medium", sx }) {
const meta = resolveDomainMeta(domain);
const padding = size === "small" ? "2px 6px" : "4px 8px";
const fontSize = size === "small" ? 11 : 12.5;
return (
<Box
sx={{
display: "inline-flex",
alignItems: "center",
borderRadius: 999,
px: padding.split(" ")[1],
py: padding.split(" ")[0],
fontSize,
gap: 0.5,
fontWeight: 500,
color: meta.textColor,
border: `1px solid ${meta.borderColor}`,
backgroundColor: meta.backgroundColor,
textTransform: "none",
...sx,
}}
>
<Typography
component="span"
sx={{
fontSize,
color: meta.textColor,
lineHeight: 1,
fontWeight: 500,
}}
>
{meta.label}
</Typography>
</Box>
);
}
export function DirtyStatePill({ compact = false, sx }) {
const padding = compact ? "2px 6px" : "4px 8px";
const fontSize = compact ? 11 : 12;
return (
<Box
sx={{
display: "inline-flex",
alignItems: "center",
borderRadius: 999,
px: padding.split(" ")[1],
py: padding.split(" ")[0],
fontSize,
gap: 0.5,
fontWeight: 600,
color: "#f8d47a",
border: "1px solid rgba(248, 212, 122, 0.4)",
backgroundColor: "rgba(248, 212, 122, 0.18)",
textTransform: "none",
...sx,
}}
>
<Typography
component="span"
sx={{
fontSize,
color: "#f8d47a",
lineHeight: 1,
fontWeight: 600,
}}
>
Queued to Write to DB
</Typography>
</Box>
);
}
export const DOMAIN_OPTIONS = [
{ value: "user", label: DOMAIN_METADATA.user.label },
{ value: "community", label: DOMAIN_METADATA.community.label },
{ value: "official", label: DOMAIN_METADATA.official.label },
];

View File

@@ -5,7 +5,7 @@ import {
Typography,
Button,
TextField,
MenuItem,
Menu, MenuItem,
Grid,
FormControlLabel,
Checkbox,
@@ -26,6 +26,7 @@ import "prismjs/components/prism-batch";
import "prismjs/themes/prism-okaidia.css";
import Editor from "react-simple-code-editor";
import { ConfirmDeleteDialog } from "../Dialogs";
import { DomainBadge, DirtyStatePill, DOMAIN_OPTIONS } from "./Assembly_Badges";
const TYPE_OPTIONS_ALL = [
{ key: "ansible", label: "Ansible Playbook", prism: "yaml" },
@@ -172,6 +173,20 @@ function formatBytes(size) {
return `${s.toFixed(1)} ${units[idx]}`;
}
function downloadJsonFile(fileName, data) {
const safeName = fileName && fileName.trim() ? fileName.trim() : "assembly.json";
const content = JSON.stringify(data, null, 2);
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = safeName.endsWith(".json") ? safeName : `${safeName}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function defaultAssembly(defaultType = "powershell") {
return {
name: "",
@@ -368,12 +383,12 @@ function toServerDocument(assembly) {
function RenameFileDialog({ open, value, onChange, onCancel, onSave }) {
return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: BACKGROUND_COLORS.dialog, color: "#fff" } }}>
<DialogTitle>Rename Assembly File</DialogTitle>
<DialogTitle>Rename Assembly</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="File Name"
label="Assembly Name"
fullWidth
variant="outlined"
value={value}
@@ -390,25 +405,34 @@ function RenameFileDialog({ open, value, onChange, onCancel, onSave }) {
}
export default function AssemblyEditor({
mode = "scripts",
initialPath = "",
initialContext = null,
mode = "script",
initialAssembly = null,
onConsumeInitialData,
onSaved
onSaved,
userRole = "User",
}) {
const isAnsible = mode === "ansible";
const normalizedMode = mode === "ansible" ? "ansible" : "script";
const isAnsible = normalizedMode === "ansible";
const defaultType = isAnsible ? "ansible" : "powershell";
const [assembly, setAssembly] = useState(() => defaultAssembly(defaultType));
const [currentPath, setCurrentPath] = useState("");
const [fileName, setFileName] = useState("");
const [folderPath, setFolderPath] = useState(() => normalizeFolderPath(initialContext?.folder || ""));
const [assemblyGuid, setAssemblyGuid] = useState(initialAssembly?.row?.assemblyGuid || null);
const [domain, setDomain] = useState(() => (initialAssembly?.row?.domain || "user").toLowerCase());
const [fileName, setFileName] = useState(() => sanitizeFileName(initialAssembly?.row?.name || ""));
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [siteOptions, setSiteOptions] = useState([]);
const [siteLoading, setSiteLoading] = useState(false);
const contextNonceRef = useRef(null);
const [queueInfo, setQueueInfo] = useState(initialAssembly?.row?.queueEntry || null);
const [isDirtyQueue, setIsDirtyQueue] = useState(Boolean(initialAssembly?.row?.isDirty));
const [devModeEnabled, setDevModeEnabled] = useState(false);
const [devModeBusy, setDevModeBusy] = useState(false);
const importInputRef = useRef(null);
const [menuAnchorEl, setMenuAnchorEl] = useState(null);
const [errorMessage, setErrorMessage] = useState("");
const isAdmin = (userRole || "").toLowerCase() === "admin";
const TYPE_OPTIONS = useMemo(
() => (isAnsible ? TYPE_OPTIONS_ALL.filter((o) => o.key === "ansible") : TYPE_OPTIONS_ALL.filter((o) => o.key !== "ansible")),
@@ -426,53 +450,123 @@ export default function AssemblyEditor({
return map;
}, [siteOptions]);
const island = isAnsible ? "ansible" : "scripts";
useEffect(() => {
if (!initialPath) return;
let canceled = false;
(async () => {
const hydrateFromDocument = (document) => {
const doc = fromServerDocument(document || {}, defaultType);
setAssembly(doc);
setFileName((prev) => prev || sanitizeFileName(doc.name || ""));
};
const hydrateNewContext = (ctx) => {
const doc = defaultAssembly(ctx?.defaultType || defaultType);
if (ctx?.name) doc.name = ctx.name;
if (ctx?.description) doc.description = ctx.description;
if (ctx?.category) doc.category = ctx.category;
if (ctx?.type) doc.type = ctx.type;
hydrateFromDocument(doc);
setAssemblyGuid(null);
setDomain((ctx?.domain || initialAssembly?.row?.domain || "user").toLowerCase());
setQueueInfo(null);
setIsDirtyQueue(false);
const suggested = ctx?.suggestedFileName || ctx?.name || doc.name || "";
setFileName(sanitizeFileName(suggested));
};
const hydrateExisting = async (guid, row) => {
try {
const resp = await fetch(`/api/assembly/load?island=${encodeURIComponent(island)}&path=${encodeURIComponent(initialPath)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
setLoading(true);
const resp = await fetch(`/api/assemblies/${encodeURIComponent(guid)}/export`);
if (!resp.ok) {
const problem = await resp.text();
throw new Error(problem || `Failed to load assembly (HTTP ${resp.status})`);
}
const data = await resp.json();
if (canceled) return;
const rel = data.rel_path || initialPath;
setCurrentPath(rel);
setFolderPath(normalizeFolderPath(rel.split("/").slice(0, -1).join("/")));
setFileName(data.file_name || rel.split("/").pop() || "");
const doc = fromServerDocument(data.assembly || data, defaultType);
setAssembly(doc);
const metadata = data?.metadata && typeof data.metadata === "object" ? data.metadata : {};
const payload = data?.payload && typeof data.payload === "object" ? data.payload : {};
const enrichedDoc = { ...payload };
const fallbackName =
metadata.display_name || data?.display_name || row?.name || assembly.name || "";
enrichedDoc.name = enrichedDoc.name || fallbackName;
enrichedDoc.display_name = enrichedDoc.display_name || fallbackName;
enrichedDoc.description =
enrichedDoc.description ||
metadata.summary ||
data?.summary ||
row?.description ||
"";
enrichedDoc.category =
enrichedDoc.category || metadata.category || data?.category || row?.category || "";
enrichedDoc.type =
enrichedDoc.type ||
metadata.assembly_type ||
data?.assembly_type ||
row?.assembly_type ||
defaultType;
if (enrichedDoc.timeout_seconds == null) {
const metaTimeout =
metadata.timeout_seconds ?? metadata.timeoutSeconds ?? metadata.timeout ?? null;
if (metaTimeout != null) enrichedDoc.timeout_seconds = metaTimeout;
}
if (!enrichedDoc.sites) {
const metaSites = metadata.sites && typeof metadata.sites === "object" ? metadata.sites : {};
enrichedDoc.sites = metaSites;
}
if (!Array.isArray(enrichedDoc.variables) || !enrichedDoc.variables.length) {
enrichedDoc.variables = Array.isArray(metadata.variables) ? metadata.variables : [];
}
if (!Array.isArray(enrichedDoc.files) || !enrichedDoc.files.length) {
enrichedDoc.files = Array.isArray(metadata.files) ? metadata.files : [];
}
hydrateFromDocument({ ...enrichedDoc });
setAssemblyGuid(data?.assembly_guid || guid);
setDomain((data?.source || data?.domain || row?.domain || "user").toLowerCase());
setQueueInfo({
dirty_since: data?.dirty_since || row?.queueEntry?.dirty_since || null,
last_persisted: data?.last_persisted || row?.queueEntry?.last_persisted || null,
});
setIsDirtyQueue(Boolean(data?.is_dirty));
const exportName = sanitizeFileName(
data?.display_name || metadata.display_name || row?.name || guid
);
setFileName(exportName);
} catch (err) {
console.error("Failed to load assembly:", err);
if (!canceled) {
setErrorMessage(err?.message || "Failed to load assembly data.");
}
} finally {
if (!canceled && onConsumeInitialData) onConsumeInitialData();
if (!canceled) {
setLoading(false);
onConsumeInitialData?.();
}
}
})();
};
const row = initialAssembly?.row;
const context = row?.createContext || initialAssembly?.createContext;
if (row?.assemblyGuid) {
hydrateExisting(row.assemblyGuid, row);
return () => {
canceled = true;
};
}
if (context) {
hydrateNewContext(context);
onConsumeInitialData?.();
return () => {
canceled = true;
};
}
return () => {
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialPath, island]);
useEffect(() => {
const ctx = initialContext;
if (!ctx || !ctx.nonce) return;
if (contextNonceRef.current === ctx.nonce) return;
contextNonceRef.current = ctx.nonce;
const doc = defaultAssembly(ctx.defaultType || defaultType);
if (ctx.name) doc.name = ctx.name;
if (ctx.description) doc.description = ctx.description;
if (ctx.category) doc.category = ctx.category;
if (ctx.type) doc.type = ctx.type;
setAssembly(doc);
setCurrentPath("");
const suggested = ctx.suggestedFileName || ctx.name || "";
setFileName(suggested ? sanitizeFileName(suggested) : "");
setFolderPath(normalizeFolderPath(ctx.folder || ""));
if (onConsumeInitialData) onConsumeInitialData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialContext?.nonce]);
}, [initialAssembly, defaultType, onConsumeInitialData]);
useEffect(() => {
let canceled = false;
@@ -593,117 +687,243 @@ export default function AssemblyEditor({
setAssembly((prev) => ({ ...prev, files: prev.files.filter((f) => f.id !== id) }));
};
const computeTargetPath = () => {
if (currentPath) return currentPath;
const baseName = sanitizeFileName(fileName || assembly.name || (isAnsible ? "playbook" : "assembly"));
const folder = normalizeFolderPath(folderPath);
return folder ? `${folder}/${baseName}` : baseName;
};
const canWriteToDomain = domain === "user" || (isAdmin && devModeEnabled);
const saveAssembly = async () => {
const handleSaveAssembly = async () => {
if (!assembly.name.trim()) {
alert("Assembly Name is required.");
return;
}
const payload = toServerDocument(assembly);
payload.type = assembly.type;
const targetPath = computeTargetPath();
if (!targetPath) {
alert("Unable to determine file path.");
return;
}
const document = toServerDocument(assembly);
setSaving(true);
setErrorMessage("");
try {
if (currentPath) {
const resp = await fetch(`/api/assembly/edit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, path: currentPath, content: payload })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
if (data?.rel_path) {
setCurrentPath(data.rel_path);
setFolderPath(normalizeFolderPath(data.rel_path.split("/").slice(0, -1).join("/")));
setFileName(data.rel_path.split("/").pop() || fileName);
}
} else {
const resp = await fetch(`/api/assembly/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: "file", path: targetPath, content: payload, type: assembly.type })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
if (data.rel_path) {
setCurrentPath(data.rel_path);
setFolderPath(data.rel_path.split("/").slice(0, -1).join("/"));
setFileName(data.rel_path.split("/").pop() || "");
} else {
setCurrentPath(targetPath);
setFileName(targetPath.split("/").pop() || "");
}
const resp = await fetch("/api/assemblies/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
document,
domain,
assembly_guid: assemblyGuid || undefined,
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
}
onSaved && onSaved();
const nextGuid = data?.assembly_guid || assemblyGuid;
setAssemblyGuid(nextGuid || null);
const nextDomain = (data?.source || data?.domain || domain || "user").toLowerCase();
setDomain(nextDomain);
setQueueInfo({
dirty_since: data?.dirty_since || null,
last_persisted: data?.last_persisted || null,
});
setIsDirtyQueue(Boolean(data?.is_dirty));
if (data?.display_name) {
setAssembly((prev) => ({ ...prev, name: data.display_name }));
setFileName(sanitizeFileName(data.display_name));
} else {
setFileName((prev) => prev || sanitizeFileName(assembly.name));
}
onSaved?.();
} catch (err) {
console.error("Failed to save assembly:", err);
alert(err.message || "Failed to save assembly");
const message = err?.message || "Failed to save assembly.";
setErrorMessage(message);
alert(message);
} finally {
setSaving(false);
}
};
const saveRename = async () => {
try {
const nextName = sanitizeFileName(renameValue || fileName || assembly.name);
const resp = await fetch(`/api/assembly/rename`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: "file", path: currentPath, new_name: nextName, type: assembly.type })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
const rel = data.rel_path || currentPath;
setCurrentPath(rel);
setFolderPath(rel.split("/").slice(0, -1).join("/"));
setFileName(rel.split("/").pop() || nextName);
setRenameOpen(false);
} catch (err) {
console.error("Failed to rename assembly:", err);
alert(err.message || "Failed to rename");
const handleRenameConfirm = () => {
const trimmed = (renameValue || assembly.name || "").trim();
if (!trimmed) {
setRenameOpen(false);
return;
}
setAssembly((prev) => ({ ...prev, name: trimmed }));
setFileName(sanitizeFileName(trimmed));
setRenameOpen(false);
};
const deleteAssembly = async () => {
if (!currentPath) {
const handleDeleteAssembly = async () => {
if (!assemblyGuid) {
setDeleteOpen(false);
return;
}
setSaving(true);
setErrorMessage("");
try {
const resp = await fetch(`/api/assembly/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: "file", path: currentPath })
const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}`, {
method: "DELETE",
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`);
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
}
setDeleteOpen(false);
setAssembly(defaultAssembly(defaultType));
setCurrentPath("");
setFileName("");
onSaved && onSaved();
onSaved?.();
} catch (err) {
console.error("Failed to delete assembly:", err);
alert(err.message || "Failed to delete assembly");
setDeleteOpen(false);
const message = err?.message || "Failed to delete assembly.";
setErrorMessage(message);
alert(message);
} finally {
setSaving(false);
}
};
const handleDevModeToggle = async (enabled) => {
setDevModeBusy(true);
setErrorMessage("");
try {
const resp = await fetch("/api/assemblies/dev-mode/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
}
setDevModeEnabled(Boolean(data?.dev_mode));
} catch (err) {
console.error("Failed to toggle Dev Mode:", err);
const message = err?.message || "Failed to update Dev Mode.";
setErrorMessage(message);
alert(message);
} finally {
setDevModeBusy(false);
}
};
const handleFlushQueue = async () => {
setDevModeBusy(true);
setErrorMessage("");
try {
const resp = await fetch("/api/assemblies/dev-mode/write", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
}
setIsDirtyQueue(false);
setQueueInfo((prev) => ({
...(prev || {}),
dirty_since: null,
last_persisted: new Date().toISOString(),
}));
} catch (err) {
console.error("Failed to flush assembly queue:", err);
const message = err?.message || "Failed to flush queued writes.";
setErrorMessage(message);
alert(message);
} finally {
setDevModeBusy(false);
}
};
const handleExportAssembly = async () => {
handleMenuClose();
setErrorMessage("");
try {
if (assemblyGuid) {
const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}/export`);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
}
const exportDoc = { ...data };
delete exportDoc.queue;
const exportName = sanitizeFileName(fileName || data?.display_name || assembly.name || assemblyGuid);
downloadJsonFile(exportName, exportDoc);
} else {
const document = toServerDocument(assembly);
const exportDoc = {
assembly_guid: assemblyGuid,
domain,
assembly_kind: isAnsible ? "ansible" : "script",
assembly_type: assembly.type,
display_name: assembly.name,
summary: assembly.description,
category: assembly.category,
payload: document,
};
const exportName = sanitizeFileName(fileName || assembly.name || "assembly");
downloadJsonFile(exportName, exportDoc);
}
} catch (err) {
console.error("Failed to export assembly:", err);
const message = err?.message || "Failed to export assembly.";
setErrorMessage(message);
alert(message);
}
};
const handleImportAssembly = async (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
setErrorMessage("");
try {
const text = await file.text();
const parsed = JSON.parse(text);
const payload = parsed?.payload || parsed;
const doc = fromServerDocument(payload || {}, defaultType);
setAssembly(doc);
setAssemblyGuid(parsed?.assembly_guid || null);
setDomain("user");
setQueueInfo(null);
setIsDirtyQueue(false);
const baseName = parsed?.display_name || parsed?.name || file.name.replace(/\.[^.]+$/, "") || "assembly";
setFileName(sanitizeFileName(baseName));
alert("Assembly imported. Review details before saving.");
} catch (err) {
console.error("Failed to import assembly:", err);
const message = err?.message || "Failed to import assembly JSON.";
setErrorMessage(message);
alert(message);
} finally {
event.target.value = "";
}
};
const handleMenuOpen = (event) => {
setMenuAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
};
const triggerImport = () => {
handleMenuClose();
importInputRef.current?.click();
};
const triggerExport = () => {
handleExportAssembly();
};
const triggerFlushQueue = () => {
handleMenuClose();
handleFlushQueue();
};
const saveDisabled = saving || loading || !canWriteToDomain;
const deleteDisabled = !assemblyGuid || saving || loading;
const renameDisabled = saving || loading;
const dirtyPillVisible = Boolean(isDirtyQueue);
const lastPersistedDisplay = queueInfo?.last_persisted
? new Date(queueInfo.last_persisted).toLocaleString()
: null;
const dirtySinceDisplay = queueInfo?.dirty_since
? new Date(queueInfo.dirty_since).toLocaleString()
: null;
const siteScopeValue = assembly.sites?.mode === "specific" ? "specific" : "all";
const selectedSiteValues = Array.isArray(assembly.sites?.values)
? assembly.sites.values.map((v) => String(v))
@@ -746,42 +966,100 @@ export default function AssemblyEditor({
mt: { xs: 2, sm: 0 }
}}
>
{currentPath ? (
<Tooltip title="Rename File">
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<DomainBadge domain={domain} size="small" />
{dirtyPillVisible ? <DirtyStatePill compact /> : null}
</Box>
<Button
size="small"
variant="outlined"
onClick={handleMenuOpen}
sx={{
textTransform: "none",
borderColor: "#2b3544",
color: "#e6edf3",
"&:hover": {
borderColor: "#58a6ff",
color: "#58a6ff",
},
}}
>
Import / Export
</Button>
{isAdmin ? (
<Button
size="small"
variant="outlined"
onClick={() => handleDevModeToggle(!devModeEnabled)}
disabled={devModeBusy}
sx={{
textTransform: "none",
borderColor: devModeEnabled ? "#ffb347" : "#2b3544",
color: devModeEnabled ? "#ffb347" : "#e6edf3",
"&:hover": {
borderColor: devModeEnabled ? "#ffcc80" : "#58a6ff",
color: devModeEnabled ? "#ffcc80" : "#58a6ff",
},
}}
>
{devModeEnabled ? "Disable Dev Mode" : "Enable Dev Mode"}
</Button>
) : null}
{isAdmin && devModeEnabled ? (
<Button
size="small"
onClick={triggerFlushQueue}
disabled={devModeBusy || !dirtyPillVisible}
sx={{ color: "#f8d47a", textTransform: "none" }}
>
Flush Queue
</Button>
) : null}
<Tooltip title="Rename Assembly">
<span>
<Button
size="small"
onClick={() => { setRenameValue(fileName); setRenameOpen(true); }}
onClick={() => {
setRenameValue(assembly.name || "");
setRenameOpen(true);
}}
disabled={renameDisabled}
sx={{ color: "#58a6ff", textTransform: "none" }}
>
Rename
</Button>
</Tooltip>
) : null}
{currentPath ? (
</span>
</Tooltip>
{assemblyGuid ? (
<Tooltip title="Delete Assembly">
<Button
size="small"
onClick={() => setDeleteOpen(true)}
sx={{ color: "#ff6b6b", textTransform: "none" }}
>
Delete
</Button>
<span>
<Button
size="small"
onClick={() => setDeleteOpen(true)}
disabled={deleteDisabled}
sx={{ color: "#ff6b6b", textTransform: "none" }}
>
Delete
</Button>
</span>
</Tooltip>
) : null}
<Button
variant="outlined"
onClick={saveAssembly}
disabled={saving}
onClick={handleSaveAssembly}
disabled={saveDisabled}
sx={{
color: "#58a6ff",
borderColor: "#58a6ff",
color: saveDisabled ? "#3c4452" : "#58a6ff",
borderColor: saveDisabled ? "#2b3544" : "#58a6ff",
textTransform: "none",
backgroundColor: saving
? BACKGROUND_COLORS.primaryActionSaving
: BACKGROUND_COLORS.field,
"&:hover": {
borderColor: "#7db7ff",
backgroundColor: BACKGROUND_COLORS.primaryActionHover
borderColor: saveDisabled ? "#2b3544" : "#7db7ff",
backgroundColor: saveDisabled
? BACKGROUND_COLORS.field
: BACKGROUND_COLORS.primaryActionHover,
},
"&.Mui-disabled": {
color: "#3c4452",
@@ -792,8 +1070,92 @@ export default function AssemblyEditor({
{saving ? "Saving..." : "Save Assembly"}
</Button>
</Box>
</Grid>
</Grid>
</Grid>
<Menu
anchorEl={menuAnchorEl}
open={Boolean(menuAnchorEl)}
onClose={handleMenuClose}
PaperProps={{ sx: { bgcolor: BACKGROUND_COLORS.dialog, color: "#fff" } }}
>
<MenuItem onClick={triggerExport}>Export JSON</MenuItem>
<MenuItem onClick={triggerImport}>Import JSON</MenuItem>
</Menu>
<input
ref={importInputRef}
type="file"
accept="application/json"
style={{ display: "none" }}
onChange={handleImportAssembly}
/>
<Box
sx={{
mt: 2,
mb: 2,
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<TextField
select
label="Domain"
value={domain}
onChange={(e) => setDomain(String(e.target.value || "").toLowerCase())}
disabled={loading}
sx={{ ...SELECT_BASE_SX, width: 220 }}
SelectProps={{ MenuProps: MENU_PROPS }}
>
{DOMAIN_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
{dirtySinceDisplay ? (
<Typography variant="caption" sx={{ color: "#9ba3b4" }}>
Dirty since: {dirtySinceDisplay}
</Typography>
) : null}
{lastPersistedDisplay ? (
<Typography variant="caption" sx={{ color: "#9ba3b4" }}>
Last persisted: {lastPersistedDisplay}
</Typography>
) : null}
</Box>
{!canWriteToDomain ? (
<Box
sx={{
mb: 2,
p: 1.5,
borderRadius: 1,
border: "1px solid rgba(248, 212, 122, 0.4)",
backgroundColor: "rgba(248, 212, 122, 0.12)",
}}
>
<Typography variant="body2" sx={{ color: "#f8d47a" }}>
This domain is read-only. Enable Dev Mode as an administrator to edit or switch to the User domain.
</Typography>
</Box>
) : null}
{errorMessage ? (
<Box
sx={{
mb: 2,
p: 1.5,
borderRadius: 1,
border: "1px solid rgba(255, 138, 138, 0.4)",
backgroundColor: "rgba(255, 138, 138, 0.12)",
}}
>
<Typography variant="body2" sx={{ color: "#ff8a8a" }}>{errorMessage}</Typography>
</Box>
) : null}
<Box
@@ -882,7 +1244,7 @@ export default function AssemblyEditor({
onValueChange={(value) => updateAssembly({ script: value })}
highlight={(src) => highlightedHtml(src, prismLanguage)}
padding={12}
placeholder={currentPath ? `Editing: ${currentPath}` : "Start typing your script..."}
placeholder={assemblyGuid ? `Editing assembly: ${assemblyGuid}` : "Start typing your script..."}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 14,
@@ -1256,14 +1618,14 @@ export default function AssemblyEditor({
value={renameValue}
onChange={setRenameValue}
onCancel={() => setRenameOpen(false)}
onSave={saveRename}
onSave={handleRenameConfirm}
/>
<ConfirmDeleteDialog
open={deleteOpen}
message="Deleting this assembly cannot be undone. Continue?"
onCancel={() => setDeleteOpen(false)}
onConfirm={deleteAssembly}
onConfirm={handleDeleteAssembly}
/>
</Box>
);
}
}

View File

@@ -23,6 +23,7 @@ import MenuBookIcon from "@mui/icons-material/MenuBook";
import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
import { ConfirmDeleteDialog, NewWorkflowDialog } from "../Dialogs";
import { DomainBadge, DirtyStatePill, resolveDomainMeta, DOMAIN_OPTIONS } from "./Assembly_Badges";
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -48,13 +49,47 @@ const iconFontFamily = '"Quartz Regular"';
const BOREALIS_BLUE = "#58a6ff";
const DARKER_GRAY = "#9aa3ad";
const PAGE_SIZE = 25;
const SELECT_BASE_SX = {
"& .MuiOutlinedInput-root": {
bgcolor: "#1C1C1C",
color: "#e6edf3",
borderRadius: 1,
"& fieldset": { borderColor: "#2b3544" },
"&:hover fieldset": { borderColor: "#3a4657" },
"&.Mui-focused fieldset": { borderColor: "#58a6ff" },
},
"& .MuiOutlinedInput-input": {
padding: "9px 12px",
fontSize: "0.95rem",
lineHeight: 1.4,
},
"& .MuiInputLabel-root": {
color: "#9ba3b4",
},
"& .MuiInputLabel-root.Mui-focused": { color: "#58a6ff" },
};
const MENU_PROPS = {
PaperProps: {
sx: {
bgcolor: "#1C1C1C",
color: "#e6edf3",
border: "1px solid #2b3544",
"& .MuiMenuItem-root.Mui-selected": {
bgcolor: "rgba(88,166,255,0.16)",
},
"& .MuiMenuItem-root.Mui-selected:hover": {
bgcolor: "rgba(88,166,255,0.24)",
},
},
},
};
const TYPE_METADATA = {
workflows: {
workflow: {
label: "Workflow",
Icon: PolylineIcon,
},
scripts: {
script: {
label: "Script",
Icon: CodeIcon,
},
@@ -115,42 +150,85 @@ const NameCellRenderer = React.memo(function NameCellRenderer(props) {
);
});
const normalizeRow = (island, item) => {
const relPath = String(item?.rel_path || "").replace(/\\/g, "/");
const fileName = String(item?.file_name || relPath.split("/").pop() || "");
const folder = relPath ? relPath.split("/").slice(0, -1).join("/") : "";
const idSeed = relPath || fileName || `${Date.now()}_${Math.random().toString(36).slice(2)}`;
const name =
island === "workflows"
? item?.tab_name || fileName.replace(/\.[^.]+$/, "") || fileName || "Workflow"
: item?.name || fileName.replace(/\.[^.]+$/, "") || fileName || "Assembly";
// For workflows, always show 'workflow' in Category per request
const category =
island === "workflows"
? "workflow"
: item?.category || "";
const description = island === "workflows" ? "" : item?.description || "";
const SourceCellRenderer = React.memo(function SourceCellRenderer(props) {
const { data } = props;
if (!data) return null;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}>
<DomainBadge domain={data.domain} size="small" />
{data.isDirty ? <DirtyStatePill compact /> : null}
</Box>
);
});
const normalizeRow = (item, queueEntry) => {
if (!item) return null;
const assemblyGuid = item.assembly_guid || item.assembly_id || "";
const assemblyKind = String(item.assembly_kind || "").toLowerCase();
const assemblyType = String(item.assembly_type || "").toLowerCase();
let typeKey = "script";
if (assemblyKind === "workflow") {
typeKey = "workflow";
} else if (assemblyKind === "ansible" || assemblyType === "ansible") {
typeKey = "ansible";
}
const metadata = item.metadata && typeof item.metadata === "object" ? item.metadata : {};
const sourcePath = String(metadata.source_path || metadata.rel_path || "").replace(/\\/g, "/");
const pathParts = sourcePath ? sourcePath.split("/") : [];
const fileName = pathParts.length ? pathParts[pathParts.length - 1] : "";
const folder = pathParts.length > 1 ? pathParts.slice(0, -1).join("/") : "";
const domain = String(item.source || "user").toLowerCase();
const domainMeta = resolveDomainMeta(domain);
const displayName =
item.display_name ||
metadata.display_name ||
fileName.replace(/\.[^.]+$/, "") ||
fileName ||
"Assembly";
const summary = item.summary || metadata.summary || "";
const category = item.category || metadata.category || "";
const queueRecord = queueEntry || null;
const isDirty = Boolean(item.is_dirty);
return {
id: `${island}:${idSeed}`,
typeKey: island,
name,
id: assemblyGuid || `${typeKey}:${displayName}`,
assemblyGuid,
typeKey,
assemblyKind,
assemblyType,
name: displayName,
category,
description,
relPath,
description: summary,
relPath: sourcePath,
sourcePath,
fileName,
folder,
raw: item || {},
domain,
domainLabel: domainMeta.label,
isDirty,
dirtySince: item.dirty_since || queueRecord?.dirty_since || "",
lastPersisted: item.last_persisted || queueRecord?.last_persisted || "",
queueEntry: queueRecord,
version: item.version ?? null,
metadata,
tags: item.tags || {},
payloadGuid: item.payload_guid,
updatedAt: item.updated_at,
createdAt: item.created_at,
raw: item,
};
};
export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole = "User" }) {
const gridRef = useRef(null);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [newMenuAnchor, setNewMenuAnchor] = useState(null);
const [scriptDialog, setScriptDialog] = useState({ open: false, island: null });
const [scriptDialog, setScriptDialog] = useState({ open: false, typeKey: null });
const [scriptName, setScriptName] = useState("");
const [workflowDialogOpen, setWorkflowDialogOpen] = useState(false);
const [workflowName, setWorkflowName] = useState("");
@@ -161,30 +239,35 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [cloneDialog, setCloneDialog] = useState({ open: false, row: null, targetDomain: "user" });
const isAdmin = (userRole || "").toLowerCase() === "admin";
const fetchAssemblies = useCallback(async () => {
setLoading(true);
setError("");
try {
const islands = ["workflows", "scripts", "ansible"];
const results = await Promise.all(
islands.map(async (island) => {
const resp = await fetch(`/api/assembly/list?island=${encodeURIComponent(island)}`);
if (!resp.ok) {
const problem = await resp.text();
throw new Error(problem || `Failed to load ${island} assemblies (HTTP ${resp.status})`);
}
const data = await resp.json();
const items = Array.isArray(data?.items) ? data.items : [];
return items.map((item) => normalizeRow(island, item));
}),
);
setRows(results.flat());
// After data load, auto-size specific columns
const resp = await fetch("/api/assemblies");
if (!resp.ok) {
const problem = await resp.text();
throw new Error(problem || `Failed to load assemblies (HTTP ${resp.status})`);
}
const payload = await resp.json();
const items = Array.isArray(payload?.items) ? payload.items : [];
const queue = Array.isArray(payload?.queue) ? payload.queue : [];
const queueMap = queue.reduce((acc, entry) => {
if (entry && entry.assembly_guid) {
acc[entry.assembly_guid] = entry;
}
return acc;
}, {});
const processed = items
.map((item) => normalizeRow(item, queueMap[item?.assembly_guid || item?.assembly_id || ""]))
.filter(Boolean);
setRows(processed);
setTimeout(() => {
const columnApi = gridRef.current?.columnApi;
if (columnApi) {
const ids = ["assemblyType", "location", "category", "name"];
const ids = ["assemblyType", "source", "category", "name"];
columnApi.autoSizeColumns(ids, false);
}
}, 0);
@@ -204,19 +287,11 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const openRow = useCallback(
(row) => {
if (!row) return;
if (row.typeKey === "workflows") {
const payload = {
...row.raw,
rel_path: row.relPath,
file_name: row.fileName,
};
if (!payload.name) payload.name = row.name;
if (!payload.tab_name) payload.tab_name = row.name;
onOpenWorkflow?.(payload);
if (row.typeKey === "workflow") {
onOpenWorkflow?.(row);
return;
}
const mode = row.typeKey === "ansible" ? "ansible" : "scripts";
onOpenScript?.(row.relPath, mode, null);
onOpenScript?.(row);
},
[onOpenWorkflow, onOpenScript],
);
@@ -250,35 +325,67 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
closeContextMenu();
};
const startClone = () => {
if (!activeRow || !activeRow.assemblyGuid) return;
const defaultTarget = activeRow.domain === "user" ? "community" : "user";
setCloneDialog({ open: true, row: activeRow, targetDomain: defaultTarget });
closeContextMenu();
};
const startDelete = () => {
if (!activeRow) return;
setDeleteDialogOpen(true);
closeContextMenu();
};
const handleCloneClose = () => setCloneDialog({ open: false, row: null, targetDomain: "user" });
const handleCloneConfirm = async () => {
const target = cloneDialog.row;
if (!target?.assemblyGuid) {
handleCloneClose();
return;
}
try {
const resp = await fetch(`/api/assemblies/${encodeURIComponent(target.assemblyGuid)}/clone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target_domain: cloneDialog.targetDomain,
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
handleCloneClose();
await fetchAssemblies();
} catch (err) {
console.error("Failed to clone assembly:", err);
alert(err?.message || "Failed to clone assembly");
handleCloneClose();
}
};
const handleRenameSave = async () => {
const target = activeRow;
const trimmed = renameValue.trim();
if (!target || !trimmed) {
if (!target || !trimmed || !target.assemblyGuid) {
setRenameDialogOpen(false);
return;
}
try {
const payload = {
island: target.typeKey,
kind: "file",
path: target.relPath,
new_name: trimmed,
};
if (target.typeKey !== "workflows" && target.raw?.type) {
payload.type = target.raw.type;
}
const resp = await fetch(`/api/assembly/rename`, {
method: "POST",
const resp = await fetch(`/api/assemblies/${encodeURIComponent(target.assemblyGuid)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
body: JSON.stringify({
display_name: trimmed,
}),
});
const data = await resp.json();
let data = null;
try {
data = await resp.json();
} catch {
data = null;
}
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
setRenameDialogOpen(false);
await fetchAssemblies();
@@ -290,21 +397,20 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const handleDeleteConfirm = async () => {
const target = activeRow;
if (!target) {
if (!target || !target.assemblyGuid) {
setDeleteDialogOpen(false);
return;
}
try {
const resp = await fetch(`/api/assembly/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
island: target.typeKey,
kind: "file",
path: target.relPath,
}),
const resp = await fetch(`/api/assemblies/${encodeURIComponent(target.assemblyGuid)}`, {
method: "DELETE",
});
const data = await resp.json();
let data = null;
try {
data = await resp.json();
} catch {
data = null;
}
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
setDeleteDialogOpen(false);
await fetchAssemblies();
@@ -329,12 +435,25 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
resizable: true,
},
{
colId: "location",
field: "location",
headerName: "Location",
colId: "source",
field: "domain",
headerName: "Source",
valueGetter: (params) => params?.data?.domain || "",
valueFormatter: (params) => resolveDomainMeta(params?.value).label,
filter: "agTextColumnFilter",
cellRenderer: SourceCellRenderer,
minWidth: 170,
flex: 0,
sortable: true,
resizable: true,
},
{
colId: "path",
field: "path",
headerName: "Path",
valueGetter: (params) => params?.data?.folder || "",
cellStyle: { color: DARKER_GRAY, fontSize: 13 },
minWidth: 180,
minWidth: 200,
flex: 0,
sortable: true,
filter: "agTextColumnFilter",
@@ -395,21 +514,21 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const handleRefresh = () => fetchAssemblies();
const handleNewAssemblyOption = (island) => {
const handleNewAssemblyOption = (typeKey) => {
setNewMenuAnchor(null);
if (island === "workflows") {
if (typeKey === "workflow") {
setWorkflowName("");
setWorkflowDialogOpen(true);
return;
}
setScriptName("");
setScriptDialog({ open: true, island });
setScriptDialog({ open: true, typeKey });
};
const handleCreateScript = () => {
const trimmed = scriptName.trim();
if (!trimmed || !scriptDialog.island) return;
const isAnsible = scriptDialog.island === "ansible";
if (!trimmed || !scriptDialog.typeKey) return;
const isAnsible = scriptDialog.typeKey === "ansible";
const context = {
folder: "",
suggestedFileName: trimmed,
@@ -418,8 +537,21 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
type: isAnsible ? "ansible" : "powershell",
category: isAnsible ? "application" : "script",
};
onOpenScript?.(null, isAnsible ? "ansible" : "scripts", context);
setScriptDialog({ open: false, island: null });
const newRow = {
assemblyGuid: null,
typeKey: isAnsible ? "ansible" : "script",
assemblyKind: isAnsible ? "ansible" : "script",
assemblyType: isAnsible ? "ansible" : context.type,
name: trimmed,
category: context.category,
domain: "user",
metadata: { ...context },
isDirty: false,
isNew: true,
createContext: context,
};
onOpenScript?.(newRow);
setScriptDialog({ open: false, typeKey: null });
setScriptName("");
};
@@ -427,7 +559,17 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const trimmed = workflowName.trim();
if (!trimmed) return;
setWorkflowDialogOpen(false);
onOpenWorkflow?.(null, "", trimmed);
const newWorkflow = {
assemblyGuid: null,
typeKey: "workflow",
assemblyKind: "workflow",
assemblyType: "workflow",
name: trimmed,
domain: "user",
metadata: { display_name: trimmed, category: "workflow" },
isNew: true,
};
onOpenWorkflow?.(newWorkflow);
setWorkflowName("");
};
@@ -494,8 +636,8 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
onClose={() => setNewMenuAnchor(null)}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
>
<MenuItem onClick={() => handleNewAssemblyOption("scripts")}>Script</MenuItem>
<MenuItem onClick={() => handleNewAssemblyOption("workflows")}>Workflow</MenuItem>
<MenuItem onClick={() => handleNewAssemblyOption("script")}>Script</MenuItem>
<MenuItem onClick={() => handleNewAssemblyOption("workflow")}>Workflow</MenuItem>
<MenuItem onClick={() => handleNewAssemblyOption("ansible")}>Ansible Playbook</MenuItem>
</Menu>
</Box>
@@ -543,7 +685,11 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
onRowDoubleClicked={handleRowDoubleClicked}
onCellContextMenu={handleCellContextMenu}
getRowId={(params) =>
params?.data?.id || params?.data?.relPath || params?.data?.fileName || String(params?.rowIndex ?? "")
params?.data?.assemblyGuid ||
params?.data?.id ||
params?.data?.relPath ||
params?.data?.fileName ||
String(params?.rowIndex ?? "")
}
theme={myTheme}
rowHeight={44}
@@ -587,6 +733,9 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
>
Open
</MenuItem>
{activeRow?.assemblyGuid && (isAdmin || activeRow.domain === "user") ? (
<MenuItem onClick={startClone}>Clone</MenuItem>
) : null}
<MenuItem onClick={startRename}>Rename</MenuItem>
<MenuItem sx={{ color: "#ff8a8a" }} onClick={startDelete}>
Delete
@@ -629,14 +778,51 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
onConfirm={handleDeleteConfirm}
/>
<Dialog open={cloneDialog.open} onClose={handleCloneClose}>
<DialogTitle>Clone Assembly</DialogTitle>
<DialogContent sx={{ minWidth: 280 }}>
<TextField
select
fullWidth
label="Target Domain"
value={cloneDialog.targetDomain}
onChange={(e) =>
setCloneDialog((prev) => ({
...prev,
targetDomain: String(e.target.value || "").toLowerCase(),
}))
}
sx={{ ...SELECT_BASE_SX, mt: 1 }}
SelectProps={{ MenuProps: MENU_PROPS }}
>
{DOMAIN_OPTIONS.filter((option) => option.value !== cloneDialog.row?.domain).map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<Typography variant="body2" sx={{ mt: 1, color: "#9ba3b4" }}>
Cloning creates a copy of the assembly in the selected domain.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleCloneClose} sx={{ textTransform: "none", color: "#58a6ff" }}>
Cancel
</Button>
<Button onClick={handleCloneConfirm} sx={{ textTransform: "none", color: "#58a6ff" }}>
Clone
</Button>
</DialogActions>
</Dialog>
<Dialog
open={scriptDialog.open}
onClose={() => {
setScriptDialog({ open: false, island: null });
setScriptDialog({ open: false, typeKey: null });
setScriptName("");
}}
>
<DialogTitle>{scriptDialog.island === "ansible" ? "New Ansible Playbook" : "New Script"}</DialogTitle>
<DialogTitle>{scriptDialog.typeKey === "ansible" ? "New Ansible Playbook" : "New Script"}</DialogTitle>
<DialogContent>
<TextField
autoFocus
@@ -657,7 +843,7 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
<DialogActions>
<Button
onClick={() => {
setScriptDialog({ open: false, island: null });
setScriptDialog({ open: false, typeKey: null });
setScriptName("");
}}
sx={{ textTransform: "none" }}

View File

@@ -91,54 +91,48 @@ export default function DeviceDetails({ device, onBack }) {
let canceled = false;
const loadAssemblyNames = async () => {
const next = {};
const storeName = (rawPath, rawName, prefix = "") => {
const storeName = (rawPath, rawName) => {
const name = typeof rawName === "string" ? rawName.trim() : "";
if (!name) return;
const normalizedPath = String(rawPath || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.trim();
const keys = new Set();
if (normalizedPath) {
keys.add(normalizedPath);
if (prefix) {
const prefixed = `${prefix}/${normalizedPath}`.replace(/\/+/g, "/");
keys.add(prefixed);
}
if (!normalizedPath) return;
if (!next[normalizedPath]) next[normalizedPath] = name;
const base = normalizedPath.split("/").pop() || "";
if (base && !next[base]) next[base] = name;
const dot = base.lastIndexOf(".");
if (dot > 0) {
const baseNoExt = base.slice(0, dot);
if (baseNoExt && !next[baseNoExt]) next[baseNoExt] = name;
}
const base = normalizedPath ? normalizedPath.split("/").pop() || "" : "";
if (base) {
keys.add(base);
const dot = base.lastIndexOf(".");
if (dot > 0) {
keys.add(base.slice(0, dot));
};
try {
const resp = await fetch("/api/assemblies");
if (!resp.ok) return;
const data = await resp.json();
const items = Array.isArray(data?.items) ? data.items : [];
items.forEach((item) => {
if (!item || typeof item !== "object") return;
const metadata = item.metadata && typeof item.metadata === "object" ? item.metadata : {};
const displayName =
(item.display_name || "").trim() ||
(metadata.display_name ? String(metadata.display_name).trim() : "") ||
item.assembly_guid ||
"";
if (!displayName) return;
storeName(metadata.source_path || metadata.legacy_path || "", displayName);
if (item.assembly_guid && !next[item.assembly_guid]) {
next[item.assembly_guid] = displayName;
}
}
keys.forEach((key) => {
if (key && !next[key]) {
next[key] = name;
if (item.payload_guid && !next[item.payload_guid]) {
next[item.payload_guid] = displayName;
}
});
};
const ingest = async (island, prefix = "") => {
try {
const resp = await fetch(`/api/assembly/list?island=${island}`);
if (!resp.ok) return;
const data = await resp.json();
const items = Array.isArray(data.items) ? data.items : [];
items.forEach((item) => {
if (!item || typeof item !== "object") return;
const rel = item.rel_path || item.path || item.file_name || item.playbook_path || "";
const label = (item.name || item.tab_name || item.display_name || item.file_name || "").trim();
storeName(rel, label, prefix);
});
} catch {
// ignore failures; map remains partial
}
};
await ingest("scripts", "Scripts");
await ingest("workflows", "Workflows");
await ingest("ansible", "Ansible_Playbooks");
} catch {
// ignore failures; map remains partial
}
if (!canceled) {
setAssemblyNameMap(next);
}

View File

@@ -20,7 +20,7 @@ import {
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
function buildTree(items, folders, rootLabel = "Scripts") {
function buildTree(items, rootLabel = "Scripts") {
const map = {};
const rootNode = {
id: "root",
@@ -31,47 +31,43 @@ function buildTree(items, folders, rootLabel = "Scripts") {
};
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = (f || "").split("/");
(items || []).forEach((item) => {
if (!item || typeof item !== "object") return;
const metadata = item.metadata && typeof item.metadata === "object" ? item.metadata : {};
const rawPath = String(metadata.source_path || metadata.legacy_path || "")
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.trim();
const pathSegments = rawPath ? rawPath.split("/").filter(Boolean) : [];
const segments = pathSegments.length
? pathSegments
: [String(item.display_name || metadata.display_name || item.assembly_guid || "Assembly").trim() || "Assembly"];
let children = rootNode.children;
let parentPath = "";
parts.forEach((part) => {
const path = parentPath ? `${parentPath}/${part}` : part;
let node = children.find((n) => n.id === path);
if (!node) {
node = { id: path, label: part, path, isFolder: true, children: [] };
children.push(node);
map[path] = node;
}
children = node.children;
parentPath = path;
});
});
(items || []).forEach((s) => {
const parts = (s.rel_path || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part, idx) => {
const path = parentPath ? `${parentPath}/${part}` : part;
const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path);
segments.forEach((segment, idx) => {
const nodeId = parentPath ? `${parentPath}/${segment}` : segment;
const isFile = idx === segments.length - 1;
let node = children.find((n) => n.id === nodeId);
if (!node) {
node = {
id: path,
label: isFile ? (s.name || s.file_name || part) : part,
path,
id: nodeId,
label: isFile ? (item.display_name || metadata.display_name || segment) : segment,
path: nodeId,
isFolder: !isFile,
fileName: s.file_name,
script: isFile ? s : null,
script: isFile ? item : null,
scriptPath: isFile ? (rawPath || nodeId) : undefined,
children: []
};
children.push(node);
map[path] = node;
map[nodeId] = node;
} else if (isFile) {
node.script = item;
node.label = item.display_name || metadata.display_name || node.label;
node.scriptPath = rawPath || nodeId;
}
if (!isFile) {
children = node.children;
parentPath = path;
parentPath = nodeId;
}
});
});
@@ -99,11 +95,19 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
const loadTree = useCallback(async () => {
try {
const island = mode === 'ansible' ? 'ansible' : 'scripts';
const resp = await fetch(`/api/assembly/list?island=${island}`);
const resp = await fetch("/api/assemblies");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildTree(data.items || [], data.folders || [], mode === 'ansible' ? 'Ansible Playbooks' : 'Scripts');
const items = Array.isArray(data?.items) ? data.items : [];
const filtered = items.filter((item) => {
const kind = String(item?.assembly_kind || "").toLowerCase();
const type = String(item?.assembly_type || "").toLowerCase();
if (mode === "ansible") {
return type === "ansible";
}
return kind === "script" && type !== "ansible";
});
const { root, map } = buildTree(filtered, mode === "ansible" ? "Ansible Playbooks" : "Scripts");
setTree(root);
setNodeMap(map);
} catch (err) {
@@ -261,7 +265,6 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
const loadAssembly = async () => {
setVariableStatus({ loading: true, error: "" });
try {
const island = mode === "ansible" ? "ansible" : "scripts";
const trimmed = (selectedPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
if (!trimmed) {
setVariables([]);
@@ -270,16 +273,26 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
setVariableStatus({ loading: false, error: "" });
return;
}
let relPath = trimmed;
if (island === "scripts" && relPath.toLowerCase().startsWith("scripts/")) {
relPath = relPath.slice("Scripts/".length);
} else if (island === "ansible" && relPath.toLowerCase().startsWith("ansible_playbooks/")) {
relPath = relPath.slice("Ansible_Playbooks/".length);
const node = nodeMap[trimmed];
const script = node?.script;
const assemblyGuid = script?.assembly_guid;
if (!assemblyGuid) {
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
return;
}
const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(relPath)}`);
const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}/export`);
if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`);
const data = await resp.json();
const defs = normalizeVariables(data?.assembly?.variables || []);
const metadata = data?.metadata && typeof data.metadata === "object" ? data.metadata : {};
const payload = data?.payload && typeof data.payload === "object" ? data.payload : {};
const varsSource =
(payload && payload.variables) ||
metadata.variables ||
[];
const defs = normalizeVariables(varsSource);
if (!canceled) {
setVariables(defs);
const initialValues = {};
@@ -303,7 +316,7 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
return () => {
canceled = true;
};
}, [selectedPath, mode]);
}, [selectedPath, mode, nodeMap]);
const handleVariableChange = (variable, rawValue) => {
const { name, type } = variable;
@@ -375,8 +388,12 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
try {
let resp;
const variableOverrides = buildVariablePayload();
const node = nodeMap[selectedPath];
if (mode === 'ansible') {
const playbook_path = selectedPath; // relative to ansible island
const rawPath = (node?.scriptPath || selectedPath || "").replace(/\\/g, "/");
const playbook_path = rawPath.toLowerCase().startsWith("ansible_playbooks/")
? rawPath
: `Ansible_Playbooks/${rawPath}`;
resp = await fetch("/api/ansible/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -389,8 +406,8 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
})
});
} else {
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
const rawPath = (node?.scriptPath || selectedPath || "").replace(/\\/g, "/");
const script_path = rawPath.toLowerCase().startsWith("scripts/") ? rawPath : `Scripts/${rawPath}`;
resp = await fetch("/api/scripts/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },