@meshxdata/fops 0.1.51 → 0.1.53

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +207 -21
  2. package/package.json +2 -6
  3. package/src/agent/agent.js +6 -0
  4. package/src/commands/setup.js +34 -0
  5. package/src/doctor.js +11 -8
  6. package/src/fleet-registry.js +38 -2
  7. package/src/plugins/__test-fixtures__/fake-plugin.js +2 -0
  8. package/src/plugins/__test-fixtures__/no-register-plugin.js +2 -0
  9. package/src/plugins/__test-fixtures__/with-register/index.js +2 -0
  10. package/src/plugins/__test-fixtures__/without-register/index.js +2 -0
  11. package/src/plugins/api.js +4 -0
  12. package/src/plugins/builtins/docker-compose.js +59 -0
  13. package/src/plugins/bundled/fops-plugin-azure/index.js +4 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +53 -53
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +151 -0
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +2 -2
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-cost.js +52 -22
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +12 -4
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +6 -2
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +113 -7
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +13 -4
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +91 -14
  23. package/src/plugins/bundled/fops-plugin-azure/lib/azure-service.js +507 -0
  24. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +146 -7
  25. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
  26. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +28 -0
  27. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +61 -0
  28. package/src/plugins/bundled/fops-plugin-cloud/api.js +712 -0
  29. package/src/plugins/bundled/fops-plugin-cloud/fops.plugin.json +6 -0
  30. package/src/plugins/bundled/fops-plugin-cloud/index.js +208 -0
  31. package/src/plugins/bundled/fops-plugin-cloud/lib/azure-provider.js +81 -0
  32. package/src/plugins/bundled/fops-plugin-cloud/lib/provider.js +50 -0
  33. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/favicon-C49brna2.svg +15 -0
  34. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-CVqQ_kKW.js +65 -0
  35. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-DZetahP3.css +1 -0
  36. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/index.html +28 -0
  37. package/src/plugins/bundled/fops-plugin-cloud/ui/index.html +27 -0
  38. package/src/plugins/bundled/fops-plugin-cloud/ui/package-lock.json +2634 -0
  39. package/src/plugins/bundled/fops-plugin-cloud/ui/package.json +29 -0
  40. package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +5 -0
  41. package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +32 -0
  42. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +114 -0
  43. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +111 -0
  44. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +162 -0
  45. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +46 -0
  46. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +147 -0
  47. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +138 -0
  48. package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +15 -0
  49. package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +19 -0
  50. package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +25 -0
  51. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +164 -0
  52. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +305 -0
  53. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +285 -0
  54. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +307 -0
  55. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +229 -0
  56. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +132 -0
  57. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +174 -0
  58. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +21 -0
  59. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +170 -0
  60. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +49 -0
  61. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +37 -0
  62. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +116 -0
  63. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +63 -0
  64. package/src/plugins/bundled/fops-plugin-cloud/ui/vite.config.js +23 -0
  65. package/src/plugins/bundled/fops-plugin-foundation/test-helpers.js +65 -0
  66. package/src/plugins/loader.js +34 -1
  67. package/src/plugins/registry.js +15 -0
  68. package/src/plugins/schemas.js +17 -0
  69. package/src/project.js +1 -1
  70. package/src/serve.js +196 -2
  71. package/src/shell.js +21 -1
  72. package/src/web/admin.html.js +236 -0
  73. package/src/web/api.js +73 -0
  74. package/src/web/dist/assets/index-BphVaAUd.css +1 -0
  75. package/src/web/dist/assets/index-CSckLzuG.js +129 -0
  76. package/src/web/dist/index.html +2 -2
  77. package/src/web/frontend/index.html +16 -0
  78. package/src/web/frontend/src/App.jsx +445 -0
  79. package/src/web/frontend/src/components/ChatView.jsx +910 -0
  80. package/src/web/frontend/src/components/InputBox.jsx +523 -0
  81. package/src/web/frontend/src/components/Sidebar.jsx +410 -0
  82. package/src/web/frontend/src/components/StatusBar.jsx +37 -0
  83. package/src/web/frontend/src/components/TabBar.jsx +87 -0
  84. package/src/web/frontend/src/hooks/useWebSocket.js +412 -0
  85. package/src/web/frontend/src/index.css +78 -0
  86. package/src/web/frontend/src/main.jsx +6 -0
  87. package/src/web/frontend/vite.config.js +21 -0
  88. package/src/web/server.js +64 -1
  89. package/src/web/dist/assets/index-NXC8Hvnp.css +0 -1
  90. 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
+ }
@@ -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.cpSync(srcDir, destDir, { recursive: true });
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
 
@@ -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
  },
@@ -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
- env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
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: { backend, frontend, storage },
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
- return execa("docker", ["compose", ...args], { cwd: root, stdio: "inherit", reject: false, ...(timeout ? { timeout } : {}) });
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
  }