@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +559 -0
  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/fleet-registry.js +38 -2
  6. package/src/plugins/__test-fixtures__/fake-plugin.js +2 -0
  7. package/src/plugins/__test-fixtures__/no-register-plugin.js +2 -0
  8. package/src/plugins/__test-fixtures__/with-register/index.js +2 -0
  9. package/src/plugins/__test-fixtures__/without-register/index.js +2 -0
  10. package/src/plugins/api.js +4 -0
  11. package/src/plugins/builtins/docker-compose.js +65 -0
  12. package/src/plugins/bundled/fops-plugin-azure/index.js +4 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +44 -53
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +2 -2
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-cost.js +52 -22
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +6 -2
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +113 -7
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +13 -4
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +91 -14
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-service.js +507 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +146 -7
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
  23. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +61 -0
  24. package/src/plugins/bundled/fops-plugin-cloud/api.js +712 -0
  25. package/src/plugins/bundled/fops-plugin-cloud/fops.plugin.json +6 -0
  26. package/src/plugins/bundled/fops-plugin-cloud/index.js +208 -0
  27. package/src/plugins/bundled/fops-plugin-cloud/lib/azure-provider.js +81 -0
  28. package/src/plugins/bundled/fops-plugin-cloud/lib/provider.js +50 -0
  29. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/favicon-C49brna2.svg +15 -0
  30. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-CVqQ_kKW.js +65 -0
  31. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-DZetahP3.css +1 -0
  32. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/index.html +28 -0
  33. package/src/plugins/bundled/fops-plugin-cloud/ui/index.html +27 -0
  34. package/src/plugins/bundled/fops-plugin-cloud/ui/package-lock.json +2634 -0
  35. package/src/plugins/bundled/fops-plugin-cloud/ui/package.json +29 -0
  36. package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +5 -0
  37. package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +32 -0
  38. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +114 -0
  39. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +111 -0
  40. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +162 -0
  41. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +46 -0
  42. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +147 -0
  43. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +138 -0
  44. package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +15 -0
  45. package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +19 -0
  46. package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +25 -0
  47. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +164 -0
  48. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +305 -0
  49. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +285 -0
  50. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +307 -0
  51. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +229 -0
  52. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +132 -0
  53. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +174 -0
  54. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +21 -0
  55. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +170 -0
  56. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +49 -0
  57. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +37 -0
  58. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +116 -0
  59. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +63 -0
  60. package/src/plugins/bundled/fops-plugin-cloud/ui/vite.config.js +23 -0
  61. package/src/plugins/bundled/fops-plugin-foundation/test-helpers.js +65 -0
  62. package/src/plugins/loader.js +34 -1
  63. package/src/plugins/registry.js +15 -0
  64. package/src/plugins/schemas.js +17 -0
  65. package/src/project.js +1 -1
  66. package/src/serve.js +196 -2
  67. package/src/shell.js +21 -1
  68. package/src/web/admin.html.js +236 -0
  69. package/src/web/api.js +73 -0
  70. package/src/web/dist/assets/index-BphVaAUd.css +1 -0
  71. package/src/web/dist/assets/index-CSckLzuG.js +129 -0
  72. package/src/web/dist/index.html +2 -2
  73. package/src/web/frontend/index.html +16 -0
  74. package/src/web/frontend/src/App.jsx +445 -0
  75. package/src/web/frontend/src/components/ChatView.jsx +910 -0
  76. package/src/web/frontend/src/components/InputBox.jsx +523 -0
  77. package/src/web/frontend/src/components/Sidebar.jsx +410 -0
  78. package/src/web/frontend/src/components/StatusBar.jsx +37 -0
  79. package/src/web/frontend/src/components/TabBar.jsx +87 -0
  80. package/src/web/frontend/src/hooks/useWebSocket.js +412 -0
  81. package/src/web/frontend/src/index.css +78 -0
  82. package/src/web/frontend/src/main.jsx +6 -0
  83. package/src/web/frontend/vite.config.js +21 -0
  84. package/src/web/server.js +64 -1
  85. package/src/web/dist/assets/index-NXC8Hvnp.css +0 -1
  86. package/src/web/dist/assets/index-QH1N4ejK.js +0 -112
@@ -0,0 +1,305 @@
1
+ import React, { useState } from "react";
2
+ import Sidebar from "../partials/Sidebar";
3
+ import Header from "../partials/Header";
4
+ import { useCosts } from "../api/queries";
5
+
6
+ const CURRENCIES = [
7
+ { code: "USD", symbol: "$" },
8
+ { code: "EUR", symbol: "€" },
9
+ { code: "GBP", symbol: "£" },
10
+ { code: "CHF", symbol: "CHF" },
11
+ { code: "AED", symbol: "AED" },
12
+ ];
13
+
14
+ function formatCost(amount, currency = "USD") {
15
+ const cur = CURRENCIES.find((c) => c.code === currency) || CURRENCIES[0];
16
+ return `${cur.symbol} ${parseFloat(amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
17
+ }
18
+
19
+ function exportCsv(vmCosts, clusterCosts, serviceCosts, currency) {
20
+ const lines = ["Type,Name,Amount,Currency"];
21
+ for (const c of vmCosts) lines.push(`VM,"${c.name}",${c.amount},${currency}`);
22
+ for (const c of clusterCosts) lines.push(`Cluster,"${c.name}",${c.amount},${currency}`);
23
+ for (const c of serviceCosts) lines.push(`Service,"${c.name}",${c.amount},${currency}`);
24
+ const blob = new Blob([lines.join("\n")], { type: "text/csv" });
25
+ const url = URL.createObjectURL(blob);
26
+ const a = document.createElement("a");
27
+ a.href = url;
28
+ a.download = `cost-report-${new Date().toISOString().slice(0, 10)}.csv`;
29
+ a.click();
30
+ URL.revokeObjectURL(url);
31
+ }
32
+
33
+ function CostBar({ amount, max, currency }) {
34
+ const pct = max > 0 ? (amount / max) * 100 : 0;
35
+ return (
36
+ <div className="flex items-center gap-3 w-full">
37
+ <div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
38
+ <div className="h-full bg-violet-500 rounded-full" style={{ width: `${Math.max(pct, 1)}%` }} />
39
+ </div>
40
+ <span className="text-sm font-mono text-gray-800 dark:text-gray-100 whitespace-nowrap w-28 text-right">{formatCost(amount, currency)}</span>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ function Costs() {
46
+ const [sidebarOpen, setSidebarOpen] = useState(false);
47
+ const [days, setDays] = useState(30);
48
+ const [selectedCurrency, setSelectedCurrency] = useState("USD");
49
+ const { data, isLoading, error } = useCosts(days);
50
+
51
+ // Merge data from all providers
52
+ const allVmCosts = [];
53
+ const allClusterCosts = [];
54
+ const allServiceCosts = [];
55
+ let currency = "USD";
56
+ let costError = null;
57
+ let cachedAt = null;
58
+
59
+ if (data) {
60
+ for (const [, providerData] of Object.entries(data)) {
61
+ if (providerData.error) {
62
+ costError = providerData.error;
63
+ continue;
64
+ }
65
+ if (providerData.currency) currency = providerData.currency;
66
+ if (providerData.fromCache && providerData.cachedAt) {
67
+ cachedAt = new Date(providerData.cachedAt);
68
+ }
69
+
70
+ for (const [name, amount] of Object.entries(providerData.vmCosts || {})) {
71
+ allVmCosts.push({ name, amount });
72
+ }
73
+ for (const [name, amount] of Object.entries(providerData.clusterCosts || {})) {
74
+ allClusterCosts.push({ name, amount });
75
+ }
76
+ for (const [name, amount] of Object.entries(providerData.byService || {})) {
77
+ allServiceCosts.push({ name, amount });
78
+ }
79
+ }
80
+ }
81
+
82
+ allVmCosts.sort((a, b) => b.amount - a.amount);
83
+ allClusterCosts.sort((a, b) => b.amount - a.amount);
84
+ allServiceCosts.sort((a, b) => b.amount - a.amount);
85
+
86
+ const vmTotal = allVmCosts.reduce((s, c) => s + c.amount, 0);
87
+ const clusterTotal = allClusterCosts.reduce((s, c) => s + c.amount, 0);
88
+ const serviceTotal = allServiceCosts.reduce((s, c) => s + c.amount, 0);
89
+ const maxVm = allVmCosts[0]?.amount || 0;
90
+ const maxCluster = allClusterCosts[0]?.amount || 0;
91
+ const maxService = allServiceCosts[0]?.amount || 0;
92
+
93
+ const hasData = allVmCosts.length > 0 || allClusterCosts.length > 0 || allServiceCosts.length > 0;
94
+
95
+ const thClass = "px-5 py-3 font-semibold text-left whitespace-nowrap";
96
+
97
+ return (
98
+ <div className="flex h-screen overflow-hidden">
99
+ <Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
100
+ <div className="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden">
101
+ <Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
102
+ <main className="grow">
103
+ <div className="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
104
+ <div className="sm:flex sm:justify-between sm:items-center mb-8">
105
+ <div className="mb-4 sm:mb-0">
106
+ <h1 className="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">Costs</h1>
107
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Azure Cost Management data for tracked resources</p>
108
+ </div>
109
+ <div className="flex items-center gap-2">
110
+ {cachedAt && (
111
+ <span className="text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 px-2 py-1 rounded-lg">
112
+ Cached {cachedAt.toLocaleString()}
113
+ </span>
114
+ )}
115
+ <select
116
+ className="form-select bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 text-sm text-gray-600 dark:text-gray-300 rounded-lg"
117
+ value={days}
118
+ onChange={(e) => setDays(Number(e.target.value))}
119
+ >
120
+ <option value={7}>Last 7 days</option>
121
+ <option value={14}>Last 14 days</option>
122
+ <option value={30}>Last 30 days</option>
123
+ <option value={60}>Last 60 days</option>
124
+ <option value={90}>Last 90 days</option>
125
+ </select>
126
+ <select
127
+ className="form-select bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 text-sm text-gray-600 dark:text-gray-300 rounded-lg"
128
+ value={selectedCurrency}
129
+ onChange={(e) => setSelectedCurrency(e.target.value)}
130
+ >
131
+ {CURRENCIES.map((c) => (
132
+ <option key={c.code} value={c.code}>{c.code} ({c.symbol})</option>
133
+ ))}
134
+ </select>
135
+ <button
136
+ className="btn bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 text-gray-600 dark:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
137
+ onClick={() => exportCsv(allVmCosts, allClusterCosts, allServiceCosts, selectedCurrency)}
138
+ disabled={isLoading || !hasData}
139
+ >
140
+ <svg className="shrink-0 fill-current mr-1" width="16" height="16" viewBox="0 0 16 16"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 1 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1ZM2 13a1 1 0 0 1 1 1h10a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1v0a1 1 0 0 1 1-1Z"/></svg>
141
+ Export CSV
142
+ </button>
143
+ </div>
144
+ </div>
145
+
146
+ {isLoading ? (
147
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl text-sm text-gray-400 dark:text-gray-500 text-center py-12">
148
+ Loading cost data... This may take a moment.
149
+ </div>
150
+ ) : error ? (
151
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl text-sm text-red-500 text-center py-12">
152
+ Failed to load costs: {error.message}
153
+ </div>
154
+ ) : costError && !hasData ? (
155
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl text-sm text-center py-12 px-5">
156
+ <div className="text-red-500 mb-2">Cost query failed: {costError}</div>
157
+ <div className="text-gray-400 dark:text-gray-500">
158
+ Make sure you are logged into Azure (<code className="text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded font-mono">az login</code>) and have Cost Management access.
159
+ </div>
160
+ </div>
161
+ ) : !hasData ? (
162
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl text-sm text-gray-400 dark:text-gray-500 text-center py-12">
163
+ No cost data available for the selected period.
164
+ </div>
165
+ ) : (
166
+ <div className="space-y-6">
167
+ {/* Summary cards */}
168
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
169
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl px-5 py-4">
170
+ <div className="text-xs uppercase text-gray-400 dark:text-gray-500 font-semibold mb-1">VM Total ({days}d)</div>
171
+ <div className="text-2xl font-bold text-gray-800 dark:text-gray-100">{formatCost(vmTotal, selectedCurrency)}</div>
172
+ <div className="text-xs text-gray-400 dark:text-gray-500 mt-1">{allVmCosts.length} VM(s)</div>
173
+ </div>
174
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl px-5 py-4">
175
+ <div className="text-xs uppercase text-gray-400 dark:text-gray-500 font-semibold mb-1">Cluster Total ({days}d)</div>
176
+ <div className="text-2xl font-bold text-gray-800 dark:text-gray-100">{formatCost(clusterTotal, selectedCurrency)}</div>
177
+ <div className="text-xs text-gray-400 dark:text-gray-500 mt-1">{allClusterCosts.length} cluster(s)</div>
178
+ </div>
179
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl px-5 py-4">
180
+ <div className="text-xs uppercase text-gray-400 dark:text-gray-500 font-semibold mb-1">Subscription Total ({days}d)</div>
181
+ <div className="text-2xl font-bold text-gray-800 dark:text-gray-100">{formatCost(serviceTotal, selectedCurrency)}</div>
182
+ <div className="text-xs text-gray-400 dark:text-gray-500 mt-1">{allServiceCosts.length} service(s)</div>
183
+ </div>
184
+ </div>
185
+
186
+ {/* VM costs */}
187
+ {allVmCosts.length > 0 && (
188
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
189
+ <header className="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
190
+ <h2 className="font-semibold text-gray-800 dark:text-gray-100">Cost by VM</h2>
191
+ </header>
192
+ <div className="overflow-x-auto">
193
+ <table className="table-auto w-full dark:text-gray-300">
194
+ <thead className="text-xs uppercase text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-900/20 border-t border-b border-gray-100 dark:border-gray-700/60">
195
+ <tr>
196
+ <th className={thClass}>VM</th>
197
+ <th className={`${thClass} w-full`}>Cost ({days}d)</th>
198
+ <th className="px-5 py-3 font-semibold text-right whitespace-nowrap">% of Total</th>
199
+ </tr>
200
+ </thead>
201
+ <tbody className="text-sm divide-y divide-gray-100 dark:divide-gray-700/60">
202
+ {allVmCosts.map((c) => (
203
+ <tr key={c.name}>
204
+ <td className="px-5 py-3 whitespace-nowrap font-medium text-gray-800 dark:text-gray-100">{c.name}</td>
205
+ <td className="px-5 py-3"><CostBar amount={c.amount} max={maxVm} currency={selectedCurrency} /></td>
206
+ <td className="px-5 py-3 whitespace-nowrap text-right text-gray-500 dark:text-gray-400 font-mono text-xs">{vmTotal > 0 ? ((c.amount / vmTotal) * 100).toFixed(1) : "0.0"}%</td>
207
+ </tr>
208
+ ))}
209
+ </tbody>
210
+ <tfoot>
211
+ <tr className="border-t border-gray-200 dark:border-gray-700">
212
+ <td className="px-5 py-3 font-semibold text-gray-800 dark:text-gray-100">Total</td>
213
+ <td className="px-5 py-3 font-mono font-semibold text-gray-800 dark:text-gray-100 text-right" colSpan="2">{formatCost(vmTotal, selectedCurrency)}</td>
214
+ </tr>
215
+ </tfoot>
216
+ </table>
217
+ </div>
218
+ </div>
219
+ )}
220
+
221
+ {/* Cluster costs */}
222
+ {allClusterCosts.length > 0 && (
223
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
224
+ <header className="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
225
+ <h2 className="font-semibold text-gray-800 dark:text-gray-100">Cost by Cluster</h2>
226
+ </header>
227
+ <div className="overflow-x-auto">
228
+ <table className="table-auto w-full dark:text-gray-300">
229
+ <thead className="text-xs uppercase text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-900/20 border-t border-b border-gray-100 dark:border-gray-700/60">
230
+ <tr>
231
+ <th className={thClass}>Cluster</th>
232
+ <th className={`${thClass} w-full`}>Cost ({days}d)</th>
233
+ <th className="px-5 py-3 font-semibold text-right whitespace-nowrap">% of Total</th>
234
+ </tr>
235
+ </thead>
236
+ <tbody className="text-sm divide-y divide-gray-100 dark:divide-gray-700/60">
237
+ {allClusterCosts.map((c) => (
238
+ <tr key={c.name}>
239
+ <td className="px-5 py-3 whitespace-nowrap font-medium text-gray-800 dark:text-gray-100">{c.name}</td>
240
+ <td className="px-5 py-3"><CostBar amount={c.amount} max={maxCluster} currency={selectedCurrency} /></td>
241
+ <td className="px-5 py-3 whitespace-nowrap text-right text-gray-500 dark:text-gray-400 font-mono text-xs">{clusterTotal > 0 ? ((c.amount / clusterTotal) * 100).toFixed(1) : "0.0"}%</td>
242
+ </tr>
243
+ ))}
244
+ </tbody>
245
+ <tfoot>
246
+ <tr className="border-t border-gray-200 dark:border-gray-700">
247
+ <td className="px-5 py-3 font-semibold text-gray-800 dark:text-gray-100">Total</td>
248
+ <td className="px-5 py-3 font-mono font-semibold text-gray-800 dark:text-gray-100 text-right" colSpan="2">{formatCost(clusterTotal, selectedCurrency)}</td>
249
+ </tr>
250
+ </tfoot>
251
+ </table>
252
+ </div>
253
+ </div>
254
+ )}
255
+
256
+ {/* Cost by service */}
257
+ {allServiceCosts.length > 0 && (
258
+ <div className="bg-white dark:bg-gray-800 shadow-xs rounded-xl">
259
+ <header className="px-5 py-4 border-b border-gray-100 dark:border-gray-700/60">
260
+ <h2 className="font-semibold text-gray-800 dark:text-gray-100">Cost by Azure Service</h2>
261
+ </header>
262
+ <div className="overflow-x-auto">
263
+ <table className="table-auto w-full dark:text-gray-300">
264
+ <thead className="text-xs uppercase text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-900/20 border-t border-b border-gray-100 dark:border-gray-700/60">
265
+ <tr>
266
+ <th className={thClass}>Service</th>
267
+ <th className={`${thClass} w-full`}>Cost ({days}d)</th>
268
+ <th className="px-5 py-3 font-semibold text-right whitespace-nowrap">% of Total</th>
269
+ </tr>
270
+ </thead>
271
+ <tbody className="text-sm divide-y divide-gray-100 dark:divide-gray-700/60">
272
+ {allServiceCosts.map((c) => (
273
+ <tr key={c.name}>
274
+ <td className="px-5 py-3 whitespace-nowrap font-medium text-gray-800 dark:text-gray-100">{c.name}</td>
275
+ <td className="px-5 py-3"><CostBar amount={c.amount} max={maxService} currency={selectedCurrency} /></td>
276
+ <td className="px-5 py-3 whitespace-nowrap text-right text-gray-500 dark:text-gray-400 font-mono text-xs">{serviceTotal > 0 ? ((c.amount / serviceTotal) * 100).toFixed(1) : "0.0"}%</td>
277
+ </tr>
278
+ ))}
279
+ </tbody>
280
+ <tfoot>
281
+ <tr className="border-t border-gray-200 dark:border-gray-700">
282
+ <td className="px-5 py-3 font-semibold text-gray-800 dark:text-gray-100">Total</td>
283
+ <td className="px-5 py-3 font-mono font-semibold text-gray-800 dark:text-gray-100 text-right" colSpan="2">{formatCost(serviceTotal, selectedCurrency)}</td>
284
+ </tr>
285
+ </tfoot>
286
+ </table>
287
+ </div>
288
+ </div>
289
+ )}
290
+
291
+ {costError && (
292
+ <div className="text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-500/10 rounded-lg px-4 py-2">
293
+ Warning: {costError}
294
+ </div>
295
+ )}
296
+ </div>
297
+ )}
298
+ </div>
299
+ </main>
300
+ </div>
301
+ </div>
302
+ );
303
+ }
304
+
305
+ export default Costs;
@@ -0,0 +1,285 @@
1
+ import React, { useState, useCallback, useEffect, useRef } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import Sidebar from "../partials/Sidebar";
4
+ import Header from "../partials/Header";
5
+ import LogPanel from "../components/LogPanel";
6
+ import { useCreateResource } from "../api/queries";
7
+ import { pollJob } from "../api/client";
8
+
9
+ const STORAGE_KEY = "cloud-provision-job";
10
+
11
+ const LOCATIONS = [
12
+ { value: "uaenorth", label: "UAE North", flag: "🇦🇪" },
13
+ { value: "eastus", label: "East US", flag: "🇺🇸" },
14
+ { value: "westeurope", label: "West Europe", flag: "🇪🇺" },
15
+ { value: "northeurope", label: "North Europe", flag: "🇪🇺" },
16
+ { value: "southeastasia", label: "Southeast Asia", flag: "🇸🇬" },
17
+ ];
18
+ const VM_SIZES = [
19
+ { value: "Standard_B2s", label: "B2s", spec: "2 vCPU · 4 GiB" },
20
+ { value: "Standard_B2ms", label: "B2ms", spec: "2 vCPU · 8 GiB" },
21
+ { value: "Standard_D2s_v5", label: "D2s v5", spec: "2 vCPU · 8 GiB" },
22
+ { value: "Standard_D4s_v5", label: "D4s v5", spec: "4 vCPU · 16 GiB" },
23
+ ];
24
+
25
+ const ALL_STEPS = ["Type", "Configure", "Review", "Deploy"];
26
+
27
+ // Shared tile class for selectable options
28
+ const tileBase = "text-left rounded-lg border transition-all cursor-pointer";
29
+ const tileActive = "border-violet-500 bg-violet-500/10 shadow-sm shadow-violet-500/10";
30
+ const tileInactive = "border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 bg-gray-50 dark:bg-gray-900/50";
31
+ const tile = (active) => `${tileBase} ${active ? tileActive : tileInactive}`;
32
+
33
+ function CreateResource() {
34
+ const [sidebarOpen, setSidebarOpen] = useState(false);
35
+ const navigate = useNavigate();
36
+ const createResource = useCreateResource();
37
+ const [step, setStep] = useState(0);
38
+ const [form, setForm] = useState({ type: "vm", vmName: "", location: "uaenorth", vmSize: "Standard_B2s", resourceGroup: "", clusterName: "", nodeCount: 2, kubernetesVersion: "1.30" });
39
+ const [logs, setLogs] = useState([]);
40
+ const [reconnecting, setReconnecting] = useState(false);
41
+ const [reconnectDone, setReconnectDone] = useState(false);
42
+ const pollingRef = useRef(false);
43
+ const set = (f) => (e) => setForm((p) => ({ ...p, [f]: e.target.value }));
44
+ const canProceed = step === 0 ? true : step === 1 ? (form.type === "vm" ? form.vmName.length >= 3 : form.clusterName.length >= 3) : true;
45
+
46
+ const onLine = useCallback((text, type) => setLogs((prev) => [...prev, { text, type }]), []);
47
+ const onJobId = useCallback((jobId) => localStorage.setItem(STORAGE_KEY, jobId), []);
48
+
49
+ useEffect(() => {
50
+ const savedJobId = localStorage.getItem(STORAGE_KEY);
51
+ if (!savedJobId || pollingRef.current) return;
52
+ pollingRef.current = true;
53
+ setStep(3);
54
+ setReconnecting(true);
55
+ pollJob(savedJobId, (text, type) => setLogs((prev) => [...prev, { text, type }]))
56
+ .then(() => { setReconnecting(false); setReconnectDone(true); localStorage.removeItem(STORAGE_KEY); })
57
+ .catch(() => { localStorage.removeItem(STORAGE_KEY); setReconnecting(false); setStep(0); pollingRef.current = false; });
58
+ }, []);
59
+
60
+ const handleCreate = () => {
61
+ const body = form.type === "vm"
62
+ ? { type: "vm", vmName: form.vmName, location: form.location, vmSize: form.vmSize, resourceGroup: form.resourceGroup || undefined }
63
+ : { type: "cluster", clusterName: form.clusterName, location: form.location, nodeCount: Number(form.nodeCount), kubernetesVersion: form.kubernetesVersion };
64
+ setLogs([]);
65
+ setStep(3);
66
+ createResource.mutate({ body, onLine, onJobId }, { onSuccess: () => localStorage.removeItem(STORAGE_KEY) });
67
+ };
68
+
69
+ const isRunning = reconnecting || createResource.isPending;
70
+ const isDone = reconnectDone || (!createResource.isPending && !createResource.isError && logs.length > 0);
71
+
72
+ return (
73
+ <div className="flex h-screen overflow-hidden">
74
+ <Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
75
+ <div className="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden">
76
+ <Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
77
+ <main className="grow">
78
+ <div className="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-4xl mx-auto">
79
+
80
+ {/* Pipeline header */}
81
+ <div className="flex items-center justify-between mb-6">
82
+ <div>
83
+ <h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">Foundation Factory</h1>
84
+ <p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">Provision a Foundation environment</p>
85
+ </div>
86
+ {step < 3 && (
87
+ <button className="btn-sm border border-gray-200 dark:border-gray-700/60 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded-lg" onClick={() => navigate("/")}>Cancel</button>
88
+ )}
89
+ </div>
90
+
91
+ {/* Pipeline stepper */}
92
+ <div className="flex items-center mb-8">
93
+ {ALL_STEPS.map((s, i) => {
94
+ const done = i < step || (i === 3 && isDone);
95
+ const active = i === step && !(i === 3 && isDone);
96
+ return (
97
+ <React.Fragment key={s}>
98
+ <div className="flex items-center gap-2">
99
+ <div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all duration-300 ${
100
+ done ? "bg-green-500 text-white shadow-sm shadow-green-500/30"
101
+ : active ? "bg-violet-500 text-white shadow-sm shadow-violet-500/30 ring-4 ring-violet-500/20"
102
+ : "bg-gray-200 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-300 dark:border-gray-700/60"
103
+ }`}>
104
+ {done ? "✓" : i + 1}
105
+ </div>
106
+ <span className={`text-sm font-medium hidden sm:inline ${
107
+ done ? "text-green-600 dark:text-green-500" : active ? "text-gray-800 dark:text-gray-100" : "text-gray-400 dark:text-gray-600"
108
+ }`}>{s}</span>
109
+ </div>
110
+ {i < ALL_STEPS.length - 1 && (
111
+ <div className={`flex-1 h-px mx-3 transition-colors duration-500 ${i < step ? "bg-green-500/40" : "bg-gray-200 dark:bg-gray-700/60"}`} />
112
+ )}
113
+ </React.Fragment>
114
+ );
115
+ })}
116
+ </div>
117
+
118
+ {/* Content card */}
119
+ <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700/60 shadow-xs rounded-xl overflow-hidden">
120
+
121
+ {/* Step 0: Type */}
122
+ {step === 0 && (
123
+ <div className="p-6">
124
+ <h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Select Target</h2>
125
+ <div className="grid grid-cols-2 gap-4">
126
+ {[
127
+ { type: "vm", label: "Virtual Machine", desc: "Single-node Foundation on Azure VM", icon: "🖥" },
128
+ { type: "cluster", label: "AKS Cluster", desc: "Distributed Foundation on managed Kubernetes", icon: "☸" },
129
+ ].map((opt) => (
130
+ <button key={opt.type} onClick={() => setForm((p) => ({ ...p, type: opt.type }))} className={`${tile(form.type === opt.type)} p-5`}>
131
+ <div className="text-2xl mb-3">{opt.icon}</div>
132
+ <div className="text-sm font-semibold text-gray-800 dark:text-gray-100">{opt.label}</div>
133
+ <div className="text-xs text-gray-500 mt-1">{opt.desc}</div>
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </div>
138
+ )}
139
+
140
+ {/* Step 1: VM Config */}
141
+ {step === 1 && form.type === "vm" && (
142
+ <div className="p-6 space-y-5">
143
+ <h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">VM Configuration</h2>
144
+ <Field label="Name" hint="Unique identifier for this VM">
145
+ <input className="field-input" placeholder="my-foundation-vm" value={form.vmName} onChange={set("vmName")} />
146
+ </Field>
147
+ <Field label="Region">
148
+ <div className="grid grid-cols-3 gap-2">
149
+ {LOCATIONS.map((l) => (
150
+ <button key={l.value} onClick={() => setForm((p) => ({ ...p, location: l.value }))} className={`${tile(form.location === l.value)} px-3 py-2.5 text-sm`}>
151
+ <span className="mr-1.5">{l.flag}</span>
152
+ <span className={form.location === l.value ? "text-gray-800 dark:text-gray-100" : "text-gray-600 dark:text-gray-400"}>{l.label}</span>
153
+ </button>
154
+ ))}
155
+ </div>
156
+ </Field>
157
+ <Field label="Size">
158
+ <div className="grid grid-cols-2 gap-2">
159
+ {VM_SIZES.map((s) => (
160
+ <button key={s.value} onClick={() => setForm((p) => ({ ...p, vmSize: s.value }))} className={`${tile(form.vmSize === s.value)} px-3 py-2.5 text-sm`}>
161
+ <div className={`font-medium ${form.vmSize === s.value ? "text-gray-800 dark:text-gray-100" : "text-gray-700 dark:text-gray-200"}`}>{s.label}</div>
162
+ <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{s.spec}</div>
163
+ </button>
164
+ ))}
165
+ </div>
166
+ </Field>
167
+ <Field label="Resource Group" hint="Leave empty for auto-generated">
168
+ <input className="field-input" placeholder="FOUNDATION-VM-RG" value={form.resourceGroup} onChange={set("resourceGroup")} />
169
+ </Field>
170
+ </div>
171
+ )}
172
+
173
+ {/* Step 1: Cluster Config */}
174
+ {step === 1 && form.type === "cluster" && (
175
+ <div className="p-6 space-y-5">
176
+ <h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">Cluster Configuration</h2>
177
+ <Field label="Cluster Name">
178
+ <input className="field-input" placeholder="my-aks-cluster" value={form.clusterName} onChange={set("clusterName")} />
179
+ </Field>
180
+ <Field label="Region">
181
+ <div className="grid grid-cols-3 gap-2">
182
+ {LOCATIONS.map((l) => (
183
+ <button key={l.value} onClick={() => setForm((p) => ({ ...p, location: l.value }))} className={`${tile(form.location === l.value)} px-3 py-2.5 text-sm`}>
184
+ <span className="mr-1.5">{l.flag}</span>
185
+ <span className={form.location === l.value ? "text-gray-800 dark:text-gray-100" : "text-gray-600 dark:text-gray-400"}>{l.label}</span>
186
+ </button>
187
+ ))}
188
+ </div>
189
+ </Field>
190
+ <Field label="Node Count">
191
+ <input className="field-input" type="number" min={1} max={20} value={form.nodeCount} onChange={set("nodeCount")} />
192
+ </Field>
193
+ <Field label="Kubernetes Version">
194
+ <input className="field-input" placeholder="1.30" value={form.kubernetesVersion} onChange={set("kubernetesVersion")} />
195
+ </Field>
196
+ </div>
197
+ )}
198
+
199
+ {/* Step 2: Review */}
200
+ {step === 2 && (
201
+ <div className="p-6">
202
+ <h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">Review Deployment</h2>
203
+ <div className="bg-gray-50 dark:bg-gray-900/60 rounded-lg border border-gray-200 dark:border-gray-700/60 divide-y divide-gray-200 dark:divide-gray-700/60">
204
+ {(form.type === "vm"
205
+ ? [
206
+ ["Target", "Virtual Machine"],
207
+ ["Name", form.vmName],
208
+ ["Region", LOCATIONS.find(l => l.value === form.location)?.label || form.location],
209
+ ["Size", (VM_SIZES.find(s => s.value === form.vmSize)?.label || "") + " — " + (VM_SIZES.find(s => s.value === form.vmSize)?.spec || form.vmSize)],
210
+ ...(form.resourceGroup ? [["Resource Group", form.resourceGroup]] : []),
211
+ ]
212
+ : [
213
+ ["Target", "AKS Cluster"],
214
+ ["Name", form.clusterName],
215
+ ["Region", LOCATIONS.find(l => l.value === form.location)?.label || form.location],
216
+ ["Nodes", String(form.nodeCount)],
217
+ ["K8s Version", form.kubernetesVersion],
218
+ ]
219
+ ).map(([label, value]) => (
220
+ <div key={label} className="flex justify-between items-center px-4 py-3">
221
+ <span className="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider">{label}</span>
222
+ <span className="text-sm font-mono text-gray-800 dark:text-gray-200">{value}</span>
223
+ </div>
224
+ ))}
225
+ </div>
226
+ {createResource.isError && <p className="text-sm text-red-500 mt-4">{createResource.error?.message || "Failed"}</p>}
227
+ </div>
228
+ )}
229
+
230
+ {/* Step 3: Deploy */}
231
+ {step === 3 && (
232
+ <div className="p-6">
233
+ <div className="flex items-center gap-3 mb-1">
234
+ {isRunning && <div className="w-4 h-4 border-2 border-violet-500 border-t-transparent rounded-full animate-spin" />}
235
+ <h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
236
+ {isRunning ? (reconnecting ? "Reconnecting..." : "Deploying") : isDone ? "Deployment Complete" : "Failed"}
237
+ </h2>
238
+ </div>
239
+ <LogPanel lines={logs} title="" isDone={isDone} showPhases />
240
+ {!isRunning && (
241
+ <div className="mt-5">
242
+ <button className="btn bg-violet-500 text-white hover:bg-violet-600" onClick={() => navigate("/")}>Go to Registry</button>
243
+ </div>
244
+ )}
245
+ </div>
246
+ )}
247
+
248
+ {/* Nav bar */}
249
+ {step < 3 && (
250
+ <div className="flex justify-between items-center px-6 py-4 border-t border-gray-100 dark:border-gray-700/60 bg-gray-50 dark:bg-gray-900/40">
251
+ <button
252
+ className="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition disabled:opacity-30"
253
+ onClick={() => setStep((s) => s - 1)}
254
+ disabled={step === 0}
255
+ >&larr; Back</button>
256
+ {step < 2 ? (
257
+ <button className="btn bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-40" onClick={() => setStep((s) => s + 1)} disabled={!canProceed}>
258
+ Continue &rarr;
259
+ </button>
260
+ ) : (
261
+ <button className="btn bg-green-600 text-white hover:bg-green-500" onClick={handleCreate}>
262
+ Deploy &rarr;
263
+ </button>
264
+ )}
265
+ </div>
266
+ )}
267
+ </div>
268
+ </div>
269
+ </main>
270
+ </div>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ function Field({ label, hint, children }) {
276
+ return (
277
+ <div>
278
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{label}</label>
279
+ {children}
280
+ {hint && <p className="text-xs text-gray-400 dark:text-gray-600 mt-1.5">{hint}</p>}
281
+ </div>
282
+ );
283
+ }
284
+
285
+ export default CreateResource;