mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-14 22:35:47 -07:00
Fixed Issues with Assemblies Populating into Editor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
113
Data/Engine/web-interface/src/Assemblies/Assembly_Badges.jsx
Normal file
113
Data/Engine/web-interface/src/Assemblies/Assembly_Badges.jsx
Normal 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 },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user