@meshxdata/fops 0.1.55 → 0.1.57

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 (34) hide show
  1. package/CHANGELOG.md +4 -4
  2. package/package.json +1 -2
  3. package/src/commands/index.js +2 -0
  4. package/src/commands/k3s-cmd.js +124 -0
  5. package/src/commands/lifecycle.js +7 -0
  6. package/src/plugins/builtins/docker-compose.js +17 -35
  7. package/src/plugins/bundled/fops-plugin-azure/lib/azure-openai.js +0 -3
  8. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +5 -2
  9. package/src/plugins/bundled/fops-plugin-cloud/api.js +14 -0
  10. package/src/project.js +12 -7
  11. package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +0 -5
  12. package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +0 -32
  13. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +0 -114
  14. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +0 -111
  15. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +0 -162
  16. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +0 -46
  17. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +0 -147
  18. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +0 -138
  19. package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +0 -15
  20. package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +0 -19
  21. package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +0 -25
  22. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +0 -164
  23. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +0 -305
  24. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +0 -285
  25. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +0 -307
  26. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +0 -229
  27. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +0 -132
  28. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +0 -174
  29. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +0 -21
  30. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +0 -170
  31. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +0 -49
  32. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +0 -37
  33. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +0 -116
  34. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +0 -63
@@ -1,164 +0,0 @@
1
- import React, { useState } from "react";
2
- import Sidebar from "../partials/Sidebar";
3
- import Header from "../partials/Header";
4
- import { useAudit } from "../api/queries";
5
-
6
- function SeverityBadge({ severity }) {
7
- const cls = severity === "warn"
8
- ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
9
- : "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400";
10
- return <span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${cls}`}>{severity}</span>;
11
- }
12
-
13
- function FindingsTable({ findings }) {
14
- if (!findings?.length) return <p className="text-sm text-green-500 py-4 text-center">No findings</p>;
15
- const sorted = [...findings].sort((a, b) => (a.severity === "warn" ? -1 : 1) - (b.severity === "warn" ? -1 : 1));
16
- return (
17
- <table className="w-full text-sm">
18
- <thead>
19
- <tr className="text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase border-b border-gray-200 dark:border-gray-700">
20
- <th className="px-4 py-2 w-20">Severity</th>
21
- <th className="px-4 py-2 w-40">Check</th>
22
- <th className="px-4 py-2">Message</th>
23
- <th className="px-4 py-2">Fix</th>
24
- </tr>
25
- </thead>
26
- <tbody>
27
- {sorted.map((f, i) => (
28
- <tr key={i} className="border-b border-gray-100 dark:border-gray-700/50">
29
- <td className="px-4 py-2"><SeverityBadge severity={f.severity} /></td>
30
- <td className="px-4 py-2"><code className="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">{f.check}</code></td>
31
- <td className="px-4 py-2 text-gray-700 dark:text-gray-300">{f.message}</td>
32
- <td className="px-4 py-2"><code className="text-xs text-gray-500 break-all">{f.fix || ""}</code></td>
33
- </tr>
34
- ))}
35
- </tbody>
36
- </table>
37
- );
38
- }
39
-
40
- function ResourceGroup({ title, resources, nameKey }) {
41
- const [expanded, setExpanded] = useState(null);
42
- if (!resources?.length) return null;
43
- return (
44
- <div className="mb-8">
45
- <h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100 mb-3">{title}</h2>
46
- <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
47
- <table className="w-full text-sm">
48
- <thead>
49
- <tr className="text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase border-b border-gray-200 dark:border-gray-700">
50
- <th className="px-5 py-3">Name</th>
51
- <th className="px-5 py-3">Location</th>
52
- <th className="px-5 py-3 w-24">Warnings</th>
53
- <th className="px-5 py-3 w-24">Info</th>
54
- </tr>
55
- </thead>
56
- <tbody>
57
- {resources.map((r, i) => {
58
- const name = r[nameKey] || "?";
59
- const warns = (r.findings || []).filter((f) => f.severity === "warn").length;
60
- const infos = (r.findings || []).filter((f) => f.severity === "info").length;
61
- return (
62
- <React.Fragment key={i}>
63
- <tr
64
- className="border-b border-gray-100 dark:border-gray-700/50 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/30"
65
- onClick={() => setExpanded(expanded === i ? null : i)}
66
- >
67
- <td className="px-5 py-3 font-medium text-gray-800 dark:text-gray-200">{name}</td>
68
- <td className="px-5 py-3 text-gray-600 dark:text-gray-400">{r.location || "-"}</td>
69
- <td className="px-5 py-3">
70
- {warns > 0 ? <><SeverityBadge severity="warn" /><span className="ml-1 text-xs">{warns > 1 ? `x${warns}` : ""}</span></> : <span className="text-gray-400">0</span>}
71
- </td>
72
- <td className="px-5 py-3 text-gray-500">{infos}</td>
73
- </tr>
74
- {expanded === i && (
75
- <tr>
76
- <td colSpan={4} className="p-0">
77
- <div className="bg-gray-50 dark:bg-gray-900/50 p-4">
78
- <FindingsTable findings={r.findings} />
79
- </div>
80
- </td>
81
- </tr>
82
- )}
83
- </React.Fragment>
84
- );
85
- })}
86
- </tbody>
87
- </table>
88
- </div>
89
- </div>
90
- );
91
- }
92
-
93
- function Audit() {
94
- const [sidebarOpen, setSidebarOpen] = useState(false);
95
- const { data, isLoading, refetch, isFetching } = useAudit();
96
-
97
- const vmData = data?.vms || {};
98
- const aksData = data?.aks || {};
99
- const storageData = data?.storage || {};
100
- const allFindings = [...(vmData.findings || []), ...(aksData.findings || []), ...(storageData.findings || [])];
101
- const totalWarns = allFindings.filter((f) => f.severity === "warn").length;
102
-
103
- return (
104
- <div className="flex h-screen overflow-hidden">
105
- <Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
106
- <div className="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden">
107
- <Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
108
- <main className="grow">
109
- <div className="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
110
- <div className="sm:flex sm:justify-between sm:items-center mb-8">
111
- <div>
112
- <h1 className="text-2xl md:text-3xl text-gray-800 dark:text-gray-100 font-bold">Security Audit</h1>
113
- <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Azure infrastructure security posture</p>
114
- </div>
115
- <button
116
- className="btn bg-gray-900 text-gray-100 hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-800 dark:hover:bg-white disabled:opacity-50"
117
- onClick={() => refetch()}
118
- disabled={isFetching}
119
- >
120
- {isFetching ? "Scanning..." : "Run Audit"}
121
- </button>
122
- </div>
123
-
124
- <div className="grid grid-cols-4 gap-4 mb-8">
125
- {[
126
- { label: "Total Findings", value: allFindings.length },
127
- { label: "Warnings", value: totalWarns, color: "text-yellow-500" },
128
- { label: "Resources", value: (vmData.vms?.length || 0) + (aksData.clusters?.length || 0) + (storageData.accounts?.length || 0) },
129
- { label: "Status", value: (isLoading || isFetching) ? "Scanning..." : !data ? "Not scanned" : totalWarns === 0 ? "Clean" : `${totalWarns} issues` },
130
- ].map((c, i) => (
131
- <div key={i} className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
132
- <p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-1">{c.label}</p>
133
- <p className={`text-2xl font-semibold ${c.color || "text-gray-800 dark:text-gray-100"}`}>{c.value}</p>
134
- </div>
135
- ))}
136
- </div>
137
-
138
- {!data && !isLoading && !isFetching ? (
139
- <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center">
140
- <p className="text-sm text-gray-500 mb-4">Click "Run Audit" to scan Azure infrastructure for security issues.</p>
141
- <button
142
- className="btn bg-gray-900 text-gray-100 hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-800 dark:hover:bg-white"
143
- onClick={() => refetch()}
144
- >Run Audit</button>
145
- </div>
146
- ) : isLoading || isFetching ? (
147
- <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-12 text-center">
148
- <p className="text-sm text-gray-500">Running security audit across all Azure resources...</p>
149
- </div>
150
- ) : (
151
- <>
152
- <ResourceGroup title="Virtual Machines" resources={vmData.vms} nameKey="vm" />
153
- <ResourceGroup title="AKS Clusters" resources={aksData.clusters} nameKey="cluster" />
154
- <ResourceGroup title="Storage Accounts" resources={storageData.accounts} nameKey="account" />
155
- </>
156
- )}
157
- </div>
158
- </main>
159
- </div>
160
- </div>
161
- );
162
- }
163
-
164
- export default Audit;
@@ -1,305 +0,0 @@
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;