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. [ ] 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. [ ] Display yellow “Queued to Write to DB” pill for assemblies whose cache entry is dirty.
[ ] Implement Import/Export dropdown in `Assembly_Editor.jsx`: [ ] Implement Import/Export dropdown in `Assembly_Editor.jsx`:
[ ] Export: bundle metadata + payload contents into legacy JSON format for download. [x] Export: bundle metadata + payload contents into legacy JSON format for download.
[ ] Import: parse JSON, populate editor form, and default to saving into `user_created`. [x] 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. [x] 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] Ensure admin-only controls are hidden for non-authorized users.
### Details ### Details
``` ```
1. Modify `Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx` (or equivalent) to: 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. 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 ## 5. Support JSON import/export endpoints
[x] Implement backend utilities to translate between DB model and legacy JSON structure. [x] Implement backend utilities to translate between DB model and legacy JSON structure.
[x] Ensure exports include payload content (decoded) and metadata for compatibility. [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 [userDisplayName, setUserDisplayName] = useState(null);
const [editingJob, setEditingJob] = useState(null); const [editingJob, setEditingJob] = useState(null);
const [jobsRefreshToken, setJobsRefreshToken] = useState(0); 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 [sessionResolved, setSessionResolved] = useState(false);
const initialPathRef = useRef(window.location.pathname + window.location.search); const initialPathRef = useRef(window.location.pathname + window.location.search);
const pendingPathRef = useRef(null); const pendingPathRef = useRef(null);
@@ -858,28 +858,36 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
async (name) => { async (name) => {
const tab = tabs.find((t) => t.id === activeTabId); const tab = tabs.find((t) => t.id === activeTabId);
if (!tab || !name) return; if (!tab || !name) return;
const payload = { const document = {
path: tab.folderPath ? `${tab.folderPath}/${name}` : name, tab_name: name,
workflow: { name,
tab_name: tab.tab_name, display_name: name,
nodes: tab.nodes, nodes: tab.nodes,
edges: tab.edges edges: tab.edges,
}
}; };
try { try {
const body = { const resp = await fetch("/api/assemblies/import", {
island: 'workflows',
kind: 'file',
path: payload.path,
content: payload.workflow
};
await fetch("/api/assembly/create", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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) => 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) { } catch (err) {
console.error("Failed to save workflow:", err); console.error("Failed to save workflow:", err);
@@ -888,6 +896,97 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
[tabs, activeTabId] [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'); const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
useEffect(() => { useEffect(() => {
@@ -972,97 +1071,31 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "workflows": case "workflows":
return ( return (
<AssemblyList <AssemblyList
onOpenWorkflow={async (workflow, folderPath, name) => { onOpenWorkflow={openWorkflowFromList}
const newId = "flow_" + Date.now(); onOpenScript={openScriptFromList}
if (workflow && workflow.rel_path) { userRole={userRole || 'User'}
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
}
});
}}
/> />
); );
case "assemblies": case "assemblies":
return ( return (
<AssemblyList <AssemblyList
onOpenWorkflow={async (workflow, folderPath, name) => { onOpenWorkflow={openWorkflowFromList}
const newId = "flow_" + Date.now(); onOpenScript={openScriptFromList}
if (workflow && workflow.rel_path) { userRole={userRole || 'User'}
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
}
});
}}
/> />
); );
case "scripts": case "scripts":
return ( return (
<AssemblyEditor <AssemblyEditor
mode="scripts" mode="script"
initialPath={assemblyEditorState?.mode === 'scripts' ? (assemblyEditorState?.path || '') : ''} initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'script' ? assemblyEditorState : null}
initialContext={assemblyEditorState?.mode === 'scripts' ? assemblyEditorState?.context : null} onConsumeInitialData={() => {
onConsumeInitialData={() => setAssemblyEditorState((prev) => (prev && prev.mode === 'script' ? null : prev));
setAssemblyEditorState((prev) => (prev && prev.mode === 'scripts' ? null : prev)) }}
}
onSaved={() => navigateTo('assemblies')} onSaved={() => navigateTo('assemblies')}
userRole={userRole || 'User'}
/> />
); );
@@ -1070,12 +1103,12 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
return ( return (
<AssemblyEditor <AssemblyEditor
mode="ansible" mode="ansible"
initialPath={assemblyEditorState?.mode === 'ansible' ? (assemblyEditorState?.path || '') : ''} initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'ansible' ? assemblyEditorState : null}
initialContext={assemblyEditorState?.mode === 'ansible' ? assemblyEditorState?.context : null} onConsumeInitialData={() => {
onConsumeInitialData={() => setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev));
setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev)) }}
}
onSaved={() => navigateTo('assemblies')} 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, Typography,
Button, Button,
TextField, TextField,
MenuItem, Menu, MenuItem,
Grid, Grid,
FormControlLabel, FormControlLabel,
Checkbox, Checkbox,
@@ -26,6 +26,7 @@ import "prismjs/components/prism-batch";
import "prismjs/themes/prism-okaidia.css"; import "prismjs/themes/prism-okaidia.css";
import Editor from "react-simple-code-editor"; import Editor from "react-simple-code-editor";
import { ConfirmDeleteDialog } from "../Dialogs"; import { ConfirmDeleteDialog } from "../Dialogs";
import { DomainBadge, DirtyStatePill, DOMAIN_OPTIONS } from "./Assembly_Badges";
const TYPE_OPTIONS_ALL = [ const TYPE_OPTIONS_ALL = [
{ key: "ansible", label: "Ansible Playbook", prism: "yaml" }, { key: "ansible", label: "Ansible Playbook", prism: "yaml" },
@@ -172,6 +173,20 @@ function formatBytes(size) {
return `${s.toFixed(1)} ${units[idx]}`; 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") { function defaultAssembly(defaultType = "powershell") {
return { return {
name: "", name: "",
@@ -368,12 +383,12 @@ function toServerDocument(assembly) {
function RenameFileDialog({ open, value, onChange, onCancel, onSave }) { function RenameFileDialog({ open, value, onChange, onCancel, onSave }) {
return ( return (
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: BACKGROUND_COLORS.dialog, color: "#fff" } }}> <Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: BACKGROUND_COLORS.dialog, color: "#fff" } }}>
<DialogTitle>Rename Assembly File</DialogTitle> <DialogTitle>Rename Assembly</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
autoFocus autoFocus
margin="dense" margin="dense"
label="File Name" label="Assembly Name"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={value} value={value}
@@ -390,25 +405,34 @@ function RenameFileDialog({ open, value, onChange, onCancel, onSave }) {
} }
export default function AssemblyEditor({ export default function AssemblyEditor({
mode = "scripts", mode = "script",
initialPath = "", initialAssembly = null,
initialContext = null,
onConsumeInitialData, 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 defaultType = isAnsible ? "ansible" : "powershell";
const [assembly, setAssembly] = useState(() => defaultAssembly(defaultType)); const [assembly, setAssembly] = useState(() => defaultAssembly(defaultType));
const [currentPath, setCurrentPath] = useState(""); const [assemblyGuid, setAssemblyGuid] = useState(initialAssembly?.row?.assemblyGuid || null);
const [fileName, setFileName] = useState(""); const [domain, setDomain] = useState(() => (initialAssembly?.row?.domain || "user").toLowerCase());
const [folderPath, setFolderPath] = useState(() => normalizeFolderPath(initialContext?.folder || "")); const [fileName, setFileName] = useState(() => sanitizeFileName(initialAssembly?.row?.name || ""));
const [renameOpen, setRenameOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [siteOptions, setSiteOptions] = useState([]); const [siteOptions, setSiteOptions] = useState([]);
const [siteLoading, setSiteLoading] = useState(false); 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( const TYPE_OPTIONS = useMemo(
() => (isAnsible ? TYPE_OPTIONS_ALL.filter((o) => o.key === "ansible") : TYPE_OPTIONS_ALL.filter((o) => o.key !== "ansible")), () => (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; return map;
}, [siteOptions]); }, [siteOptions]);
const island = isAnsible ? "ansible" : "scripts";
useEffect(() => { useEffect(() => {
if (!initialPath) return;
let canceled = false; 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 { try {
const resp = await fetch(`/api/assembly/load?island=${encodeURIComponent(island)}&path=${encodeURIComponent(initialPath)}`); setLoading(true);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); 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(); const data = await resp.json();
if (canceled) return; if (canceled) return;
const rel = data.rel_path || initialPath; const metadata = data?.metadata && typeof data.metadata === "object" ? data.metadata : {};
setCurrentPath(rel); const payload = data?.payload && typeof data.payload === "object" ? data.payload : {};
setFolderPath(normalizeFolderPath(rel.split("/").slice(0, -1).join("/"))); const enrichedDoc = { ...payload };
setFileName(data.file_name || rel.split("/").pop() || ""); const fallbackName =
const doc = fromServerDocument(data.assembly || data, defaultType); metadata.display_name || data?.display_name || row?.name || assembly.name || "";
setAssembly(doc); 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) { } catch (err) {
console.error("Failed to load assembly:", err); console.error("Failed to load assembly:", err);
} finally { if (!canceled) {
if (!canceled && onConsumeInitialData) onConsumeInitialData(); setErrorMessage(err?.message || "Failed to load assembly data.");
} }
})(); } finally {
if (!canceled) {
setLoading(false);
onConsumeInitialData?.();
}
}
};
const row = initialAssembly?.row;
const context = row?.createContext || initialAssembly?.createContext;
if (row?.assemblyGuid) {
hydrateExisting(row.assemblyGuid, row);
return () => { return () => {
canceled = true; canceled = true;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps }
}, [initialPath, island]);
useEffect(() => { if (context) {
const ctx = initialContext; hydrateNewContext(context);
if (!ctx || !ctx.nonce) return; onConsumeInitialData?.();
if (contextNonceRef.current === ctx.nonce) return; return () => {
contextNonceRef.current = ctx.nonce; canceled = true;
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; return () => {
if (ctx.type) doc.type = ctx.type; canceled = true;
setAssembly(doc); };
setCurrentPath(""); }, [initialAssembly, defaultType, onConsumeInitialData]);
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]);
useEffect(() => { useEffect(() => {
let canceled = false; let canceled = false;
@@ -593,117 +687,243 @@ export default function AssemblyEditor({
setAssembly((prev) => ({ ...prev, files: prev.files.filter((f) => f.id !== id) })); setAssembly((prev) => ({ ...prev, files: prev.files.filter((f) => f.id !== id) }));
}; };
const computeTargetPath = () => { const canWriteToDomain = domain === "user" || (isAdmin && devModeEnabled);
if (currentPath) return currentPath;
const baseName = sanitizeFileName(fileName || assembly.name || (isAnsible ? "playbook" : "assembly"));
const folder = normalizeFolderPath(folderPath);
return folder ? `${folder}/${baseName}` : baseName;
};
const saveAssembly = async () => { const handleSaveAssembly = async () => {
if (!assembly.name.trim()) { if (!assembly.name.trim()) {
alert("Assembly Name is required."); alert("Assembly Name is required.");
return; return;
} }
const payload = toServerDocument(assembly); const document = toServerDocument(assembly);
payload.type = assembly.type;
const targetPath = computeTargetPath();
if (!targetPath) {
alert("Unable to determine file path.");
return;
}
setSaving(true); setSaving(true);
setErrorMessage("");
try { try {
if (currentPath) { const resp = await fetch("/api/assemblies/import", {
const resp = await fetch(`/api/assembly/edit`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, path: currentPath, content: payload }) body: JSON.stringify({
document,
domain,
assembly_guid: assemblyGuid || undefined,
}),
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok) { if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`); throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
} }
if (data?.rel_path) { const nextGuid = data?.assembly_guid || assemblyGuid;
setCurrentPath(data.rel_path); setAssemblyGuid(nextGuid || null);
setFolderPath(normalizeFolderPath(data.rel_path.split("/").slice(0, -1).join("/"))); const nextDomain = (data?.source || data?.domain || domain || "user").toLowerCase();
setFileName(data.rel_path.split("/").pop() || fileName); setDomain(nextDomain);
} setQueueInfo({
} else { dirty_since: data?.dirty_since || null,
const resp = await fetch(`/api/assembly/create`, { last_persisted: data?.last_persisted || null,
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(); setIsDirtyQueue(Boolean(data?.is_dirty));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); if (data?.display_name) {
if (data.rel_path) { setAssembly((prev) => ({ ...prev, name: data.display_name }));
setCurrentPath(data.rel_path); setFileName(sanitizeFileName(data.display_name));
setFolderPath(data.rel_path.split("/").slice(0, -1).join("/"));
setFileName(data.rel_path.split("/").pop() || "");
} else { } else {
setCurrentPath(targetPath); setFileName((prev) => prev || sanitizeFileName(assembly.name));
setFileName(targetPath.split("/").pop() || "");
} }
} onSaved?.();
onSaved && onSaved();
} catch (err) { } catch (err) {
console.error("Failed to save assembly:", 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 { } finally {
setSaving(false); setSaving(false);
} }
}; };
const saveRename = async () => { const handleRenameConfirm = () => {
try { const trimmed = (renameValue || assembly.name || "").trim();
const nextName = sanitizeFileName(renameValue || fileName || assembly.name); if (!trimmed) {
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");
setRenameOpen(false); setRenameOpen(false);
return;
} }
setAssembly((prev) => ({ ...prev, name: trimmed }));
setFileName(sanitizeFileName(trimmed));
setRenameOpen(false);
}; };
const deleteAssembly = async () => { const handleDeleteAssembly = async () => {
if (!currentPath) { if (!assemblyGuid) {
setDeleteOpen(false); setDeleteOpen(false);
return; return;
} }
setSaving(true);
setErrorMessage("");
try { try {
const resp = await fetch(`/api/assembly/delete`, { const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}`, {
method: "POST", method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ island, kind: "file", path: currentPath })
}); });
if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`); if (!resp.ok) {
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
} }
setDeleteOpen(false); setDeleteOpen(false);
setAssembly(defaultAssembly(defaultType)); onSaved?.();
setCurrentPath("");
setFileName("");
onSaved && onSaved();
} catch (err) { } catch (err) {
console.error("Failed to delete assembly:", err); console.error("Failed to delete assembly:", err);
alert(err.message || "Failed to delete assembly"); const message = err?.message || "Failed to delete assembly.";
setDeleteOpen(false); 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 siteScopeValue = assembly.sites?.mode === "specific" ? "specific" : "all";
const selectedSiteValues = Array.isArray(assembly.sites?.values) const selectedSiteValues = Array.isArray(assembly.sites?.values)
? assembly.sites.values.map((v) => String(v)) ? assembly.sites.values.map((v) => String(v))
@@ -746,42 +966,100 @@ export default function AssemblyEditor({
mt: { xs: 2, sm: 0 } mt: { xs: 2, sm: 0 }
}} }}
> >
{currentPath ? ( <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Tooltip title="Rename File"> <DomainBadge domain={domain} size="small" />
{dirtyPillVisible ? <DirtyStatePill compact /> : null}
</Box>
<Button <Button
size="small" size="small"
onClick={() => { setRenameValue(fileName); setRenameOpen(true); }} 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(assembly.name || "");
setRenameOpen(true);
}}
disabled={renameDisabled}
sx={{ color: "#58a6ff", textTransform: "none" }} sx={{ color: "#58a6ff", textTransform: "none" }}
> >
Rename Rename
</Button> </Button>
</span>
</Tooltip> </Tooltip>
) : null} {assemblyGuid ? (
{currentPath ? (
<Tooltip title="Delete Assembly"> <Tooltip title="Delete Assembly">
<span>
<Button <Button
size="small" size="small"
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
disabled={deleteDisabled}
sx={{ color: "#ff6b6b", textTransform: "none" }} sx={{ color: "#ff6b6b", textTransform: "none" }}
> >
Delete Delete
</Button> </Button>
</span>
</Tooltip> </Tooltip>
) : null} ) : null}
<Button <Button
variant="outlined" variant="outlined"
onClick={saveAssembly} onClick={handleSaveAssembly}
disabled={saving} disabled={saveDisabled}
sx={{ sx={{
color: "#58a6ff", color: saveDisabled ? "#3c4452" : "#58a6ff",
borderColor: "#58a6ff", borderColor: saveDisabled ? "#2b3544" : "#58a6ff",
textTransform: "none", textTransform: "none",
backgroundColor: saving backgroundColor: saving
? BACKGROUND_COLORS.primaryActionSaving ? BACKGROUND_COLORS.primaryActionSaving
: BACKGROUND_COLORS.field, : BACKGROUND_COLORS.field,
"&:hover": { "&:hover": {
borderColor: "#7db7ff", borderColor: saveDisabled ? "#2b3544" : "#7db7ff",
backgroundColor: BACKGROUND_COLORS.primaryActionHover backgroundColor: saveDisabled
? BACKGROUND_COLORS.field
: BACKGROUND_COLORS.primaryActionHover,
}, },
"&.Mui-disabled": { "&.Mui-disabled": {
color: "#3c4452", color: "#3c4452",
@@ -795,6 +1073,90 @@ export default function AssemblyEditor({
</Grid> </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 <Box
sx={{ sx={{
@@ -882,7 +1244,7 @@ export default function AssemblyEditor({
onValueChange={(value) => updateAssembly({ script: value })} onValueChange={(value) => updateAssembly({ script: value })}
highlight={(src) => highlightedHtml(src, prismLanguage)} highlight={(src) => highlightedHtml(src, prismLanguage)}
padding={12} padding={12}
placeholder={currentPath ? `Editing: ${currentPath}` : "Start typing your script..."} placeholder={assemblyGuid ? `Editing assembly: ${assemblyGuid}` : "Start typing your script..."}
style={{ style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 14, fontSize: 14,
@@ -1256,13 +1618,13 @@ export default function AssemblyEditor({
value={renameValue} value={renameValue}
onChange={setRenameValue} onChange={setRenameValue}
onCancel={() => setRenameOpen(false)} onCancel={() => setRenameOpen(false)}
onSave={saveRename} onSave={handleRenameConfirm}
/> />
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={deleteOpen} open={deleteOpen}
message="Deleting this assembly cannot be undone. Continue?" message="Deleting this assembly cannot be undone. Continue?"
onCancel={() => setDeleteOpen(false)} onCancel={() => setDeleteOpen(false)}
onConfirm={deleteAssembly} onConfirm={handleDeleteAssembly}
/> />
</Box> </Box>
); );

View File

@@ -23,6 +23,7 @@ import MenuBookIcon from "@mui/icons-material/MenuBook";
import { AgGridReact } from "ag-grid-react"; import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
import { ConfirmDeleteDialog, NewWorkflowDialog } from "../Dialogs"; import { ConfirmDeleteDialog, NewWorkflowDialog } from "../Dialogs";
import { DomainBadge, DirtyStatePill, resolveDomainMeta, DOMAIN_OPTIONS } from "./Assembly_Badges";
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
@@ -48,13 +49,47 @@ const iconFontFamily = '"Quartz Regular"';
const BOREALIS_BLUE = "#58a6ff"; const BOREALIS_BLUE = "#58a6ff";
const DARKER_GRAY = "#9aa3ad"; const DARKER_GRAY = "#9aa3ad";
const PAGE_SIZE = 25; 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 = { const TYPE_METADATA = {
workflows: { workflow: {
label: "Workflow", label: "Workflow",
Icon: PolylineIcon, Icon: PolylineIcon,
}, },
scripts: { script: {
label: "Script", label: "Script",
Icon: CodeIcon, Icon: CodeIcon,
}, },
@@ -115,42 +150,85 @@ const NameCellRenderer = React.memo(function NameCellRenderer(props) {
); );
}); });
const normalizeRow = (island, item) => { const SourceCellRenderer = React.memo(function SourceCellRenderer(props) {
const relPath = String(item?.rel_path || "").replace(/\\/g, "/"); const { data } = props;
const fileName = String(item?.file_name || relPath.split("/").pop() || ""); if (!data) return null;
const folder = relPath ? relPath.split("/").slice(0, -1).join("/") : ""; return (
const idSeed = relPath || fileName || `${Date.now()}_${Math.random().toString(36).slice(2)}`; <Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}>
const name = <DomainBadge domain={data.domain} size="small" />
island === "workflows" {data.isDirty ? <DirtyStatePill compact /> : null}
? item?.tab_name || fileName.replace(/\.[^.]+$/, "") || fileName || "Workflow" </Box>
: item?.name || fileName.replace(/\.[^.]+$/, "") || fileName || "Assembly"; );
// For workflows, always show 'workflow' in Category per request });
const category =
island === "workflows" const normalizeRow = (item, queueEntry) => {
? "workflow" if (!item) return null;
: item?.category || ""; const assemblyGuid = item.assembly_guid || item.assembly_id || "";
const description = island === "workflows" ? "" : item?.description || ""; 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 { return {
id: `${island}:${idSeed}`, id: assemblyGuid || `${typeKey}:${displayName}`,
typeKey: island, assemblyGuid,
name, typeKey,
assemblyKind,
assemblyType,
name: displayName,
category, category,
description, description: summary,
relPath, relPath: sourcePath,
sourcePath,
fileName, fileName,
folder, 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 gridRef = useRef(null);
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [newMenuAnchor, setNewMenuAnchor] = useState(null); 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 [scriptName, setScriptName] = useState("");
const [workflowDialogOpen, setWorkflowDialogOpen] = useState(false); const [workflowDialogOpen, setWorkflowDialogOpen] = useState(false);
const [workflowName, setWorkflowName] = useState(""); const [workflowName, setWorkflowName] = useState("");
@@ -161,30 +239,35 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const [renameDialogOpen, setRenameDialogOpen] = useState(false); const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [cloneDialog, setCloneDialog] = useState({ open: false, row: null, targetDomain: "user" });
const isAdmin = (userRole || "").toLowerCase() === "admin";
const fetchAssemblies = useCallback(async () => { const fetchAssemblies = useCallback(async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const islands = ["workflows", "scripts", "ansible"]; const resp = await fetch("/api/assemblies");
const results = await Promise.all(
islands.map(async (island) => {
const resp = await fetch(`/api/assembly/list?island=${encodeURIComponent(island)}`);
if (!resp.ok) { if (!resp.ok) {
const problem = await resp.text(); const problem = await resp.text();
throw new Error(problem || `Failed to load ${island} assemblies (HTTP ${resp.status})`); throw new Error(problem || `Failed to load assemblies (HTTP ${resp.status})`);
} }
const data = await resp.json(); const payload = await resp.json();
const items = Array.isArray(data?.items) ? data.items : []; const items = Array.isArray(payload?.items) ? payload.items : [];
return items.map((item) => normalizeRow(island, item)); const queue = Array.isArray(payload?.queue) ? payload.queue : [];
}), const queueMap = queue.reduce((acc, entry) => {
); if (entry && entry.assembly_guid) {
setRows(results.flat()); acc[entry.assembly_guid] = entry;
// After data load, auto-size specific columns }
return acc;
}, {});
const processed = items
.map((item) => normalizeRow(item, queueMap[item?.assembly_guid || item?.assembly_id || ""]))
.filter(Boolean);
setRows(processed);
setTimeout(() => { setTimeout(() => {
const columnApi = gridRef.current?.columnApi; const columnApi = gridRef.current?.columnApi;
if (columnApi) { if (columnApi) {
const ids = ["assemblyType", "location", "category", "name"]; const ids = ["assemblyType", "source", "category", "name"];
columnApi.autoSizeColumns(ids, false); columnApi.autoSizeColumns(ids, false);
} }
}, 0); }, 0);
@@ -204,19 +287,11 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const openRow = useCallback( const openRow = useCallback(
(row) => { (row) => {
if (!row) return; if (!row) return;
if (row.typeKey === "workflows") { if (row.typeKey === "workflow") {
const payload = { onOpenWorkflow?.(row);
...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);
return; return;
} }
const mode = row.typeKey === "ansible" ? "ansible" : "scripts"; onOpenScript?.(row);
onOpenScript?.(row.relPath, mode, null);
}, },
[onOpenWorkflow, onOpenScript], [onOpenWorkflow, onOpenScript],
); );
@@ -250,35 +325,67 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
closeContextMenu(); 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 = () => { const startDelete = () => {
if (!activeRow) return; if (!activeRow) return;
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
closeContextMenu(); 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 handleRenameSave = async () => {
const target = activeRow; const target = activeRow;
const trimmed = renameValue.trim(); const trimmed = renameValue.trim();
if (!target || !trimmed) { if (!target || !trimmed || !target.assemblyGuid) {
setRenameDialogOpen(false); setRenameDialogOpen(false);
return; return;
} }
try { try {
const payload = { const resp = await fetch(`/api/assemblies/${encodeURIComponent(target.assemblyGuid)}`, {
island: target.typeKey, method: "PUT",
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",
headers: { "Content-Type": "application/json" }, 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}`); if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
setRenameDialogOpen(false); setRenameDialogOpen(false);
await fetchAssemblies(); await fetchAssemblies();
@@ -290,21 +397,20 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const handleDeleteConfirm = async () => { const handleDeleteConfirm = async () => {
const target = activeRow; const target = activeRow;
if (!target) { if (!target || !target.assemblyGuid) {
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
return; return;
} }
try { try {
const resp = await fetch(`/api/assembly/delete`, { const resp = await fetch(`/api/assemblies/${encodeURIComponent(target.assemblyGuid)}`, {
method: "POST", method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
island: target.typeKey,
kind: "file",
path: target.relPath,
}),
}); });
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}`); if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
await fetchAssemblies(); await fetchAssemblies();
@@ -329,12 +435,25 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
resizable: true, resizable: true,
}, },
{ {
colId: "location", colId: "source",
field: "location", field: "domain",
headerName: "Location", 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 || "", valueGetter: (params) => params?.data?.folder || "",
cellStyle: { color: DARKER_GRAY, fontSize: 13 }, cellStyle: { color: DARKER_GRAY, fontSize: 13 },
minWidth: 180, minWidth: 200,
flex: 0, flex: 0,
sortable: true, sortable: true,
filter: "agTextColumnFilter", filter: "agTextColumnFilter",
@@ -395,21 +514,21 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const handleRefresh = () => fetchAssemblies(); const handleRefresh = () => fetchAssemblies();
const handleNewAssemblyOption = (island) => { const handleNewAssemblyOption = (typeKey) => {
setNewMenuAnchor(null); setNewMenuAnchor(null);
if (island === "workflows") { if (typeKey === "workflow") {
setWorkflowName(""); setWorkflowName("");
setWorkflowDialogOpen(true); setWorkflowDialogOpen(true);
return; return;
} }
setScriptName(""); setScriptName("");
setScriptDialog({ open: true, island }); setScriptDialog({ open: true, typeKey });
}; };
const handleCreateScript = () => { const handleCreateScript = () => {
const trimmed = scriptName.trim(); const trimmed = scriptName.trim();
if (!trimmed || !scriptDialog.island) return; if (!trimmed || !scriptDialog.typeKey) return;
const isAnsible = scriptDialog.island === "ansible"; const isAnsible = scriptDialog.typeKey === "ansible";
const context = { const context = {
folder: "", folder: "",
suggestedFileName: trimmed, suggestedFileName: trimmed,
@@ -418,8 +537,21 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
type: isAnsible ? "ansible" : "powershell", type: isAnsible ? "ansible" : "powershell",
category: isAnsible ? "application" : "script", category: isAnsible ? "application" : "script",
}; };
onOpenScript?.(null, isAnsible ? "ansible" : "scripts", context); const newRow = {
setScriptDialog({ open: false, island: null }); 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(""); setScriptName("");
}; };
@@ -427,7 +559,17 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
const trimmed = workflowName.trim(); const trimmed = workflowName.trim();
if (!trimmed) return; if (!trimmed) return;
setWorkflowDialogOpen(false); 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(""); setWorkflowName("");
}; };
@@ -494,8 +636,8 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
onClose={() => setNewMenuAnchor(null)} onClose={() => setNewMenuAnchor(null)}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
> >
<MenuItem onClick={() => handleNewAssemblyOption("scripts")}>Script</MenuItem> <MenuItem onClick={() => handleNewAssemblyOption("script")}>Script</MenuItem>
<MenuItem onClick={() => handleNewAssemblyOption("workflows")}>Workflow</MenuItem> <MenuItem onClick={() => handleNewAssemblyOption("workflow")}>Workflow</MenuItem>
<MenuItem onClick={() => handleNewAssemblyOption("ansible")}>Ansible Playbook</MenuItem> <MenuItem onClick={() => handleNewAssemblyOption("ansible")}>Ansible Playbook</MenuItem>
</Menu> </Menu>
</Box> </Box>
@@ -543,7 +685,11 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
onRowDoubleClicked={handleRowDoubleClicked} onRowDoubleClicked={handleRowDoubleClicked}
onCellContextMenu={handleCellContextMenu} onCellContextMenu={handleCellContextMenu}
getRowId={(params) => 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} theme={myTheme}
rowHeight={44} rowHeight={44}
@@ -587,6 +733,9 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
> >
Open Open
</MenuItem> </MenuItem>
{activeRow?.assemblyGuid && (isAdmin || activeRow.domain === "user") ? (
<MenuItem onClick={startClone}>Clone</MenuItem>
) : null}
<MenuItem onClick={startRename}>Rename</MenuItem> <MenuItem onClick={startRename}>Rename</MenuItem>
<MenuItem sx={{ color: "#ff8a8a" }} onClick={startDelete}> <MenuItem sx={{ color: "#ff8a8a" }} onClick={startDelete}>
Delete Delete
@@ -629,14 +778,51 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
onConfirm={handleDeleteConfirm} 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 <Dialog
open={scriptDialog.open} open={scriptDialog.open}
onClose={() => { onClose={() => {
setScriptDialog({ open: false, island: null }); setScriptDialog({ open: false, typeKey: null });
setScriptName(""); setScriptName("");
}} }}
> >
<DialogTitle>{scriptDialog.island === "ansible" ? "New Ansible Playbook" : "New Script"}</DialogTitle> <DialogTitle>{scriptDialog.typeKey === "ansible" ? "New Ansible Playbook" : "New Script"}</DialogTitle>
<DialogContent> <DialogContent>
<TextField <TextField
autoFocus autoFocus
@@ -657,7 +843,7 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript }) {
<DialogActions> <DialogActions>
<Button <Button
onClick={() => { onClick={() => {
setScriptDialog({ open: false, island: null }); setScriptDialog({ open: false, typeKey: null });
setScriptName(""); setScriptName("");
}} }}
sx={{ textTransform: "none" }} sx={{ textTransform: "none" }}

View File

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

View File

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