@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,29 @@
1
+ {
2
+ "name": "@meshxdata/fops-cloud-ui",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@auth0/auth0-react": "^2.16.0",
13
+ "@tanstack/react-query": "^5.60.0",
14
+ "clsx": "^2.1.1",
15
+ "lucide-react": "^0.460.0",
16
+ "react": "^19.0.0",
17
+ "react-dom": "^19.0.0",
18
+ "react-router-dom": "^7.1.0",
19
+ "tailwind-merge": "^2.5.5"
20
+ },
21
+ "devDependencies": {
22
+ "@tailwindcss/forms": "^0.5.7",
23
+ "@tailwindcss/postcss": "^4.0.0",
24
+ "@vitejs/plugin-react": "^4.3.0",
25
+ "postcss": "^8.4.32",
26
+ "tailwindcss": "^4.0.0",
27
+ "vite": "^6.0.0"
28
+ }
29
+ }
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
@@ -0,0 +1,32 @@
1
+ import React, { useEffect } from "react";
2
+ import { Routes, Route, useLocation } from "react-router-dom";
3
+
4
+ import "./css/style.css";
5
+
6
+ import Resources from "./pages/Resources";
7
+ import CreateResource from "./pages/CreateResource";
8
+ import Fleet from "./pages/Fleet";
9
+ import Costs from "./pages/Costs";
10
+ import Audit from "./pages/Audit";
11
+
12
+ function App() {
13
+ const location = useLocation();
14
+
15
+ useEffect(() => {
16
+ document.querySelector("html").style.scrollBehavior = "auto";
17
+ window.scroll({ top: 0 });
18
+ document.querySelector("html").style.scrollBehavior = "";
19
+ }, [location.pathname]);
20
+
21
+ return (
22
+ <Routes>
23
+ <Route exact path="/" element={<Resources />} />
24
+ <Route path="/resources/new" element={<CreateResource />} />
25
+ <Route path="/fleet" element={<Fleet />} />
26
+ <Route path="/costs" element={<Costs />} />
27
+ <Route path="/audit" element={<Audit />} />
28
+ </Routes>
29
+ );
30
+ }
31
+
32
+ export default App;
@@ -0,0 +1,114 @@
1
+ const BASE = "/cloud/api";
2
+
3
+ // Token getter — set by AuthContext once Auth0 is initialized
4
+ let _getToken = null;
5
+
6
+ export function setTokenGetter(fn) {
7
+ _getToken = fn;
8
+ }
9
+
10
+ async function authHeaders(extra = {}) {
11
+ if (!_getToken) return extra;
12
+ try {
13
+ const token = await _getToken();
14
+ return { ...extra, Authorization: `Bearer ${token}` };
15
+ } catch {
16
+ return extra;
17
+ }
18
+ }
19
+
20
+ export async function apiFetch(path, opts = {}) {
21
+ const headers = await authHeaders({ "Content-Type": "application/json", ...opts.headers });
22
+ const res = await fetch(`${BASE}${path}`, { ...opts, headers });
23
+ if (!res.ok) {
24
+ const body = await res.json().catch(() => ({}));
25
+ throw new Error(body.error || `HTTP ${res.status}`);
26
+ }
27
+ return res.json();
28
+ }
29
+
30
+ /**
31
+ * Make a streaming POST/DELETE request and call onLine for each SSE event.
32
+ * Returns the final result from the "done" event, or throws on "error".
33
+ */
34
+ export async function apiStream(path, { method = "POST", body, onLine, onJobId } = {}) {
35
+ const headers = await authHeaders({ "Content-Type": "application/json" });
36
+ const res = await fetch(`${BASE}${path}`, {
37
+ method,
38
+ headers,
39
+ body: body ? JSON.stringify(body) : undefined,
40
+ });
41
+
42
+ if (!res.ok) {
43
+ const err = await res.json().catch(() => ({}));
44
+ throw new Error(err.error || `HTTP ${res.status}`);
45
+ }
46
+
47
+ const reader = res.body.getReader();
48
+ const decoder = new TextDecoder();
49
+ let buffer = "";
50
+ let finalResult = null;
51
+ let finalError = null;
52
+
53
+ while (true) {
54
+ const { done, value } = await reader.read();
55
+ if (done) break;
56
+ buffer += decoder.decode(value, { stream: true });
57
+
58
+ const lines = buffer.split("\n");
59
+ buffer = lines.pop();
60
+
61
+ for (const line of lines) {
62
+ if (!line.startsWith("data: ")) continue;
63
+ try {
64
+ const evt = JSON.parse(line.slice(6));
65
+ if (evt.type === "job") {
66
+ onJobId?.(evt.jobId);
67
+ } else if (evt.type === "done") {
68
+ finalResult = evt.result;
69
+ onLine?.("\u2713 Operation complete", "done");
70
+ } else if (evt.type === "error" && !evt.text?.startsWith(" ")) {
71
+ finalError = evt.text;
72
+ } else if (evt.type === "log" || evt.type === "error") {
73
+ onLine?.(evt.text, evt.type);
74
+ }
75
+ } catch { /* ignore */ }
76
+ }
77
+ }
78
+
79
+ if (finalError) throw new Error(finalError);
80
+ return finalResult;
81
+ }
82
+
83
+ /**
84
+ * Poll a job's buffered logs for reconnection after page reload.
85
+ */
86
+ export async function pollJob(jobId, onLine) {
87
+ let offset = 0;
88
+
89
+ while (true) {
90
+ const headers = await authHeaders();
91
+ const res = await fetch(`${BASE}/jobs/${jobId}?since=${offset}`, { headers });
92
+ if (!res.ok) {
93
+ if (res.status === 404) throw new Error("Job not found \u2014 it may have expired");
94
+ throw new Error(`HTTP ${res.status}`);
95
+ }
96
+
97
+ const data = await res.json();
98
+
99
+ for (const log of data.logs) {
100
+ onLine?.(log.text, log.type);
101
+ }
102
+ offset = data.offset + data.logs.length;
103
+
104
+ if (data.status === "done") {
105
+ onLine?.("\u2713 Operation complete", "done");
106
+ return { status: "done", result: data.result };
107
+ }
108
+ if (data.status === "error") {
109
+ return { status: "error", error: data.error };
110
+ }
111
+
112
+ await new Promise((r) => setTimeout(r, 1000));
113
+ }
114
+ }
@@ -0,0 +1,111 @@
1
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { apiFetch, apiStream } from "./client";
3
+
4
+ export function useResources() {
5
+ return useQuery({ queryKey: ["resources"], queryFn: () => apiFetch("/resources") });
6
+ }
7
+
8
+ export function useHealth() {
9
+ return useQuery({ queryKey: ["health"], queryFn: () => apiFetch("/health") });
10
+ }
11
+
12
+ export function useCosts(days = 30) {
13
+ return useQuery({
14
+ queryKey: ["costs", days],
15
+ queryFn: () => apiFetch(`/costs?days=${days}`),
16
+ staleTime: 5 * 60 * 1000, // cache for 5 minutes — cost queries are slow
17
+ });
18
+ }
19
+
20
+ export function useFleet() {
21
+ return useQuery({ queryKey: ["fleet"], queryFn: () => apiFetch("/fleet") });
22
+ }
23
+
24
+ export function useAudit() {
25
+ return useQuery({ queryKey: ["audit"], queryFn: () => apiFetch("/audit"), staleTime: 300_000 });
26
+ }
27
+
28
+ /**
29
+ * Streaming mutations — accept an onLine callback to show live output.
30
+ */
31
+
32
+ export function useSyncResources() {
33
+ const qc = useQueryClient();
34
+ return useMutation({
35
+ mutationFn: ({ onLine } = {}) =>
36
+ apiStream("/sync", { onLine }),
37
+ onSuccess: () => {
38
+ qc.invalidateQueries({ queryKey: ["resources"] });
39
+ qc.invalidateQueries({ queryKey: ["fleet"] });
40
+ qc.invalidateQueries({ queryKey: ["health"] });
41
+ },
42
+ });
43
+ }
44
+
45
+ export function useResourceAction() {
46
+ const qc = useQueryClient();
47
+ return useMutation({
48
+ mutationFn: ({ type, name, action, onLine }) =>
49
+ apiStream(`/resources/${type}/${name}/${action}`, { onLine }),
50
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["resources"] }),
51
+ });
52
+ }
53
+
54
+ export function useDeleteResource() {
55
+ const qc = useQueryClient();
56
+ return useMutation({
57
+ mutationFn: ({ type, name, onLine }) =>
58
+ apiStream(`/resources/${type}/${name}`, { method: "DELETE", onLine }),
59
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["resources"] }),
60
+ });
61
+ }
62
+
63
+ export function useFeatureFlags(vmName) {
64
+ return useQuery({
65
+ queryKey: ["flags", vmName],
66
+ queryFn: () => apiFetch(`/flags/${vmName}`),
67
+ enabled: !!vmName,
68
+ });
69
+ }
70
+
71
+ export function useSetFeatureFlags() {
72
+ const qc = useQueryClient();
73
+ return useMutation({
74
+ mutationFn: ({ vmName, flags, onLine }) =>
75
+ apiStream(`/flags/${vmName}`, { body: { flags }, onLine }),
76
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["flags"] }),
77
+ });
78
+ }
79
+
80
+ export function useDeploy() {
81
+ const qc = useQueryClient();
82
+ return useMutation({
83
+ mutationFn: ({ vmName, opts = {}, onLine }) =>
84
+ apiStream(`/deploy/${vmName}`, { body: opts, onLine }),
85
+ onSuccess: () => {
86
+ qc.invalidateQueries({ queryKey: ["resources"] });
87
+ qc.invalidateQueries({ queryKey: ["fleet"] });
88
+ },
89
+ });
90
+ }
91
+
92
+ export function useGrantAdmin() {
93
+ const qc = useQueryClient();
94
+ return useMutation({
95
+ mutationFn: ({ vmName, username, onLine }) =>
96
+ apiStream(`/resources/vm/${vmName}/grant-admin`, {
97
+ body: username ? { username } : {},
98
+ onLine,
99
+ }),
100
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["fleet"] }),
101
+ });
102
+ }
103
+
104
+ export function useCreateResource() {
105
+ const qc = useQueryClient();
106
+ return useMutation({
107
+ mutationFn: ({ body, onLine, onJobId }) =>
108
+ apiStream(`/resources/${body.type}`, { body, onLine, onJobId }),
109
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["resources"] }),
110
+ });
111
+ }
@@ -0,0 +1,162 @@
1
+ import React, { useRef, useEffect, useMemo } from "react";
2
+
3
+ // ANSI color code → Tailwind class mapping
4
+ const ANSI_COLORS = {
5
+ "30": "text-gray-900", "31": "text-red-400", "32": "text-green-400",
6
+ "33": "text-yellow-400", "34": "text-blue-400", "35": "text-purple-400",
7
+ "36": "text-cyan-400", "37": "text-gray-200", "90": "text-gray-500",
8
+ "91": "text-red-300", "92": "text-green-300", "93": "text-yellow-300",
9
+ "94": "text-blue-300", "95": "text-purple-300", "96": "text-cyan-300",
10
+ "97": "text-white",
11
+ };
12
+
13
+ const ANSI_RE = /\x1b\[([0-9;]*)m/g;
14
+
15
+ function parseAnsi(str) {
16
+ const segments = [];
17
+ let lastIndex = 0;
18
+ let currentClass = "text-gray-300";
19
+ ANSI_RE.lastIndex = 0;
20
+ let match;
21
+ while ((match = ANSI_RE.exec(str)) !== null) {
22
+ if (match.index > lastIndex) {
23
+ segments.push({ text: str.slice(lastIndex, match.index), className: currentClass });
24
+ }
25
+ const codes = match[1].split(";");
26
+ for (const code of codes) {
27
+ if (code === "0" || code === "") currentClass = "text-gray-300";
28
+ else if (code === "1") currentClass = currentClass + " font-bold";
29
+ else if (ANSI_COLORS[code]) currentClass = ANSI_COLORS[code];
30
+ }
31
+ lastIndex = ANSI_RE.lastIndex;
32
+ }
33
+ if (lastIndex < str.length) {
34
+ segments.push({ text: str.slice(lastIndex), className: currentClass });
35
+ }
36
+ return segments;
37
+ }
38
+
39
+ function stripAnsi(s) {
40
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
41
+ }
42
+
43
+ // Provisioning phases detected from section headers in the log stream
44
+ const PHASES = [
45
+ { id: "infra", label: "Infrastructure", match: "Infrastructure" },
46
+ { id: "firewall", label: "Firewall", match: "Firewall" },
47
+ { id: "security", label: "Security", match: "Security" },
48
+ { id: "connect", label: "Connectivity", match: "Connectivity" },
49
+ { id: "services", label: "Services", match: "Services" },
50
+ { id: "post", label: "Post-start", match: "Post-start" },
51
+ ];
52
+
53
+ function detectPhases(lines) {
54
+ const reached = new Set();
55
+ let current = null;
56
+
57
+ for (const line of lines) {
58
+ const clean = stripAnsi(line.text);
59
+
60
+ // Detect section headers: "─ Infrastructure ─" or "── Post-start ──"
61
+ for (const phase of PHASES) {
62
+ if (clean.includes(phase.match)) {
63
+ reached.add(phase.id);
64
+ current = phase.id;
65
+ }
66
+ }
67
+
68
+ // "Reconciliation complete" means all reconciliation phases done
69
+ if (clean.includes("Reconciliation complete")) {
70
+ reached.add("post");
71
+ current = "post";
72
+ }
73
+ }
74
+
75
+ return { reached, current };
76
+ }
77
+
78
+ export default function LogPanel({ lines = [], title = "Output", isDone = false, showPhases = false }) {
79
+ const endRef = useRef(null);
80
+
81
+ useEffect(() => {
82
+ endRef.current?.scrollIntoView({ behavior: "smooth" });
83
+ }, [lines.length]);
84
+
85
+ const { reached, current } = useMemo(() => detectPhases(lines), [lines]);
86
+
87
+ const completedChecks = useMemo(
88
+ () => lines.filter((l) => stripAnsi(l.text).includes("✓")).length,
89
+ [lines],
90
+ );
91
+
92
+ const phaseIndex = PHASES.findIndex((p) => p.id === current);
93
+ const progressPercent = isDone ? 100 : Math.min(95, Math.floor(((phaseIndex + 1) / PHASES.length) * 100));
94
+
95
+ if (lines.length === 0) return null;
96
+
97
+ return (
98
+ <div className="mt-6">
99
+ {/* Phase steps */}
100
+ {showPhases && (
101
+ <div className="mb-5">
102
+ <div className="flex items-center gap-1">
103
+ {PHASES.map((phase, i) => {
104
+ const done = isDone || (reached.has(phase.id) && current !== phase.id);
105
+ const active = !isDone && current === phase.id;
106
+ return (
107
+ <React.Fragment key={phase.id}>
108
+ <div className="flex items-center gap-1.5">
109
+ <div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 ${
110
+ done ? "bg-green-500 text-white"
111
+ : active ? "bg-violet-500 text-white"
112
+ : "bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500"
113
+ }`}>
114
+ {done ? "✓" : i + 1}
115
+ </div>
116
+ <span className={`text-xs font-medium whitespace-nowrap hidden sm:inline ${
117
+ done ? "text-green-600 dark:text-green-400"
118
+ : active ? "text-gray-800 dark:text-gray-100"
119
+ : "text-gray-400 dark:text-gray-500"
120
+ }`}>
121
+ {phase.label}
122
+ </span>
123
+ </div>
124
+ {i < PHASES.length - 1 && (
125
+ <div className={`flex-1 h-px min-w-4 ${done ? "bg-green-500/40" : "bg-gray-200 dark:bg-gray-700"}`} />
126
+ )}
127
+ </React.Fragment>
128
+ );
129
+ })}
130
+ </div>
131
+ </div>
132
+ )}
133
+
134
+ {/* Header + progress */}
135
+ <div className="flex items-center justify-between mb-2">
136
+ <h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase">{title}</h3>
137
+ <span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
138
+ {isDone ? "Complete" : `${completedChecks} checks — ${progressPercent}%`}
139
+ </span>
140
+ </div>
141
+
142
+ <div className="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full mb-3 overflow-hidden">
143
+ <div
144
+ className={`h-full rounded-full transition-all duration-500 ease-out ${isDone ? "bg-green-500" : "bg-violet-500"}`}
145
+ style={{ width: `${progressPercent}%` }}
146
+ />
147
+ </div>
148
+
149
+ {/* Terminal output */}
150
+ <div className={`bg-gray-900 dark:bg-gray-950 border border-gray-200 dark:border-gray-700/60 rounded-lg p-4 overflow-y-auto font-mono text-xs leading-relaxed ${showPhases ? "max-h-[60vh]" : "max-h-80"}`}>
151
+ {lines.map((line, i) => (
152
+ <div key={i}>
153
+ {parseAnsi(line.text).map((seg, j) => (
154
+ <span key={j} className={seg.className}>{seg.text}</span>
155
+ ))}
156
+ </div>
157
+ ))}
158
+ <div ref={endRef} />
159
+ </div>
160
+ </div>
161
+ );
162
+ }
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { useThemeProvider } from "../utils/ThemeContext";
3
+
4
+ export default function ThemeToggle() {
5
+ const { currentTheme, changeCurrentTheme } = useThemeProvider();
6
+
7
+ return (
8
+ <div>
9
+ <input
10
+ type="checkbox"
11
+ name="light-switch"
12
+ id="light-switch"
13
+ className="light-switch sr-only"
14
+ checked={currentTheme === "light"}
15
+ onChange={() => changeCurrentTheme(currentTheme === "light" ? "dark" : "light")}
16
+ />
17
+ <label
18
+ className="flex items-center justify-center cursor-pointer w-8 h-8 hover:bg-gray-100 lg:hover:bg-gray-200 dark:hover:bg-gray-700/50 dark:lg:hover:bg-gray-800 rounded-full"
19
+ htmlFor="light-switch"
20
+ >
21
+ <svg
22
+ className="dark:hidden fill-current text-gray-500/80 dark:text-gray-400/80"
23
+ width={16}
24
+ height={16}
25
+ viewBox="0 0 16 16"
26
+ xmlns="http://www.w3.org/2000/svg"
27
+ >
28
+ <path d="M8 0a1 1 0 0 1 1 1v.5a1 1 0 1 1-2 0V1a1 1 0 0 1 1-1Z" />
29
+ <path d="M12 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0Zm-4 2a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" />
30
+ <path d="M13.657 3.757a1 1 0 0 0-1.414-1.414l-.354.354a1 1 0 0 0 1.414 1.414l.354-.354ZM13.5 8a1 1 0 0 1 1-1h.5a1 1 0 1 1 0 2h-.5a1 1 0 0 1-1-1ZM13.303 11.889a1 1 0 0 0-1.414 1.414l.354.354a1 1 0 0 0 1.414-1.414l-.354-.354ZM8 13.5a1 1 0 0 1 1 1v.5a1 1 0 1 1-2 0v-.5a1 1 0 0 1 1-1ZM4.111 13.303a1 1 0 1 0-1.414-1.414l-.354.354a1 1 0 1 0 1.414 1.414l.354-.354ZM0 8a1 1 0 0 1 1-1h.5a1 1 0 0 1 0 2H1a1 1 0 0 1-1-1ZM3.757 2.343a1 1 0 1 0-1.414 1.414l.354.354A1 1 0 1 0 4.11 2.697l-.354-.354Z" />
31
+ </svg>
32
+ <svg
33
+ className="hidden dark:block fill-current text-gray-500/80 dark:text-gray-400/80"
34
+ width={16}
35
+ height={16}
36
+ viewBox="0 0 16 16"
37
+ xmlns="http://www.w3.org/2000/svg"
38
+ >
39
+ <path d="M11.875 4.375a.625.625 0 1 0 1.25 0c.001-.69.56-1.249 1.25-1.25a.625.625 0 1 0 0-1.25 1.252 1.252 0 0 1-1.25-1.25.625.625 0 1 0-1.25 0 1.252 1.252 0 0 1-1.25 1.25.625.625 0 1 0 0 1.25c.69.001 1.249.56 1.25 1.25Z" />
40
+ <path d="M7.019 1.985a1.55 1.55 0 0 0-.483-1.36 1.44 1.44 0 0 0-1.53-.277C2.056 1.553 0 4.5 0 7.9 0 12.352 3.648 16 8.1 16c3.407 0 6.246-2.058 7.51-4.963a1.446 1.446 0 0 0-.25-1.55 1.554 1.554 0 0 0-1.372-.502c-4.01.552-7.539-2.987-6.97-7ZM2 7.9C2 5.64 3.193 3.664 4.961 2.6 4.82 7.245 8.72 11.158 13.36 11.04 12.265 12.822 10.341 14 8.1 14 4.752 14 2 11.248 2 7.9Z" />
41
+ </svg>
42
+ <span className="sr-only">Switch to light / dark version</span>
43
+ </label>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,147 @@
1
+ /* Cloud panel field inputs */
2
+ .field-input {
3
+ @apply w-full bg-white dark:bg-gray-900/60 border border-gray-200 dark:border-gray-700/60 text-sm text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2.5 placeholder-gray-400 dark:placeholder-gray-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/30 shadow-xs transition;
4
+ }
5
+
6
+ /* Typography */
7
+ .h1 {
8
+ @apply text-4xl font-extrabold tracking-tighter;
9
+ }
10
+
11
+ .h2 {
12
+ @apply text-3xl font-extrabold tracking-tighter;
13
+ }
14
+
15
+ .h3 {
16
+ @apply text-3xl font-extrabold;
17
+ }
18
+
19
+ .h4 {
20
+ @apply text-2xl font-extrabold tracking-tight;
21
+ }
22
+
23
+ @media (width >= theme(--breakpoint-md)) {
24
+ .h1 {
25
+ @apply text-5xl;
26
+ }
27
+
28
+ .h2 {
29
+ @apply text-4xl;
30
+ }
31
+ }
32
+
33
+ /* Buttons */
34
+ .btn,
35
+ .btn-lg,
36
+ .btn-sm,
37
+ .btn-xs {
38
+ @apply font-medium text-sm inline-flex items-center justify-center border border-transparent rounded-lg leading-5 shadow-xs transition;
39
+ }
40
+
41
+ .btn {
42
+ @apply px-3 py-2;
43
+ }
44
+
45
+ .btn-lg {
46
+ @apply px-4 py-3;
47
+ }
48
+
49
+ .btn-sm {
50
+ @apply px-2 py-1;
51
+ }
52
+
53
+ .btn-xs {
54
+ @apply px-2 py-0.5;
55
+ }
56
+
57
+ /* Forms */
58
+ input[type="search"]::-webkit-search-decoration,
59
+ input[type="search"]::-webkit-search-cancel-button,
60
+ input[type="search"]::-webkit-search-results-button,
61
+ input[type="search"]::-webkit-search-results-decoration {
62
+ -webkit-appearance: none;
63
+ }
64
+
65
+ .form-input,
66
+ .form-textarea,
67
+ .form-multiselect,
68
+ .form-select,
69
+ .form-checkbox,
70
+ .form-radio {
71
+ @apply bg-white dark:bg-gray-900/30 border focus:ring-0 focus:ring-offset-0 dark:disabled:bg-gray-700/30 dark:disabled:border-gray-700 dark:disabled:hover:border-gray-700;
72
+ }
73
+
74
+ .form-checkbox {
75
+ @apply rounded-sm;
76
+ }
77
+
78
+ .form-input,
79
+ .form-textarea,
80
+ .form-multiselect,
81
+ .form-select {
82
+ @apply text-sm text-gray-800 dark:text-gray-100 leading-5 py-2 px-3 border-gray-200 hover:border-gray-300 focus:border-gray-300 dark:border-gray-700/60 dark:hover:border-gray-600 dark:focus:border-gray-600 shadow-xs rounded-lg;
83
+ }
84
+
85
+ .form-input,
86
+ .form-textarea {
87
+ @apply placeholder-gray-400 dark:placeholder-gray-500;
88
+ }
89
+
90
+ .form-select {
91
+ @apply pr-10;
92
+ }
93
+
94
+ .form-checkbox,
95
+ .form-radio {
96
+ @apply text-violet-500 checked:bg-violet-500 checked:border-transparent border border-gray-300 dark:border-gray-700/60 dark:checked:border-transparent focus-visible:ring-2 focus-visible:ring-violet-500/50;
97
+ }
98
+
99
+ /* Switch element */
100
+ .form-switch {
101
+ @apply relative select-none;
102
+ width: 44px;
103
+ }
104
+
105
+ .form-switch label {
106
+ @apply block overflow-hidden cursor-pointer h-6 rounded-full;
107
+ }
108
+
109
+ .form-switch label > span:first-child {
110
+ @apply absolute block rounded-full;
111
+ width: 20px;
112
+ height: 20px;
113
+ top: 2px;
114
+ left: 2px;
115
+ right: 50%;
116
+ transition: all .15s ease-out;
117
+ }
118
+
119
+ .form-switch input[type="checkbox"] + label {
120
+ @apply bg-gray-400 dark:bg-gray-700;
121
+ }
122
+
123
+ .form-switch input[type="checkbox"]:checked + label {
124
+ @apply bg-violet-500;
125
+ }
126
+
127
+ .form-switch input[type="checkbox"]:checked + label > span:first-child {
128
+ left: 22px;
129
+ }
130
+
131
+ .form-switch input[type="checkbox"]:disabled + label {
132
+ @apply cursor-not-allowed bg-gray-100 dark:bg-gray-700/20 border border-gray-200 dark:border-gray-700/60;
133
+ }
134
+
135
+ .form-switch input[type="checkbox"]:disabled + label > span:first-child {
136
+ @apply bg-gray-400 dark:bg-gray-600;
137
+ }
138
+
139
+ /* Chrome, Safari and Opera */
140
+ .no-scrollbar::-webkit-scrollbar {
141
+ display: none;
142
+ }
143
+
144
+ .no-scrollbar {
145
+ -ms-overflow-style: none; /* IE and Edge */
146
+ scrollbar-width: none; /* Firefox */
147
+ }