mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 17:35:48 -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.
|
[ ] 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.
|
||||||
|
|||||||
@@ -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'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
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,
|
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);
|
||||||
|
if (!canceled) {
|
||||||
|
setErrorMessage(err?.message || "Failed to load assembly data.");
|
||||||
|
}
|
||||||
} finally {
|
} 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 () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [initialAssembly, defaultType, onConsumeInitialData]);
|
||||||
}, [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]);
|
|
||||||
|
|
||||||
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({
|
||||||
body: JSON.stringify({ island, path: currentPath, content: payload })
|
document,
|
||||||
});
|
domain,
|
||||||
const data = await resp.json().catch(() => ({}));
|
assembly_guid: assemblyGuid || undefined,
|
||||||
if (!resp.ok) {
|
}),
|
||||||
throw new Error(data?.error || `HTTP ${resp.status}`);
|
});
|
||||||
}
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (data?.rel_path) {
|
if (!resp.ok) {
|
||||||
setCurrentPath(data.rel_path);
|
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
|
||||||
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() || "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
} 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 })
|
|
||||||
});
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const data = await resp.json().catch(() => ({}));
|
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
|
||||||
throw new Error(data?.error || `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
|
||||||
|
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
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => { setRenameValue(fileName); setRenameOpen(true); }}
|
onClick={() => {
|
||||||
|
setRenameValue(assembly.name || "");
|
||||||
|
setRenameOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={renameDisabled}
|
||||||
sx={{ color: "#58a6ff", textTransform: "none" }}
|
sx={{ color: "#58a6ff", textTransform: "none" }}
|
||||||
>
|
>
|
||||||
Rename
|
Rename
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</span>
|
||||||
) : null}
|
</Tooltip>
|
||||||
{currentPath ? (
|
{assemblyGuid ? (
|
||||||
<Tooltip title="Delete Assembly">
|
<Tooltip title="Delete Assembly">
|
||||||
<Button
|
<span>
|
||||||
size="small"
|
<Button
|
||||||
onClick={() => setDeleteOpen(true)}
|
size="small"
|
||||||
sx={{ color: "#ff6b6b", textTransform: "none" }}
|
onClick={() => setDeleteOpen(true)}
|
||||||
>
|
disabled={deleteDisabled}
|
||||||
Delete
|
sx={{ color: "#ff6b6b", textTransform: "none" }}
|
||||||
</Button>
|
>
|
||||||
|
Delete
|
||||||
|
</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",
|
||||||
@@ -792,8 +1070,92 @@ export default function AssemblyEditor({
|
|||||||
{saving ? "Saving..." : "Save Assembly"}
|
{saving ? "Saving..." : "Save Assembly"}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</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
|
||||||
@@ -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,14 +1618,14 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
if (!resp.ok) {
|
||||||
islands.map(async (island) => {
|
const problem = await resp.text();
|
||||||
const resp = await fetch(`/api/assembly/list?island=${encodeURIComponent(island)}`);
|
throw new Error(problem || `Failed to load assemblies (HTTP ${resp.status})`);
|
||||||
if (!resp.ok) {
|
}
|
||||||
const problem = await resp.text();
|
const payload = await resp.json();
|
||||||
throw new Error(problem || `Failed to load ${island} assemblies (HTTP ${resp.status})`);
|
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||||
}
|
const queue = Array.isArray(payload?.queue) ? payload.queue : [];
|
||||||
const data = await resp.json();
|
const queueMap = queue.reduce((acc, entry) => {
|
||||||
const items = Array.isArray(data?.items) ? data.items : [];
|
if (entry && entry.assembly_guid) {
|
||||||
return items.map((item) => normalizeRow(island, item));
|
acc[entry.assembly_guid] = entry;
|
||||||
}),
|
}
|
||||||
);
|
return acc;
|
||||||
setRows(results.flat());
|
}, {});
|
||||||
// After data load, auto-size specific columns
|
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" }}
|
||||||
|
|||||||
@@ -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, "/");
|
const dot = base.lastIndexOf(".");
|
||||||
keys.add(prefixed);
|
if (dot > 0) {
|
||||||
}
|
const baseNoExt = base.slice(0, dot);
|
||||||
|
if (baseNoExt && !next[baseNoExt]) next[baseNoExt] = name;
|
||||||
}
|
}
|
||||||
const base = normalizedPath ? normalizedPath.split("/").pop() || "" : "";
|
};
|
||||||
if (base) {
|
try {
|
||||||
keys.add(base);
|
const resp = await fetch("/api/assemblies");
|
||||||
const dot = base.lastIndexOf(".");
|
if (!resp.ok) return;
|
||||||
if (dot > 0) {
|
const data = await resp.json();
|
||||||
keys.add(base.slice(0, dot));
|
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;
|
||||||
}
|
}
|
||||||
}
|
if (item.payload_guid && !next[item.payload_guid]) {
|
||||||
keys.forEach((key) => {
|
next[item.payload_guid] = displayName;
|
||||||
if (key && !next[key]) {
|
|
||||||
next[key] = name;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
} catch {
|
||||||
const ingest = async (island, prefix = "") => {
|
// ignore failures; map remains partial
|
||||||
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");
|
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
setAssemblyNameMap(next);
|
setAssemblyNameMap(next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user