From 5188250a78a237c8cd23bf5b2f6882627a6fadd3 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Mon, 3 Nov 2025 02:20:06 -0700 Subject: [PATCH] Fixed Issues with Assemblies Populating into Editor --- .../Engine/Assemblies/DB_MIGRATION_TRACKER.md | 13 +- Data/Engine/web-interface/src/App.jsx | 233 +++--- .../src/Assemblies/Assembly_Badges.jsx | 113 +++ .../src/Assemblies/Assembly_Editor.jsx | 662 ++++++++++++++---- .../src/Assemblies/Assembly_List.jsx | 374 +++++++--- .../src/Devices/Device_Details.jsx | 70 +- .../src/Scheduling/Quick_Job.jsx | 109 +-- 7 files changed, 1142 insertions(+), 432 deletions(-) create mode 100644 Data/Engine/web-interface/src/Assemblies/Assembly_Badges.jsx diff --git a/Data/Engine/Assemblies/DB_MIGRATION_TRACKER.md b/Data/Engine/Assemblies/DB_MIGRATION_TRACKER.md index ea493522..f0c2c4bd 100644 --- a/Data/Engine/Assemblies/DB_MIGRATION_TRACKER.md +++ b/Data/Engine/Assemblies/DB_MIGRATION_TRACKER.md @@ -98,10 +98,10 @@ [ ] Add “Source” column to AG Grid with domain filter badges. [ ] Display yellow “Queued to Write to DB” pill for assemblies whose cache entry is dirty. [ ] Implement Import/Export dropdown in `Assembly_Editor.jsx`: - [ ] Export: bundle metadata + payload contents into legacy JSON format for download. - [ ] Import: parse JSON, populate editor form, and default to saving into `user_created`. -[ ] Add Dev Mode banner/toggles and domain picker when Dev Mode is active; otherwise show read-only warnings. -[ ] Ensure admin-only controls are hidden for non-authorized users. + [x] Export: bundle metadata + payload contents into legacy JSON format for download. + [x] Import: parse JSON, populate editor form, and default to saving into `user_created`. +[x] Add Dev Mode banner/toggles and domain picker when Dev Mode is active; otherwise show read-only warnings. +[x] Ensure admin-only controls are hidden for non-authorized users. ### Details ``` 1. Modify `Data/Engine/web-interface/src/Assemblies/Assembly_List.jsx` (or equivalent) to: @@ -119,6 +119,11 @@ 5. Update i18n/strings files if the UI uses localization. ``` +**Stage Notes** +- `Assembly_List.jsx` now consumes `/api/assemblies`, renders domain badges plus dirty-state pills, exposes clone-to-domain via a new dialog, and surfaces queue metadata for each row. +- `Assembly_Editor.jsx` loads and saves assemblies by GUID through the cache APIs, adds import/export tooling, domain selection with read-only warnings, Dev Mode enable/flush controls, and reuses shared badge components for status display. +- App routing (workflows/scripts) and quick job tooling were updated to pass assembly GUID/domain metadata, rely on the import/export endpoints, and hydrate variables from the new Assembly service. + ## 5. Support JSON import/export endpoints [x] Implement backend utilities to translate between DB model and legacy JSON structure. [x] Ensure exports include payload content (decoded) and metadata for compatibility. diff --git a/Data/Engine/web-interface/src/App.jsx b/Data/Engine/web-interface/src/App.jsx index 114296f9..8c1c15f2 100644 --- a/Data/Engine/web-interface/src/App.jsx +++ b/Data/Engine/web-interface/src/App.jsx @@ -113,7 +113,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; const [userDisplayName, setUserDisplayName] = useState(null); const [editingJob, setEditingJob] = useState(null); const [jobsRefreshToken, setJobsRefreshToken] = useState(0); - const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { path, mode, context, nonce } + const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { mode: 'script'|'ansible', row, nonce } const [sessionResolved, setSessionResolved] = useState(false); const initialPathRef = useRef(window.location.pathname + window.location.search); const pendingPathRef = useRef(null); @@ -858,28 +858,36 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; async (name) => { const tab = tabs.find((t) => t.id === activeTabId); if (!tab || !name) return; - const payload = { - path: tab.folderPath ? `${tab.folderPath}/${name}` : name, - workflow: { - tab_name: tab.tab_name, - nodes: tab.nodes, - edges: tab.edges - } + const document = { + tab_name: name, + name, + display_name: name, + nodes: tab.nodes, + edges: tab.edges, }; try { - const body = { - island: 'workflows', - kind: 'file', - path: payload.path, - content: payload.workflow - }; - await fetch("/api/assembly/create", { + const resp = await fetch("/api/assemblies/import", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body) + body: JSON.stringify({ + document, + domain: tab.domain || "user", + assembly_guid: tab.assemblyGuid || undefined, + }), }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data?.error || data?.message || `HTTP ${resp.status}`); setTabs((prev) => - prev.map((t) => (t.id === activeTabId ? { ...t, tab_name: name } : t)) + prev.map((t) => + t.id === activeTabId + ? { + ...t, + tab_name: name, + assemblyGuid: data?.assembly_guid || t.assemblyGuid || null, + domain: (data?.source || data?.domain || t.domain || "user").toLowerCase(), + } + : t + ) ); } catch (err) { console.error("Failed to save workflow:", err); @@ -888,6 +896,97 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; [tabs, activeTabId] ); + const openScriptFromList = useCallback( + (row) => { + if (!row) return; + const normalizedRow = { + ...row, + domain: (row?.domain || "user").toLowerCase(), + }; + const mode = normalizedRow.typeKey === "ansible" || normalizedRow.mode === "ansible" ? "ansible" : "script"; + const nonce = Date.now(); + const state = { + mode, + row: normalizedRow, + nonce, + }; + setAssemblyEditorState(state); + navigateTo(mode === "ansible" ? "ansible_editor" : "scripts", { assemblyState: state }); + }, + [navigateTo, setAssemblyEditorState] + ); + + const openWorkflowFromList = useCallback( + async (row) => { + const newId = "flow_" + Date.now(); + const rawDomain = (row?.domain || "user").toLowerCase(); + const sourcePath = row?.sourcePath || row?.metadata?.source_path || ""; + const folderPath = sourcePath ? sourcePath.split("/").slice(0, -1).join("/") : ""; + if (row?.assemblyGuid) { + try { + const resp = await fetch(`/api/assemblies/${encodeURIComponent(row.assemblyGuid)}/export`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + let payload = data?.payload; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + payload = {}; + } + } + const nodes = Array.isArray(payload?.nodes) ? payload.nodes : []; + const edges = Array.isArray(payload?.edges) ? payload.edges : []; + const tabName = payload?.tab_name || data?.display_name || row?.name || "Workflow"; + const domain = (data?.domain || rawDomain).toLowerCase(); + setTabs([ + { + id: newId, + tab_name: tabName, + nodes, + edges, + folderPath, + assemblyGuid: data?.assembly_guid || row?.assemblyGuid || null, + domain, + sourceRow: row, + exportMetadata: data, + }, + ]); + } catch (err) { + console.error("Failed to load workflow:", err); + setTabs([ + { + id: newId, + tab_name: row?.name || "Workflow", + nodes: [], + edges: [], + folderPath, + assemblyGuid: row?.assemblyGuid || null, + domain: rawDomain, + sourceRow: row, + }, + ]); + } + } else { + setTabs([ + { + id: newId, + tab_name: row?.name || "Workflow", + nodes: [], + edges: [], + folderPath, + assemblyGuid: null, + domain: rawDomain, + sourceRow: row, + }, + ]); + } + setActiveTabId(newId); + navigateTo("workflow-editor"); + }, + [navigateTo, setTabs, setActiveTabId] + ); + const isAdmin = (String(userRole || '').toLowerCase() === 'admin'); useEffect(() => { @@ -972,97 +1071,31 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; case "workflows": return ( { - const newId = "flow_" + Date.now(); - if (workflow && workflow.rel_path) { - const folder = workflow.rel_path.split("/").slice(0, -1).join("/"); - try { - const resp = await fetch(`/api/assembly/load?island=workflows&path=${encodeURIComponent(workflow.rel_path)}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - setTabs([{ id: newId, tab_name: data.tab_name || workflow.name || workflow.file_name || "Workflow", nodes: data.nodes || [], edges: data.edges || [], folderPath: folder }]); - } catch (err) { - console.error("Failed to load workflow:", err); - setTabs([{ id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [], folderPath: folder }]); - } - } else { - setTabs([{ id: newId, tab_name: name || "Flow", nodes: [], edges: [], folderPath: folderPath || "" }]); - } - setActiveTabId(newId); - navigateTo("workflow-editor"); - }} - onOpenScript={(rel, mode, context) => { - const nonce = Date.now(); - setAssemblyEditorState({ - path: rel || '', - mode, - context: context ? { ...context, nonce } : null, - nonce - }); - navigateTo(mode === 'ansible' ? 'ansible_editor' : 'scripts', { - assemblyState: { - path: rel || '', - mode, - context: context ? { ...context, nonce } : null, - nonce - } - }); - }} + onOpenWorkflow={openWorkflowFromList} + onOpenScript={openScriptFromList} + userRole={userRole || 'User'} /> ); case "assemblies": return ( { - const newId = "flow_" + Date.now(); - if (workflow && workflow.rel_path) { - const folder = workflow.rel_path.split("/").slice(0, -1).join("/"); - try { - const resp = await fetch(`/api/assembly/load?island=workflows&path=${encodeURIComponent(workflow.rel_path)}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - setTabs([{ id: newId, tab_name: data.tab_name || workflow.name || workflow.file_name || "Workflow", nodes: data.nodes || [], edges: data.edges || [], folderPath: folder }]); - } catch (err) { - console.error("Failed to load workflow:", err); - setTabs([{ id: newId, tab_name: workflow?.name || "Workflow", nodes: [], edges: [], folderPath: folder }]); - } - } else { - setTabs([{ id: newId, tab_name: name || "Flow", nodes: [], edges: [], folderPath: folderPath || "" }]); - } - setActiveTabId(newId); - navigateTo("workflow-editor"); - }} - onOpenScript={(rel, mode, context) => { - const nonce = Date.now(); - setAssemblyEditorState({ - path: rel || '', - mode, - context: context ? { ...context, nonce } : null, - nonce - }); - navigateTo(mode === 'ansible' ? 'ansible_editor' : 'scripts', { - assemblyState: { - path: rel || '', - mode, - context: context ? { ...context, nonce } : null, - nonce - } - }); - }} + onOpenWorkflow={openWorkflowFromList} + onOpenScript={openScriptFromList} + userRole={userRole || 'User'} /> ); case "scripts": return ( - setAssemblyEditorState((prev) => (prev && prev.mode === 'scripts' ? null : prev)) - } + mode="script" + initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'script' ? assemblyEditorState : null} + onConsumeInitialData={() => { + setAssemblyEditorState((prev) => (prev && prev.mode === 'script' ? null : prev)); + }} onSaved={() => navigateTo('assemblies')} + userRole={userRole || 'User'} /> ); @@ -1070,12 +1103,12 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; return ( - setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev)) - } + initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'ansible' ? assemblyEditorState : null} + onConsumeInitialData={() => { + setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev)); + }} onSaved={() => navigateTo('assemblies')} + userRole={userRole || 'User'} /> ); diff --git a/Data/Engine/web-interface/src/Assemblies/Assembly_Badges.jsx b/Data/Engine/web-interface/src/Assemblies/Assembly_Badges.jsx new file mode 100644 index 00000000..a4d9b25e --- /dev/null +++ b/Data/Engine/web-interface/src/Assemblies/Assembly_Badges.jsx @@ -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 ( + + + {meta.label} + + + ); +} + +export function DirtyStatePill({ compact = false, sx }) { + const padding = compact ? "2px 6px" : "4px 8px"; + const fontSize = compact ? 11 : 12; + return ( + + + Queued to Write to DB + + + ); +} + +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 }, +]; + diff --git a/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx b/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx index f9ef28f3..c64f92d7 100644 --- a/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx +++ b/Data/Engine/web-interface/src/Assemblies/Assembly_Editor.jsx @@ -5,7 +5,7 @@ import { Typography, Button, TextField, - MenuItem, + Menu, MenuItem, Grid, FormControlLabel, Checkbox, @@ -26,6 +26,7 @@ import "prismjs/components/prism-batch"; import "prismjs/themes/prism-okaidia.css"; import Editor from "react-simple-code-editor"; import { ConfirmDeleteDialog } from "../Dialogs"; +import { DomainBadge, DirtyStatePill, DOMAIN_OPTIONS } from "./Assembly_Badges"; const TYPE_OPTIONS_ALL = [ { key: "ansible", label: "Ansible Playbook", prism: "yaml" }, @@ -172,6 +173,20 @@ function formatBytes(size) { return `${s.toFixed(1)} ${units[idx]}`; } +function downloadJsonFile(fileName, data) { + const safeName = fileName && fileName.trim() ? fileName.trim() : "assembly.json"; + const content = JSON.stringify(data, null, 2); + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = safeName.endsWith(".json") ? safeName : `${safeName}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + function defaultAssembly(defaultType = "powershell") { return { name: "", @@ -368,12 +383,12 @@ function toServerDocument(assembly) { function RenameFileDialog({ open, value, onChange, onCancel, onSave }) { return ( - Rename Assembly File + Rename Assembly defaultAssembly(defaultType)); - const [currentPath, setCurrentPath] = useState(""); - const [fileName, setFileName] = useState(""); - const [folderPath, setFolderPath] = useState(() => normalizeFolderPath(initialContext?.folder || "")); + const [assemblyGuid, setAssemblyGuid] = useState(initialAssembly?.row?.assemblyGuid || null); + const [domain, setDomain] = useState(() => (initialAssembly?.row?.domain || "user").toLowerCase()); + const [fileName, setFileName] = useState(() => sanitizeFileName(initialAssembly?.row?.name || "")); const [renameOpen, setRenameOpen] = useState(false); const [renameValue, setRenameValue] = useState(""); const [deleteOpen, setDeleteOpen] = useState(false); + const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [siteOptions, setSiteOptions] = useState([]); const [siteLoading, setSiteLoading] = useState(false); - const contextNonceRef = useRef(null); + const [queueInfo, setQueueInfo] = useState(initialAssembly?.row?.queueEntry || null); + const [isDirtyQueue, setIsDirtyQueue] = useState(Boolean(initialAssembly?.row?.isDirty)); + const [devModeEnabled, setDevModeEnabled] = useState(false); + const [devModeBusy, setDevModeBusy] = useState(false); + const importInputRef = useRef(null); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const [errorMessage, setErrorMessage] = useState(""); + const isAdmin = (userRole || "").toLowerCase() === "admin"; const TYPE_OPTIONS = useMemo( () => (isAnsible ? TYPE_OPTIONS_ALL.filter((o) => o.key === "ansible") : TYPE_OPTIONS_ALL.filter((o) => o.key !== "ansible")), @@ -426,53 +450,123 @@ export default function AssemblyEditor({ return map; }, [siteOptions]); - const island = isAnsible ? "ansible" : "scripts"; - useEffect(() => { - if (!initialPath) return; let canceled = false; - (async () => { + + const hydrateFromDocument = (document) => { + const doc = fromServerDocument(document || {}, defaultType); + setAssembly(doc); + setFileName((prev) => prev || sanitizeFileName(doc.name || "")); + }; + + const hydrateNewContext = (ctx) => { + const doc = defaultAssembly(ctx?.defaultType || defaultType); + if (ctx?.name) doc.name = ctx.name; + if (ctx?.description) doc.description = ctx.description; + if (ctx?.category) doc.category = ctx.category; + if (ctx?.type) doc.type = ctx.type; + hydrateFromDocument(doc); + setAssemblyGuid(null); + setDomain((ctx?.domain || initialAssembly?.row?.domain || "user").toLowerCase()); + setQueueInfo(null); + setIsDirtyQueue(false); + const suggested = ctx?.suggestedFileName || ctx?.name || doc.name || ""; + setFileName(sanitizeFileName(suggested)); + }; + + const hydrateExisting = async (guid, row) => { try { - const resp = await fetch(`/api/assembly/load?island=${encodeURIComponent(island)}&path=${encodeURIComponent(initialPath)}`); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + setLoading(true); + const resp = await fetch(`/api/assemblies/${encodeURIComponent(guid)}/export`); + if (!resp.ok) { + const problem = await resp.text(); + throw new Error(problem || `Failed to load assembly (HTTP ${resp.status})`); + } const data = await resp.json(); if (canceled) return; - const rel = data.rel_path || initialPath; - setCurrentPath(rel); - setFolderPath(normalizeFolderPath(rel.split("/").slice(0, -1).join("/"))); - setFileName(data.file_name || rel.split("/").pop() || ""); - const doc = fromServerDocument(data.assembly || data, defaultType); - setAssembly(doc); + const metadata = data?.metadata && typeof data.metadata === "object" ? data.metadata : {}; + const payload = data?.payload && typeof data.payload === "object" ? data.payload : {}; + const enrichedDoc = { ...payload }; + const fallbackName = + metadata.display_name || data?.display_name || row?.name || assembly.name || ""; + enrichedDoc.name = enrichedDoc.name || fallbackName; + enrichedDoc.display_name = enrichedDoc.display_name || fallbackName; + enrichedDoc.description = + enrichedDoc.description || + metadata.summary || + data?.summary || + row?.description || + ""; + enrichedDoc.category = + enrichedDoc.category || metadata.category || data?.category || row?.category || ""; + enrichedDoc.type = + enrichedDoc.type || + metadata.assembly_type || + data?.assembly_type || + row?.assembly_type || + defaultType; + if (enrichedDoc.timeout_seconds == null) { + const metaTimeout = + metadata.timeout_seconds ?? metadata.timeoutSeconds ?? metadata.timeout ?? null; + if (metaTimeout != null) enrichedDoc.timeout_seconds = metaTimeout; + } + if (!enrichedDoc.sites) { + const metaSites = metadata.sites && typeof metadata.sites === "object" ? metadata.sites : {}; + enrichedDoc.sites = metaSites; + } + if (!Array.isArray(enrichedDoc.variables) || !enrichedDoc.variables.length) { + enrichedDoc.variables = Array.isArray(metadata.variables) ? metadata.variables : []; + } + if (!Array.isArray(enrichedDoc.files) || !enrichedDoc.files.length) { + enrichedDoc.files = Array.isArray(metadata.files) ? metadata.files : []; + } + hydrateFromDocument({ ...enrichedDoc }); + setAssemblyGuid(data?.assembly_guid || guid); + setDomain((data?.source || data?.domain || row?.domain || "user").toLowerCase()); + setQueueInfo({ + dirty_since: data?.dirty_since || row?.queueEntry?.dirty_since || null, + last_persisted: data?.last_persisted || row?.queueEntry?.last_persisted || null, + }); + setIsDirtyQueue(Boolean(data?.is_dirty)); + const exportName = sanitizeFileName( + data?.display_name || metadata.display_name || row?.name || guid + ); + setFileName(exportName); } catch (err) { console.error("Failed to load assembly:", err); + if (!canceled) { + setErrorMessage(err?.message || "Failed to load assembly data."); + } } finally { - if (!canceled && onConsumeInitialData) onConsumeInitialData(); + if (!canceled) { + setLoading(false); + onConsumeInitialData?.(); + } } - })(); + }; + + const row = initialAssembly?.row; + const context = row?.createContext || initialAssembly?.createContext; + + if (row?.assemblyGuid) { + hydrateExisting(row.assemblyGuid, row); + return () => { + canceled = true; + }; + } + + if (context) { + hydrateNewContext(context); + onConsumeInitialData?.(); + return () => { + canceled = true; + }; + } + return () => { canceled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialPath, island]); - - useEffect(() => { - const ctx = initialContext; - if (!ctx || !ctx.nonce) return; - if (contextNonceRef.current === ctx.nonce) return; - contextNonceRef.current = ctx.nonce; - const doc = defaultAssembly(ctx.defaultType || defaultType); - if (ctx.name) doc.name = ctx.name; - if (ctx.description) doc.description = ctx.description; - if (ctx.category) doc.category = ctx.category; - if (ctx.type) doc.type = ctx.type; - setAssembly(doc); - setCurrentPath(""); - const suggested = ctx.suggestedFileName || ctx.name || ""; - setFileName(suggested ? sanitizeFileName(suggested) : ""); - setFolderPath(normalizeFolderPath(ctx.folder || "")); - if (onConsumeInitialData) onConsumeInitialData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialContext?.nonce]); + }, [initialAssembly, defaultType, onConsumeInitialData]); useEffect(() => { let canceled = false; @@ -593,117 +687,243 @@ export default function AssemblyEditor({ setAssembly((prev) => ({ ...prev, files: prev.files.filter((f) => f.id !== id) })); }; - const computeTargetPath = () => { - if (currentPath) return currentPath; - const baseName = sanitizeFileName(fileName || assembly.name || (isAnsible ? "playbook" : "assembly")); - const folder = normalizeFolderPath(folderPath); - return folder ? `${folder}/${baseName}` : baseName; - }; + const canWriteToDomain = domain === "user" || (isAdmin && devModeEnabled); - const saveAssembly = async () => { + const handleSaveAssembly = async () => { if (!assembly.name.trim()) { alert("Assembly Name is required."); return; } - const payload = toServerDocument(assembly); - payload.type = assembly.type; - const targetPath = computeTargetPath(); - if (!targetPath) { - alert("Unable to determine file path."); - return; - } + const document = toServerDocument(assembly); setSaving(true); + setErrorMessage(""); try { - if (currentPath) { - const resp = await fetch(`/api/assembly/edit`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, path: currentPath, content: payload }) - }); - const data = await resp.json().catch(() => ({})); - if (!resp.ok) { - throw new Error(data?.error || `HTTP ${resp.status}`); - } - if (data?.rel_path) { - setCurrentPath(data.rel_path); - setFolderPath(normalizeFolderPath(data.rel_path.split("/").slice(0, -1).join("/"))); - setFileName(data.rel_path.split("/").pop() || fileName); - } - } else { - const resp = await fetch(`/api/assembly/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, kind: "file", path: targetPath, content: payload, type: assembly.type }) - }); - const data = await resp.json(); - if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); - if (data.rel_path) { - setCurrentPath(data.rel_path); - setFolderPath(data.rel_path.split("/").slice(0, -1).join("/")); - setFileName(data.rel_path.split("/").pop() || ""); - } else { - setCurrentPath(targetPath); - setFileName(targetPath.split("/").pop() || ""); - } + const resp = await fetch("/api/assemblies/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + document, + domain, + assembly_guid: assemblyGuid || undefined, + }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data?.error || data?.message || `HTTP ${resp.status}`); } - onSaved && onSaved(); + const nextGuid = data?.assembly_guid || assemblyGuid; + setAssemblyGuid(nextGuid || null); + const nextDomain = (data?.source || data?.domain || domain || "user").toLowerCase(); + setDomain(nextDomain); + setQueueInfo({ + dirty_since: data?.dirty_since || null, + last_persisted: data?.last_persisted || null, + }); + setIsDirtyQueue(Boolean(data?.is_dirty)); + if (data?.display_name) { + setAssembly((prev) => ({ ...prev, name: data.display_name })); + setFileName(sanitizeFileName(data.display_name)); + } else { + setFileName((prev) => prev || sanitizeFileName(assembly.name)); + } + onSaved?.(); } catch (err) { console.error("Failed to save assembly:", err); - alert(err.message || "Failed to save assembly"); + const message = err?.message || "Failed to save assembly."; + setErrorMessage(message); + alert(message); } finally { setSaving(false); } }; - const saveRename = async () => { - try { - const nextName = sanitizeFileName(renameValue || fileName || assembly.name); - const resp = await fetch(`/api/assembly/rename`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, kind: "file", path: currentPath, new_name: nextName, type: assembly.type }) - }); - const data = await resp.json(); - if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); - const rel = data.rel_path || currentPath; - setCurrentPath(rel); - setFolderPath(rel.split("/").slice(0, -1).join("/")); - setFileName(rel.split("/").pop() || nextName); - setRenameOpen(false); - } catch (err) { - console.error("Failed to rename assembly:", err); - alert(err.message || "Failed to rename"); + const handleRenameConfirm = () => { + const trimmed = (renameValue || assembly.name || "").trim(); + if (!trimmed) { setRenameOpen(false); + return; } + setAssembly((prev) => ({ ...prev, name: trimmed })); + setFileName(sanitizeFileName(trimmed)); + setRenameOpen(false); }; - const deleteAssembly = async () => { - if (!currentPath) { + const handleDeleteAssembly = async () => { + if (!assemblyGuid) { setDeleteOpen(false); return; } + setSaving(true); + setErrorMessage(""); try { - const resp = await fetch(`/api/assembly/delete`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ island, kind: "file", path: currentPath }) + const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}`, { + method: "DELETE", }); + const data = await resp.json().catch(() => ({})); if (!resp.ok) { - const data = await resp.json().catch(() => ({})); - throw new Error(data?.error || `HTTP ${resp.status}`); + throw new Error(data?.error || data?.message || `HTTP ${resp.status}`); } setDeleteOpen(false); - setAssembly(defaultAssembly(defaultType)); - setCurrentPath(""); - setFileName(""); - onSaved && onSaved(); + onSaved?.(); } catch (err) { console.error("Failed to delete assembly:", err); - alert(err.message || "Failed to delete assembly"); - setDeleteOpen(false); + const message = err?.message || "Failed to delete assembly."; + setErrorMessage(message); + alert(message); + } finally { + setSaving(false); } }; + const handleDevModeToggle = async (enabled) => { + setDevModeBusy(true); + setErrorMessage(""); + try { + const resp = await fetch("/api/assemblies/dev-mode/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data?.error || data?.message || `HTTP ${resp.status}`); + } + setDevModeEnabled(Boolean(data?.dev_mode)); + } catch (err) { + console.error("Failed to toggle Dev Mode:", err); + const message = err?.message || "Failed to update Dev Mode."; + setErrorMessage(message); + alert(message); + } finally { + setDevModeBusy(false); + } + }; + + const handleFlushQueue = async () => { + setDevModeBusy(true); + setErrorMessage(""); + try { + const resp = await fetch("/api/assemblies/dev-mode/write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data?.error || data?.message || `HTTP ${resp.status}`); + } + setIsDirtyQueue(false); + setQueueInfo((prev) => ({ + ...(prev || {}), + dirty_since: null, + last_persisted: new Date().toISOString(), + })); + } catch (err) { + console.error("Failed to flush assembly queue:", err); + const message = err?.message || "Failed to flush queued writes."; + setErrorMessage(message); + alert(message); + } finally { + setDevModeBusy(false); + } + }; + + const handleExportAssembly = async () => { + handleMenuClose(); + setErrorMessage(""); + try { + if (assemblyGuid) { + const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}/export`); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data?.error || data?.message || `HTTP ${resp.status}`); + } + const exportDoc = { ...data }; + delete exportDoc.queue; + const exportName = sanitizeFileName(fileName || data?.display_name || assembly.name || assemblyGuid); + downloadJsonFile(exportName, exportDoc); + } else { + const document = toServerDocument(assembly); + const exportDoc = { + assembly_guid: assemblyGuid, + domain, + assembly_kind: isAnsible ? "ansible" : "script", + assembly_type: assembly.type, + display_name: assembly.name, + summary: assembly.description, + category: assembly.category, + payload: document, + }; + const exportName = sanitizeFileName(fileName || assembly.name || "assembly"); + downloadJsonFile(exportName, exportDoc); + } + } catch (err) { + console.error("Failed to export assembly:", err); + const message = err?.message || "Failed to export assembly."; + setErrorMessage(message); + alert(message); + } + }; + + const handleImportAssembly = async (event) => { + const file = event.target.files && event.target.files[0]; + if (!file) return; + setErrorMessage(""); + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const payload = parsed?.payload || parsed; + const doc = fromServerDocument(payload || {}, defaultType); + setAssembly(doc); + setAssemblyGuid(parsed?.assembly_guid || null); + setDomain("user"); + setQueueInfo(null); + setIsDirtyQueue(false); + const baseName = parsed?.display_name || parsed?.name || file.name.replace(/\.[^.]+$/, "") || "assembly"; + setFileName(sanitizeFileName(baseName)); + alert("Assembly imported. Review details before saving."); + } catch (err) { + console.error("Failed to import assembly:", err); + const message = err?.message || "Failed to import assembly JSON."; + setErrorMessage(message); + alert(message); + } finally { + event.target.value = ""; + } + }; + + const handleMenuOpen = (event) => { + setMenuAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setMenuAnchorEl(null); + }; + + const triggerImport = () => { + handleMenuClose(); + importInputRef.current?.click(); + }; + + const triggerExport = () => { + handleExportAssembly(); + }; + + const triggerFlushQueue = () => { + handleMenuClose(); + handleFlushQueue(); + }; + + const saveDisabled = saving || loading || !canWriteToDomain; + const deleteDisabled = !assemblyGuid || saving || loading; + const renameDisabled = saving || loading; + const dirtyPillVisible = Boolean(isDirtyQueue); + const lastPersistedDisplay = queueInfo?.last_persisted + ? new Date(queueInfo.last_persisted).toLocaleString() + : null; + const dirtySinceDisplay = queueInfo?.dirty_since + ? new Date(queueInfo.dirty_since).toLocaleString() + : null; + const siteScopeValue = assembly.sites?.mode === "specific" ? "specific" : "all"; const selectedSiteValues = Array.isArray(assembly.sites?.values) ? assembly.sites.values.map((v) => String(v)) @@ -746,42 +966,100 @@ export default function AssemblyEditor({ mt: { xs: 2, sm: 0 } }} > - {currentPath ? ( - + + + {dirtyPillVisible ? : null} + + + {isAdmin ? ( + + ) : null} + {isAdmin && devModeEnabled ? ( + + ) : null} + + - - ) : null} - {currentPath ? ( + + + {assemblyGuid ? ( - + + + ) : null} + + + + { - setScriptDialog({ open: false, island: null }); + setScriptDialog({ open: false, typeKey: null }); setScriptName(""); }} > - {scriptDialog.island === "ansible" ? "New Ansible Playbook" : "New Script"} + {scriptDialog.typeKey === "ansible" ? "New Ansible Playbook" : "New Script"}