@meshxdata/fops 0.1.52 → 0.1.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +559 -0
- package/package.json +2 -6
- package/src/agent/agent.js +6 -0
- package/src/commands/setup.js +34 -0
- package/src/fleet-registry.js +38 -2
- package/src/plugins/__test-fixtures__/fake-plugin.js +2 -0
- package/src/plugins/__test-fixtures__/no-register-plugin.js +2 -0
- package/src/plugins/__test-fixtures__/with-register/index.js +2 -0
- package/src/plugins/__test-fixtures__/without-register/index.js +2 -0
- package/src/plugins/api.js +4 -0
- package/src/plugins/builtins/docker-compose.js +65 -0
- package/src/plugins/bundled/fops-plugin-azure/index.js +4 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +44 -53
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +2 -2
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-cost.js +52 -22
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +6 -2
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +113 -7
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +13 -4
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +91 -14
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-service.js +507 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +146 -7
- package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +61 -0
- package/src/plugins/bundled/fops-plugin-cloud/api.js +712 -0
- package/src/plugins/bundled/fops-plugin-cloud/fops.plugin.json +6 -0
- package/src/plugins/bundled/fops-plugin-cloud/index.js +208 -0
- package/src/plugins/bundled/fops-plugin-cloud/lib/azure-provider.js +81 -0
- package/src/plugins/bundled/fops-plugin-cloud/lib/provider.js +50 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/favicon-C49brna2.svg +15 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-CVqQ_kKW.js +65 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-DZetahP3.css +1 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/dist/index.html +28 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/index.html +27 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/package-lock.json +2634 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/package.json +29 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +5 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +32 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +114 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +111 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +162 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +46 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +147 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +138 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +15 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +19 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +25 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +164 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +305 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +285 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +307 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +229 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +132 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +174 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +21 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +170 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +49 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +37 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +116 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +63 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/vite.config.js +23 -0
- package/src/plugins/bundled/fops-plugin-foundation/test-helpers.js +65 -0
- package/src/plugins/loader.js +34 -1
- package/src/plugins/registry.js +15 -0
- package/src/plugins/schemas.js +17 -0
- package/src/project.js +1 -1
- package/src/serve.js +196 -2
- package/src/shell.js +21 -1
- package/src/web/admin.html.js +236 -0
- package/src/web/api.js +73 -0
- package/src/web/dist/assets/index-BphVaAUd.css +1 -0
- package/src/web/dist/assets/index-CSckLzuG.js +129 -0
- package/src/web/dist/index.html +2 -2
- package/src/web/frontend/index.html +16 -0
- package/src/web/frontend/src/App.jsx +445 -0
- package/src/web/frontend/src/components/ChatView.jsx +910 -0
- package/src/web/frontend/src/components/InputBox.jsx +523 -0
- package/src/web/frontend/src/components/Sidebar.jsx +410 -0
- package/src/web/frontend/src/components/StatusBar.jsx +37 -0
- package/src/web/frontend/src/components/TabBar.jsx +87 -0
- package/src/web/frontend/src/hooks/useWebSocket.js +412 -0
- package/src/web/frontend/src/index.css +78 -0
- package/src/web/frontend/src/main.jsx +6 -0
- package/src/web/frontend/vite.config.js +21 -0
- package/src/web/server.js +64 -1
- package/src/web/dist/assets/index-NXC8Hvnp.css +0 -1
- package/src/web/dist/assets/index-QH1N4ejK.js +0 -112
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useContext } from 'react';
|
|
2
|
+
import { CSSTransition as ReactCSSTransition } from 'react-transition-group';
|
|
3
|
+
|
|
4
|
+
const TransitionContext = React.createContext({
|
|
5
|
+
parent: {},
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
function useIsInitialRender() {
|
|
9
|
+
const isInitialRender = useRef(true);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
isInitialRender.current = false;
|
|
12
|
+
}, [])
|
|
13
|
+
return isInitialRender.current;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function CSSTransition({
|
|
17
|
+
show,
|
|
18
|
+
enter = '',
|
|
19
|
+
enterStart = '',
|
|
20
|
+
enterEnd = '',
|
|
21
|
+
leave = '',
|
|
22
|
+
leaveStart = '',
|
|
23
|
+
leaveEnd = '',
|
|
24
|
+
appear,
|
|
25
|
+
unmountOnExit,
|
|
26
|
+
tag = 'div',
|
|
27
|
+
children,
|
|
28
|
+
...rest
|
|
29
|
+
}) {
|
|
30
|
+
const enterClasses = enter.split(' ').filter((s) => s.length);
|
|
31
|
+
const enterStartClasses = enterStart.split(' ').filter((s) => s.length);
|
|
32
|
+
const enterEndClasses = enterEnd.split(' ').filter((s) => s.length);
|
|
33
|
+
const leaveClasses = leave.split(' ').filter((s) => s.length);
|
|
34
|
+
const leaveStartClasses = leaveStart.split(' ').filter((s) => s.length);
|
|
35
|
+
const leaveEndClasses = leaveEnd.split(' ').filter((s) => s.length);
|
|
36
|
+
const removeFromDom = unmountOnExit;
|
|
37
|
+
|
|
38
|
+
function addClasses(node, classes) {
|
|
39
|
+
classes.length && node.classList.add(...classes);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function removeClasses(node, classes) {
|
|
43
|
+
classes.length && node.classList.remove(...classes);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const nodeRef = React.useRef(null);
|
|
47
|
+
const Component = tag;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ReactCSSTransition
|
|
51
|
+
appear={appear}
|
|
52
|
+
nodeRef={nodeRef}
|
|
53
|
+
unmountOnExit={removeFromDom}
|
|
54
|
+
in={show}
|
|
55
|
+
addEndListener={(done) => {
|
|
56
|
+
nodeRef.current.addEventListener('transitionend', done, false)
|
|
57
|
+
}}
|
|
58
|
+
onEnter={() => {
|
|
59
|
+
if (!removeFromDom) nodeRef.current.style.display = null;
|
|
60
|
+
addClasses(nodeRef.current, [...enterClasses, ...enterStartClasses])
|
|
61
|
+
}}
|
|
62
|
+
onEntering={() => {
|
|
63
|
+
removeClasses(nodeRef.current, enterStartClasses)
|
|
64
|
+
addClasses(nodeRef.current, enterEndClasses)
|
|
65
|
+
}}
|
|
66
|
+
onEntered={() => {
|
|
67
|
+
removeClasses(nodeRef.current, [...enterEndClasses, ...enterClasses])
|
|
68
|
+
}}
|
|
69
|
+
onExit={() => {
|
|
70
|
+
addClasses(nodeRef.current, [...leaveClasses, ...leaveStartClasses])
|
|
71
|
+
}}
|
|
72
|
+
onExiting={() => {
|
|
73
|
+
removeClasses(nodeRef.current, leaveStartClasses)
|
|
74
|
+
addClasses(nodeRef.current, leaveEndClasses)
|
|
75
|
+
}}
|
|
76
|
+
onExited={() => {
|
|
77
|
+
removeClasses(nodeRef.current, [...leaveEndClasses, ...leaveClasses])
|
|
78
|
+
if (!removeFromDom) nodeRef.current.style.display = 'none';
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<Component ref={nodeRef} {...rest} style={{ display: !removeFromDom ? 'none': null }}>{children}</Component>
|
|
82
|
+
</ReactCSSTransition>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Transition({ show, appear, ...rest }) {
|
|
87
|
+
const { parent } = useContext(TransitionContext);
|
|
88
|
+
const isInitialRender = useIsInitialRender();
|
|
89
|
+
const isChild = show === undefined;
|
|
90
|
+
|
|
91
|
+
if (isChild) {
|
|
92
|
+
return (
|
|
93
|
+
<CSSTransition
|
|
94
|
+
appear={parent.appear || !parent.isInitialRender}
|
|
95
|
+
show={parent.show}
|
|
96
|
+
{...rest}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<TransitionContext.Provider
|
|
103
|
+
value={{
|
|
104
|
+
parent: {
|
|
105
|
+
show,
|
|
106
|
+
isInitialRender,
|
|
107
|
+
appear,
|
|
108
|
+
},
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<CSSTransition appear={appear} show={show} {...rest} />
|
|
112
|
+
</TransitionContext.Provider>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default Transition;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const formatValue = (value) => Intl.NumberFormat('en-US', {
|
|
2
|
+
style: 'currency',
|
|
3
|
+
currency: 'USD',
|
|
4
|
+
maximumSignificantDigits: 3,
|
|
5
|
+
notation: 'compact',
|
|
6
|
+
}).format(value);
|
|
7
|
+
|
|
8
|
+
export const formatThousands = (value) => Intl.NumberFormat('en-US', {
|
|
9
|
+
maximumSignificantDigits: 3,
|
|
10
|
+
notation: 'compact',
|
|
11
|
+
}).format(value);
|
|
12
|
+
|
|
13
|
+
export const getCssVariable = (variable) => {
|
|
14
|
+
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const adjustHexOpacity = (hexColor, opacity) => {
|
|
18
|
+
// Remove the '#' if it exists
|
|
19
|
+
hexColor = hexColor.replace('#', '');
|
|
20
|
+
|
|
21
|
+
// Convert hex to RGB
|
|
22
|
+
const r = parseInt(hexColor.substring(0, 2), 16);
|
|
23
|
+
const g = parseInt(hexColor.substring(2, 4), 16);
|
|
24
|
+
const b = parseInt(hexColor.substring(4, 6), 16);
|
|
25
|
+
|
|
26
|
+
// Return RGBA string
|
|
27
|
+
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const adjustHSLOpacity = (hslColor, opacity) => {
|
|
31
|
+
// Convert HSL to HSLA
|
|
32
|
+
return hslColor.replace('hsl(', 'hsla(').replace(')', `, ${opacity})`);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const adjustOKLCHOpacity = (oklchColor, opacity) => {
|
|
36
|
+
// Add alpha value to OKLCH color
|
|
37
|
+
return oklchColor.replace(/oklch\((.*?)\)/, (match, p1) => `oklch(${p1} / ${opacity})`);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const adjustColorOpacity = (color, opacity) => {
|
|
41
|
+
if (color.startsWith('#')) {
|
|
42
|
+
return adjustHexOpacity(color, opacity);
|
|
43
|
+
} else if (color.startsWith('hsl')) {
|
|
44
|
+
return adjustHSLOpacity(color, opacity);
|
|
45
|
+
} else if (color.startsWith('oklch')) {
|
|
46
|
+
return adjustOKLCHOpacity(color, opacity);
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error('Unsupported color format');
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const oklchToRGBA = (oklchColor) => {
|
|
53
|
+
// Create a temporary div to use for color conversion
|
|
54
|
+
const tempDiv = document.createElement('div');
|
|
55
|
+
tempDiv.style.color = oklchColor;
|
|
56
|
+
document.body.appendChild(tempDiv);
|
|
57
|
+
|
|
58
|
+
// Get the computed style and convert to RGB
|
|
59
|
+
const computedColor = window.getComputedStyle(tempDiv).color;
|
|
60
|
+
document.body.removeChild(tempDiv);
|
|
61
|
+
|
|
62
|
+
return computedColor;
|
|
63
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [react()],
|
|
6
|
+
base: "/cloud/",
|
|
7
|
+
server: {
|
|
8
|
+
port: 5174,
|
|
9
|
+
proxy: {
|
|
10
|
+
"/cloud/api": {
|
|
11
|
+
target: "http://localhost:3099",
|
|
12
|
+
changeOrigin: true,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
build: {
|
|
17
|
+
outDir: "dist",
|
|
18
|
+
emptyOutDir: true,
|
|
19
|
+
commonjsOptions: {
|
|
20
|
+
transformMixedEsModules: true,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test setup for Foundation plugin tool tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides mockClient, vi.mock() calls, and setup() used across all
|
|
5
|
+
* per-category test files (quality, profiling, classification, etc.).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
export const mockClient = {
|
|
11
|
+
get: vi.fn(),
|
|
12
|
+
post: vi.fn(),
|
|
13
|
+
put: vi.fn(),
|
|
14
|
+
delete: vi.fn(),
|
|
15
|
+
patch: vi.fn(),
|
|
16
|
+
deleteWithBody: vi.fn(),
|
|
17
|
+
_config: { user: "test@example.com" },
|
|
18
|
+
healthCheck: vi.fn(),
|
|
19
|
+
ensureAuth: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
vi.mock("./lib/client.js", () => ({
|
|
23
|
+
FoundationClient: vi.fn(function () { return mockClient; }),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("./lib/storage.js", () => ({
|
|
27
|
+
StorageClient: vi.fn(function () { return { healthCheck: vi.fn() }; }),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("./lib/templates.js", () => ({
|
|
31
|
+
listTemplates: vi.fn(() => []),
|
|
32
|
+
getTemplate: vi.fn(),
|
|
33
|
+
resolveBuilder: vi.fn(),
|
|
34
|
+
buildPipeline: vi.fn((opts) => ({
|
|
35
|
+
config: { docker_tag: opts.docker_tag || "0.0.1" },
|
|
36
|
+
inputs: {},
|
|
37
|
+
transformations: opts.transformations || [],
|
|
38
|
+
finalisers: { input: "step_0" },
|
|
39
|
+
preview: false,
|
|
40
|
+
})),
|
|
41
|
+
normalizeSchemaField: vi.fn((f) => f),
|
|
42
|
+
resolveSchema: vi.fn(({ fields, data_product_type }) => ({
|
|
43
|
+
details: { data_product_type: data_product_type || "user", fields },
|
|
44
|
+
})),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const { register } = await import("./index.js");
|
|
48
|
+
|
|
49
|
+
export function setup() {
|
|
50
|
+
const tools = {};
|
|
51
|
+
const agents = [];
|
|
52
|
+
const api = {
|
|
53
|
+
config: { user: "test@example.com" },
|
|
54
|
+
registerTool: (t) => { tools[t.name] = t; },
|
|
55
|
+
registerCommand: vi.fn(),
|
|
56
|
+
registerAgent: (a) => { agents.push(a); },
|
|
57
|
+
registerDoctorCheck: vi.fn(),
|
|
58
|
+
registerKnowledgeSource: vi.fn(),
|
|
59
|
+
registerPreflight: vi.fn(),
|
|
60
|
+
registerService: vi.fn(),
|
|
61
|
+
getService: vi.fn(),
|
|
62
|
+
};
|
|
63
|
+
register(api);
|
|
64
|
+
return { tools, agents, api };
|
|
65
|
+
}
|
package/src/plugins/loader.js
CHANGED
|
@@ -22,6 +22,30 @@ function syncBundledPlugins() {
|
|
|
22
22
|
const globalDir = path.join(os.homedir(), ".fops", "plugins");
|
|
23
23
|
fs.mkdirSync(globalDir, { recursive: true });
|
|
24
24
|
|
|
25
|
+
// Lock to prevent concurrent sync from parallel fops processes
|
|
26
|
+
const lockFile = path.join(globalDir, ".sync.lock");
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
|
|
29
|
+
} catch {
|
|
30
|
+
// Lock exists — another process is syncing, skip
|
|
31
|
+
try {
|
|
32
|
+
const lockPid = parseInt(fs.readFileSync(lockFile, "utf8"));
|
|
33
|
+
const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs;
|
|
34
|
+
// Stale lock (>30s), take over
|
|
35
|
+
if (lockAge > 30_000) {
|
|
36
|
+
fs.writeFileSync(lockFile, String(process.pid));
|
|
37
|
+
} else {
|
|
38
|
+
return; // Another process is actively syncing
|
|
39
|
+
}
|
|
40
|
+
} catch { return; }
|
|
41
|
+
}
|
|
42
|
+
try { _doSync(bundledDir, globalDir); } finally {
|
|
43
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _doSync(bundledDir, globalDir) {
|
|
48
|
+
|
|
25
49
|
const rootPkg = path.join(globalDir, "package.json");
|
|
26
50
|
if (!fs.existsSync(rootPkg)) {
|
|
27
51
|
fs.writeFileSync(rootPkg, '{ "type": "module" }\n');
|
|
@@ -49,7 +73,16 @@ function syncBundledPlugins() {
|
|
|
49
73
|
const destDir = path.join(globalDir, entry.name);
|
|
50
74
|
|
|
51
75
|
// Always overwrite — bundled plugins must stay in sync with the CLI
|
|
52
|
-
fs.
|
|
76
|
+
try { fs.rmSync(destDir, { recursive: true, force: true }); } catch {}
|
|
77
|
+
try {
|
|
78
|
+
fs.cpSync(srcDir, destDir, { recursive: true, dereference: false, filter: (src) => {
|
|
79
|
+
// Skip node_modules to avoid broken symlinks and save time
|
|
80
|
+
return !src.includes("node_modules");
|
|
81
|
+
}});
|
|
82
|
+
} catch (cpErr) {
|
|
83
|
+
// Best-effort — skip plugins that fail to copy
|
|
84
|
+
if (process.env.DEBUG) console.error(` Plugin sync failed for ${entry.name}: ${cpErr.message}`);
|
|
85
|
+
}
|
|
53
86
|
}
|
|
54
87
|
}
|
|
55
88
|
|
package/src/plugins/registry.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
doctorCheckSchema,
|
|
15
15
|
knowledgeSourceSchema,
|
|
16
16
|
preflightSchema,
|
|
17
|
+
webPanelSchema,
|
|
17
18
|
mcpServerSchema,
|
|
18
19
|
formatZodError,
|
|
19
20
|
} from "./schemas.js";
|
|
@@ -47,6 +48,7 @@ export function createRegistry() {
|
|
|
47
48
|
tools: [], // { pluginId, name, description, inputSchema, execute, requiresConfirmation, annotations? }
|
|
48
49
|
preflights: [], // { pluginId, name, description, check }
|
|
49
50
|
services: [], // { pluginId, name, instance }
|
|
51
|
+
webPanels: [], // { pluginId, name, title, prefix, distDir, apiFactory? }
|
|
50
52
|
mcpConnections: [], // { name, client, transport, toolNames, status, error }
|
|
51
53
|
_pendingMcpServers: [], // queued by plugins via registerMcpServer(), connected in loader
|
|
52
54
|
_foundationCredentialPrompter: null,
|
|
@@ -141,6 +143,19 @@ export function createRegistry() {
|
|
|
141
143
|
return validated;
|
|
142
144
|
},
|
|
143
145
|
|
|
146
|
+
addWebPanel(panel) {
|
|
147
|
+
const validated = validate(webPanelSchema, panel, "web panel");
|
|
148
|
+
const existing = this.webPanels.find((p) => p.prefix === validated.prefix);
|
|
149
|
+
if (existing) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Web panel prefix "${validated.prefix}" is already registered by plugin "${existing.pluginId}". ` +
|
|
152
|
+
`Plugin "${validated.pluginId}" cannot register a duplicate.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
this.webPanels.push(validated);
|
|
156
|
+
return validated;
|
|
157
|
+
},
|
|
158
|
+
|
|
144
159
|
setFoundationCredentialPrompter(fn) {
|
|
145
160
|
this._foundationCredentialPrompter = fn;
|
|
146
161
|
},
|
package/src/plugins/schemas.js
CHANGED
|
@@ -108,6 +108,23 @@ export const preflightSchema = z.object({
|
|
|
108
108
|
),
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
// ── Web Panel ───────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export const webPanelSchema = z.object({
|
|
114
|
+
pluginId: z.string().min(1),
|
|
115
|
+
name: z.string().min(1, "Web panel name is required"),
|
|
116
|
+
title: z.string().min(1, "Web panel title is required"),
|
|
117
|
+
prefix: z.string().min(1, "Web panel prefix is required").refine(
|
|
118
|
+
(v) => v.startsWith("/"),
|
|
119
|
+
{ message: "prefix must start with /" },
|
|
120
|
+
),
|
|
121
|
+
distDir: z.string().min(1, "Web panel distDir is required"),
|
|
122
|
+
apiFactory: z.any().refine(
|
|
123
|
+
(v) => v === undefined || v === null || typeof v === "function",
|
|
124
|
+
{ message: "apiFactory must be a function or omitted" },
|
|
125
|
+
).optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
111
128
|
// ── MCP Server ───────────────────────────────────────────────────────
|
|
112
129
|
|
|
113
130
|
export const mcpServerSchema = z.object({
|
package/src/project.js
CHANGED
|
@@ -105,7 +105,7 @@ export function requireRoot(program) {
|
|
|
105
105
|
chalk.red("Not a Foundation project (no docker-compose + Makefile).")
|
|
106
106
|
);
|
|
107
107
|
console.error(chalk.dim(" Run `fops init` to set up, or set FOUNDATION_ROOT."));
|
|
108
|
-
program.error({ exitCode: 1 });
|
|
108
|
+
program.error("", { exitCode: 1 });
|
|
109
109
|
}
|
|
110
110
|
return r;
|
|
111
111
|
}
|
package/src/serve.js
CHANGED
|
@@ -39,6 +39,9 @@ const FOUNDATION_ENTITIES_TTL_MS = 60_000; // 1 min in-memory; file cache used f
|
|
|
39
39
|
* GET /api/test/compare Test result comparison from blob (delta, p50, p95)
|
|
40
40
|
* POST /api/fleet/scrape Trigger immediate fleet scrape
|
|
41
41
|
* POST /api/fleet/scrape/:vm Scrape a single VM
|
|
42
|
+
* GET /api/grafana/:env/prometheus Prometheus instant query (SSH → VM:9091)
|
|
43
|
+
* GET /api/grafana/:env/prometheus/range Prometheus range query
|
|
44
|
+
* GET /api/grafana/:env/loki Loki log query (SSH → VM:3100)
|
|
42
45
|
* GET /api/docs/vms List VMs available for API docs
|
|
43
46
|
* GET /docs Docs index page (HTML)
|
|
44
47
|
* GET /docs/:vm Swagger UI for a VM (HTML)
|
|
@@ -60,6 +63,68 @@ export function setFleetRegistry(fleet) {
|
|
|
60
63
|
|
|
61
64
|
const COMPOSE_ROOT_DEFAULT = process.env.COMPOSE_ROOT || "/opt/foundation-compose";
|
|
62
65
|
|
|
66
|
+
// ── Grafana SSH proxy helpers ─────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async function _resolveVm(envName) {
|
|
69
|
+
if (!_fleet) return null;
|
|
70
|
+
const vms = await _fleet._getTrackedVms();
|
|
71
|
+
return vms.find(v => v.vmName === envName) || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function _sshCurl(envName, localUrl) {
|
|
75
|
+
const vm = await _resolveVm(envName);
|
|
76
|
+
if (!vm) return { error: `VM "${envName}" not found` };
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const { lazyExeca, sshCmd, knockForVm } = await import(
|
|
80
|
+
"./plugins/bundled/fops-plugin-azure/lib/azure.js"
|
|
81
|
+
);
|
|
82
|
+
const execa = await lazyExeca();
|
|
83
|
+
const user = vm.adminUser || "azureuser";
|
|
84
|
+
await knockForVm(vm);
|
|
85
|
+
// Encode the URL to base64 to avoid shell escaping issues with brackets, pipes, etc.
|
|
86
|
+
const b64 = Buffer.from(localUrl).toString("base64");
|
|
87
|
+
const { stdout, exitCode } = await sshCmd(
|
|
88
|
+
execa, vm.publicIp, user,
|
|
89
|
+
`curl -sf "$(echo ${b64} | base64 -d)"`,
|
|
90
|
+
30000
|
|
91
|
+
);
|
|
92
|
+
if (exitCode !== 0) return { error: `SSH curl failed (exit ${exitCode})` };
|
|
93
|
+
return JSON.parse(stdout);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { error: err.message };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function _sshPromQuery(envName, query, time) {
|
|
100
|
+
const qs = new URLSearchParams({ query });
|
|
101
|
+
if (time) qs.set("time", time);
|
|
102
|
+
return _sshCurl(envName, `http://localhost:9091/api/v1/query?${qs.toString()}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function _sshPromRangeQuery(envName, query, start, end, step) {
|
|
106
|
+
const qs = new URLSearchParams({ query, start, end, step });
|
|
107
|
+
return _sshCurl(envName, `http://localhost:9091/api/v1/query_range?${qs.toString()}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function _sshLokiQuery(envName, query, limit, start, end) {
|
|
111
|
+
const qs = new URLSearchParams({ query, limit, start, end, direction: "backward" });
|
|
112
|
+
return _sshCurl(envName, `http://localhost:3100/loki/api/v1/query_range?${qs.toString()}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function _sshTempoSearch(envName, serviceName, limit) {
|
|
116
|
+
const qs = new URLSearchParams({ "service.name": serviceName, limit: String(limit) });
|
|
117
|
+
return _sshCurl(envName, `http://localhost:3200/api/search?${qs.toString()}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function _sshTempoTrace(envName, traceID) {
|
|
121
|
+
return _sshCurl(envName, `http://localhost:3200/api/traces/${traceID}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function _sshHealthCheck(envName, port, path = "/api/ping/json") {
|
|
125
|
+
return _sshCurl(envName, `http://localhost:${port}${path}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
63
128
|
/**
|
|
64
129
|
* Start embed index in the background so query data is warm on this node (e.g. each VM's fops-api).
|
|
65
130
|
* Skips when FOPS_SERVE_SKIP_EMBED_INDEX=1 or skipEmbedIndex option (e.g. when --scrape is used).
|
|
@@ -265,10 +330,12 @@ async function route(req, res, registry) {
|
|
|
265
330
|
const { execa } = await import("execa");
|
|
266
331
|
const args = command.split(/\s+/).filter(Boolean);
|
|
267
332
|
try {
|
|
333
|
+
const root = process.env.FOUNDATION_ROOT || COMPOSE_ROOT_DEFAULT;
|
|
268
334
|
const { stdout, stderr, exitCode } = await execa("fops", args, {
|
|
269
335
|
timeout: (parsed.timeout || 300) * 1000,
|
|
270
336
|
reject: false,
|
|
271
|
-
|
|
337
|
+
cwd: root,
|
|
338
|
+
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1", FOUNDATION_ROOT: root },
|
|
272
339
|
});
|
|
273
340
|
json(res, 200, { command, exitCode, stdout, stderr });
|
|
274
341
|
} catch (err) {
|
|
@@ -738,6 +805,105 @@ async function route(req, res, registry) {
|
|
|
738
805
|
return;
|
|
739
806
|
}
|
|
740
807
|
|
|
808
|
+
// ── Grafana proxy: SSH into VM → query Prometheus (:9091) / Loki (:3100) ──
|
|
809
|
+
|
|
810
|
+
// GET /api/grafana/:env/prometheus?query=...&time=...
|
|
811
|
+
const promMatch = method === "GET" && pathname.match(/^\/api\/grafana\/([^/]+)\/prometheus$/);
|
|
812
|
+
if (promMatch) {
|
|
813
|
+
if (!_fleet) {
|
|
814
|
+
json(res, 503, { error: "Fleet registry not active", message: "Start with: fops serve --scrape" });
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const envName = decodeURIComponent(promMatch[1]);
|
|
818
|
+
const query = url.searchParams.get("query");
|
|
819
|
+
if (!query) { json(res, 400, { error: "Missing ?query= parameter" }); return; }
|
|
820
|
+
const time = url.searchParams.get("time") || "";
|
|
821
|
+
const result = await _sshPromQuery(envName, query, time);
|
|
822
|
+
json(res, result.error ? 502 : 200, result);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// GET /api/grafana/:env/prometheus/range?query=...&start=...&end=...&step=...
|
|
827
|
+
const promRangeMatch = method === "GET" && pathname.match(/^\/api\/grafana\/([^/]+)\/prometheus\/range$/);
|
|
828
|
+
if (promRangeMatch) {
|
|
829
|
+
if (!_fleet) {
|
|
830
|
+
json(res, 503, { error: "Fleet registry not active", message: "Start with: fops serve --scrape" });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const envName = decodeURIComponent(promRangeMatch[1]);
|
|
834
|
+
const query = url.searchParams.get("query");
|
|
835
|
+
if (!query) { json(res, 400, { error: "Missing ?query= parameter" }); return; }
|
|
836
|
+
const start = url.searchParams.get("start") || String(Math.floor(Date.now() / 1000) - 1800);
|
|
837
|
+
const end = url.searchParams.get("end") || String(Math.floor(Date.now() / 1000));
|
|
838
|
+
const step = url.searchParams.get("step") || "60";
|
|
839
|
+
const result = await _sshPromRangeQuery(envName, query, start, end, step);
|
|
840
|
+
json(res, result.error ? 502 : 200, result);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// GET /api/grafana/:env/loki?query=...&limit=...
|
|
845
|
+
const lokiMatch = method === "GET" && pathname.match(/^\/api\/grafana\/([^/]+)\/loki$/);
|
|
846
|
+
if (lokiMatch) {
|
|
847
|
+
if (!_fleet) {
|
|
848
|
+
json(res, 503, { error: "Fleet registry not active", message: "Start with: fops serve --scrape" });
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const envName = decodeURIComponent(lokiMatch[1]);
|
|
852
|
+
const query = url.searchParams.get("query");
|
|
853
|
+
if (!query) { json(res, 400, { error: "Missing ?query= parameter" }); return; }
|
|
854
|
+
const limit = url.searchParams.get("limit") || "50";
|
|
855
|
+
const start = url.searchParams.get("start") || String((Date.now() - 30 * 60 * 1000) * 1_000_000);
|
|
856
|
+
const end = url.searchParams.get("end") || String(Date.now() * 1_000_000);
|
|
857
|
+
const result = await _sshLokiQuery(envName, query, limit, start, end);
|
|
858
|
+
json(res, result.error ? 502 : 200, result);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// GET /api/grafana/:env/tempo?service=...&limit=...
|
|
863
|
+
const tempoMatch = method === "GET" && pathname.match(/^\/api\/grafana\/([^/]+)\/tempo$/);
|
|
864
|
+
if (tempoMatch) {
|
|
865
|
+
if (!_fleet) {
|
|
866
|
+
json(res, 503, { error: "Fleet registry not active", message: "Start with: fops serve --scrape" });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const envName = decodeURIComponent(tempoMatch[1]);
|
|
870
|
+
const service = url.searchParams.get("service");
|
|
871
|
+
if (!service) { json(res, 400, { error: "Missing ?service= parameter" }); return; }
|
|
872
|
+
const limit = url.searchParams.get("limit") || "10";
|
|
873
|
+
const result = await _sshTempoSearch(envName, service, limit);
|
|
874
|
+
json(res, result.error ? 502 : 200, result);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// GET /api/grafana/:env/tempo/:traceID — get full trace detail
|
|
879
|
+
const tempoTraceMatch = method === "GET" && pathname.match(/^\/api\/grafana\/([^/]+)\/tempo\/([a-f0-9]+)$/);
|
|
880
|
+
if (tempoTraceMatch) {
|
|
881
|
+
if (!_fleet) {
|
|
882
|
+
json(res, 503, { error: "Fleet registry not active", message: "Start with: fops serve --scrape" });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const envName = decodeURIComponent(tempoTraceMatch[1]);
|
|
886
|
+
const traceID = tempoTraceMatch[2];
|
|
887
|
+
const result = await _sshTempoTrace(envName, traceID);
|
|
888
|
+
json(res, result.error ? 502 : 200, result);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// GET /api/grafana/:env/health/:port — health check a service on a VM
|
|
893
|
+
const healthMatch = method === "GET" && pathname.match(/^\/api\/grafana\/([^/]+)\/health\/(\d+)$/);
|
|
894
|
+
if (healthMatch) {
|
|
895
|
+
if (!_fleet) {
|
|
896
|
+
json(res, 503, { error: "Fleet registry not active", message: "Start with: fops serve --scrape" });
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const envName = decodeURIComponent(healthMatch[1]);
|
|
900
|
+
const port = healthMatch[2];
|
|
901
|
+
const healthPath = url.searchParams.get("path") || "/api/ping/json";
|
|
902
|
+
const result = await _sshHealthCheck(envName, port, healthPath);
|
|
903
|
+
json(res, result.error ? 502 : 200, result);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
741
907
|
// GET / — Admin dashboard (only when --admin flag is set)
|
|
742
908
|
if (method === "GET" && (pathname === "/" || pathname === "/index.html") && registry._adminEnabled) {
|
|
743
909
|
const html = getAdminHtml();
|
|
@@ -770,6 +936,9 @@ async function route(req, res, registry) {
|
|
|
770
936
|
"POST /api/fleet/scrape",
|
|
771
937
|
"POST /api/fleet/scrape/:vm",
|
|
772
938
|
"GET /api/foundation/entities",
|
|
939
|
+
"GET /api/grafana/:env/prometheus?query=...",
|
|
940
|
+
"GET /api/grafana/:env/prometheus/range?query=...&start=...&end=...&step=...",
|
|
941
|
+
"GET /api/grafana/:env/loki?query=...&limit=...",
|
|
773
942
|
"GET /api/docs/vms",
|
|
774
943
|
"GET /docs",
|
|
775
944
|
"GET /docs/:vm",
|
|
@@ -1034,7 +1203,32 @@ async function collectNodeSnapshot() {
|
|
|
1034
1203
|
featureFlags,
|
|
1035
1204
|
},
|
|
1036
1205
|
|
|
1037
|
-
services: {
|
|
1206
|
+
services: {
|
|
1207
|
+
backend,
|
|
1208
|
+
frontend,
|
|
1209
|
+
storage,
|
|
1210
|
+
// Extract version tags from container images for the fleet UI
|
|
1211
|
+
versions: (() => {
|
|
1212
|
+
const SERVICE_IMAGE_MAP = {
|
|
1213
|
+
be: "foundation-backend",
|
|
1214
|
+
fe: "foundation-frontend",
|
|
1215
|
+
pr: "foundation-processor",
|
|
1216
|
+
wa: "foundation-watcher",
|
|
1217
|
+
sc: "foundation-scheduler",
|
|
1218
|
+
se: "foundation-storage-engine",
|
|
1219
|
+
};
|
|
1220
|
+
const versions = {};
|
|
1221
|
+
for (const [key, imageName] of Object.entries(SERVICE_IMAGE_MAP)) {
|
|
1222
|
+
const c = containers.find((c) => c.image?.includes(imageName));
|
|
1223
|
+
if (c?.image) {
|
|
1224
|
+
const tag = c.image.split(":").pop() || "";
|
|
1225
|
+
const sha = c.image.includes("@sha256:") ? c.image.split("@sha256:")[1]?.substring(0, 7) : null;
|
|
1226
|
+
versions[key] = { tag, sha, health: c.health || c.state };
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return versions;
|
|
1230
|
+
})(),
|
|
1231
|
+
},
|
|
1038
1232
|
sessions: (() => {
|
|
1039
1233
|
const all = listLocalSessions();
|
|
1040
1234
|
return {
|
package/src/shell.js
CHANGED
|
@@ -1,9 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
1
4
|
let _execa;
|
|
2
5
|
async function lazyExeca() {
|
|
3
6
|
if (!_execa) _execa = (await import("execa")).execa;
|
|
4
7
|
return _execa;
|
|
5
8
|
}
|
|
6
9
|
|
|
10
|
+
function loadVersionsEnv(root) {
|
|
11
|
+
try {
|
|
12
|
+
const content = readFileSync(join(root, ".env.versions"), "utf8");
|
|
13
|
+
const env = {};
|
|
14
|
+
for (const line of content.split(/\r?\n/)) {
|
|
15
|
+
const trimmed = line.trim();
|
|
16
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
17
|
+
const eq = trimmed.indexOf("=");
|
|
18
|
+
if (eq > 0) env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
19
|
+
}
|
|
20
|
+
return env;
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
export async function retry(fn, { attempts = 3, delay = 1000, onRetry } = {}) {
|
|
8
27
|
let lastErr;
|
|
9
28
|
for (let i = 0; i < attempts; i++) {
|
|
@@ -44,5 +63,6 @@ export async function makeNormalized(root, target, args = []) {
|
|
|
44
63
|
|
|
45
64
|
export async function dockerCompose(root, args, { timeout } = {}) {
|
|
46
65
|
const execa = await lazyExeca();
|
|
47
|
-
|
|
66
|
+
const versionsEnv = loadVersionsEnv(root);
|
|
67
|
+
return execa("docker", ["compose", ...args], { cwd: root, env: { ...process.env, ...versionsEnv }, stdio: "inherit", reject: false, ...(timeout ? { timeout } : {}) });
|
|
48
68
|
}
|